Skip to content

Commit

Permalink
Add openapi spec for generated blueprint
Browse files Browse the repository at this point in the history
  • Loading branch information
hdoordt committed Oct 29, 2024
1 parent f46d9d9 commit 672cb9b
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 51 deletions.
2 changes: 2 additions & 0 deletions blueprint/db/Cargo.toml.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ doctest = false

[features]
test-helpers = ["dep:fake", "dep:rand", "dep:regex"]
openapi = ["dep:utoipa"]

[dependencies]
anyhow = "1.0"
Expand All @@ -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"] }
2 changes: 2 additions & 0 deletions blueprint/db/src/entities/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)"))]
Expand Down
6 changes: 6 additions & 0 deletions blueprint/macros/src/lib.rs.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion blueprint/web/Cargo.toml.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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.2" }
utoipa-swagger-ui = { version = "8.0.3", features = ["axum", "reqwest"] }

[dev-dependencies]
fake = "2.9"
Expand Down
29 changes: 19 additions & 10 deletions blueprint/web/src/controllers/greeting.rs
Original file line number Diff line number Diff line change
@@ -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<Greeting> {
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<HelloResponse> {
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,
}
}
61 changes: 49 additions & 12 deletions blueprint/web/src/controllers/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
Json(payload): Json<CreateRequestPayload>,
Expand All @@ -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<AppState>,
Json(payload): Json<CreateBatchRequestPayload>,
Expand Down Expand Up @@ -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<AppState>,
) -> Result<Json<Vec<tasks::Task>>, StatusCode> {
Expand All @@ -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<AppState>,
Path(id): Path<Uuid>,
Expand All @@ -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<AppState>,
Path(id): Path<Uuid>,
Expand All @@ -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<AppState>,
Path(id): Path<Uuid>,
Expand All @@ -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<TaskChangeset>);

#[derive(Debug)]
#[response_payload]
/// The tasks that were created
pub struct CreateBatchResponsePayload(Vec<Task>);

#[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<Task>);

#[response_payload]
/// The task
pub struct ReadOneResponsePayload(Task);
}

19 changes: 18 additions & 1 deletion blueprint/web/src/middlewares/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
71 changes: 44 additions & 27 deletions blueprint/web/src/routes.rs.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
}

0 comments on commit 672cb9b

Please sign in to comment.