From 38fb6af5671872eb47832660fc517262967429f3 Mon Sep 17 00:00:00 2001 From: Kilerd Date: Mon, 14 Sep 2026 15:12:43 +0900 Subject: [PATCH] refactor(openapi)!: use static operation constructors --- MIGRATION.md | 37 ++++++++++ gotcha/src/openapi/mod.rs | 36 +++++++--- gotcha/tests/static_operables.rs | 117 +++++++++++++++++++++++++++++++ gotcha_macro/Cargo.toml | 2 - gotcha_macro/src/route.rs | 38 ++++------ 5 files changed, 194 insertions(+), 36 deletions(-) create mode 100644 gotcha/tests/static_operables.rs diff --git a/MIGRATION.md b/MIGRATION.md index d8164b9..de84e59 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,6 @@ # Migration Guide +- [Unreleased: static OpenAPI descriptors](#unreleased-static-openapi-descriptors) - [Unreleased: owned scheduled tasks](#unreleased-owned-scheduled-tasks) - [Unreleased: shared startup](#unreleased-shared-startup) - [Unreleased: runtime type bounds](#unreleased-runtime-type-bounds) @@ -15,6 +16,42 @@ --- +# Unreleased: static OpenAPI descriptors + +`#[api]` keeps its existing syntax and OpenAPI behavior. Its generated `Operable` now stores +constructor function pointers directly, without `Lazy`, boxed closures, or a runtime constructor +vector. The macro no longer needs UUID-named helper statics or a UUID dependency. + +Only code using the low-level descriptor types directly needs to migrate: + +| Public type or field | Previous representation | New representation | +| --- | --- | --- | +| `ParamConstructor` | `Box ParamType + Send + Sync>` | `fn(String) -> ParamType` | +| `Operable::parameters` | `&'static Lazy>` | `&'static [ParamConstructor]` | +| `Operable::responses` | `&'static Lazy Responses + Send + Sync>>` | `fn() -> Responses` | + +Replace lazy constructor collections with a static slice. Replace a lazy boxed response constructor +with a function or a non-capturing closure. For example, these fields can be written directly in a +static `Operable` initializer: + +```rust,ignore +parameters: &[ as ParameterProvider>::generate], +responses: ::response, +``` + +Capturing closures cannot be stored in the new function-pointer fields. Apply configuration-dependent +changes through the existing `.openapi(move |spec| ...)` transform instead. User transforms still +support captured state. `ParameterProvider::generate`, `ParamType`, and the public `gotcha::Lazy` +and `gotcha::Either` re-exports retain their existing interfaces. + +Static constructor storage does not cache generated parameters, responses, schemas, or complete +OpenAPI documents. Constructors still run inside each document's schema collection scope, preserving +path-specific parameter names, independent documents, generic DTOs, recursive references, and +`errors(...)` / `responses(...)` behavior. This is a structural simplification; no benchmarked +performance improvement is claimed. + +--- + # Unreleased: owned scheduled tasks **Task registration no longer starts background work.** The application owns its scheduled tasks diff --git a/gotcha/src/openapi/mod.rs b/gotcha/src/openapi/mod.rs index aaf08c7..10a50ad 100644 --- a/gotcha/src/openapi/mod.rs +++ b/gotcha/src/openapi/mod.rs @@ -37,7 +37,6 @@ use axum::response::Html; use convert_case::{Case, Casing}; use either::Either; use oas::{Components, Info, OpenAPIV3, Operation, Parameter, PathItem, Referenceable, RequestBody, Responses, SecurityRequirement, Tag}; -use once_cell::sync::Lazy; use regex::Regex; use crate::Responder; @@ -65,7 +64,8 @@ pub(crate) async fn scalar_html() -> impl Responder { pub type ParamType = Either, RequestBody>; /// Builds an argument's [`ParamType`] given the route path (needed to name path parameters). -pub type ParamConstructor = Box ParamType + Sync + Send + 'static>; +/// A constructor is a function or a non-capturing closure; its result is built for each operation. +pub type ParamConstructor = fn(String) -> ParamType; /// Normalise a route path into the form OpenAPI uses for path templating. /// @@ -83,8 +83,26 @@ pub fn path_variable_names(path: &str) -> Vec { regex.captures_iter(path).map(|caps| caps[1].to_string()).collect() } -#[derive()] /// Everything the `#[api]` macro records about one handler, collected via `inventory`. +/// Constructor pointers are static; parameters, responses, and schemas are built per document. +/// +/// Manual descriptors can use functions or non-capturing closures directly: +/// +/// ``` +/// use gotcha::{Operable, ParameterProvider, Path, Responsible}; +/// +/// static OPERATION: Operable = Operable { +/// type_name: "my_app::read_item", +/// id: "read_item", +/// group: None, +/// summary: None, +/// description: None, +/// deprecated: false, +/// security: None, +/// parameters: &[ as ParameterProvider>::generate], +/// responses: ::response, +/// }; +/// ``` pub struct Operable { /// Fully qualified name of the handler function, used to match a route to its `Operable`. pub type_name: &'static str, @@ -100,10 +118,10 @@ pub struct Operable { pub deprecated: bool, /// Name of a security scheme this operation requires. pub security: Option<&'static str>, - /// One constructor per handler argument. - pub parameters: &'static Lazy>, - /// Builds the operation's responses from the handler's return type. - pub responses: &'static Lazy Responses + Sync + Send + 'static>>, + /// One constructor per documented handler argument, stored in a static slice. + pub parameters: &'static [ParamConstructor], + /// Builds fresh responses inside the current document's schema collection scope. + pub responses: fn() -> Responses, } impl Operable { @@ -115,9 +133,9 @@ impl Operable { for item in self.parameters.iter() { match item(path.clone()) { Either::Left(params_vec) => { - params.extend(params_vec.into_iter().map(|param| Referenceable::Data(param.clone()))); + params.extend(params_vec.into_iter().map(Referenceable::Data)); } - Either::Right(req_body) => request_body = Some(Referenceable::Data(req_body.clone())), + Either::Right(req_body) => request_body = Some(Referenceable::Data(req_body)), } } let responses = (self.responses)(); diff --git a/gotcha/tests/static_operables.rs b/gotcha/tests/static_operables.rs new file mode 100644 index 0000000..0e132c3 --- /dev/null +++ b/gotcha/tests/static_operables.rs @@ -0,0 +1,117 @@ +#![cfg(feature = "openapi")] +#![allow(dead_code)] + +use gotcha::axum::http::{Method, StatusCode}; +use gotcha::{ + api, + openapi::{generate_openapi, ParamConstructor}, + Json, Operable, ParameterProvider, Path, Query, Schematic, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Schematic, Serialize, Deserialize)] +struct Envelope { + data: T, +} + +#[derive(Schematic, Serialize, Deserialize)] +struct Node { + label: String, + children: Vec, +} + +#[derive(Schematic, Deserialize)] +struct Filter { + limit: Option, +} + +// Deliberately has neither ParameterProvider nor Responsible. +struct Opaque; + +/// Replace a tree and describe its accepted result. +#[api( + id = "replace_tree", + group = "trees", + summary = "Replace a tree", + deprecated, + security = "token", + errors(response(status = 409, body = "Envelope")), + responses( + response(status = 202, body = "Envelope"), + response(status = 409, body = "Envelope", description = "Tree conflict") + ), + drop_default +)] +async fn replace_tree( + _path: Path, _query: Query, #[api(skip)] _opaque: Opaque, _body: Json>, +) -> Result<(StatusCode, Json>), Opaque> { + unimplemented!() +} + +// Static public descriptors need no generated helper variables or lazy initialization. +static PARAMETERS: &[ParamConstructor] = &[ + as ParameterProvider>::generate, + as ParameterProvider>::generate, + > as ParameterProvider>::generate, +]; +static MANUAL: Operable = Operable { + type_name: concat!(module_path!(), "::replace_tree"), + id: "replace_tree", + group: Some("trees"), + summary: Some("Replace a tree"), + description: Some("Replace a tree and describe its accepted result."), + deprecated: true, + security: Some("token"), + parameters: PARAMETERS, + responses: || { + let mut responses = gotcha::response::response::>(202, "application/json", "HTTP 202"); + gotcha::response::merge_responses( + &mut responses, + gotcha::response::response::>(409, "application/json", "Tree conflict"), + ); + responses + }, +}; + +fn descriptor() -> &'static Operable { + gotcha::inventory::iter::.into_iter().find(|op| op.id == "replace_tree").unwrap() +} + +fn document(op: &'static Operable, path: &str) -> Value { + let routes = HashMap::from([((path.to_owned(), Method::POST), op)]); + serde_json::to_value(generate_openapi(routes)).unwrap() +} + +#[test] +fn descriptors_build_fresh_path_specific_documents() { + let op = descriptor(); + let first = document(op, "/trees/{tree_id}"); + assert_eq!(first, document(&MANUAL, "/trees/{tree_id}"), "macro and static public descriptors must agree"); + let operation = &first["paths"]["/trees/{tree_id}"]["post"]; + assert_eq!(operation["parameters"][0]["name"], "tree_id"); + assert_eq!(operation["parameters"][1]["name"], "limit"); + assert_eq!( + operation["parameters"].as_array().unwrap().len(), + 2, + "skipped arguments must have no constructor" + ); + assert_eq!( + operation["requestBody"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/Envelope_Node" + ); + assert_eq!( + operation["responses"].as_object().unwrap().keys().map(String::as_str).collect::>(), + ["202", "409"] + ); + assert_eq!(operation["responses"]["409"]["description"], "Tree conflict"); + let schemas = &first["components"]["schemas"]; + assert_eq!(schemas["Node"]["properties"]["children"]["items"]["$ref"], "#/components/schemas/Node"); + assert_eq!(schemas["Envelope_String"]["properties"]["data"]["type"], "string"); + assert_eq!(schemas["Envelope_Node"]["properties"]["data"]["$ref"], "#/components/schemas/Node"); + let second = document(op, "/archives/{archive_id}"); + assert_eq!(second["paths"]["/archives/{archive_id}"]["post"]["parameters"][0]["name"], "archive_id"); + assert_eq!(second["components"], first["components"], "each collection must register its own schemas"); + assert_eq!(document(op, "/trees/{tree_id}"), first); +} diff --git a/gotcha_macro/Cargo.toml b/gotcha_macro/Cargo.toml index 75d2aba..c8f1beb 100644 --- a/gotcha_macro/Cargo.toml +++ b/gotcha_macro/Cargo.toml @@ -20,5 +20,3 @@ quote = "1.0" proc-macro2 = "1.0.47" darling = "0.14.2" proc-macro-error = { version = "1", default-features = false } -uuid = {version = "1.10.0", features = ["v4"] } - diff --git a/gotcha_macro/src/route.rs b/gotcha_macro/src/route.rs index 0208842..0271848 100644 --- a/gotcha_macro/src/route.rs +++ b/gotcha_macro/src/route.rs @@ -1,7 +1,6 @@ use proc_macro::TokenStream; -use quote::{format_ident, quote}; +use quote::quote; use syn::{parse_macro_input, AttributeArgs, FnArg, ItemFn, ReturnType}; -use uuid::Uuid; use darling::util::Flag; @@ -140,9 +139,6 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T } }; - let random_uuid = Uuid::new_v4().simple().to_string(); - let uuid_ident = format_ident!("__PARAM_{}", random_uuid); - let ret_uuid_ident = format_ident!("__RET_{}", random_uuid); let params_token: Vec = input .sig .inputs @@ -165,7 +161,7 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T return None; } let ty = &typed.ty; - Some(quote! { Box::new(|path:String| {<#ty as ::gotcha::ParameterProvider>::generate(path) }) }) + Some(quote! { <#ty as ::gotcha::ParameterProvider>::generate }) } }) .collect(); @@ -191,23 +187,6 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T #input - static #uuid_ident : ::gotcha::Lazy ::gotcha::Either, ::gotcha::oas::RequestBody> + Send + Sync + 'static>>> = ::gotcha::Lazy::new(||{ - vec![ - #( #params_token , )* - ] - }); - static #ret_uuid_ident : ::gotcha::Lazy ::gotcha::oas::Responses + Send + Sync + 'static>> = ::gotcha::Lazy::new(||{ - Box::new(|| { - let mut responses = #ret_responses; - // Declared Err alternatives join the inferred Ok contract, including shared statuses. - #(::gotcha::response::merge_responses(&mut responses, #error_responses);)* - // Explicit declarations replace inference for their status only. - #(responses.data.extend((#extra_responses).data);)* - if #drop_default { responses.default = None; } - assert!(!responses.data.is_empty() || responses.default.is_some(), "OpenAPI operation must declare at least one response"); - responses - }) - }); ::gotcha::inventory::submit! { ::gotcha::Operable { type_name: concat!(module_path!(), "::", #fn_ident_string), @@ -217,8 +196,17 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T description: #docs, deprecated: #deprecated, security: #security, - parameters: &#uuid_ident, - responses: &#ret_uuid_ident, + parameters: &[#(#params_token,)*], + responses: || { + let mut responses = #ret_responses; + // Declared Err alternatives join the inferred Ok contract, including shared statuses. + #(::gotcha::response::merge_responses(&mut responses, #error_responses);)* + // Explicit declarations replace inference for their status only. + #(responses.data.extend((#extra_responses).data);)* + if #drop_default { responses.default = None; } + assert!(!responses.data.is_empty() || responses.default.is_some(), "OpenAPI operation must declare at least one response"); + responses + }, } } };