Although we can build more complex HTTP applications in Fermyon Spin by composing multiple components, having a single component is easier, reduces development time, and is more flexible in the first place. In this article, I will explain how to implement simple routing capabilities using enumerations in Rust.

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

A router in HTTP applications serves as a mechanism 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

Let’s create Spin HTTP application for demonstration purposes

We need a Spin application for demonstration purposes. We’ll create a simple app for managing tasks here. No worries, we won’t entirely implement yet another Todo-API. Instead of implementing all the necessary handlers, we’ll provide simple stubs to ensure our application compiles, and we can test the routing capabilities.

To follow the commands and samples shown here, ensure you have installed Rust on your machine along with the wasm32-wasi target. Additionally, you need Spin CLI (spin) on your local machine. If you still need to install spin, consult the Spin documentation and find the detailed installation instructions.

# move to your source folder
cd ~/projects

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

# move into the todo-app
cd todo-app

# Add necessary dependencies (serde & serde_json)
cargo add serde -F derive
cargo add serde_json

# fire up your editor of choice (helix here)
hx .

We want our app to respond to the following HTTP requests individually:

  • GET /: Return all tasks
  • POST /: Create a new task. The new task is specified using a JSON payload
  • GET /:id: Find and return a task using its identifier (i32)
  • PUT /:id: Update a task using its identifier (i32). The updated task is specified using a JSON payload
  • DELETE /:id: Delete a task using its identifier (i32)

Layout your API models

First, let’s create a new Rust file called models.rs in the src folder and specify our API models.

// src/models.rs

use anyhow::{bail, Result};
use bytes::Bytes;
use serde::{Deserialize, Serialize};

// Model representing payload of a POST / request

#[derive(Deserialize, Debug, PartialEq)]
pub struct CreateTaskModel {
    pub title: String,
}

impl TryFrom<Option<Bytes>> for CreateTaskModel {
    fn try_from(opt: Option<Bytes>) -> Result<Self, Self::Error> {
        match opt {
            Some(b) => serde_json::from_slice::<Self>(&b).map_err(anyhow::Error::from),
            None => bail!("No body")
        }
    }
    type Error = anyhow::Error;
}

// Model representing payload of a PUT /:id request
#[derive(Deserialize, Debug, PartialEq)]
pub struct UpdateTaskModel {
    pub title: String,
    #[serde(rename = "isCompleted")]
    pub is_completed: bool,
}

impl TryFrom<Option<Bytes>> for UpdateTaskModel {
    fn try_from(opt: Option<Bytes>) -> Result<Self, Self::Error> {
        match opt {
            Some(b) => serde_json::from_slice::<Self>(&b).map_err(anyhow::Error::from),
            None => bail!("No body"),
        }
    }
    type Error = anyhow::Error;
}

// Model representing the result of GET /:id & GET / requests
#[derive(Serialize, Debug)]
pub struct TaskModel {
    pub id: i32,
    pub title: String,
    #[serde(rename = "isCompleted")]
    pub is_completed: bool,
}

Provide handler stubs

As mentioned earlier, we’ll just implement some simple stubs for the handlers we need. Add the handlers.rs file in the src folder and add the following code:

// handlers.rs

use anyhow::Result;
use spin_sdk::http::Response;

use crate::models::{TaskModel, UpdateTaskModel, CreateTaskModel};

pub(crate) fn handle_create(model: CreateTaskModel) -> Result<Response>
{
    let location = format!("/{}", 1);
    println!("Created task: {}", model.title);
    Ok(http::Response::builder()
        .status(http::StatusCode::CREATED)
        .header(http::header::LOCATION, location)
        .body(None)?)
}

pub(crate) fn handle_read_all() -> Result<Response>
{
    let tasks: Vec<TaskModel> = vec![];
    let body = serde_json::to_string(&tasks)?;

    Ok(http::Response::builder()
        .status(http::StatusCode::OK)
        .body(Some(body.into()))?)
}

pub(crate) fn handle_read_by_id(id: i32) -> Result<Response>
{
    let task = TaskModel {
        id,
        title: "Fake Task".to_string(),
        is_completed: false,
    };
    let body = serde_json::to_string(&task)?;
    Ok(http::Response::builder()
        .status(http::StatusCode::OK)
        .body(Some(body.into()))?)
}

pub(crate) fn handle_update_by_id(id: i32, model: UpdateTaskModel) -> Result<Response>
{
    let task = TaskModel {
        id,
        title: model.title,
        is_completed: model.is_completed,
    };
    let body = serde_json::to_string(&task)?;
    Ok(http::Response::builder()
        .status(http::StatusCode::OK)
        .body(Some(body.into()))?)
}

pub(crate) fn handle_delete_by_id(id: i32) -> Result<Response>
{
    println!("Deleted task: {}", id);
    Ok(http::Response::builder()
        .status(http::StatusCode::NO_CONTENT)
        .body(None)?)
}

pub(crate) fn handle_status_code(code: http::StatusCode) -> Result<Response>
{
    Ok(http::Response::builder()
        .status(code)
        .body(None)?)
}

Now that we’ve our API models and the handler-stubs in place, we can finally take care about the router.

Layout the API as Rust enum

We’ve already talked about the most obvious APIs, however from a response/request perspective, we want our router also to identify mal-formatted, and invalid requests to generate appropriate responses. Add api.rs in the src folder and layout the API enum as shown here:

// api.rs
use crate::models::{CreateTaskModel, UpdateTaskModel};

pub(crate) enum Api {
    Create(CreateTaskModel),
    ReadAll,
    ReadById(i32),
    UpdateById(i32, UpdateTaskModel),
    DeleteById(i32),
    InternalServerError,
    BadRequest,
    MethodNotAllowed,
}

In real-world scenarios you may have even more variants (just think about Cross-Origin Resource Sharing CORS). Because we want to extract the identifier of a particular todo-item in some cases, we will add a small helper method to api.rs for parsing the route segments:

// api.rs

pub enum ParseRouteResult {
    Id(i32),
    Invalid,
    None,
    Error,
}

pub fn parse_route(req: &Request) -> ParseRouteResult {
    let Some(value) = req.headers().get("spin-path-info") else {
        return ParseRouteResult::Error;
    };
    let Ok(route) = value.to_str() else {
        return ParseRouteResult::Error;
    };
    let segments = route.split('/').collect::<Vec<&str>>();
    let slices = segments.as_slice();
    match slices {
        ["", ""] => ParseRouteResult::None,
        ["", id] => {
            let Ok(id) = id.parse::<i32>() else {
                return ParseRouteResult::Invalid;
            };
            ParseRouteResult::Id(id)
        }
        _ => {
            println!("Route not found for: {:?}", slices);
            ParseRouteResult::Error
        }
    }
}

At this point you may wonder how to find the correct variant of Api at runtime. Luckily, Rust standard library comes with the From<T> trait which we will use now to find the correct variant for an incoming Request in our Spin application:

// api.rs

use spin_sdk::http::Request;

impl From<&Request> for Api {
    fn from(req: &Request) -> Self {
        match *req.method() {
            http::Method::POST => {
                let Ok(model) = CreateTaskModel::try_from(req.body().clone()) else {
                    return Api::BadRequest;
                };
                Api::Create(model)
            }
            http::Method::GET => match parse_route(&req) {
                ParseRouteResult::Error => Api::InternalServerError,
                ParseRouteResult::Id(id) => Api::ReadById(id),
                ParseRouteResult::Invalid => Api::BadRequest,
                ParseRouteResult::None => Api::ReadAll,
            },
            http::Method::PUT => match parse_route(&req) {
                ParseRouteResult::Error => Api::InternalServerError,
                ParseRouteResult::Id(id) => {
                    let Ok(model) = UpdateTaskModel::try_from(req.body().clone()) else {
                            return Api::BadRequest;
                        };
                    Api::UpdateById(id, model)
                }
                ParseRouteResult::Invalid => Api::BadRequest,
                ParseRouteResult::None => Api::MethodNotAllowed,
            },
            http::Method::DELETE => match parse_route(&req) {
                ParseRouteResult::Error => Api::InternalServerError,
                ParseRouteResult::Id(id) => Api::DeleteById(id),
                ParseRouteResult::Invalid => Api::BadRequest,
                ParseRouteResult::None => Api::MethodNotAllowed,
            },
            _ => Api::MethodNotAllowed,
        }
    }
}

Implement the Spin HTTP component

Having the From<T> trait implemented, we can update our actual component implementation (see src/lib.rs) and use pattern matching to call the corresponding handler:

// lib.rs

mod api;
mod handlers;
mod models;

use anyhow::Result;
use spin_sdk::{
    http::{Request, Response},
    http_component,
};

use crate::api::Api;

/// A simple Spin HTTP component.
#[http_component]
fn handle_todo_app(req: Request) -> Result<Response> {
    let api = Api::from(&req);
    match api {
        Api::Create(model) => handlers::handle_create(model),
        Api::ReadAll => handlers::handle_read_all(),
        Api::ReadById(id) => handlers::handle_read_by_id(id),
        Api::UpdateById(id, model) => handlers::handle_update_by_id(id, model),
        Api::DeleteById(id) => handlers::handle_delete_by_id(id),
        Api::InternalServerError => {
            handlers::handle_status_code(http::StatusCode::INTERNAL_SERVER_ERROR)
        }
        Api::BadRequest => handlers::handle_status_code(http::StatusCode::BAD_REQUEST),
        Api::MethodNotAllowed => handlers::handle_status_code(http::StatusCode::METHOD_NOT_ALLOWED),
    }
}

Testing the HTTP Router with cURL

You can simply test your router by starting the Spin application with spin build --up and sending proper requests to the endpoint (typically exposed at http://localhost:3000) using curl.

Conclusion

Although we can compose complex Spin applications by adding multiple Spin components to a single Spin application, it’s sometimes convenient and more efficient to stick with a single WebAssembly component and implement a HTTP router.

The combination of Rust enums and pattern matching makes implementing such a router super simple and fun.