Cookbook / Getting started

A complete REST API

CRUD for one resource with shared state, validation, proper status codes and tests, in one file.

Everything from the earlier recipes in one place: a router mounted under /api, shared state, typed input, validation, Option for 404, and in-process tests. The store here is an in-memory map; swap it for a database (see axeon-orm) without changing the handlers' shape.

src/main.rs
use axeon::validate::{Validate, ValidationErrors};
use axeon::{Json, Router, Server, ServerError};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Mutex;

#[derive(Clone, Serialize)]
struct Todo {
    id: u64,
    title: String,
    done: bool,
}

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

impl Validate for NewTodo {
    fn validate(&self) -> Result<(), ValidationErrors> {
        let mut errors = ValidationErrors::new();
        errors.check(!self.title.trim().is_empty(), "title", "must not be empty");
        errors.check(self.title.len() <= 120, "title", "must be at most 120 characters");
        errors.into_result()
    }
}

#[derive(Deserialize)]
struct Patch {
    done: bool,
}

#[derive(Default)]
struct Store {
    next_id: u64,
    todos: BTreeMap<u64, Todo>,
}

type Shared = Mutex<Store>;

pub fn app() -> Server {
    let mut app = Server::new();
    app.state::<Shared>(Mutex::new(Store::default()));

    let mut api = Router::new();

    api.get("/todos", |req| async move {
        let store = req.state::<Shared>().unwrap().lock().unwrap();
        Json(store.todos.values().cloned().collect::<Vec<_>>())
    });

    api.post("/todos", |req| async move {
        let input: NewTodo = req.valid_json()?;
        let mut store = req.state::<Shared>().unwrap().lock().unwrap();
        store.next_id += 1;
        let todo = Todo { id: store.next_id, title: input.title, done: false };
        store.todos.insert(todo.id, todo.clone());
        Ok::<_, ServerError>((201, Json(todo)))
    });

    api.get("/todos/:id", |req| async move {
        let id: u64 = req.param("id")?;
        let store = req.state::<Shared>().unwrap().lock().unwrap();
        Ok::<_, ServerError>(store.todos.get(&id).cloned().map(Json)) // None is a 404
    });

    api.patch("/todos/:id", |req| async move {
        let id: u64 = req.param("id")?;
        let patch: Patch = req.json()?;
        let mut store = req.state::<Shared>().unwrap().lock().unwrap();
        Ok::<_, ServerError>(store.todos.get_mut(&id).map(|todo| {
            todo.done = patch.done;
            Json(todo.clone())
        }))
    });

    api.delete("/todos/:id", |req| async move {
        let id: u64 = req.param("id")?;
        let mut store = req.state::<Shared>().unwrap().lock().unwrap();
        Ok::<_, ServerError>(store.todos.remove(&id).map(|_| ())) // 204, or 404 if it was not there
    });

    app.mount("/api", api);
    app
}

fn main() {
    app().listen("127.0.0.1:3000").expect("server failed to start");
}

#[cfg(test)]
mod tests {
    use super::*;
    use axeon::{json, Method, Request};

    #[tokio::test]
    async fn create_read_update_delete() {
        let app = app().into_app().await.unwrap();

        let res = app
            .call(Request::new(Method::POST, "/api/todos").with_json(&json!({ "title": "write docs" })))
            .await;
        assert_eq!(res.status, 201);

        let res = app.call(Request::new(Method::GET, "/api/todos/1")).await;
        assert_eq!(res.status, 200);
        assert!(res.body_text().contains("write docs"));

        let res = app
            .call(Request::new(Method::PATCH, "/api/todos/1").with_json(&json!({ "done": true })))
            .await;
        assert!(res.body_text().contains("\"done\":true"));

        assert_eq!(app.call(Request::new(Method::DELETE, "/api/todos/1")).await.status, 204);
        assert_eq!(app.call(Request::new(Method::GET, "/api/todos/1")).await.status, 404);
    }

    #[tokio::test]
    async fn rejects_an_empty_title() {
        let app = app().into_app().await.unwrap();
        let req = Request::new(Method::POST, "/api/todos").with_json(&json!({ "title": "  " }));
        assert_eq!(app.call(req).await.status, 422);
    }

    #[tokio::test]
    async fn bad_ids_are_400() {
        let app = app().into_app().await.unwrap();
        assert_eq!(app.call(Request::new(Method::GET, "/api/todos/abc")).await.status, 400);
    }
}
Terminal
curl -X POST http://127.0.0.1:3000/api/todos \
  -H 'content-type: application/json' -d '{"title":"write docs"}'
curl http://127.0.0.1:3000/api/todos
curl -X PATCH http://127.0.0.1:3000/api/todos/1 \
  -H 'content-type: application/json' -d '{"done":true}'
curl -i -X DELETE http://127.0.0.1:3000/api/todos/1

Notes#

  • Handlers lock the store only for as long as they need it and never across an .await, which keeps std::sync::Mutex safe to use here.
  • Add JWT to the router, and hardening to the server, to take it to production.