From 24d25defda07402f56f061b06ddee4aa52ac560d Mon Sep 17 00:00:00 2001 From: Jordan Baxter Date: Mon, 17 Nov 2025 13:31:31 -0800 Subject: [PATCH 1/3] Added operation output derive macro. --- crates/aide-macro-impl/Cargo.toml | 9 ++ crates/aide-macro-impl/src/lib.rs | 1 + .../aide-macro-impl/src/operation_output.rs | 144 ++++++++++++++++++ crates/aide-macros/Cargo.toml | 3 +- crates/aide-macros/src/lib.rs | 64 ++++++++ crates/aide/Cargo.toml | 16 +- crates/aide/src/lib.rs | 2 +- crates/aide/src/operation.rs | 2 +- crates/aide/tests/test_op_out_derive.rs | 51 +++++++ 9 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 crates/aide-macro-impl/Cargo.toml create mode 100644 crates/aide-macro-impl/src/lib.rs create mode 100644 crates/aide-macro-impl/src/operation_output.rs create mode 100644 crates/aide/tests/test_op_out_derive.rs diff --git a/crates/aide-macro-impl/Cargo.toml b/crates/aide-macro-impl/Cargo.toml new file mode 100644 index 00000000..e9cf9245 --- /dev/null +++ b/crates/aide-macro-impl/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "aide-macro-impl" +version = "0.1.0" +edition = "2024" + +[dependencies] +proc-macro2 = "1.0.103" +quote = "1.0.42" +syn = "2.0.110" diff --git a/crates/aide-macro-impl/src/lib.rs b/crates/aide-macro-impl/src/lib.rs new file mode 100644 index 00000000..7e0c2cbd --- /dev/null +++ b/crates/aide-macro-impl/src/lib.rs @@ -0,0 +1 @@ +pub mod operation_output; diff --git a/crates/aide-macro-impl/src/operation_output.rs b/crates/aide-macro-impl/src/operation_output.rs new file mode 100644 index 00000000..f491ae26 --- /dev/null +++ b/crates/aide-macro-impl/src/operation_output.rs @@ -0,0 +1,144 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{DeriveInput, Expr}; + +/// Derive macro implementation for OperationOutput trait on enum response types +pub fn operation_output_impl(input: DeriveInput) -> Result { + let enum_name = &input.ident; + let generics = &input.generics; + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + + // Ensure this is an enum + let data_enum = match &input.data { + syn::Data::Enum(data) => data, + _ => { + return Err(syn::Error::new_spanned( + input, + "OperationOutput can only be derived for enums", + )); + } + }; + + // Parse variants and extract status codes from attributes + let mut variants_with_status: Vec<(syn::Ident, syn::Type, Option)> = Vec::new(); + + for variant in &data_enum.variants { + // Ensure the variant is a tuple variant with exactly one field + let field = match &variant.fields { + syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => &fields.unnamed[0], + _ => { + return Err(syn::Error::new_spanned( + variant, + "OperationOutput can only be derived for enums with tuple variants containing exactly one field", + )); + } + }; + + let variant_name = variant.ident.clone(); + let field_type = field.ty.clone(); + + // Look for #[aide(status_code = ...)] attribute + let mut status_code = None; + for attr in &variant.attrs { + if attr.path().is_ident("aide") { + // Parse the attribute tokens directly to support both literals and const expressions + let parsed = attr.parse_args_with(|input: syn::parse::ParseStream| { + // Parse: status_code = + let name: syn::Ident = input.parse()?; + if name == "status_code" { + let _eq: syn::Token![=] = input.parse()?; + let expr: Expr = input.parse()?; + Ok(Some(expr)) + } else { + Ok(None) + } + })?; + + if parsed.is_some() { + status_code = parsed; + } + } + } + + variants_with_status.push((variant_name, field_type, status_code)); + } + + // Determine if this is a single-variant enum (with only one error type) + let is_single_variant = variants_with_status.len() == 1; + + // Generate the implementation + let operation_response_impl = if is_single_variant { + let (_, field_type, _) = &variants_with_status[0]; + quote! { + fn operation_response( + ctx: &mut ::aide::generate::GenContext, + operation: &mut ::aide::openapi::Operation, + ) -> Option<::aide::openapi::Response> { + <#field_type as ::aide::OperationOutput>::operation_response(ctx, operation) + } + } + } else { + quote! { + fn operation_response( + _ctx: &mut ::aide::generate::GenContext, + _operation: &mut ::aide::openapi::Operation, + ) -> Option<::aide::openapi::Response> { + // For enum responses with multiple variants, we return None here + // and let inferred_responses handle it + None + } + } + }; + + let inferred_responses_impl = if is_single_variant { + let (_, field_type, _) = &variants_with_status[0]; + quote! { + fn inferred_responses( + ctx: &mut ::aide::generate::GenContext, + operation: &mut ::aide::openapi::Operation, + ) -> Vec<(Option<::aide::openapi::StatusCode>, ::aide::openapi::Response)> { + <#field_type as ::aide::OperationOutput>::inferred_responses(ctx, operation) + } + } + } else { + let response_arms = variants_with_status.iter().map(|(_variant_name, field_type, status_code)| { + if let Some(code) = status_code { + // If status_code attribute is provided, use it and get the response from operation_response + quote! { + if let Some(err_resp) = <#field_type as ::aide::OperationOutput>::operation_response(ctx, operation) { + responses.push(( + Some(::aide::openapi::StatusCode::Code(#code)), + err_resp, + )); + } + } + } else { + // If no status_code attribute, use the type's inferred_responses + quote! { + responses.extend(<#field_type as ::aide::OperationOutput>::inferred_responses(ctx, operation)); + } + } + }); + + quote! { + fn inferred_responses( + ctx: &mut ::aide::generate::GenContext, + operation: &mut ::aide::openapi::Operation, + ) -> Vec<(Option<::aide::openapi::StatusCode>, ::aide::openapi::Response)> { + let mut responses = Vec::new(); + #(#response_arms)* + responses + } + } + }; + + Ok(quote! { + impl #impl_generics ::aide::OperationOutput for #enum_name #ty_generics #where_clause { + type Inner = Self; + + #operation_response_impl + + #inferred_responses_impl + } + }) +} diff --git a/crates/aide-macros/Cargo.toml b/crates/aide-macros/Cargo.toml index bd1ecec2..65f2743a 100644 --- a/crates/aide-macros/Cargo.toml +++ b/crates/aide-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aide-macros" -version = "0.16.0-alpha.1" # remember to update the dependency in aide, even for patch releases +version = "0.16.0-alpha.1" # remember to update the dependency in aide, even for patch releases authors = ["tamasfe"] edition = "2021" license = "MIT OR Apache-2.0" @@ -14,6 +14,7 @@ darling = "0.20.0" quote = "1.0.21" syn = "2.0.15" proc-macro2 = "1.0" +aide-macro-impl = { path = "../aide-macro-impl" } [features] axum-extra-typed-routing = [] diff --git a/crates/aide-macros/src/lib.rs b/crates/aide-macros/src/lib.rs index d17855ae..db11b134 100644 --- a/crates/aide-macros/src/lib.rs +++ b/crates/aide-macros/src/lib.rs @@ -1,3 +1,4 @@ +use aide_macro_impl::operation_output::operation_output_impl; use darling::FromDeriveInput; use proc_macro::TokenStream; use quote::quote; @@ -189,3 +190,66 @@ pub fn axum_typed_path(_attr: TokenStream, item: TokenStream) -> TokenStream { } .into() } + +/// Derive macro for OperationOutput trait. +/// +/// NOTE: This is currently under development and is only available for enums +/// that contain only tuple variants with a single value. All values must already implement +/// OperationOutput. +/// +/// ```ignore +/// use aide::axum::IntoApiResponse; +/// use aide::{OperationIo, OperationOutputDerive}; +/// use axum::response::IntoResponse; +/// use schemars::JsonSchema; +/// use serde::Serialize; +/// +/// pub const INTERNAL_SERVER_ERROR: u16 = 500; +/// +/// // Using the simple implementation of OperationOutput here for brevity. +/// #[derive(Debug, Serialize, JsonSchema, OperationIo)] +/// #[aide(output)] +/// pub struct MyResponse { +/// pub success: bool, +/// } +/// +/// #[derive(Debug, Serialize, OperationOutputDerive)] +/// pub enum MyMultiResponse { +/// // Use a literal status code. +/// #[aide(status_code = 200)] +/// Success(MyResponse), +/// // Use any const status code. +/// //Hint: This includes http::StatusCode::as_u16() +/// #[aide(status_code=INTERNAL_SERVER_ERROR)] +/// Failure(MyResponse), +/// // Use an existing implementation's default status code. +/// DefaultStatusCode(String), +/// // Override an existing implementation's status code. +/// #[aide(status_code = 201)] +/// DefaultStatusCodeOverride(String), +/// // Use no status code. +/// NoStatusCode(MyResponse), +/// } +/// +/// /// This implementation is required for the blanket implementation of [`IntoApiResponse`] +/// /// however it is not within the perview of this macro, so we will keep it short. (And +/// /// incorrect) +/// impl IntoResponse for MyMultiResponse { +/// fn into_response(self) -> axum::response::Response { +/// (StatusCode::OK, Body::from("Success")).into_response() +/// } +/// } +/// +/// pub async fn my_handler() -> impl IntoApiResponse { +/// MyMultiResponse::Success(MyResponse { success: true }) +/// } +/// ``` +#[proc_macro_derive(OperationOutput, attributes(aide))] +pub fn derive_operation_output(item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as syn::DeriveInput); + + match operation_output_impl(item) { + Ok(tokens) => tokens.into(), + Err(err) => TokenStream::from(err.to_compile_error()), + } +} diff --git a/crates/aide/Cargo.toml b/crates/aide/Cargo.toml index 8f7d21c3..d3037f44 100644 --- a/crates/aide/Cargo.toml +++ b/crates/aide/Cargo.toml @@ -29,6 +29,7 @@ tower-service = { version = "0.3", optional = true } cfg-if = "1.0.0" [features] +default = ["macros", "axum"] macros = ["dep:aide-macros"] redoc = [] swagger = [] @@ -36,7 +37,14 @@ scalar = [] skip_serializing_defaults = [] serde_qs = ["dep:serde_qs"] -axum = ["dep:axum", "bytes", "http", "dep:tower-layer", "dep:tower-service", "serde_qs?/axum"] +axum = [ + "dep:axum", + "bytes", + "http", + "dep:tower-layer", + "dep:tower-service", + "serde_qs?/axum", +] axum-form = ["axum", "axum/form"] axum-json = ["axum", "axum/json"] axum-matched-path = ["axum", "axum/matched-path"] @@ -56,9 +64,9 @@ axum-extra-query = ["axum-extra", "axum-extra/query"] axum-extra-json-deserializer = ["axum-extra", "axum-extra/json-deserializer"] axum-extra-routing = ["axum-extra", "axum-extra/routing"] axum-extra-typed-routing = [ - "axum-extra-routing", - "axum-extra/typed-routing", - "aide-macros?/axum-extra-typed-routing", + "axum-extra-routing", + "axum-extra/typed-routing", + "aide-macros?/axum-extra-typed-routing", ] axum-extra-with-rejection = ["axum-extra", "axum-extra/with-rejection"] diff --git a/crates/aide/src/lib.rs b/crates/aide/src/lib.rs index 32ffbcc3..0c6ba2d5 100644 --- a/crates/aide/src/lib.rs +++ b/crates/aide/src/lib.rs @@ -158,4 +158,4 @@ pub use error::Error; pub use operation::{OperationInput, OperationOutput}; #[cfg(feature = "macros")] -pub use aide_macros::OperationIo; +pub use aide_macros::{OperationIo, OperationOutput as OperationOutputDerive}; diff --git a/crates/aide/src/operation.rs b/crates/aide/src/operation.rs index e00e602b..e7d7f4d8 100644 --- a/crates/aide/src/operation.rs +++ b/crates/aide/src/operation.rs @@ -11,7 +11,7 @@ use crate::openapi::{ use crate::Error; #[cfg(feature = "macros")] -pub use aide_macros::OperationIo; +pub use aide_macros::{OperationIo, OperationOutput}; /// A trait for operation input schema generation. /// diff --git a/crates/aide/tests/test_op_out_derive.rs b/crates/aide/tests/test_op_out_derive.rs new file mode 100644 index 00000000..f07be76c --- /dev/null +++ b/crates/aide/tests/test_op_out_derive.rs @@ -0,0 +1,51 @@ +/// This is the same code that lives in the [`OperationOutputDerive`] doc. +#[cfg(feature = "axum")] +#[test] +fn test_op_out_derive() { + use aide::axum::IntoApiResponse; + use aide::{OperationIo, OperationOutputDerive}; + use axum::{body::Body, response::IntoResponse}; + use http::StatusCode; + use schemars::JsonSchema; + use serde::Serialize; + + pub const INTERNAL_SERVER_ERROR: u16 = 500; + + // Using the simple implementation of OperationOutput here for brevity. + #[derive(Debug, Serialize, JsonSchema, OperationIo)] + #[aide(output)] + pub struct MyResponse { + pub success: bool, + } + + #[derive(Debug, Serialize, OperationOutputDerive)] + pub enum MyMultiResponse { + // Use a literal status code. + #[aide(status_code = 200)] + Success(MyResponse), + // Use any const status code. Hint: This includes + // http::StatusCode::as_u16() + #[aide(status_code=INTERNAL_SERVER_ERROR)] + Failure(MyResponse), + // Use an existing implementation's default status code. + DefaultStatusCode(String), + // Override an existing implementation's status code. + #[aide(status_code = 201)] + DefaultStatusCodeOverride(String), + // Use no status code. + NoStatusCode(MyResponse), + } + + /// This implementation is required for the blanket implementation of [`IntoApiResponse`] + /// however it is not within the perview of this macro, so we will keep it short. (And + /// incorrect) + impl IntoResponse for MyMultiResponse { + fn into_response(self) -> axum::response::Response { + (StatusCode::OK, Body::from("Success")).into_response() + } + } + + pub async fn my_handler() -> impl IntoApiResponse { + MyMultiResponse::Success(MyResponse { success: true }) + } +} From 07f8af69dccfeeefef8fe600d521ed80005f4403 Mon Sep 17 00:00:00 2001 From: Jordan Baxter Date: Mon, 17 Nov 2025 13:46:43 -0800 Subject: [PATCH 2/3] Cleaned up macro impl --- crates/aide-macro-impl/src/operation_output.rs | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/crates/aide-macro-impl/src/operation_output.rs b/crates/aide-macro-impl/src/operation_output.rs index f491ae26..375925a2 100644 --- a/crates/aide-macro-impl/src/operation_output.rs +++ b/crates/aide-macro-impl/src/operation_output.rs @@ -63,17 +63,15 @@ pub fn operation_output_impl(input: DeriveInput) -> Result Option<::aide::openapi::Response> { + // For single variant enums, we can use the field type's operation_response <#field_type as ::aide::OperationOutput>::operation_response(ctx, operation) } } @@ -90,17 +88,7 @@ pub fn operation_output_impl(input: DeriveInput) -> Result Vec<(Option<::aide::openapi::StatusCode>, ::aide::openapi::Response)> { - <#field_type as ::aide::OperationOutput>::inferred_responses(ctx, operation) - } - } - } else { + let inferred_responses_impl = { let response_arms = variants_with_status.iter().map(|(_variant_name, field_type, status_code)| { if let Some(code) = status_code { // If status_code attribute is provided, use it and get the response from operation_response From a1be43cae9c1897940c0a07d717f89dea00dfcd4 Mon Sep 17 00:00:00 2001 From: Jordan Baxter Date: Mon, 17 Nov 2025 13:59:46 -0800 Subject: [PATCH 3/3] Put macro behind a mod namespace --- crates/aide-macros/src/lib.rs | 4 ++-- crates/aide/Cargo.toml | 1 - crates/aide/src/lib.rs | 8 +++++++- crates/aide/src/operation.rs | 2 +- crates/aide/tests/test_op_out_derive.rs | 4 ++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/aide-macros/src/lib.rs b/crates/aide-macros/src/lib.rs index db11b134..b838268d 100644 --- a/crates/aide-macros/src/lib.rs +++ b/crates/aide-macros/src/lib.rs @@ -199,7 +199,7 @@ pub fn axum_typed_path(_attr: TokenStream, item: TokenStream) -> TokenStream { /// /// ```ignore /// use aide::axum::IntoApiResponse; -/// use aide::{OperationIo, OperationOutputDerive}; +/// use aide::{OperationIo, derive::OperationOutput}; /// use axum::response::IntoResponse; /// use schemars::JsonSchema; /// use serde::Serialize; @@ -213,7 +213,7 @@ pub fn axum_typed_path(_attr: TokenStream, item: TokenStream) -> TokenStream { /// pub success: bool, /// } /// -/// #[derive(Debug, Serialize, OperationOutputDerive)] +/// #[derive(Debug, Serialize, OperationOutput)] /// pub enum MyMultiResponse { /// // Use a literal status code. /// #[aide(status_code = 200)] diff --git a/crates/aide/Cargo.toml b/crates/aide/Cargo.toml index d3037f44..50fb81ea 100644 --- a/crates/aide/Cargo.toml +++ b/crates/aide/Cargo.toml @@ -29,7 +29,6 @@ tower-service = { version = "0.3", optional = true } cfg-if = "1.0.0" [features] -default = ["macros", "axum"] macros = ["dep:aide-macros"] redoc = [] swagger = [] diff --git a/crates/aide/src/lib.rs b/crates/aide/src/lib.rs index 0c6ba2d5..e58afc5e 100644 --- a/crates/aide/src/lib.rs +++ b/crates/aide/src/lib.rs @@ -158,4 +158,10 @@ pub use error::Error; pub use operation::{OperationInput, OperationOutput}; #[cfg(feature = "macros")] -pub use aide_macros::{OperationIo, OperationOutput as OperationOutputDerive}; +pub use aide_macros::OperationIo; + +/// Derive macros that are under development. +#[cfg(feature = "macros")] +pub mod derive { + pub use aide_macros::OperationOutput; +} diff --git a/crates/aide/src/operation.rs b/crates/aide/src/operation.rs index e7d7f4d8..e00e602b 100644 --- a/crates/aide/src/operation.rs +++ b/crates/aide/src/operation.rs @@ -11,7 +11,7 @@ use crate::openapi::{ use crate::Error; #[cfg(feature = "macros")] -pub use aide_macros::{OperationIo, OperationOutput}; +pub use aide_macros::OperationIo; /// A trait for operation input schema generation. /// diff --git a/crates/aide/tests/test_op_out_derive.rs b/crates/aide/tests/test_op_out_derive.rs index f07be76c..246bbb20 100644 --- a/crates/aide/tests/test_op_out_derive.rs +++ b/crates/aide/tests/test_op_out_derive.rs @@ -3,7 +3,7 @@ #[test] fn test_op_out_derive() { use aide::axum::IntoApiResponse; - use aide::{OperationIo, OperationOutputDerive}; + use aide::{derive::OperationOutput, OperationIo}; use axum::{body::Body, response::IntoResponse}; use http::StatusCode; use schemars::JsonSchema; @@ -18,7 +18,7 @@ fn test_op_out_derive() { pub success: bool, } - #[derive(Debug, Serialize, OperationOutputDerive)] + #[derive(Debug, Serialize, OperationOutput)] pub enum MyMultiResponse { // Use a literal status code. #[aide(status_code = 200)]