#![deny(warnings)] use std::env; use warp::Filter; /// 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. /// - `PUT /todos/:id`: update a specific Todo. /// - `DELETE /todos/:id`: delete a specific Todo. #[tokio::main] async fn main() { if env::var_os("RUST_LOG").is_none() { // Set `RUST_LOG=todos=debug` to see debug logs, // this only shows access logs. env::set_var("RUST_LOG", "todos=info"); } pretty_env_logger::init(); let db = models::blank_db(); let api = filters::todos(db); // View access logs by setting `RUST_LOG=todos`. let routes = api.with(warp::log("todos")); // Start up the server... warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; } mod filters { use super::handlers; use super::models::{Db, ListOptions, Todo}; use warp::Filter; /// The 4 TODOs filters combined. pub fn todos( db: Db, ) -> impl Filter + Clone { todos_list(db.clone()) .or(todos_create(db.clone())) .or(todos_update(db.clone())) .or(todos_delete(db)) } /// GET /todos?offset=3&limit=5 pub fn todos_list( db: Db, ) -> impl Filter + Clone { warp::path!("todos") .and(warp::get()) .and(warp::query::()) .and(with_db(db)) .and_then(handlers::list_todos) } /// POST /todos with JSON body pub fn todos_create( db: Db, ) -> impl Filter + Clone { warp::path!("todos") .and(warp::post()) .and(json_body()) .and(with_db(db)) .and_then(handlers::create_todo) } /// PUT /todos/:id with JSON body pub fn todos_update( db: Db, ) -> impl Filter + Clone { warp::path!("todos" / u64) .and(warp::put()) .and(json_body()) .and(with_db(db)) .and_then(handlers::update_todo) } /// DELETE /todos/:id pub fn todos_delete( db: Db, ) -> impl Filter + Clone { // We'll make one of our endpoints admin-only to show how authentication filters are used let admin_only = warp::header::exact("authorization", "Bearer admin"); warp::path!("todos" / u64) // It is important to put the auth check _after_ the path filters. // If we put the auth check before, the request `PUT /todos/invalid-string` // would try this filter and reject because the authorization header doesn't match, // rather because the param is wrong for that other path. .and(admin_only) .and(warp::delete()) .and(with_db(db)) .and_then(handlers::delete_todo) } fn with_db(db: Db) -> impl Filter + Clone { warp::any().map(move || db.clone()) } fn json_body() -> impl Filter + Clone { // When accepting a body, we want a JSON body // (and to reject huge payloads)... warp::body::content_length_limit(1024 * 16).and(warp::body::json()) } } /// These are our API handlers, the ends of each filter chain. /// Notice how thanks to using `Filter::and`, we can define a function /// with the exact arguments we'd expect from each filter in the chain. /// No tuples are needed, it's auto flattened for the functions. mod handlers { use super::models::{Db, ListOptions, Todo}; use std::convert::Infallible; use warp::http::StatusCode; pub async fn list_todos(opts: ListOptions, db: Db) -> Result { // Just return a JSON array of todos, applying the limit and offset. let todos = db.lock().await; let todos: Vec = todos .clone() .into_iter() .skip(opts.offset.unwrap_or(0)) .take(opts.limit.unwrap_or(std::usize::MAX)) .collect(); Ok(warp::reply::json(&todos)) } pub async fn create_todo(create: Todo, db: Db) -> Result { log::debug!("create_todo: {:?}", create); let mut vec = db.lock().await; for todo in vec.iter() { if todo.id == create.id { log::debug!(" -> id already exists: {}", create.id); // Todo with id already exists, return `400 BadRequest`. return Ok(StatusCode::BAD_REQUEST); } } // No existing Todo with id, so insert and return `201 Created`. vec.push(create); Ok(StatusCode::CREATED) } pub async fn update_todo( id: u64, update: Todo, db: Db, ) -> Result { log::debug!("update_todo: id={}, todo={:?}", id, update); let mut vec = db.lock().await; // Look for the specified Todo... for todo in vec.iter_mut() { if todo.id == id { *todo = update; return Ok(StatusCode::OK); } } log::debug!(" -> todo id not found!"); // If the for loop didn't return OK, then the ID doesn't exist... Ok(StatusCode::NOT_FOUND) } pub async fn delete_todo(id: u64, db: Db) -> Result { log::debug!("delete_todo: id={}", id); let mut vec = db.lock().await; let len = vec.len(); vec.retain(|todo| { // Retain all Todos that aren't this id... // In other words, remove all that *are* this id... todo.id != id }); // If the vec is smaller, we found and deleted a Todo! let deleted = vec.len() != len; if deleted { // respond with a `204 No Content`, which means successful, // yet no body expected... Ok(StatusCode::NO_CONTENT) } else { log::debug!(" -> todo id not found!"); Ok(StatusCode::NOT_FOUND) } } } mod models { use serde_derive::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; /// So we don't have to tackle how different database work, we'll just use /// a simple in-memory DB, a vector synchronized by a mutex. pub type Db = Arc>>; pub fn blank_db() -> Db { Arc::new(Mutex::new(Vec::new())) } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Todo { pub id: u64, pub text: String, pub completed: bool, } // The query parameters for list_todos. #[derive(Debug, Deserialize)] pub struct ListOptions { pub offset: Option, pub limit: Option, } } #[cfg(test)] mod tests { use warp::http::StatusCode; use warp::test::request; use super::{ filters, models::{self, Todo}, }; #[tokio::test] async fn test_post() { let db = models::blank_db(); let api = filters::todos(db); let resp = request() .method("POST") .path("/todos") .json(&todo1()) .reply(&api) .await; assert_eq!(resp.status(), StatusCode::CREATED); } #[tokio::test] async fn test_post_conflict() { let db = models::blank_db(); db.lock().await.push(todo1()); let api = filters::todos(db); let resp = request() .method("POST") .path("/todos") .json(&todo1()) .reply(&api) .await; assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_put_unknown() { let _ = pretty_env_logger::try_init(); let db = models::blank_db(); let api = filters::todos(db); let resp = request() .method("PUT") .path("/todos/1") .header("authorization", "Bearer admin") .json(&todo1()) .reply(&api) .await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); } fn todo1() -> Todo { Todo { id: 1, text: "test 1".into(), completed: false, } } }