Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate OpenAPI spec using Utoipa #122

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"] }
4 changes: 3 additions & 1 deletion 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 @@ -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)"))]
Expand Down
135 changes: 132 additions & 3 deletions blueprint/macros/src/lib.rs.liquid
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -110,4 +110,133 @@ pub fn db_test(_: TokenStream, item: TokenStream) -> TokenStream {

TokenStream::from(output)
}
{%- endunless %}
{%- 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<Self, Self::Error> {
::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<Self, Self::Error> {
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,
}
}
}
13 changes: 7 additions & 6 deletions blueprint/web/Cargo.toml.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"
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,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<Greeting> {
Json(Greeting {
#[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,
}
}
Loading
Loading