Skip to content
Merged
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
37 changes: 37 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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<dyn Fn(String) -> ParamType + Send + Sync>` | `fn(String) -> ParamType` |
| `Operable::parameters` | `&'static Lazy<Vec<ParamConstructor>>` | `&'static [ParamConstructor]` |
| `Operable::responses` | `&'static Lazy<Box<dyn Fn() -> 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: &[<Path<u32> as ParameterProvider>::generate],
responses: <String as Responsible>::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
Expand Down
36 changes: 27 additions & 9 deletions gotcha/src/openapi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,7 +64,8 @@ pub(crate) async fn scalar_html() -> impl Responder {
pub type ParamType = Either<Vec<Parameter>, RequestBody>;

/// Builds an argument's [`ParamType`] given the route path (needed to name path parameters).
pub type ParamConstructor = Box<dyn Fn(String) -> 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.
///
Expand All @@ -83,8 +83,26 @@ pub fn path_variable_names(path: &str) -> Vec<String> {
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: &[<Path<u32> as ParameterProvider>::generate],
/// responses: <String as Responsible>::response,
/// };
/// ```
pub struct Operable {
/// Fully qualified name of the handler function, used to match a route to its `Operable`.
pub type_name: &'static str,
Expand All @@ -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<Vec<ParamConstructor>>,
/// Builds the operation's responses from the handler's return type.
pub responses: &'static Lazy<Box<dyn Fn() -> 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 {
Expand All @@ -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)();
Expand Down
117 changes: 117 additions & 0 deletions gotcha/tests/static_operables.rs
Original file line number Diff line number Diff line change
@@ -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<T: Schematic> {
data: T,
}

#[derive(Schematic, Serialize, Deserialize)]
struct Node {
label: String,
children: Vec<Node>,
}

#[derive(Schematic, Deserialize)]
struct Filter {
limit: Option<u32>,
}

// 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<String>")),
responses(
response(status = 202, body = "Envelope<Node>"),
response(status = 409, body = "Envelope<String>", description = "Tree conflict")
),
drop_default
)]
async fn replace_tree(
_path: Path<u64>, _query: Query<Filter>, #[api(skip)] _opaque: Opaque, _body: Json<Envelope<Node>>,
) -> Result<(StatusCode, Json<Envelope<Node>>), Opaque> {
unimplemented!()
}

// Static public descriptors need no generated helper variables or lazy initialization.
static PARAMETERS: &[ParamConstructor] = &[
<Path<u64> as ParameterProvider>::generate,
<Query<Filter> as ParameterProvider>::generate,
<Json<Envelope<Node>> 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::<Envelope<Node>>(202, "application/json", "HTTP 202");
gotcha::response::merge_responses(
&mut responses,
gotcha::response::response::<Envelope<String>>(409, "application/json", "Tree conflict"),
);
responses
},
};

fn descriptor() -> &'static Operable {
gotcha::inventory::iter::<Operable>.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::<Vec<_>>(),
["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);
}
2 changes: 0 additions & 2 deletions gotcha_macro/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

38 changes: 13 additions & 25 deletions gotcha_macro/src/route.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<proc_macro2::TokenStream> = input
.sig
.inputs
Expand All @@ -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();
Expand All @@ -191,23 +187,6 @@ pub(crate) fn request_handler(args: TokenStream, input_stream: TokenStream) -> T

#input

static #uuid_ident : ::gotcha::Lazy<Vec<Box<dyn Fn(String) -> ::gotcha::Either<Vec<::gotcha::oas::Parameter>, ::gotcha::oas::RequestBody> + Send + Sync + 'static>>> = ::gotcha::Lazy::new(||{
vec![
#( #params_token , )*
]
});
static #ret_uuid_ident : ::gotcha::Lazy<Box<dyn Fn() -> ::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),
Expand All @@ -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
},
}
}
};
Expand Down
Loading