Creating my first AWS Lambda using Rust

Rust Runtime for AWS Lambda

What we are building

Together we are going to create an example AWS Lambda function using the Rust runtime. The Rust function will be tasked with checking if a provided serial number is correct and that it is unique (not already part of an existing dataset).

Let’s outline the specific features the Rust function will provide:

  • It implements a handler for a ValidationEvent and a JSON payload consisting of the serialNumber field which we are going to validate.
{
"serialNumber": ""
}
  • It checks that the serial number is an alpha-numeric string with a minimum length of 6 characters
  • It checks that the serial number is unique, that is not already part of an existing data set.
  • It returns a JSON response with the validation result and any potential validation errors
{
"valid": false,
"errors": ["invalid_format", "already_exists", ...]
}

By the way, I am relatively new to Rust so if you, dear reader find that some of the code or concepts used does not conform to the accepted ergonomics of the ecosystem, please share it in the comments. I welcome any feedback that can help me improve in this area!

1. Implementing the Rust Function

Let’s start by creating a new Rust executable project:

$ cargo new aws_validate_serial --bin

Cargo creates a fresh Hello, world! application which we are now going to extend.

In order to keep things simple, the validators will be several functions which individually check different aspects of the serial number returning true when the serial is valid, or false when the provided string does not conform to a given requirement. For a serial number to be considered valid, all validators need to return true.

a. Validating the length

The serial number is valid if it contains 6 or more characters.

fn validate_serial_length(serial_number: &str) -> bool {
serial_number.chars().count() >= 6
}

Let’s add a few unit tests to make sure this works in the way we expect. One of my favorite aspects of Rust is that it encourages inline unit testing, that is keeping the code and the corresponding unit tests very close together. So, let’s add a tests module:

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn validates_length_of_four_characters_as_invalid() {
let test_serial = "i234";
let validation_result = validate_serial_length(test_serial);
assert_eq!(false, validation_result);
}

#[test]
fn validates_length_of_six_characters_as_valid() {
let test_serial = "i23456";
let validation_result = validate_serial_length(test_serial);
assert_eq!(true, validation_result);
}

#[test]
fn validates_length_of_ten_characters_as_valid() {
let test_serial = "i234567891";
let validation_result = validate_serial_length(test_serial);
assert_eq!(true, validation_result);
}
}

b. Validating string format

The serial number is valid if it only contains alphanumeric characters.

fn validate_serial_alphanumeric(serial_number: &str) -> bool {
serial_number.chars().all(char::is_alphanumeric)
}

We are taking advantage of the is_alphanumeric method to validate if the string is alphanumeric or not. Let’s add a few unit tests to ensure our expectations are achieved:

#[test]
fn validates_string_with_numbers_as_valid() {
let test_serial = "234567891";
let validation_result = validate_serial_alphanumeric(test_serial);
assert_eq!(true, validation_result);
}

#[test]
fn validates_string_with_az_characters_as_valid() {
let test_serial = "abcd1234";
let validation_result = validate_serial_alphanumeric(test_serial);
assert_eq!(true, validation_result);
}

#[test]
fn validates_string_with_unicode_characters_as_valid() {
let test_serial = "абвгдежзийюя1234";
let validation_result = validate_serial_alphanumeric(test_serial);
assert_eq!(true, validation_result);
}

#[test]
fn validates_string_with_special_characters_as_invalid() {
let test_serial = "abcd!1234";
let validation_result = validate_serial_alphanumeric(test_serial);
assert_eq!(false, validation_result);
}

c. Validating the string is unique

Validating if the string is unique depends heavily on the specific architecture and implementation of system. To achieve this validation, we need to know for example where and how existing serial numbers are stored. For this post, I will use a hardcoded array of existing serial numbers to validate against.

fn validate_serial_unique(serial_number: &str) -> bool {
let existing_serials = vec!["serial1", "serial2", "serial3"];
!existing_serial_numbers.contains(&existing_serials)
}

And let’s add a few unit tests to ensure the sanity of our solution:

#[test]
fn validates_existing_serial1_as_invalid() {
let test_serial = "serial1";
let validation_result = validate_serial_unique(test_serial);
assert_eq!(false, validation_result);
}

#[test]
fn validates_new_serial4_as_valid() {
let test_serial = "serial4";
let validation_result = validate_serial_unique(test_serial);
assert_eq!(true, validation_result);
}

d. Putting the validators together

Once we receive an input value to validate, we need to go through each validator we created above. Once we encounter a validation error, we need to create and return an appropriate validation response.

Let’s create an enumeration with the possible validation errors our service can return:

enum ValidationError {
InvalidFormat,
AlreadyExists
}

Since we are going to serialize the response to JSON, I prefer to also implement a way to associate a string representation for each of the validation errors which are going to be part of the output response of the function:

impl ValidationError {
fn value(&self) -> String {
match *self {
ValidationError::InvalidFormat => String::from("invalid_format"),
ValidationError::AlreadyExists => String::from("already_exists"),
}
}
}

And finally, the response we are going to eventually serialize and return:

struct ValidationResult {
is_valid: bool,
errors: Vec<String>
}

Now that we have our models, let’s write a function that puts it all together — it takes a serial number and returns a validation result.

fn validate_serial(serial_number: &str) -> ValidationResult {
let mut result = ValidationResult { is_valid: true, errors: Vec::new() };

if !validate_serial_length(serial_number) {
result.is_valid = false;
result.errors.push(ValidationError::InvalidFormat.value());
}

if !validate_serial_alphanumeric(serial_number) {
result.is_valid = false;
result.errors.push(ValidationError::InvalidFormat.value());
}

if !validate_serial_unique(serial_number) {
result.is_valid = false;
result.errors.push(ValidationError::AlreadyExists.value());
}

return result;
}

2. Integrating with AWS Lambda

Now that we have a working implementation of a validation function, it’s time to configure the project to work with AWS Lambda so we can receive actual requests and respond back with the validation result.

a. Adding external crates

We are going to adopt two dependencies in our project:

Open Cargo.toml and add the following under the dependencies section. I am going to include the package versions at the time of writing but feel free to lookup the latest version of the crates.

[dependencies]
serde = "1.0.80"
serde_derive
= "1.0.80"
lambda_runtime
= "0.1.0"

Let’s import the creates at the top of main.rs

#[macro_use]
extern crate lambda_runtime as lambda;
#[macro_use]
extern crate serde_derive;

b. Implementing the AWS Lambda Handler

In order for the Rust function to run, it needs to implement a Handler method with the following signature:

pub type Handler<E, O> = fn(E, Context) -> Result<O, HandlerError>

The first argument of the handler method is the event object which contains the payload that triggers the execution of the lambda function. We haven’t defined that object yet, so let’s do it now:

struct ValidationEvent {
serial_number: String
}

Since both the event payload and response are going to be serialized and deserialized, we need to annotate them with the Serde macros:

#[derive(Serialize, Deserialize)]
struct ValidationResult {
#[serde(rename = "isValid")]
is_valid: bool,
errors: Vec<String>
}

#[derive(Serialize, Deserialize)]
struct ValidationEvent {
#[serde(rename = "serialNumber")]
serial_number: String
}

Finally, we need to implement the Lambda event handler and register it using the lambda! macros:

fn main() -> Result<(), Box<dyn Error>> {
lambda!(validation_handler);
Ok(())
}

fn validation_handler(event: ValidationEvent, ctx: Context) -> Result<ValidationResult, HandlerError> {
Ok(validate_serial(event.serial_number.as_str()))
}

This last part makes it possible for incoming ValidationEvent requests to be passed to our lambda function handler and result in a ValidationResult responses to be sent back.

c. Building for the AWS Linux environment

From the AWS Lambda documentation we see that AWS Lambdas are executed x86 64bit Linux environment. In this case, we should make sure that our Rust project is also targeting and successfully building in that environment.

Note: if you are currently on a x86_64 Linux environment, then you can skip this section.

I found an excellent tutorial which guided me through the needed steps in order to be able to build for this architecture. You can read it yourself here.

rustup target add x86_64-unknown-linux-musl

Let’s configure our project to build against this target.

  • In the project root folder, create a subfolder named .cargo
  • In this subfolder, create a file named config with the following contents:
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-musl-gcc"

You can read more about the cargo configuration file in the docs.

On macOS we also need to install the corresponding linker tools so that cargo can produce a binary compatible with x86 64-bit Linux. Luckily, that’s not hard using Homebrew.

brew install filosottile/musl-cross/musl-cross

Once installation is complete, it is possible that the new musl-gcc binary is not discovered during the build process. To address this, we can manually create a symbolik link to it as follows:

ln -s /usr/local/bin/x86_64-linux-musl-gcc /usr/local/bin/musl-gcc

Running cargo build --release --target x86_64-unknown-linux-musl at this point should be successful.

d. Publishing the executable

Before we can create the AWS Lambda, we need to package the executable.

The AWS runtime expects a binary named bootstrap so we need to rename it before publishing. Here is a command line snippet that can take care of renaming and zipping the binary for upload:

cp ./target/x86_64-unknown-linux-musl/release/aws_validate_serial ./bootstrap && zip lambda.zip bootstrap && rm bootstrap

Next, head over to the AWS Lambda dashboard and create a new function.

Our function will use a provided runtime

Next, upload the lambda.zip package

Note: you can leave the value in the Handler field as is, it doesn’t really matter in our case.

Click Save.

e. Testing our Rust function

In the Lambda console, click the Test button on the top right.

Let’s try an invalid serialNumber (too short):

{
"serialNumber": "short"
}

Let’s try a valid payload:

{
"serialNumber": "123456b"
}

It works! 🎊🤩

The full source for this post can be found here.