[package]
name = "example-todos"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
axum = { path = "../../axum" }
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
tower = { version = "0.5.2", features = ["util", "timeout"] }
tower-http = { version = "0.6.1", features = ["add-extension", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.0", features = ["serde", "v4"] }
[dev-dependencies]
http-body-util = "0.1.0"
mime = "0.3.17"
serde_json = "1.0.140"
//! Provides a RESTful web server managing some Todos.
//!
//! API will be:
//!
//! - `GET /todos`: return a JSON list of Todos.
//! - `POST /todos`: create a new Todo.
//! - `PATCH /todos/{id}`: update a specific Todo.
//! - `DELETE /todos/{id}`: delete a specific Todo.
//!
//! Run with
//!
//! ```not_rust
//! cargo run -p example-todos
//! ```
use axum::{
error_handling::HandleErrorLayer,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
sync::{Arc, RwLock},
time::Duration,
};
use tower::{BoxError, ServiceBuilder};
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!("{}=debug,tower_http=debug", env!("CARGO_CRATE_NAME")).into()
}),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Compose the routes
let app = app();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
tracing::debug!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
fn app() -> Router {
let db = Db::default();
Router::new()
.route("/todos", get(todos_index).post(todos_create))
.route("/todos/{id}", patch(todos_update).delete(todos_delete))
// Add middleware to all routes
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|error: BoxError| async move {
if error.is::<tower::timeout::error::Elapsed>() {
Ok(StatusCode::REQUEST_TIMEOUT)
} else {
Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unhandled internal error: {error}"),
))
}
}))
.timeout(Duration::from_secs(10))
.layer(TraceLayer::new_for_http())
.into_inner(),
)
.with_state(db)
}
// The query parameters for todos index
#[derive(Debug, Deserialize, Default)]
pub struct Pagination {
pub offset: Option<usize>,
pub limit: Option<usize>,
}
async fn todos_index(pagination: Query<Pagination>, State(db): State<Db>) -> impl IntoResponse {
let todos = db.read().unwrap();
let todos = todos
.values()
.skip(pagination.offset.unwrap_or(0))
.take(pagination.limit.unwrap_or(usize::MAX))
.cloned()
.collect::<Vec<_>>();
Json(todos)
}
#[derive(Debug, Deserialize)]
struct CreateTodo {
text: String,
}
async fn todos_create(State(db): State<Db>, Json(input): Json<CreateTodo>) -> impl IntoResponse {
let todo = Todo {
id: Uuid::new_v4(),
text: input.text,
completed: false,
};
db.write().unwrap().insert(todo.id, todo.clone());
(StatusCode::CREATED, Json(todo))
}
#[derive(Debug, Deserialize)]
struct UpdateTodo {
text: Option<String>,
completed: Option<bool>,
}
async fn todos_update(
Path(id): Path<Uuid>,
State(db): State<Db>,
Json(input): Json<UpdateTodo>,
) -> Result<impl IntoResponse, StatusCode> {
let mut todo = db
.read()
.unwrap()
.get(&id)
.cloned()
.ok_or(StatusCode::NOT_FOUND)?;
if let Some(text) = input.text {
todo.text = text;
}
if let Some(completed) = input.completed {
todo.completed = completed;
}
db.write().unwrap().insert(todo.id, todo.clone());
Ok(Json(todo))
}
async fn todos_delete(Path(id): Path<Uuid>, State(db): State<Db>) -> impl IntoResponse {
if db.write().unwrap().remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
type Db = Arc<RwLock<HashMap<Uuid, Todo>>>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
struct Todo {
id: Uuid,
text: String,
completed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{self, Request, StatusCode},
routing::RouterIntoService,
};
use http_body_util::BodyExt;
use serde_json::json;
use tower::{Service, ServiceExt};
#[tokio::test]
async fn test_empty_list_of_todos() {
let response = app()
.oneshot(
Request::builder()
.uri("/todos")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
let todos = serde_json::from_str::<Vec<Todo>>(&html).unwrap();
assert_eq!(todos, []);
}
#[tokio::test]
async fn test_add_todo() {
let mut app = app().into_service();
let request = Request::builder()
.method(http::Method::POST)
.uri("/todos")
.header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
.body(Body::from(json!({"text": "Write more tests!"}).to_string()))
.unwrap();
let response = ServiceExt::<Request<Body>>::ready(&mut app)
.await
.unwrap()
.call(request)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
let todo = serde_json::from_str::<Todo>(&html).unwrap();
assert_eq!(todo.text, "Write more tests!");
assert!(!todo.completed);
let todos = get_todos(&mut app).await;
assert_eq!(todos.len(), 1);
assert_eq!(todos[0], todo);
}
#[tokio::test]
async fn test_complex() {
let mut app = app().into_service();
let todos = get_todos(&mut app).await;
assert_eq!(todos.len(), 0);
let mut todo0 = add_todo(&mut app, "Add more tests!").await;
let todo1 = add_todo(&mut app, "Some other thing to do.").await;
let todo2 = add_todo(&mut app, "Write a book about axum.").await;
let mut todos = get_todos(&mut app).await;
assert_eq!(todos.len(), 3);
// Ensure the order is correct for the tests
todos.sort_by_key(|todo| todo.text.clone());
assert_eq!(todos[0], todo0);
assert_eq!(todos[1], todo1);
assert_eq!(todos[2], todo2);
let (status, res) = update_todo(&mut app, todo0.id, &todo0.text, true).await;
assert_eq!(status, StatusCode::OK);
todo0.completed = true;
assert_eq!(res, Some(todo0.clone()));
let status = delete_todo(&mut app, todo1.id).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let mut todos = get_todos(&mut app).await;
assert_eq!(todos.len(), 2);
// Ensure the order is correct for the tests
todos.sort_by_key(|todo| todo.text.clone());
assert_eq!(todos[0], todo0);
assert_eq!(todos[1], todo2);
let status = delete_todo(&mut app, todo1.id).await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, res) = update_todo(&mut app, todo1.id, "", true).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(res, None);
}
async fn get_todos(app: &mut RouterIntoService<Body>) -> Vec<Todo> {
let request = Request::builder()
.uri("/todos")
.body(Body::empty())
.unwrap();
let response = ServiceExt::<Request<Body>>::ready(app)
.await
.unwrap()
.call(request)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
serde_json::from_str::<Vec<Todo>>(&html).unwrap()
}
async fn add_todo(app: &mut RouterIntoService<Body>, text: &str) -> Todo {
let request = Request::builder()
.method(http::Method::POST)
.uri("/todos")
.header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
.body(Body::from(json!({"text": text}).to_string()))
.unwrap();
let response = ServiceExt::<Request<Body>>::ready(app)
.await
.unwrap()
.call(request)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
serde_json::from_str::<Todo>(&html).unwrap()
}
async fn update_todo(
app: &mut RouterIntoService<Body>,
id: Uuid,
text: &str,
completed: bool,
) -> (StatusCode, Option<Todo>) {
let request = Request::builder()
.method(http::Method::PATCH)
.uri(format!("/todos/{id}"))
.header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
.body(Body::from(
json!({"text": text, "completed": completed}).to_string(),
))
.unwrap();
let response = ServiceExt::<Request<Body>>::ready(app)
.await
.unwrap()
.call(request)
.await
.unwrap();
let status = response.status();
if status != StatusCode::OK {
return (status, None);
}
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
let html = String::from_utf8(bytes.to_vec()).unwrap();
(status, Some(serde_json::from_str::<Todo>(&html).unwrap()))
}
async fn delete_todo(app: &mut RouterIntoService<Body>, id: Uuid) -> StatusCode {
let request = Request::builder()
.method(http::Method::DELETE)
.uri(format!("/todos/{id}"))
.header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref())
.body(Body::empty())
.unwrap();
let response = ServiceExt::<Request<Body>>::ready(app)
.await
.unwrap()
.call(request)
.await
.unwrap();
response.status()
}
}
$ curl -X POST -H "Content-Type: application/json" -d '{"text": "Hello World!"}' http://localhost:3000/todos
{"id":"ccd0ebd7-f2b3-4395-bf4a-273f1d0c9851","text":"Hello World!","completed":false}
$ curl -X POST -H "Content-Type: application/json" -d '{"text": "Another item"}' http://localhost:3000/todos
{"id":"5903e415-e162-4767-9b57-bf6583e89c3f","text":"Another item","completed":false}