Back Original

Build a Simple Single-File Rust Web API

DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)

I've spent the last few months learning Rust as part of my 12 week programming retreat at Recurse Center. I came to Rust because it scored well in my missing programming language analysis and I heard rumors that it worked well as a high-level language.

Now a few months later, I can confidently say that Rust is a good high-level language if you stick to a few rules - see: High-Level Rust.

In this post we'll walk through a simple, single-file webapi written in Rust to help demonstrate some of these principles.

What We're Building

In this post we'll build a simple CRUD todo list API all in a single file:

The routes:

Tech stack:

Approach: High-Level Rust.

The result is code that reads like a high-level language (C#, Go) but gets most of the benefits of Rust - expressive types, fast native binary, excellent tooling, and growing community.

How it works

Data Models

With our type-first modeling approach, we should start with the types to describe our domain.

This is a simple todo list api so all it has are:

#[derive(Clone, Serialize, LightClone)]
struct Todo {
    id: Uuid,
    title: Arc<str>,
    completed: bool,
}

#[derive(Deserialize)]
struct CreateTodo {
    title: String,
}

Todo:

CreateTodo

Error Handling

Error handling is a bit overkill for a simple app like this but I like to build most apps with enumerated error types as it really helps as systems grow and systems tend to grow vs shrink. I'm a fan of using Result types throughout my systems so that each layer understands the expect success and failure cases which helps with composability and correctness.

But this is still a simple app so we only really have one error type - a NotFound error.

We then implement the IntoResponse trait on TodoError so that Axum knows how it should turn that into a status code.

enum TodoError {
    NotFound(Uuid),
}

impl IntoResponse for TodoError {
    fn into_response(self) -> axum::response::Response {
        match self {
            TodoError::NotFound(id) => (
                StatusCode::NOT_FOUND,
                Json(serde_json::json!({ "error": format!("Todo {id} not found") })),
            )
                .into_response(),
        }
    }
}

Service Trait

In order to power our todo list app, we need a service that will actually do the CRUD actions. We're using a High-Level Rust approach so we're going to be using a trait to provide a type-first definition of the service before implementing it.

Our trait looks like this:

trait TodoService: Send + Sync {
    fn list(&self) -> Vec<Todo>;
    fn get(&self, id: Uuid) -> Result<Todo, TodoError>;
    fn create(&self, input: CreateTodo) -> Todo;
    fn complete(&self, id: Uuid) -> Result<Todo, TodoError>;
    fn delete(&self, id: Uuid) -> Result<Todo, TodoError>;
}

Some notes:

Having a trait like this is overkill for a single-file example but similar to errors I find that building apps like this from the start makes them much easier to scale when you do eventually need them.

Service Implementation

Now onto the implementation of our Service Trait with the InMemoryTodoService. We're doing in memory data store because it's simple and works for our usecase. For a real app you'll probably use a DB for storing data.

First we define the data InMemoryTodoService needs:

struct InMemoryTodoService {
    store: Mutex<Vec<Todo>>,
}

impl InMemoryTodoService {
    fn new() -> Self {
        Self {
            store: Mutex::new(Vec::new()),
        }
    }
}

This basically says we have a store which contains a list of Todos with vec<Todo>. We could use a Hashmap as well but doesn't matter for our example.

What is worth diving into is the usage of the Mutex. Vec<Todo> is not Sync by itself as it's not safe to share that data across threads - if multiple are mutating them at the same time, you get data races. Mutex<Vec<Todo>> IS Sync as it is safe to reference from multiple threads.

So here the compiler is forcing us to handle a possible data race condition. So it is noisier than code you may see in other languages but it does solve a very real potential bug.

impl TodoService for InMemoryTodoService {
    fn list(&self) -> Vec<Todo> {
        let store = self.store.lock();
        store.clone()
    }

    fn get(&self, id: Uuid) -> Result<Todo, TodoError> {
        let store = self.store.lock();
        store
            .iter()
            .find(|t| t.id == id)
            .cloned()
            .ok_or(TodoError::NotFound(id))
    }

    fn create(&self, input: CreateTodo) -> Todo {
        let mut store = self.store.lock();
        let todo = Todo {
            id: Uuid::now_v7(),
            title: input.title.into(),
            completed: false,
        };
        store.push(todo.lc());
        todo
    }

    fn complete(&self, id: Uuid) -> Result<Todo, TodoError> {
        let mut store = self.store.lock();
        let pos = store
            .iter()
            .position(|t| t.id == id)
            .ok_or(TodoError::NotFound(id))?;
        let completed = Todo {
            completed: true,
            ..store[pos].lc()
        };
        store[pos] = completed.lc();
        Ok(completed)
    }

    fn delete(&self, id: Uuid) -> Result<Todo, TodoError> {
        let mut store = self.store.lock();
        let pos = store
            .iter()
            .position(|t| t.id == id)
            .ok_or(TodoError::NotFound(id))?;
        Ok(store.remove(pos))
    }
}

The rest of this code is just implementation of our CRUD methods:

Some of this looks a tad complicated but if you look at the chained functions similar to pipes in F#, LINQ in C#, or map / filter in JS / TS then it should look familiar.

Routing + API Handlers

Now that we have the core service implementation built, we need a way to route incoming http requests to the appropriate service implementation. For that, we define handlers which basically map incoming http requests to the service implementations and then spin up a router which maps routes to the appropriate handlers.

We spin up our InMemoryTodoService and pass it into our router as state which it will pass to each handler and it to each service call - effectively doing app composition at the root of our app.

We then spin up a listener on port 3000 and tell axum to serve the app.

async fn hello() -> &'static str {
    "Hello, World!"
}

async fn list_todos(State(service): State<Arc<dyn TodoService>>) -> Json<Vec<Todo>> {
    Json(service.list())
}

async fn get_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, TodoError> {
    service.get(id).map(Json)
}

async fn create_todo(
    State(service): State<Arc<dyn TodoService>>,
    Json(input): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
    let todo = service.create(input);
    (StatusCode::CREATED, Json(todo))
}

async fn complete_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, TodoError> {
    service.complete(id).map(Json)
}

async fn delete_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Json<Todo>, TodoError> {
    service.delete(id).map(Json)
}

// Main

#[tokio::main]
async fn main() {
    let service: Arc<dyn TodoService> = Arc::new(InMemoryTodoService::new());

    let app = Router::new()
        .route("/", get(hello))
        .route("/todos", get(list_todos).post(create_todo))
        .route("/todos/{id}", get(get_todo).delete(delete_todo))
        .route("/todos/{id}/complete", post(complete_todo))
        .with_state(service);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Next

So that's a simple single-file web API with Rust and Axum coming in at around 175 lines.

I'll admit that it's definitely more verbose in some areas than a similar app in e.g. C#, F#, or TypeScript and it requires you to think more about memory and threading by default. But I'd also say it forces you to be a bit more robust as well - thinking about threading and data races and how you want your memory laid out at the outset.

This has short term overhead in implementation but long term can catch many types of bugs that might go by implicitly otherwise. Plus with agentic engineering, you largely don't have to remember all the exact syntax and deal with the verbosity, you can just set the patterns you want to see and have those implemented everywhere.

If you want to get access to the full example project source code so you can clone it and run it yourself, check it out in the HAMY LABS Code Example repo which is available to HAMINIONs Members. HAMINIONs Members get access to the full source code from this guide as well as dozens of others and early access to content and discounts on projects - plus you support me in continuing these experiments and sharing my learnings.

If you want to get started building your own web apps in Rust, checkout CloudSeed Rust which helps you ship production-ready webapps in minutes. This is what I use to start all my Rust web projects and implements all the best practices I've found for building with High-Level Rust.

If you liked this post you might also like: