Build a "todo list" backend with AssemblyLift ππ
Dan
Posted on October 24, 2020
I don't think it's possible to publish a new framework without demonstrating the classic todo app with it. Definitely frowned upon. Maybe even illegal (check your local laws).
Fermenting AssemblyLift to the point that it could run the "hello world" of web & cloud frameworks was the first usability milestone I wanted to hit, and here we are! π
This post will introduce some AssemblyLift concepts, and walk you through writing and deploying a backend for a simple todo list app.
Getting Started
The AssemblyLift CLI is available via Cargo with cargo install assemblylift-cli
. At least Rust 1.39 is required for async
support, however I haven't confirmed if there are any other features used that would require a higher version. For reference, I've been working with 1.45.
As of writing, the CLI is compatible with macOS and Linux.
You will also need an Amazon AWS account, as AssemblyLift deploys to AWS Lambda & API Gateway (with support for other clouds planned!). Additionally, this guide uses AWS DynamoDB as a database. If you don't have an account already, note that each of these services offer a free tier which should be plenty to experiment with π.
β οΈ
Please reach out if these services are not offered in your region! I canβt do much short-term, but Iβd like to be aware of blind spots :)β οΈ
For this article I am assuming some familiarity with both AWS and Rust.
Creating a New Project
The AssemblyLift CLI installs itself as asml
. Run asml help
to see a list of commands, as well as the version. This guide is current for version 0.2.5 (but I'll try to keep it updated :)).
Create your project by running asml init -n todo
. This will create a project named todo
in ./todo
. Then cd
into the directory, as the remaining commands should be run from the project root (the location of assemblylift.toml
).
The CLI should have generated a basic project skeleton including a default service, itself including a default function.
π
A project in the AssemblyLift sense is essentially a collection of services. What that collection represents is up to you. For example a project may be a collection of services consumed by an application (like our todo app), or a group of related services that are consumed by other services and/or apps.
Let's take a look first at assemblylift.toml
. The one generated for you should like something like this:
[project]
name = "examples"
version = "0.1.0"
[services]
default = { name = "my-service" }
At the moment this configuration file defines only some brief info about the project, and the services it contains. It may be expanded with options for project-wide configuration in future releases.
Let's update the generated values with something specific to our todo project.
[project]
name = "todo-app"
version = "0.1.0"
[services]
default = { name = "todo-service" }
You will also need to rename the service's directory via mv ./services/my-service ./services/todo-service
.
Your First Service
Next, let's open up the service.toml
file for todo-service
.
[service]
name = "my-service"
[api]
name = "my-service-api"
[api.functions.my-function]
name = "my-function"
handler_name = "handler"
Start by editing the names to match the ones we gave in assemblylift.toml
above. We'll also update the default function name to the first function we'll demonstrate here, the "create todo" function. Like with the service, you should rename the function's directory with mv ./services/todo-service/my-function/ ./services/todo-service/create/
.
[service]
name = "todo-service"
[api]
name = "todo-service-api"
[api.functions.create]
name = "create"
handler_name = "handler"
The handler_name
field should generally be left alone (and will probably be elided in a future release); you should only change it if you know what you're doing! π€
π
A service at its core is a collection of functions. A service may optionally declare an HTTP API, mapping (verb,path) pairs to functions. Services are required to have at least one function (otherwise there's not much point, right? π).
Since we're here, let's also define the HTTP API for our create function.
[api.functions.create]
name = "create"
handler_name = "handler"
http = { verb = "POST", path = "/todos" }
With http
defined, AssemblyLift will create an HTTP API for the service with routes defined by the functions' verb
(method) and path
. Take a look at the API Gateway docs for information on using path parameters.
Adding Dependencies
Our services are going to depend on IO modules (IOmods). IOmods are similar to packages in other environments such as Node.js. However in AssemblyLift, IOmods are implemented as binary "plugins" and they are how our functions communicate with the outside world.
Add the following blocks to your service.toml
:
[iomod.dependencies.aws-dynamodb]
version = "0.1.0"
type = "file"
from = "/your/path/to/iomod/akkoro-aws-dynamodb"
[iomod.dependencies.std-crypto]
version = "0.1.0"
type = "file"
from = "/your/path/to/iomod/akkoro-std-crypto"
Currently AssemblyLift only supports importing local files. Hopefully in the near future we'll have some registry infrastructure for IOmods. In the meantime, we have a manual step to fetch the latest build of the IOmod standard library. You can download a zip from GitHub here, and extract the binaries to a directory somewhere on your local system. At the moment the stdlib contains a whopping 2 (!!) modules, for DynamoDB and basic crypto respectively.
Your First Function
Finally, some Rust code! π¦ Let's look at the lib.rs
that was generated for us.
extern crate asml_awslambda;
use asml_core::GuestCore;
use asml_awslambda::{*, AwsLambdaClient, LambdaContext};
handler!(context: LambdaContext, async {
let event = context.event;
AwsLambdaClient::console_log(format!("Read event: {:?}", event));
AwsLambdaClient::success("OK".to_string());
});
This is the bare minimum you need to have a complete AssemlyLift function written in Rust -- essentially just enough to prove that events are read in and status is written out correctly. The handler!
macro provides the entry point to our function, and hides the boilerplate code necessary to bootstrap the function.
Let's make it more interesting. We'll start by rewriting this for HTTP invocation:
extern crate asml_awslambda;
use asml_core::GuestCore;
use asml_awslambda::*;
handler!(context: LambdaContext, async {
let event: ApiGatewayEvent = context.event;
match event.body {
Some(content) => {
// TODO
}
None => {
http_error!(String::from("missing request payload"));
}
}
});
The http_error!
macro is a helper which wraps AwsLambdaClient::success
and returns an HTTP 520 with a JSON response indicating a function error.
Our create function is going to use the DynamoDB PutItem
call to store a new todo item. Each item will use a UUID for its primary key, for which we'll use the UUID v4 call from the crypto IOmod.
First we'll need to import a few crates; add the following to the Cargo dependencies:
serde = "1.0.53"
asml_iomod_dynamodb = { version = "0.1.2", package = "assemblylift-iomod-dynamodb-guest" }
asml_iomod_crypto = { version = "0.1.1", package = "assemblylift-iomod-crypto-guest" }
We'll need serde
to serialize/deserialize our request & response JSON. The other two are the "guest" crates for each IOmod we depend on.
Next, let's add simple request & response structs to lib.rs
.
use serde::{Serialize, Deserialize};
.
.
.
#[derive(Serialize, Deserialize)]
struct CreateTodoRequest {
pub body: String,
}
#[derive(Serialize, Deserialize)]
struct CreateTodoResponse{
pub uuid: String,
}
The request contains only the body
field, which will store the text of the todo item. The item's ID and timestamp will be generated on the function.
Let's move on to the body of our function. We'll start by deserializing the request body to our CreateTodoRequest
struct.
.
.
Some(content) => {
let content: CreateTodoRequest = serde_json::from_str(&content).unwrap();
}
.
.
Easy enough. Next, lets use one of our IOmods! Import the uuid4
call from crypto, and use it to generate a UUID for the item.
use asml_iomod_crypto::uuid4;
.
.
Some(content) => {
let content: CreateTodoRequest = serde_json::from_str(&content).unwrap();
let uuid = uuid4(()).await;
}
.
.
IOmod calls always take a single argument for the call's input, and always return a Future
. The uuid4
call doesn't actually require any input, so we pass ()
for the input argument.
We'll use this value as the primary key for the todo item. We'll construct this item as part of the PutItemInput
struct that our DynamoDB call will take as input.
use asml_core_io::get_time;
use asml_iomod_crypto::uuid4;
.
.
Some(content) => {
let content: CreateTodoRequest = serde_json::from_str(&content).unwrap();
let uuid = uuid4(()).await;
let mut input: structs::PutItemInput = Default::default();
input.table_name = String::from("todo-example");
input.item = Default::default();
input.item.insert(String::from("uuid"), val!(S => uuid));
input.item.insert(String::from("timestamp"), val!(N => get_time()));
input.item.insert(String::from("body"), val!(S => content.body));
}
.
.
The timestamp is generated by get_time()
, which returns the system time as the duration in seconds since the "Unix epoch". The get_time
function is an AssemblyLift ABI call, and is always available.
The val!
macro provides a shorthand for writing the DynamoDB value JSON, and is borrowed from the rusoto_dynamodb
crate (as are the structs π).
The last thing we need to add is a call to DynamoDB's PutItem
, and a response from our a function.
use asml_iomod_crypto::uuid4;
use asml_iomod_dynamodb::{structs, structs::AttributeValue, *};
.
.
Some(content) => {
let content: CreateTodoRequest = serde_json::from_str(&content).unwrap();
let uuid = uuid4(()).await;
let mut input: structs::PutItemInput = Default::default();
input.table_name = String::from("todo-example");
input.item = Default::default();
input.item.insert(String::from("uuid"), val!(S => uuid));
input.item.insert(String::from("timestamp"), val!(N => get_time()));
input.item.insert(String::from("body"), val!(S => content.body));
match put_item(input).await {
Ok(_) =>
let response = CreateTodoResponse { uuid };
http_ok!(response);
}
Err(why) => http_error!(why.to_string())
}
}
.
.
Here we've introduced the http_ok!
macro, which returns our response as an HTTP 200. If the call to put_item
fails for some reason, we call our error macro again.
Putting it all together, the code for our create function should like like this!
extern crate asml_awslambda;
use serde::{Serialize, Deserialize};
use asml_core::GuestCore;
use asml_awslambda::*;
use asml_iomod_crypto::uuid4;
use asml_iomod_dynamodb::{structs, structs::AttributeValue, *};
handler!(context: LambdaContext, async {
let event: ApiGatewayEvent = context.event;
match event.body {
Some(content) => {
let content: CreateTodoRequest = serde_json::from_str(&content).unwrap();
let uuid = uuid4(()).await;
let mut input: structs::PutItemInput = Default::default();
input.table_name = String::from("todo-example");
input.item = Default::default();
input.item.insert(String::from("uuid"), val!(S => uuid));
input.item.insert(String::from("timestamp"), val!(N => get_time()));
input.item.insert(String::from("body"), val!(S => content.body));
match put_item(input).await {
Ok(_) => {
let response = CreateTodoResponse { uuid };
http_ok!(response);
}
Err(why) => http_error!(why.to_string())
}
}
None => {
http_error!(String::from("missing request payload"));
}
}
});
#[derive(Serialize, Deserialize)]
struct CreateTodoRequest {
pub body: String,
}
#[derive(Serialize, Deserialize)]
struct CreateTodoResponse{
pub uuid: String,
}
Building the Project
Before getting too far, now would be a good time to try building our project to make sure everything is working.
The CLI command you'll use for this is asml cast
. The cast
command compiles all function source & generates an infrastructure plan with Terraform. All build artifacts are written to the net
directory, which is the frozen (and deployable) representation of your project.
You may need to scroll the output a little to verify there were no errors -- the Terraform plan will still run even if function compilation fails (we'll fix this soon :)).
Adding Additional Functions
I'm going to leave the implementation of the remaning functions as an exercise to the reader. This is already long, plus there's a complete example on Github if you'd like the full source.
You should be aware of the asml make
command while you do this, which will let you generate & add a new service or function to your existing project.
Running asml make service <service-name>
will, as you probably guessed, create a new service. This uses the same template as the init
command to generate the stub.
What about adding a function to this service, that we already have? You do this by running asml make function <service-name>.<function-name>
.
For example, if you want to add a delete
function to our todo service you might run asml make function todo.delete
.
For now you will still need to update your .toml
files by hand, but I would like make
to take care of that for you in the future as well in a future update π.
Deployment
The last command you're going to need is asml bind
. This command will take an existing net
structure and bind
it to the backend (i.e. AWS Lambda).
If you aren't getting errors during cast
, there shouldn't be any issues running bind
successfully. If everything goes smoothly (or even if not), you should receive output from the underlying Terraform process.
If it worked, head over to your AWS console and take a look for your API in API Gateway. You should be able to grab the endpoint URL it generated and start testing!
π§
AssemblyLift doesn't yet provide a built-in means of adding IAM policies to the Roles generated for each Lambda. In the meantime you will have to attach policies manually (such as for DynamoDB access). A unique role has been generated byasml
for each function in your service, and should be easily identified by name in the console.
fin
That's all! Please please please reach out here or on Github if you have any issues! Things can't be fixed if I don't know that they're broken :).
I'll do my best to keep this guide updated as changes & fixes are introduced. We'll keep a changelog here when the time comes.
Happy coding!
Posted on October 24, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.