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.
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);
}
}
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