-
Notifications
You must be signed in to change notification settings - Fork 221
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
327 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
[package] | ||
name = "todo-axum" | ||
description = "Simple axum todo example api with utoipa and Swagger UI" | ||
version = "0.1.0" | ||
edition = "2021" | ||
license = "MIT" | ||
authors = [ | ||
"Elli Example <example@example.com>" | ||
] | ||
|
||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||
|
||
[dependencies] | ||
axum = "0.5" | ||
hyper = { version = "0.14", features = ["full"] } | ||
tokio = { version = "1.17", features = ["full"] } | ||
tower = "0.4" | ||
utoipa = { path = "../.." } | ||
utoipa-swagger-ui = { path = "../../utoipa-swagger-ui" } | ||
serde = { version = "1.0", features = ["derive"] } | ||
serde_json = "1.0" | ||
env_logger = "0.9.0" | ||
log = "0.4" | ||
|
||
[workspace] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
# todo-axum ~ utoipa with utoipa-swagger-ui example | ||
|
||
This is demo `axum` 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 | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,288 @@ | ||
use std::{ | ||
net::{Ipv4Addr, SocketAddr}, | ||
sync::Arc, | ||
}; | ||
|
||
use axum::{extract::Path, response::IntoResponse, routing, Extension, Json, Router, Server}; | ||
use hyper::{Error, StatusCode}; | ||
use utoipa::{ | ||
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, | ||
Modify, OpenApi, | ||
}; | ||
use utoipa_swagger_ui::Config; | ||
|
||
use crate::todo::{Store, Todo, TodoError}; | ||
|
||
#[tokio::main] | ||
async fn main() -> Result<(), Error> { | ||
#[derive(OpenApi)] | ||
#[openapi( | ||
handlers( | ||
todo::list_todos, | ||
todo::create_todo, | ||
todo::mark_done, | ||
todo::delete_todo, | ||
), | ||
components(Todo, TodoError), | ||
modifiers(&SecurityAddon), | ||
tags( | ||
(name = "todo", description = "Todo items management API") | ||
) | ||
)] | ||
struct ApiDoc; | ||
|
||
struct SecurityAddon; | ||
|
||
impl Modify for SecurityAddon { | ||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { | ||
if let Some(components) = openapi.components.as_mut() { | ||
components.add_security_scheme( | ||
"api_key", | ||
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), | ||
) | ||
} | ||
} | ||
} | ||
|
||
let api_doc = ApiDoc::openapi(); | ||
let config = Arc::new(Config::from("/api-doc/openapi.json")); | ||
|
||
let store = Arc::new(Store::default()); | ||
let app = Router::new() | ||
.route( | ||
"/api-doc/openapi.json", | ||
routing::get({ | ||
let doc = api_doc.clone(); | ||
move || async { Json(doc) } | ||
}), | ||
) | ||
.route( | ||
"/swagger-ui/*tail", | ||
routing::get(serve_swagger_ui).layer(Extension(config)), | ||
) | ||
.route( | ||
"/todo", | ||
routing::get(todo::list_todos) | ||
.post(todo::create_todo) | ||
.put(todo::mark_done), | ||
) | ||
.route( | ||
"/todo/:id", | ||
routing::put(todo::mark_done).delete(todo::delete_todo), | ||
) | ||
.layer(Extension(store)); | ||
|
||
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 8080)); | ||
Server::bind(&address).serve(app.into_make_service()).await | ||
} | ||
|
||
async fn serve_swagger_ui( | ||
Path(tail): Path<String>, | ||
Extension(state): Extension<Arc<Config<'static>>>, | ||
) -> impl IntoResponse { | ||
match utoipa_swagger_ui::serve(&tail[1..], state) { | ||
Ok(file) => file | ||
.map(|file| { | ||
( | ||
StatusCode::OK, | ||
[("Content-Type", file.content_type)], | ||
file.bytes, | ||
) | ||
.into_response() | ||
}) | ||
.unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()), | ||
Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), | ||
} | ||
} | ||
|
||
mod todo { | ||
use std::sync::Arc; | ||
|
||
use axum::{extract::Path, response::IntoResponse, Extension, Json}; | ||
use hyper::{HeaderMap, StatusCode}; | ||
use serde::{Deserialize, Serialize}; | ||
use tokio::sync::Mutex; | ||
use utoipa::Component; | ||
|
||
/// In-memonry todo store | ||
pub(super) type Store = Mutex<Vec<Todo>>; | ||
|
||
/// Item to do. | ||
#[derive(Serialize, Deserialize, Component, Clone)] | ||
pub(super) struct Todo { | ||
id: i32, | ||
#[component(example = "Buy groceries")] | ||
value: String, | ||
done: bool, | ||
} | ||
|
||
/// Todo operation errors | ||
#[derive(Serialize, Deserialize, Component)] | ||
pub(super) enum TodoError { | ||
/// Todo already exists conflict. | ||
#[component(example = "Todo already exists")] | ||
Conflict(String), | ||
/// Todo not found by id. | ||
#[component(example = "id = 1")] | ||
NotFound(String), | ||
/// Todo operation unauthorized | ||
#[component(example = "missing api key")] | ||
Unauthorized(String), | ||
} | ||
|
||
/// List all Todo items | ||
/// | ||
/// List all Todo items from in-memory storage. | ||
#[utoipa::path( | ||
get, | ||
path = "/todo", | ||
responses( | ||
(status = 200, description = "List all todos successfully", body = [Todo]) | ||
) | ||
)] | ||
pub(super) async fn list_todos(Extension(store): Extension<Arc<Store>>) -> Json<Vec<Todo>> { | ||
let todos = store.lock().await.clone(); | ||
|
||
Json(todos) | ||
} | ||
|
||
/// Create new Todo | ||
/// | ||
/// Tries to create a new Todo item to in-memory storage or fails with 409 conflict if already exists. | ||
#[utoipa::path( | ||
post, | ||
path = "/todo", | ||
request_body = Todo, | ||
responses( | ||
(status = 201, description = "Todo item created successfully", body = Todo), | ||
(status = 409, description = "Todo already exists", body = TodoError) | ||
) | ||
)] | ||
pub(super) async fn create_todo( | ||
Json(todo): Json<Todo>, | ||
Extension(store): Extension<Arc<Store>>, | ||
) -> impl IntoResponse { | ||
let mut todos = store.lock().await; | ||
|
||
todos | ||
.iter_mut() | ||
.find(|existing_todo| existing_todo.id == todo.id) | ||
.map(|found| { | ||
( | ||
StatusCode::CONFLICT, | ||
Json(TodoError::Conflict(format!( | ||
"todo already exists: {}", | ||
found.id | ||
))), | ||
) | ||
.into_response() | ||
}) | ||
.unwrap_or_else(|| { | ||
todos.push(todo.clone()); | ||
|
||
(StatusCode::CREATED, Json(todo)).into_response() | ||
}) | ||
} | ||
|
||
/// Mark Todo item done by id | ||
/// | ||
/// Mark Todo item done by given id. Return only status 200 on success or 404 if Todo is not found. | ||
#[utoipa::path( | ||
put, | ||
path = "/todo/{id}", | ||
responses( | ||
(status = 200, description = "Todo marked done successfully"), | ||
(status = 404, description = "Todo not found") | ||
), | ||
params( | ||
("id" = i32, path, description = "Todo database id") | ||
), | ||
security( | ||
(), // <-- make optional authentication | ||
("api_key" = []) | ||
) | ||
)] | ||
pub(super) async fn mark_done( | ||
Path(id): Path<i32>, | ||
Extension(store): Extension<Arc<Store>>, | ||
headers: HeaderMap, | ||
) -> StatusCode { | ||
match check_api_key(false, headers) { | ||
Ok(_) => (), | ||
Err(_) => return StatusCode::UNAUTHORIZED, | ||
} | ||
|
||
let mut todos = store.lock().await; | ||
|
||
todos | ||
.iter_mut() | ||
.find(|todo| todo.id == id) | ||
.map(|todo| { | ||
todo.done = true; | ||
StatusCode::OK | ||
}) | ||
.unwrap_or(StatusCode::NOT_FOUND) | ||
} | ||
|
||
/// Delete Todo item by id | ||
/// | ||
/// Delete Todo item from in-memory storage by id. Returns either 200 success of 404 with TodoError if Todo is not found. | ||
#[utoipa::path( | ||
delete, | ||
path = "/todo/{id}", | ||
responses( | ||
(status = 200, description = "Todo marked done successfully"), | ||
(status = 401, description = "Unauthorized to delete Todo", body = TodoError, example = json!(TodoError::Unauthorized(String::from("missing api key")))), | ||
(status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) | ||
), | ||
params( | ||
("id" = i32, path, description = "Todo database id") | ||
), | ||
security( | ||
("api_key" = []) | ||
) | ||
)] | ||
pub(super) async fn delete_todo( | ||
Path(id): Path<i32>, | ||
Extension(store): Extension<Arc<Store>>, | ||
headers: HeaderMap, | ||
) -> impl IntoResponse { | ||
match check_api_key(true, headers) { | ||
Ok(_) => (), | ||
Err(error) => return error.into_response(), | ||
} | ||
|
||
let mut todos = store.lock().await; | ||
|
||
let len = todos.len(); | ||
|
||
todos.retain(|todo| todo.id != id); | ||
|
||
if todos.len() != len { | ||
StatusCode::OK.into_response() | ||
} else { | ||
( | ||
StatusCode::NOT_FOUND, | ||
Json(TodoError::NotFound(format!("id = {id}"))), | ||
) | ||
.into_response() | ||
} | ||
} | ||
|
||
// normally you should create a midleware for this but this is sufficient for sake of example. | ||
fn check_api_key(require_api_key: bool, headers: HeaderMap) -> Result<(), impl IntoResponse> { | ||
match headers.get("todo_apikey") { | ||
Some(header) if header != "utoipa-rocks" => Err(( | ||
StatusCode::UNAUTHORIZED, | ||
Json(TodoError::Unauthorized(String::from("incorrect api key"))), | ||
) | ||
.into_response()), | ||
None if require_api_key => Err(( | ||
StatusCode::UNAUTHORIZED, | ||
Json(TodoError::Unauthorized(String::from("missing api key"))), | ||
) | ||
.into_response()), | ||
_ => Ok(()), | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters