Build a  "todo list" backend with AssemblyLift 🚀🔒
Tags: rust, webassembly, serverless, assemblylift
Published: 2020-10-24 14:43:38

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](https://assemblylift.akkoro.io) 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:
```toml
[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.
```toml
[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`.
```toml
[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/`.

```toml
[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.
```toml
[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](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-routes.html) for information on using path parameters.

### Adding Dependencies

Our services are going to depend on [IO modules](https://dev.to/dotxlem/assemblylift-v0-2-preview-rpc-based-io-modules-2d38) (*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`:
```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](https://github.com/akkoro/assemblylift-iomod-stdlib/suites/1348514110/artifacts/21839165), 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.
```rust
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:
```rust
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:

```toml
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`.
```rust
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.
```rust
.
.
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.
```rust
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`](https://dev.to/dotxlem/async-rust-but-less-intimidating-2c13). 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.
```rust
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. 
```rust
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!
```rust
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](https://github.com/akkoro/assemblylift-examples) 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!