diff --git a/blueprint/db/Cargo.toml.liquid b/blueprint/db/Cargo.toml.liquid index b905e642..f465bf22 100644 --- a/blueprint/db/Cargo.toml.liquid +++ b/blueprint/db/Cargo.toml.liquid @@ -10,6 +10,7 @@ doctest = false [features] test-helpers = ["dep:fake", "dep:rand", "dep:regex"] +openapi = ["dep:utoipa"] [dependencies] anyhow = "1.0" @@ -20,5 +21,6 @@ regex = { version = "1.10", optional = true } serde = { version = "1.0", features = ["derive"] } sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-rustls", "postgres", "macros", "uuid", "migrate", "chrono" ] } thiserror = "1.0" +utoipa = { version = "5.1.3", features = ["uuid"], optional = true } uuid = { version = "1.5", features = ["serde"] } validator = { version = "0.18", features = ["derive"] } diff --git a/blueprint/db/src/entities/tasks.rs b/blueprint/db/src/entities/tasks.rs index 7a64a0c8..5ecf4152 100644 --- a/blueprint/db/src/entities/tasks.rs +++ b/blueprint/db/src/entities/tasks.rs @@ -8,6 +8,7 @@ use validator::Validate; /// A task, i.e. TODO item. #[derive(Serialize, Debug, Deserialize)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct Task { /// The id of the record. pub id: Uuid, @@ -26,6 +27,7 @@ pub struct Task { /// ``` #[derive(Debug, Deserialize, Validate, Clone)] #[cfg_attr(feature = "test-helpers", derive(Serialize, Dummy))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct TaskChangeset { /// The description must be at least 1 character long. #[cfg_attr(feature = "test-helpers", dummy(faker = "Sentence(3..8)"))] diff --git a/blueprint/macros/src/lib.rs.liquid b/blueprint/macros/src/lib.rs.liquid index 13ddb3b0..adada23b 100644 --- a/blueprint/macros/src/lib.rs.liquid +++ b/blueprint/macros/src/lib.rs.liquid @@ -123,6 +123,8 @@ pub fn request_payload(_: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(quote! { #[derive(::serde::Deserialize)] + #[derive(::utoipa::ToSchema)] + #[derive(::core::fmt::Debug)] #[serde(try_from = #inner_ty_lit_str)] #input @@ -154,6 +156,8 @@ pub fn batch_request_payload(_: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(quote! { #[derive(::serde::Deserialize)] + #[derive(::utoipa::ToSchema)] + #[derive(::core::fmt::Debug)] #[serde(try_from = #inner_ty_lit_str)] #input @@ -193,6 +197,8 @@ pub fn response_payload(_: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(quote! { #[derive(::serde::Serialize)] + #[derive(::utoipa::ToSchema)] + #[derive(::core::fmt::Debug)] #[serde(try_from = #inner_ty_lit_str)] #input diff --git a/blueprint/web/Cargo.toml.liquid b/blueprint/web/Cargo.toml.liquid index 2883f708..c0005f18 100644 --- a/blueprint/web/Cargo.toml.liquid +++ b/blueprint/web/Cargo.toml.liquid @@ -16,7 +16,7 @@ anyhow = "1.0" axum = { version = "0.7", features = ["macros"] } {{project-name}}-config = { path = "../config" } {% unless template_type == "minimal" -%} -{{project-name}}-db = { path = "../db" } +{{project-name}}-db = { path = "../db", features = ["openapi"] } {%- endunless %} serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.34", features = ["full"] } @@ -33,6 +33,9 @@ tower = { version = "0.5", features = ["util"], optional = true } hyper = { version = "1.0", features = ["full"], optional = true } {{project-name}}-macros = { path = "../macros" } validator = { version = "0.18.1", features = ["derive"] } +utoipa = { version = "5.1.2", features = ["axum_extras", "uuid"] } +utoipa-axum = {version = "0.1.2" } +utoipa-swagger-ui = { version = "8.0.3", features = ["axum", "reqwest"] } [dev-dependencies] fake = "2.9" diff --git a/blueprint/web/src/controllers/greeting.rs b/blueprint/web/src/controllers/greeting.rs index 04abe81a..72ad3804 100644 --- a/blueprint/web/src/controllers/greeting.rs +++ b/blueprint/web/src/controllers/greeting.rs @@ -1,17 +1,26 @@ use axum::response::Json; -use serde::{Deserialize, Serialize}; +use payloads::*; -/// A greeting to respond with to the requesting client -#[derive(Deserialize, Serialize)] -pub struct Greeting { - /// Who do we say hello to? - pub hello: String, -} +pub const OPENAPI_TAG: &str = "Greeting"; -/// Responds with a [`Greeting`], encoded as JSON. +/// Responds with a [`HelloResponse`], encoded as JSON. #[axum::debug_handler] -pub async fn hello() -> Json { - Json(Greeting { +#[utoipa::path(get, path = "/tasks", tag = OPENAPI_TAG, responses( + (status = OK, description = "Hello there!", body = HelloResponse) +))] +pub async fn hello() -> Json { + Json(HelloResponse { hello: String::from("world"), }) } + +mod payloads { + /// A greeting to respond with to the requesting client + #[derive(serde::Serialize)] + #[derive(utoipa::ToSchema)] + #[derive(Debug)] + pub struct HelloResponse { + /// Who do we say hello to? + pub hello: String, + } +} diff --git a/blueprint/web/src/controllers/tasks.rs b/blueprint/web/src/controllers/tasks.rs index fa5190e6..978722ea 100644 --- a/blueprint/web/src/controllers/tasks.rs +++ b/blueprint/web/src/controllers/tasks.rs @@ -5,10 +5,16 @@ use payloads::*; use tracing::info; use uuid::Uuid; +pub const OPENAPI_TAG: &str = "Tasks"; + /// Creates a task in the database. /// /// This function creates a task in the database (see [`{{crate_name}}_db::entities::tasks::create`]) based on a [`{{crate_name}}_db::entities::tasks::TaskChangeset`] (sent as JSON). If the task is created successfully, a 201 response is returned with the created [`{{crate_name}}_db::entities::tasks::Task`]'s JSON representation in the response body. If the changeset is invalid, a 422 response is returned. #[axum::debug_handler] +#[utoipa::path(post, path = "/tasks", tag = OPENAPI_TAG, security(("User Token" = [])), responses( + (status = CREATED, description = "Task created successfully", body = CreateResponsePayload), + (status = UNPROCESSABLE_ENTITY, description = "Validation failed"), +))] pub async fn create( State(app_state): State, Json(payload): Json, @@ -20,10 +26,14 @@ pub async fn create( /// Creates multiple tasks in the database. /// -/// This function creates multiple tasks in the database (see [`getest_db::entities::tasks::create`]) based on [`getest_db::entities::tasks::TaskChangeset`]s (sent as JSON). If all tasks are created successfully, a 201 response is returned with the created [`getest_db::entities::tasks::Task`]s' JSON representation in the response body. If any of the passed changesets is invalid, a 422 response is returned. +/// This function creates multiple tasks in the database (see [`{{crate_name}}_db::entities::tasks::create`]) based on [`{{crate_name}}_db::entities::tasks::TaskChangeset`]s (sent as JSON). If all tasks are created successfully, a 201 response is returned with the created [`{{crate_name}}_db::entities::tasks::Task`]s' JSON representation in the response body. If any of the passed changesets is invalid, a 422 response is returned. /// /// This function creates all tasks in a transaction so that either all are created successfully or none is. #[axum::debug_handler] +#[utoipa::path(put, path = "/tasks", tag = OPENAPI_TAG, security(("User Token" = [])), responses( + (status = CREATED, description = "Task created successfully", body = CreateBatchResponsePayload), + (status = UNPROCESSABLE_ENTITY, description = "Validation failed"), +))] pub async fn create_batch( State(app_state): State, Json(payload): Json, @@ -45,30 +55,43 @@ pub async fn create_batch( /// /// This function reads all [`{{crate_name}}_db::entities::tasks::Task`]s from the database (see [`{{crate_name}}_db::entities::tasks::load_all`]) and responds with their JSON representations. #[axum::debug_handler] -pub async fn read_all(State(app_state): State) -> Result>, Error> { +#[utoipa::path(get, path = "/tasks", tag = OPENAPI_TAG, responses( + (status = OK, body = ReadAllResponsePayload) +))] +pub async fn read_all(State(app_state): State) -> Result, Error> { let tasks = tasks::load_all(&app_state.db_pool).await?; info!("responding with {:?}", tasks); - Ok(Json(tasks)) + Ok(Json(tasks.into())) } /// Reads and responds with a task identified by its ID. /// /// This function reads one [`{{crate_name}}_db::entities::tasks::Task`] identified by its ID from the database (see [`{{crate_name}}_db::entities::tasks::load`]) and responds with its JSON representations. If no task is found for the ID, a 404 response is returned. #[axum::debug_handler] +#[utoipa::path(get, path = "/tasks/{id}", tag = OPENAPI_TAG, responses( + (status = OK, body = ReadOneResponsePayload), + (status = UNPROCESSABLE_ENTITY, description = "Validation failed"), + (status = NOT_FOUND, description = "No task found with that id") +))] pub async fn read_one( State(app_state): State, Path(id): Path, -) -> Result, Error> { +) -> Result, Error> { let task = tasks::load(id, &app_state.db_pool).await?; - Ok(Json(task)) + Ok(Json(task.into())) } /// Updates a task in the database. /// /// This function updates a task identified by its ID in the database (see [`{{crate_name}}_db::entities::tasks::update`]) with the data from the passed [`{{crate_name}}_db::entities::tasks::TaskChangeset`] (sent as JSON). If the task is updated successfully, a 200 response is returned with the created [`{{crate_name}}_db::entities::tasks::Task`]'s JSON representation in the response body. If the changeset is invalid, a 422 response is returned. #[axum::debug_handler] +#[utoipa::path(put, path = "/tasks/{id}", tag = OPENAPI_TAG, security(("User Token" = [])), responses( + (status = OK, body = UpdateResponsePayload), + (status = UNPROCESSABLE_ENTITY, description = "Validation failed"), + (status = NOT_FOUND, description = "No task found with that id") +))] pub async fn update( State(app_state): State, Path(id): Path, @@ -82,6 +105,11 @@ pub async fn update( /// /// This function deletes one [`{{crate_name}}_db::entities::tasks::Task`] identified by the entity's id from the database (see [`{{crate_name}}_db::entities::tasks::delete`]) and responds with a 204 status code and empty response body. If no task is found for the ID, a 404 response is returned. #[axum::debug_handler] +#[utoipa::path(delete, path = "/tasks/{id}", tag = OPENAPI_TAG, security(("User Token" = [])), responses( + (status = NO_CONTENT), + (status = UNPROCESSABLE_ENTITY, description = "Validation failed"), + (status = NOT_FOUND, description = "No task found with that id") +))] pub async fn delete( State(app_state): State, Path(id): Path, @@ -94,27 +122,36 @@ mod payloads { use {{crate_name}}_db::entities::tasks::{Task, TaskChangeset}; use {{crate_name}}_macros::{batch_request_payload, request_payload, response_payload}; - #[derive(Debug)] #[request_payload] + /// Create a task pub struct CreateRequestPayload(TaskChangeset); - #[derive(Debug)] #[response_payload] + /// The task that was created pub struct CreateResponsePayload(Task); - #[derive(Debug)] #[batch_request_payload] + /// Create multiple tasks pub struct CreateBatchRequestPayload(Vec); - #[derive(Debug)] #[response_payload] + /// The tasks that were created pub struct CreateBatchResponsePayload(Vec); - #[derive(Debug)] #[request_payload] + /// Update a task pub struct UpdateRequestPayload(TaskChangeset); - #[derive(Debug)] #[response_payload] + /// The task that was updated pub struct UpdateResponsePayload(Task); + + #[response_payload] + /// The tasks + pub struct ReadAllResponsePayload(Vec); + + #[response_payload] + /// The task + pub struct ReadOneResponsePayload(Task); } + diff --git a/blueprint/web/src/middlewares/auth.rs b/blueprint/web/src/middlewares/auth.rs index 1a33dc6c..32821954 100644 --- a/blueprint/web/src/middlewares/auth.rs +++ b/blueprint/web/src/middlewares/auth.rs @@ -7,7 +7,24 @@ use axum::{ response::Response, }; use {{crate_name}}_db::entities::users; -use tracing::Span; +use tracing::Span;use utoipa::openapi::security::SecurityScheme; + +pub struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "User Token", + SecurityScheme::ApiKey(utoipa::openapi::security::ApiKey::Header( + utoipa::openapi::security::ApiKeyValue::new( + http::header::AUTHORIZATION.as_str(), + ), + )), + ) + } + } +} /// Authenticates an incoming request based on an auth token. /// diff --git a/blueprint/web/src/routes.rs.liquid b/blueprint/web/src/routes.rs.liquid index a520ed20..8a0e40c5 100644 --- a/blueprint/web/src/routes.rs.liquid +++ b/blueprint/web/src/routes.rs.liquid @@ -4,38 +4,58 @@ use axum::Router; {% elsif template_type == "full" -%} use crate::controllers::tasks; -use crate::middlewares::auth::auth; +use crate::middlewares::auth::{auth, SecurityAddon}; use crate::state::AppState; -use axum::{ - middleware, - routing::{delete, get, post, put}, - Router, -}; +use axum::{middleware, Router}; +use utoipa_axum::routes; {%- elsif template_type == "minimal" %} use crate::controllers::greeting; use crate::state::AppState; -use axum::{routing::get, Router}; +use axum::Router; +use utoipa_axum::routes; +{%- endif %} +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; +use utoipa_swagger_ui::SwaggerUi; + +#[derive(OpenApi)] +#[openapi( +{% if template_type == "full" -%} + modifiers(&SecurityAddon), +{%- endif %} + tags( +{% if template_type == "full" -%} + (name = tasks::OPENAPI_TAG, description = "Task API endpoints"), +{%- elsif template_type == "minimal" %} + (name = greeting::OPENAPI_TAG, description = "Greeting API endpoints"), {%- endif %} + ) +)] +struct ApiDoc; /// Initializes the application's routes. /// /// This function maps paths (e.g. "/greet") and HTTP methods (e.g. "GET") to functions in [`crate::controllers`] as well as includes middlewares defined in [`crate::middlewares`] into the routing layer (see [`axum::Router`]). pub fn init_routes(app_state: AppState) -> Router { {% if template_type == "default" -%} - Router::new().with_state(app_state) + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .split_for_parts(); {% elsif template_type == "full" -%} - Router::new() - .route("/tasks", post(tasks::create)) - .route("/tasks", put(tasks::create_batch)) - .route("/tasks/:id", delete(tasks::delete)) - .route("/tasks/:id", put(tasks::update)) + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .routes(routes!(tasks::create)) + .routes(routes!(tasks::create_batch)) + .routes(routes!(tasks::delete)) + .routes(routes!(tasks::update)) .route_layer(middleware::from_fn_with_state(app_state.clone(), auth)) - .route("/tasks", get(tasks::read_all)) - .route("/tasks/:id", get(tasks::read_one)) - .with_state(app_state) + .routes(routes!(tasks::read_all)) + .routes(routes!(tasks::read_one)) + .split_for_parts(); {%- elsif template_type == "minimal" %} - Router::new() - .route("/greet", get(greeting::hello)) - .with_state(app_state) + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .routes(routes!(greeting::hello)) + .split_for_parts(); {%- endif %} + router + .merge(SwaggerUi::new("/swagger-ui").url("/apidoc/openapi.json", api)) + .with_state(app_state) }