diff --git a/examples/rocket-todo/Cargo.toml b/examples/rocket-todo/Cargo.toml new file mode 100644 index 00000000..202babb8 --- /dev/null +++ b/examples/rocket-todo/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rocket-todo" +description = "Simple rocket todo example api with utoipa and Swagger UI" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Elli Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rocket = { version = "0.5.0-rc.1", features = ["json"] } +utoipa = { path = "../..", features = ["rocket_extras"] } +utoipa-swagger-ui = { path = "../../utoipa-swagger-ui", features = ["rocket"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.9.0" +log = "0.4" + +[workspace] \ No newline at end of file diff --git a/examples/rocket-todo/REAMDE.md b/examples/rocket-todo/REAMDE.md new file mode 100644 index 00000000..89f81644 --- /dev/null +++ b/examples/rocket-todo/REAMDE.md @@ -0,0 +1,16 @@ +# todo-rocket ~ utoipa with utoipa-swagger-ui example + +This is demo `rocket` application with in-memory storage to manage Todo items. The API +demostrates `utoipa` with `utoipa-swagger-ui` functionalities. + +For security restricted endpoints the super secret api key is: `utoipa-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. +```bash +cargo run +``` + +If you want to see some logging you may prepend the command with `RUST_LOG=debug` as shown below. +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/rocket-todo/src/main.rs b/examples/rocket-todo/src/main.rs new file mode 100644 index 00000000..ae335bca --- /dev/null +++ b/examples/rocket-todo/src/main.rs @@ -0,0 +1,298 @@ +use rocket::{catch, catchers, routes, Build, Request, Rocket}; +use serde_json::json; +use todo::RequireApiKey; +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_swagger_ui::SwaggerUi; + +use crate::todo::{Todo, TodoError, TodoStore}; + +#[rocket::launch] +fn rocket() -> Rocket { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + handlers( + todo::get_tasks, + todo::create_todo, + todo::mark_done, + todo::delete_todo, + todo::search_todos + ), + components(Todo, TodoError), + tags( + (name = "todo", description = "Todo management endpoints.") + ), + modifiers(&SecurityAddon) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + rocket::build() + .manage(TodoStore::default()) + .register("/todo", catchers![unauthorized]) + .mount( + "/", + SwaggerUi::new("/swagger-ui/<_..>").url("/api-doc/openapi.json", ApiDoc::openapi()), + ) + .mount( + "/todo", + routes![ + todo::get_tasks, + todo::create_todo, + todo::mark_done, + todo::delete_todo, + todo::search_todos + ], + ) +} + +#[catch(401)] +async fn unauthorized(req: &Request<'_>) -> serde_json::Value { + let (_, todo_error) = req.guard::().await.failed().unwrap(); + + json!(todo_error) +} + +mod todo { + use std::sync::{Arc, Mutex}; + + use rocket::{ + delete, get, + http::Status, + outcome::Outcome, + post, put, + request::{self, FromRequest}, + response::{status::Custom, Responder}, + serde::json::Json, + Request, State, + }; + use serde::{Deserialize, Serialize}; + use utoipa::Component; + + pub(super) type TodoStore = Arc>>; + + /// Todo operation error. + #[derive(Serialize, Component, Responder, Debug)] + pub(super) enum TodoError { + /// When there is conflict creating a new todo. + #[response(status = 409)] + Conflict(String), + + /// When todo item is not found from storage. + #[response(status = 404)] + NotFound(String), + + /// When unauthorized to complete operation + #[response(status = 401)] + Unauthorized(String), + } + + pub(super) struct RequireApiKey; + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for RequireApiKey { + type Error = TodoError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + match request.headers().get("todo_apikey").next() { + Some(key) if key == "utoipa-rocks" => Outcome::Success(RequireApiKey), + None => Outcome::Failure(( + Status::Unauthorized, + TodoError::Unauthorized(String::from("missing api key")), + )), + _ => Outcome::Failure(( + Status::Unauthorized, + TodoError::Unauthorized(String::from("invalid api key")), + )), + } + } + } + + pub(super) struct LogApiKey; + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for LogApiKey { + type Error = TodoError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + match request.headers().get("todo_apikey").next() { + Some(key) if key == "utoipa-rocks" => { + log::info!("authenticated"); + Outcome::Success(LogApiKey) + } + _ => { + log::info!("no api key"); + Outcome::Forward(()) + } + } + } + } + + /// Task to do. + #[derive(Serialize, Deserialize, Component, Clone)] + pub(super) struct Todo { + /// Unique todo id. + #[component(example = 1)] + id: i32, + /// Description of a taks. + #[component(example = "Buy groceries")] + value: String, + /// Indicatation whether task is done or not. + done: bool, + } + + /// List all available todo items. + #[utoipa::path( + context_path = "/todo", + responses( + (status = 200, description = "Get all todos", body = [Todo]) + ) + )] + #[get("/")] + pub(super) async fn get_tasks(store: &State) -> Json> { + Json(store.lock().unwrap().clone()) + } + + /// Create new todo item. + /// + /// Create new todo item and add it to the storage. + #[utoipa::path( + context_path = "/todo", + request_body = Todo, + responses( + (status = 201, description = "Todo item created successfully", body = Todo), + (status = 409, description = "Todo already exists", body = TodoError, example = json!(TodoError::Conflict(String::from("id = 1")))) + ) + )] + #[post("/", data = "")] + pub(super) async fn create_todo( + todo: Json, + store: &State, + ) -> Result>, TodoError> { + let mut todos = store.lock().unwrap(); + todos + .iter() + .find(|existing| existing.id == todo.id) + .map(|todo| Err(TodoError::Conflict(format!("id = {}", todo.id)))) + .unwrap_or_else(|| { + todos.push(todo.0.clone()); + + Ok(Custom(Status::Created, Json(todo.0))) + }) + } + + /// Mark Todo item done by given id + /// + /// Tries to find todo item by given id and mark it done if found. Will return not found in case todo + /// item does not exists. + #[utoipa::path( + context_path = "/todo", + responses( + (status = 200, description = "Todo item marked done successfully"), + (status = 404, description = "Todo item not found from storage", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Todo item unique id") + ), + security( + (), + ("api_key" = []) + ) + )] + #[put("/")] + pub(super) async fn mark_done( + id: i32, + _api_key: LogApiKey, + store: &State, + ) -> Result { + store + .lock() + .unwrap() + .iter_mut() + .find(|todo| todo.id == id) + .map(|todo| { + todo.done = true; + + Ok(Status::Ok) + }) + .unwrap_or_else(|| Err(TodoError::NotFound(format!("id = {id}")))) + } + + /// Delete Todo by given id. + /// + /// Delete Todo from storage by Todo id if found. + #[utoipa::path( + context_path = "/todo", + responses( + (status = 200, description = "Todo deleted successfully"), + (status = 401, description = "Unauthorized to delete Todos", body = TodoError, example = json!(TodoError::Unauthorized(String::from("id = 1")))), + (status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Todo item id") + ), + security( + ("api_key" = []) + ) + )] + #[delete("/")] + pub(super) async fn delete_todo( + id: i32, + _api_key: RequireApiKey, + store: &State, + ) -> Result { + let mut todos = store.lock().unwrap(); + let len = todos.len(); + todos.retain(|todo| todo.id != id); + + if len == todos.len() { + Err(TodoError::NotFound(format!("id = {id}"))) + } else { + Ok(Status::Ok) + } + } + + /// Search Todo items by their value. + /// + /// Search is performed in case sensitive manner from value of Todo. + #[utoipa::path( + context_path = "/todo", + responses( + (status = 200, description = "Found Todo items", body = [Todo]) + ) + )] + #[get("/search?")] + pub(super) async fn search_todos( + value: Option<&str>, + store: &State, + ) -> Json> { + Json( + store + .lock() + .unwrap() + .iter() + .filter(|todo| { + value + .map(|value| todo.value.to_lowercase().contains(&value.to_lowercase())) + .unwrap_or(true) + }) + .cloned() + .collect(), + ) + } +}