With the release of Fermyon Spin 1.1 (April 15th, 2023), the Spin SDKs for Rust and Go now provide a built-in router for HTTP applications. In this post, we will take a quick look at the built-in router supplied by the Spin SDK for Rust and build a small sample application to illustrate how one could solve day-to-day requirements when building serverless applications with WebAssembly (Wasm).



What’s the purpose of a router in HTTP apps?

In HTTP applications, a router serves as a central component to direct incoming HTTP requests to the appropriate handler functions that can process and respond to those requests. The router does so by looking at different parts of an incoming HTTP request, such as:

  • The HTTP method
  • The request URL and its components
  • HTTP headers
  • The request body

Our demo application

For demonstration purposes, we will implement a simple HTTP API that allows us to keep track of books. API users can perform all CRUD (Create, Read, Update, Delete) operations. The new built-in router allows us to build the entire API using a single Spin component.

We will use the built-in key-value storage (SQLite) for persistence. If you haven’t yet used the key-value store provided by Spin, consider reading my detailed guide on how to use built-in key-value store first.

How requests flow through a Spin app

From a conceptual point of view, user requests are forwarded from the Spin runtime to a shiny, new instance of our WebAssembly component (assuming the request matches the base and path specified in Spin.toml when creating the Spin application).

The underlying runtime initializes everything and calls the function decorated with #[http_component]. That’s precisely where the new built-in router comes into play and delegates requests to handlers we can register according to our needs. Finally, our handlers construct the desired HTTP responses.

Built-in HTTP router in Fermyon Spin 1.1

Built-in HTTP router in Fermyon Spin 1.1

Let’s implement the book API sample

Prerequirements

To follow the commands and samples shown here, ensure you have installed Rust and the wasm32-wasi target on your machine. Additionally, you must have the Spin CLI (spin) on your local device. You can find detailed installation instructions for the spin CLI in the Spin documentation.

Additionally, please verify that you’re using at least version 1.1.0 of the spin CLI. You can invoke spin --version to check the Spin version. Also, remember to install the most recent templates. Previous template installations can be upgraded easily using the spin templates upgrade command.

Find the sample repository on GitHub

This post won’t contain all the source code I’ve written, instead checkout the sample repository on GitHub at https://github.com/ThorstenHans/spin_book_api.

Create the Spin application

First, create a new Spin application and open it in your editor of choice:

# move to your source folder
cd ~/projects

# create a new Spin application using the http-rust template
spin new --accept-defaults http-rust book_api

# move into the book-api
cd book-api

# Add necessary dependencies to serialize and deserialize (serde & serde_json)
cargo add serde -F derive
cargo add serde_json

# Add uuid (feature v4) as a dependency to generate some guids
cargo add uuid -F v4

# fire up your editor of choice (VSCode here)
code .

key-value store configuration

Having our Spin app created and all dependencies installed, we must ensure that our Spin component is able to use the fully managed key-value store (sqlite). To do so, update the Spin.toml file and add the following line to the [[component]] configuration:

key_value_stores = ["default"]

If you want to learn more about the fully managed key-value store provided by Spin, consult my corresponding article: “Let Spin lift your key-value store”.

API models, book entity, persistence & handlers

Because we focus on the HTTP router in this article, we’ll quickly skim over a representative API model, the book entity, and the handlers responsible for composing HTTP responses from incoming HTTP requests. The other models and handlers are found in the repository.

Our API will return only fundamental insights about a book when all book lists are requested. That said, the API model for the list of books is relatively small:

// models.rs
use serde::{Deserialize, Serialize};
use crate::entities::Book;

#[derive(Debug, Serialize)]
pub(crate) struct BookListModel {
    pub id: String,
    pub title: String,
    pub author: String,
}

impl From<Book> for BookListModel {
    fn from(value: Book) -> Self {
        Self {
            id: value.id,
            title: value.title,
            author: value.author,
        }
    }
}

The BookListModel contains essential metadata of a particular book. Most important is the id, which users can use to ask the API for further details of a specific book, update it, or delete it.

Decoupling outer-facing API models from data structures used internally (e.g., for persistence) is common when building APIs. I’ve also applied that technique to the bock_api sample and came up with the following Book entity:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, PartialEq, Default)]
pub(crate) struct Book {
    pub(crate) id: String,
    pub(crate) title: String,
    pub(crate) author: String,
    pub(crate) category: String,
    pub(crate) year: i32,
}

Finally, let’s take a quick look at the handler responsible for retrieving the list of all books. First, it pulls all keys from the built-in key-value store provided by Spin. Using those keys, it does the following actions in a controlled sequence:

  • Load the byte representation of every book from the store
  • Deserialize every byte representation into an instance of the Book entity
  • Convert every instance of Book into BookListModel
  • Push the BookListModel into a Vec<BookListModel>
  • Serialize the entire list to JSON and
  • Construct the HTTP response
use anyhow::Result;
use spin_sdk::{
    http::{Params, Request, Response},
    key_value,
};

use crate::{entities::Book, models::BookListModel};

pub(crate) fn handle_get_all(_req: Request, _params: Params) -> Result<Response> {
    let store = key_value::Store::open_default()?;
    let Ok(keys) = store.get_keys() else {
        return Ok(http::Response::builder()
            .status(http::StatusCode::INTERNAL_SERVER_ERROR)
            .body(None)?);
    };
    let mut books = Vec::<BookListModel>::default();
    keys.iter()
        .flat_map(|id| store.get(id))
        .flat_map(|json| serde_json::from_slice::<Book>(&json))
        .map(BookListModel::from)
        .for_each(|book| books.push(book));

    let body = serde_json::to_string(&books)?;
    Ok(http::Response::builder()
        .status(http::StatusCode::OK)
        .body(Some(body.into()))?)
}

Configure the built-in Router of Spin

Finally, we can use the new HTTP router to lay out our API. Find the component stub in lib.rs, created by spin new at the beginning of this article. It should look like this:

#[http_component]
fn handle_book_api(req: Request) -> Result<Response> {
    println!("{:?}", req.headers());
    Ok(http::Response::builder()
        .status(200)
        .header("foo", "bar")
        .body(Some("Hello, Fermyon".into()))?)
}

Let’s bring in the Spin HTTP router and replace the stub implementation:

use crate::handlers::{
    handle_create, handle_delete_by_id, handle_get_all, handle_get_by_id, handle_update_by_id,
};
use spin_sdk::{
    http_component,
    http::{Request, Response, Router},
};

#[http_component]
fn handle_book_api(req: Request) -> Result<Response> {
    let mut router = Router::default();

    router.get("/", handle_get_all);
    
    // check the GitHub Repository for all handlers shown here...
    router.get("/:id", handle_get_by_id);
    router.post("/", handle_create);
    router.put("/:id", handle_update_by_id);
    router.delete("/:id", handle_delete_by_id);

    // Demonstration purposes!
    // Obviously, this could be achieved using router.get... 
    router.add("/healthz/readiness", http::Method::GET, handle_healthz);

    router.handle(req)
}

The new router provides functions matching commonly used HTTP methods (get, post, put, delete, …) that we use to lay out our API. On top of that, the router provides three special functions that I want to highlight explicitly:

  1. The add(path, method, handler) function and register handlers matching requests sent using rarely used HTTP methods
  2. The any(path, handler) function is used to invoke a specific handler at a particular path, no matter which HTTP method was specified when issuing the HTTP request.
  3. The handle(req) -> Result<Response> function will dispatch the incoming request to the appropriate handler along with the URI parameters

To test the routing capabilities, we can run the Spin app spin build --up, and hammer the API with curl:

# create a new book
id=$(curl -sXPOST \
  --json '{"title": "The art of Wasm", "author": "John Doe", "category":"Programming", "year": 2023 }' \
  http://localhost:3000 | jq -r ".id")

# get all books
curl http://localhost:3000 | jq

# update the book we've created previously
curl -XPUT \
  --json '{"title": "The art of WebAssembly", "author": "John Doe", "category":"Programming", "year": 2024 }' \
  http://localhost:3000/$id

# get the book using its identifier
curl http://localhost:3000/$id | jq

# delete the book
curl -XDELETE http://localhost:3000/$id

Conclusion

Robust routing capabilities are a must for every real-world HTTP workload. That’s exactly why I came up with my routing approach leveraging enums and pattern matching as described in my article “Let’s build an HTTP router for Spin with Rust”. Now that Spin SDKs for Rust and GoLang provide a built-in HTTP router, we - as developers - can rely on the SDK to build full-fledged HTTP APIs with Spin and WebAssembly.

Although the current implementation looks quite simple, it’s capable enough to assist you in building complex HTTP APIs following the single-component approach in Spin.