loading...

Build a "todo list" backend with AssemblyLift πŸš€πŸ”’

dotxlem profile image Dan ・Updated on ・9 min read

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" }
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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"));
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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();
}
.
.
Enter fullscreen mode Exit fullscreen mode

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;
}
.
.
Enter fullscreen mode Exit fullscreen mode

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));
}
.
.
Enter fullscreen mode Exit fullscreen mode

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())
    }
}
.
.
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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 by asml 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!

Discussion

pic
Editor guide