diff --git a/blueprint/Cargo.toml.liquid b/blueprint/Cargo.toml.liquid index 0bac1041..459d5f7e 100644 --- a/blueprint/Cargo.toml.liquid +++ b/blueprint/Cargo.toml.liquid @@ -10,3 +10,8 @@ members = [ ] resolver = "2" default-members = ["web"] + +[patch.crates-io] +# TODO remove us once https://github.com/juhaku/utoipa/pull/1171 is released +utoipa-axum = { git = "https://github.com/juhaku/utoipa.git", rev = "8a5ed1e" } +utoipa = { git = "https://github.com/juhaku/utoipa.git", rev = "8a5ed1e" } 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 acff59a3..e4a28856 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 bdc5f355..b04db461 100644 --- a/blueprint/web/Cargo.toml.liquid +++ b/blueprint/web/Cargo.toml.liquid @@ -16,7 +16,7 @@ anyhow = "1.0" axum = "0.7" {{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"] } @@ -32,6 +32,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.1" } +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 4d7e37dc..0af15cbd 100644 --- a/blueprint/web/src/controllers/greeting.rs +++ b/blueprint/web/src/controllers/greeting.rs @@ -1,16 +1,25 @@ 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. -pub async fn hello() -> Json { - Json(Greeting { +/// Responds with a [`HelloResponse`], encoded as JSON. +#[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, + } +} \ No newline at end of file diff --git a/blueprint/web/src/controllers/tasks.rs b/blueprint/web/src/controllers/tasks.rs index 0a951242..27d3d671 100644 --- a/blueprint/web/src/controllers/tasks.rs +++ b/blueprint/web/src/controllers/tasks.rs @@ -8,9 +8,15 @@ 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 [`getest_db::entities::tasks::create`]) based on a [`getest_db::entities::tasks::TaskChangeset`] (sent as JSON). If the task is created successfully, a 201 response is returned with the created [`getest_db::entities::tasks::Task`]'s JSON representation in the response body. If the changeset is invalid, a 422 response is returned. +/// 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. +#[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, @@ -27,9 +33,13 @@ 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. +#[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, @@ -59,7 +69,10 @@ pub async fn create_batch( /// Reads and responds with all the tasks currently present in the database. /// -/// This function reads all [`getest_db::entities::tasks::Task`]s from the database (see [`getest_db::entities::tasks::load_all`]) and responds with their JSON representations. +/// 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. +#[utoipa::path(get, path = "/tasks", tag = OPENAPI_TAG, responses( + (status = OK, body = ReadAllResponsePayload) +))] pub async fn read_all( State(app_state): State, ) -> Result>, StatusCode> { @@ -74,7 +87,12 @@ pub async fn read_all( /// Reads and responds with a task identified by its ID. /// -/// This function reads one [`getest_db::entities::tasks::Task`] identified by its ID from the database (see [`getest_db::entities::tasks::load`]) and responds with its JSON representations. If no task is found for the ID, a 404 response is returned. +/// 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. +#[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, @@ -88,7 +106,12 @@ pub async fn read_one( /// Updates a task in the database. /// -/// This function updates a task identified by its ID in the database (see [`getest_db::entities::tasks::update`]) with the data from the passed [`getest_db::entities::tasks::TaskChangeset`] (sent as JSON). If the task is updated successfully, a 200 response is returned with the created [`getest_db::entities::tasks::Task`]'s JSON representation in the response body. If the changeset is invalid, a 422 response is returned. +/// 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. +#[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, @@ -107,7 +130,12 @@ pub async fn update( /// Deletes a task identified by its ID from the database. /// -/// This function deletes one [`getest_db::entities::tasks::Task`] identified by the entity's id from the database (see [`getest_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. +/// 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. +#[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, @@ -123,27 +151,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 dffb9e40..39ec99a7 100644 --- a/blueprint/web/src/routes.rs.liquid +++ b/blueprint/web/src/routes.rs.liquid @@ -3,42 +3,59 @@ use crate::state::AppState; use axum::Router; {% elsif template_type == "full" -%} -use crate::controllers::tasks::{ - create as create_task, create_batch as create_tasks, delete as delete_task, - read_all as get_tasks, read_one as get_task, update as update_task, -}; -use crate::middlewares::auth::auth; +use crate::controllers::tasks; +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::hello; +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) -{% elsif template_type == "full" -%} - Router::new() - .route("/tasks", post(create_task)) - .route("/tasks", put(create_tasks)) - .route("/tasks/:id", delete(delete_task)) - .route("/tasks/:id", put(update_task)) + {% if template_type == "default" -%} + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .split_for_parts(); + {% elsif template_type == "full" -%} + 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(get_tasks)) - .route("/tasks/:id", get(get_task)) + .routes(routes!(tasks::read_all)) + .routes(routes!(tasks::read_one)) + .split_for_parts(); + {%- elsif template_type == "minimal" %} + 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) -{%- elsif template_type == "minimal" %} - Router::new() - .route("/greet", get(hello)) - .with_state(app_state) -{%- endif %} }