Rust + Lambda using CDK & Github Actions (Part 1)
Mike Blydenburgh
Posted on March 7, 2022
I have recently been on a Rust kick trying to see why it is consistently ranked as a most loved language and seeing what makes it tick.
After going through the book, it was time to try to apply what I've learned to something I do at work all the time - deploy code to a Lambda. Standing up the project myself made me realize that I had to reference a number of different sources to get things working correctly and there was not a good pattern to follow to automatically deploy via CDK so I wanted to share what I learned along the way!
I am by no means a Rust expert and am not claiming that this is the most proficient way to set things up, but hopefully this can help someone else on their Rust journey in the cloud!
Overview
What we're going to build is a simple lambda function that receives a request via API Gateway and performs an operation on a DynamoDB table. GitHub Actions will be used as a CI/CD pipeline to deploy a CDK stack to AWS.
Since AWS does not currently provide an official Lambda runtime for Rust, we will configure a custom runtime for Amazon Linux 2.
Part 1 will go over the Rust code, and in Part 2 we'll deploy using GitHub Actions and CDK!.
Generating Project & Setting Dependencies
To get generate a new project we'll call rust_lambda
, run cargo new rust_lambda
.
The following dependencies (crates) will be needed in Cargo.toml
:
[package]
name = "rust_lambda"
version = "0.1.0"
authors = ["Your name"]
edition = "2021"
[dependencies]
aws-config = "0.6.0"
aws-sdk-dynamodb = "0.6.0"
tokio = { version = "1", features = ["full"] }
lambda_runtime = "0.4.1"
lambda_http = "0.4.1"
serde = "1.0.136"
serde_derive = "1.0.136"
serde_json = "1.0.78"
log = "0.4.0"
uuid = { version = "0.8.2", features = ["v4"]}
Let's quickly go over how these crates will be used:
- aws-config: used to create an AWS environment config
- aws-sdk-dynamodb: used to define a Dynamo client and perform operations on a table
- tokio: Rust async network runtime that provides building blocks to build web apps
- lambda_runtime: provides methods to execute a function as a lambda handler
- lambda_http: provides additional handler and request definitions used in create an http invoked lambda
- serde: Rust serialization/deserialization library
- serde_derive: provides the
derive
macro that is used to implement various traits on specific struct definitions - serde_json: provides methods to handle json
- log: Rust logging library that provides various additional logging macros
- uuid: generates a random uuid
Data Model
Overview
Being getting into the code it would be useful to get an idea of how we're going to be receiving requests and saving items into our table.
For our request, let's assume we are going to get a POST request with the following JSON payload:
{
"firstName": "firstName",
"lastName": "lastName"
}
The DynamoDB table configuration will be pretty simple:
- PartitionKey(
userId
): String - SortKey(
modelTypeAndId
): String - FirstName: String
- LastName: String
Setting up the partition and sort keys like this enable a relational data model of many different item types on a single dynamo table (Posts that are owned by Users for example).
Creating a Model Module
In order to keep the struct definitions separate, a models
folder can be created, and inside a mod.rs
and user.rs
.
user.rs:
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct AddUserEvent {
pub first_name: String,
pub last_name: String,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub uuid: String,
pub first_name: String,
pub last_name: String,
}
mod.rs:
pub mod user;
In user.rs
we define a struct for the incoming add user event, and a struct that will be used to map a dynamo GetItem result to. Using serde's rename_all
we can maintain Rust's convention for snake_case in code but has camelCase in json requests/responses.
mod.rs
is defining a public a module that can be imported elsewhere.
Defining the Lambda Handler
Boilerplate
Starting off with writing the lambda handler itself, the usual entry-point is the main function:
use lambda_http::{handler, Request};
use lambda_runtime::{Context, Error as LambdaError};
#[tokio::main]
async fn main() -> Result<(), LambdaError> {
lambda_runtime::run(handler(handler_func)).await?;
Ok(())
}
A few things are happening here...
First, the function needs to be annotated for tokio
in order to execute the function in the tokio runtime (read more here). Normally, the main function cannot be async, but adding this annotation enables this.
Next, we have to set the return type to be a Result<(), LambdaError>
so we can gracefully handle any errors.
Finally, we are using the lambda_runtime
crate to easily bootstrap (as the name implies) a working lambda runtime. Since our lambda is going to be invoked via an API Gateway request, we can then use the lambda_http
crate to set ourselves up to accept an Http Request. We .await?
the call to lambda_runtime::run
to allow for execution and bubble up any errors.
This is all boilerplate code that will only have to been done until Rust gets an official Lambda runtime from AWS.
Basic Routing Handler
At its most basic, the handler function has a signature that should be familiar to anyone who has written a lambda handler before- it takes in an event object and a context object (unused here so by convention labeled with _
), and returns a value or error.
async fn handler_func(event: Request, _c: Context) -> Result<Value, LambdaError> {
Here, Request
represents an HttpRequest type that has the properties you would expect to find:
- method
- uri
- version
- headers
- extensions
- body
Value
represents a json value for a response.
Since this lambda will handle multiple HTTP methods, some basic routing will be needed in order to execute different functions depending on the incoming request method.
use lambda_http::{handler, Request};
use lambda_runtime::{Context, Error as LambdaError};
use serde_json::{json, Value};
--snip--
async fn handler_func(event: Request, _c: Context) -> Result<Value, LambdaError> {
let result = match event.method() {
&lambda_http::http::method::Method::GET => {
json!("get")
}
&lambda_http::http::method::Method::POST => {
json!("post")
}
_ => {
println!("Handling other request");
json!("other")
}
};
Ok(json!(result))
}
Here, a match
statement can be used to run different code based upon the incoming Request Method. Separate handlers for both creating a user and getting a user will be defined.
Add User Handler
All the created handlers can be placed in a new folder called handlers
. For now create a mod.rs
and create_user_handler.rs
mod.rs:
pub mod create_user_handler;
The handler itself needs the Dynamo client defined in the parent handler, as well as request payload so it can serialize it into a struct (this is done be using serde_json). Finally in order to successfully submit a PutItem request, a uuid needs to be generated.
create_user_handler.rs:
use aws_sdk_dynamodb::model::AttributeValue;
use aws_sdk_dynamodb::Client;
use serde_json::{json, Value};
use lambda_runtime::{Error as LambdaError};
use uuid::Uuid;
use crate::{
models::{
user::{AddUserEvent, User}
}
};
pub async fn create_user(client: &Client, payload: &str) -> Result<Value, LambdaError> {
let uuid = Uuid::new_v4().to_string();
let add_user_event: AddUserEvent = serde_json::from_str(payload)?;
let request = client
.put_item()
.table_name("rust-lambda-table")
.item("userId", AttributeValue::S(String::from(&uuid)))
.item(
"modelTypeAndId",
AttributeValue::S(format!("User#{}", String::from(&uuid))),
)
.item("first_name", AttributeValue::S(add_user_event.first_name.clone()))
.item("last_name", AttributeValue::S(add_user_event.last_name.clone()));
request.send().await?;
let created_user = User {
uuid: uuid,
first_name: add_user_event.first_name,
last_name: add_user_event.last_name
};
let user_json = serde_json::to_value(&created_user).unwrap();
Ok(json!(user_json))
}
Now we have a basic handler function that takes in a json payload and create a new Dynamo item. In order to send back the correct Value
type, we can useserde_json::to_value()
. Now we're returning a dynamic response based on the payload!
Get User Handler
With a handler created for creating a User, let's move on to defining the handler required for retrieving that User from the table. Create a new get_user_handler.rs
. This handler will also need a Dynamo client as an argument as well as an id of the User that is to be requested.
get_user_handler.rs:
use serde_json::Value;
use aws_sdk_dynamodb::model::AttributeValue;
use aws_sdk_dynamodb::Client;
use lambda_runtime::{Error as LambdaError};
use crate::{
models::{
user::User
}
};
pub async fn get_user(client: &Client, id: &str) -> Result<Value, LambdaError> {
let request = client
.query()
.table_name("rust-lambda-table")
.key_condition_expression("#id = :uuid")
.expression_attribute_names("#id", "userId")
.expression_attribute_values(":uuid", AttributeValue::S(id.to_string()));
let result = request.send().await?;
let user = match result.items.unwrap().first() {
Some(res) => User {
uuid: res.get("userId").unwrap().as_s().unwrap().clone(),
first_name: res.get("first_name").unwrap().as_s().unwrap().clone(),
last_name: res.get("last_name").unwrap().as_s().unwrap().clone()
},
_ => User {
uuid: String::from(""),
first_name: String::from(""),
last_name: String::from("")
}
};
let user_json = serde_json::to_value(&user).unwrap();
Ok(user_json)
}
For now, if no User is found a blank User will be returned. Since we created a second handler we want to export as part of the handlers module, mod.rs
needs to be updated.
mod.rs:
pub mod create_user_handler;
pub mod get_user_handler;
Update Routing Handler
The only thing left to do is introduce the DynamoDB configuration and implement the calls to the newly created handlers.
main.rs:
use aws_config::meta::region::RegionProviderChain;
use aws_sdk_dynamodb::model::AttributeValue;
use aws_sdk_dynamodb::Client;
use self::{
models::{
user::AddUserEvent
},
handlers::{
create_user_handler::create_user,
get_user_handler::get_user
}
};
--snip--
async fn handler_func(event: Request, _c: Context) -> Result<Value, LambdaError> {
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
let config = aws_config::from_env().region(region_provider).load().await;
let client = Client::new(&config);
let body_string: &str = match event.body() {
lambda_http::Body::Text(text) => text.as_str(),
_ => "",
};
let result = match event.method() {
&lambda_http::http::method::Method::GET => {
println!("Handling GET request");
json!(get_user(&client, event.query_string_parameters().get("id").unwrap()).await?)
}
&lambda_http::http::method::Method::POST => {
println!("Handling POST request");
json!(create_user(&client, body_string).await?)
}
_ => {
println!("Handling other request");
json!("other")
}
};
Ok(json!(result))
}
Here us-east-1
region is used as the default, defining a default configuration object and passing that into a client constructor method to instantiate an instance of a DynamoDB client for use.
Using the AWS SDK makes interacting with Dynamo fairly straightforward. With a client instantiated, we can use it in the different user handlers to access methods to perform actions on a given table.
With that done, we now have all the handler code written and are ready to set up the cloud resources that will be required to successfully deploy and invoke this lambda! That will all be covered in part 2.
Github Link: https://github.com/mblydenburgh/rust-lambda
Posted on March 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.