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 ce47aa05..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, @@ -24,8 +25,9 @@ pub struct Task { /// ``` /// let task_changeset: TaskChangeset = Faker.fake(); /// ``` -#[derive(Deserialize, Validate, Clone)] +#[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 52708fae..adada23b 100644 --- a/blueprint/macros/src/lib.rs.liquid +++ b/blueprint/macros/src/lib.rs.liquid @@ -1,8 +1,8 @@ //! The {{crate_name}}-macros crate contains the `test`{%- unless template_type == "minimal" %} and `db_test`{%- endunless %} macro{%- unless template_type == "minimal" -%} s{% endunless -%}. use proc_macro::TokenStream; -use quote::quote; -use syn::{parse_macro_input, ItemFn}; +use quote::{quote, ToTokens}; +use syn::{parse_macro_input, Fields, Ident, ItemFn, ItemStruct, Type}; #[allow(clippy::test_attr_in_doctest)] /// Used to mark an application test. @@ -110,4 +110,133 @@ pub fn db_test(_: TokenStream, item: TokenStream) -> TokenStream { TokenStream::from(output) } -{%- endunless %} \ No newline at end of file +{%- endunless %} + +#[proc_macro_attribute] +pub fn request_payload(_: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemStruct); + let PayloadStructInfo { + outer_ty, + inner_ty, + inner_ty_lit_str, + } = PayloadStructInfo::from_input(&input); + + TokenStream::from(quote! { + #[derive(::serde::Deserialize)] + #[derive(::utoipa::ToSchema)] + #[derive(::core::fmt::Debug)] + #[serde(try_from = #inner_ty_lit_str)] + #input + + impl TryFrom<#inner_ty> for #outer_ty { + type Error = ::validator::ValidationErrors; + + fn try_from(inner: #inner_ty) -> Result { + ::validator::Validate::validate(&inner)?; + Ok(Self(inner)) + } + } + + impl From<#outer_ty> for #inner_ty { + fn from(#outer_ty(inner): #outer_ty) -> Self { + inner + } + } + }) +} + +#[proc_macro_attribute] +pub fn batch_request_payload(_: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemStruct); + let PayloadStructInfo { + outer_ty, + inner_ty, + inner_ty_lit_str, + } = PayloadStructInfo::from_input(&input); + + TokenStream::from(quote! { + #[derive(::serde::Deserialize)] + #[derive(::utoipa::ToSchema)] + #[derive(::core::fmt::Debug)] + #[serde(try_from = #inner_ty_lit_str)] + #input + + impl TryFrom<#inner_ty> for #outer_ty { + type Error = ::validator::ValidationErrors; + + fn try_from(inner: #inner_ty) -> Result { + let cap = inner.len(); + + inner + .into_iter() + .try_fold(Vec::with_capacity(cap), |mut v, item| { + ::validator::Validate::validate(&item)?; + v.push(item); + Ok(v) + }) + .map(Self) + } + } + + impl From<#outer_ty> for #inner_ty { + fn from(#outer_ty(inner): #outer_ty) -> Self { + inner + } + } + }) +} + +#[proc_macro_attribute] +pub fn response_payload(_: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemStruct); + let PayloadStructInfo { + outer_ty, + inner_ty, + inner_ty_lit_str, + } = PayloadStructInfo::from_input(&input); + + TokenStream::from(quote! { + #[derive(::serde::Serialize)] + #[derive(::utoipa::ToSchema)] + #[derive(::core::fmt::Debug)] + #[serde(try_from = #inner_ty_lit_str)] + #input + + impl From<#inner_ty> for #outer_ty { + fn from(inner: #inner_ty) -> Self { + Self(inner) + } + } + }) +} + +struct PayloadStructInfo<'input> { + outer_ty: &'input Ident, + inner_ty: &'input Type, + inner_ty_lit_str: String, +} + +impl<'input> PayloadStructInfo<'input> { + fn from_input(input: &'input ItemStruct) -> Self { + fn error() -> ! { + panic!("Macro can only be applied to tuple structs with a single field") + } + + let outer_ty = &input.ident; + + let Fields::Unnamed(fields) = &input.fields else { + error() + }; + let mut fields = fields.unnamed.iter(); + let Some(field) = fields.next() else { error() }; + let None = fields.next() else { error() }; + + let inner_ty = &field.ty; + let inner_ty_lit_str = inner_ty.clone().to_token_stream().to_string(); + Self { + outer_ty, + inner_ty, + inner_ty_lit_str, + } + } +} diff --git a/blueprint/web/Cargo.toml.liquid b/blueprint/web/Cargo.toml.liquid index f2e64fc4..c0005f18 100644 --- a/blueprint/web/Cargo.toml.liquid +++ b/blueprint/web/Cargo.toml.liquid @@ -9,14 +9,14 @@ publish = false doctest = false [features] -test-helpers = ["dep:serde_json", "dep:tower", "dep:hyper", "dep:{{project-name}}-macros"] +test-helpers = ["dep:serde_json", "dep:tower", "dep:hyper"] [dependencies] 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"] } @@ -31,10 +31,11 @@ serde_json = { version = "1.0", optional = true } thiserror = "1.0" tower = { version = "0.5", features = ["util"], optional = true } hyper = { version = "1.0", features = ["full"], optional = true } -{% unless template_type == "minimal" -%} -validator = "0.18" -{%- endunless %} -{{project-name}}-macros = { path = "../macros", 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 f5dc50eb..978722ea 100644 --- a/blueprint/web/src/controllers/tasks.rs +++ b/blueprint/web/src/controllers/tasks.rs @@ -1,20 +1,27 @@ use crate::{error::Error, state::AppState}; use axum::{extract::Path, extract::State, http::StatusCode, Json}; use {{crate_name}}_db::{entities::tasks, transaction}; +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(task): Json, -) -> Result<(StatusCode, Json), Error> { - Ok(tasks::create(task, &app_state.db_pool) + Json(payload): Json, +) -> Result<(StatusCode, Json), Error> { + Ok(tasks::create(payload.into(), &app_state.db_pool) .await - .map(|task| (StatusCode::CREATED, Json(task)))?) + .map(|task| (StatusCode::CREATED, Json(task.into())))?) } /// Creates multiple tasks in the database. @@ -23,64 +30,86 @@ pub async fn create( /// /// 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(tasks): Json>, -) -> Result<(StatusCode, Json>), Error> { + Json(payload): Json, +) -> Result<(StatusCode, Json), Error> { let mut tx = transaction(&app_state.db_pool).await?; let mut results: Vec = vec![]; - for task in tasks { + for task in Vec::<_>::from(payload) { let task = tasks::create(task, &mut *tx).await?; results.push(task); } tx.commit().await.map_err(anyhow::Error::from)?; - Ok((StatusCode::CREATED, Json(results))) + Ok((StatusCode::CREATED, Json(results.into()))) } /// Reads and responds with all the tasks currently present in the database. /// /// 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, - Json(task): Json, -) -> Result, Error> { - let task = tasks::update(id, task, &app_state.db_pool).await?; - Ok(Json(task)) + Json(payload): Json, +) -> Result, Error> { + let task = tasks::update(id, payload.into(), &app_state.db_pool).await?; + Ok(Json(task.into())) } /// Deletes a task identified by its ID from the database. /// /// 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, @@ -88,3 +117,41 @@ pub async fn delete( tasks::delete(id, &app_state.db_pool).await?; Ok(StatusCode::NO_CONTENT) } + +mod payloads { + use {{crate_name}}_db::entities::tasks::{Task, TaskChangeset}; + use {{crate_name}}_macros::{batch_request_payload, request_payload, response_payload}; + + #[request_payload] + /// Create a task + pub struct CreateRequestPayload(TaskChangeset); + + #[response_payload] + /// The task that was created + pub struct CreateResponsePayload(Task); + + #[batch_request_payload] + /// Create multiple tasks + pub struct CreateBatchRequestPayload(Vec); + + #[response_payload] + /// The tasks that were created + pub struct CreateBatchResponsePayload(Vec); + + #[request_payload] + /// Update a task + pub struct UpdateRequestPayload(TaskChangeset); + + #[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) }