Skip to main content

autumn_web/
openapi.rs

1// OpenAPI/JSON/JSON-schema all appear frequently here and are legitimate
2// acronyms, so silence clippy::doc_markdown rather than wrapping every
3// mention in backticks.
4#![allow(clippy::doc_markdown)]
5
6//! OpenAPI (Swagger) specification auto-generation.
7//!
8//! Autumn automatically infers an OpenAPI 3.1 document from your
9//! annotated routes ([`get`](crate::get), [`post`](crate::post), etc.),
10//! their path parameters, and the extractor / response types in each
11//! handler signature. The generated spec is served at `/openapi.json` and
12//! a Swagger UI is served at `/swagger-ui` when the feature is enabled.
13//!
14//! Narrative guide: `docs/guide/openapi.md`.
15//!
16//! # Quick start
17//!
18//! Enable the `openapi` feature in `Cargo.toml`, then:
19//!
20//! ```toml
21//! [dependencies]
22//! autumn-web = { version = "0.2", features = ["openapi"] }
23//! ```
24//!
25//! ```rust,ignore
26//! use autumn_web::prelude::*;
27//!
28//! #[get("/hello")]
29//! async fn hello() -> &'static str { "hi" }
30//!
31//! #[autumn_web::main]
32//! async fn main() {
33//!     autumn_web::app()
34//!         .routes(routes![hello])
35//!         .openapi(autumn_web::openapi::OpenApiConfig::new("My API", "1.0.0"))
36//!         .run()
37//!         .await;
38//! }
39//! ```
40//!
41//! With `.openapi(...)` enabled, the following endpoints are mounted:
42//! * `GET /openapi.json` — serves the generated spec document.
43//! * `GET /swagger-ui` — serves a Swagger UI HTML page loading the JSON
44//!   above.
45//!
46//! # Enriching the auto-generated docs
47//!
48//! Decorate handlers with [`#[api_doc(...)]`](crate::api_doc) to override
49//! or add documentation fields that cannot be inferred from the signature
50//! (summaries, descriptions, tags, custom status codes, etc.):
51//!
52//! ```rust,no_run
53//! use autumn_web::prelude::*;
54//!
55//! #[get("/users/{id}")]
56//! #[api_doc(summary = "Fetch a user by id", tag = "users")]
57//! async fn get_user(_id: Path<i32>) -> &'static str { "user" }
58//! ```
59//!
60//! # Custom schemas
61//!
62//! Types that need rich schemas (beyond the generic "object" fallback)
63//! implement the `OpenApiSchema` trait and are registered with
64//! `OpenApiConfig::register_schema`.
65
66// autumn-determinism-gate: production code in this module must read time and
67// mint identifiers through the framework's injected seams (ClockSource /
68// Entropy), never `Instant::now()` / `Utc::now()` / `SystemTime::now()` /
69// `Uuid::new_v4()` directly. See CONTRIBUTING.md "Determinism seam gate"
70// (issue #1797). Justify exceptions with
71// #[allow(clippy::disallowed_methods, reason = "…")] at the narrowest scope.
72#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
73
74use std::collections::BTreeMap;
75
76#[cfg(feature = "openapi")]
77use serde::{Deserialize, Serialize};
78
79// ──────────────────────────────────────────────────────────────────
80// `autumn openapi export` dump protocol
81// ──────────────────────────────────────────────────────────────────
82
83/// Machine-readable stderr marker saying this binary cannot produce a spec.
84///
85/// Emitted by the `AUTUMN_DUMP_OPENAPI` dump instead of a spec document, with a
86/// human-readable reason following on the same line. `autumn openapi export`
87/// scans stderr for it so it can turn "this app has no OpenAPI surface" into an
88/// actionable message rather than a JSON parse failure on empty stdout.
89///
90/// Two things produce it: the binary was built without the `openapi` feature,
91/// or it was built with the feature but never called
92/// [`AppBuilder::openapi`](crate::app::AppBuilder::openapi).
93pub const OPENAPI_UNAVAILABLE_MARKER: &str = "[autumn:openapi-unavailable] ";
94
95/// Reason text following [`OPENAPI_UNAVAILABLE_MARKER`] when the binary was
96/// compiled without the `openapi` feature.
97pub const OPENAPI_UNAVAILABLE_FEATURE: &str = "this binary was built without the `openapi` feature";
98
99/// Reason text following [`OPENAPI_UNAVAILABLE_MARKER`] when the app never
100/// configured a spec.
101pub const OPENAPI_UNAVAILABLE_UNCONFIGURED: &str =
102    "this app never called `.openapi(OpenApiConfig::new(..))`";
103
104// ──────────────────────────────────────────────────────────────────
105// Public metadata attached to each Route
106// ──────────────────────────────────────────────────────────────────
107
108/// OpenAPI metadata emitted alongside every annotated route.
109///
110/// Populated by the route macros ([`get`](crate::get),
111/// [`post`](crate::post), etc.) from the handler's path, signature, and
112/// any [`#[api_doc(...)]`](crate::api_doc) overrides.
113#[derive(Clone, Debug, Default)]
114// A flat, generated metadata descriptor. Each boolean flag (hidden, secured,
115// sunset_opt_out, has_policy, public, mcp_tool, mcp_exclude, mcp_stream,
116// mcp_task, mcp_core, webmcp_tool, webmcp_exclude) models a distinct route property. A sub-struct would
117// obscure them.
118#[allow(clippy::struct_excessive_bools)]
119pub struct ApiDoc {
120    /// HTTP method as an uppercase string (e.g. `"GET"`).
121    pub method: &'static str,
122    /// Raw route path with `{param}` placeholders (e.g. `"/users/{id}"`).
123    pub path: &'static str,
124    /// Handler function name — used as the default `operationId`.
125    pub operation_id: &'static str,
126    /// Short human-readable summary (from `#[api_doc(summary = ...)]`).
127    pub summary: Option<&'static str>,
128    /// Longer free-form description.
129    pub description: Option<&'static str>,
130    /// Grouping tags. Defaults to the first path segment when unset.
131    pub tags: &'static [&'static str],
132    /// Path parameter names extracted from the URL template.
133    ///
134    /// Built at compile time from `{...}` segments in the route path.
135    pub path_params: &'static [&'static str],
136    /// Optional schema for the request body (typically the inner type of
137    /// a `Json<T>` extractor).
138    pub request_body: Option<SchemaEntry>,
139    /// Optional schema for the success response (typically the inner type
140    /// of a `Json<T>` return value).
141    pub response: Option<SchemaEntry>,
142    /// Success HTTP status code, defaults to `200`.
143    pub success_status: u16,
144    /// When `true`, the route is excluded from the generated spec.
145    pub hidden: bool,
146    /// Optional query-parameter schema inferred from `Query<T>` extractors.
147    pub query_schema: Option<SchemaEntry>,
148    /// True when the route requires authentication (`#[secured]`).
149    pub secured: bool,
150    /// Roles required by `#[secured("role1")]`. Empty means any authenticated user.
151    pub required_roles: &'static [&'static str],
152    /// Scopes required by `#[secured(scopes = ["scope"])]`. When non-empty the
153    /// route is documented as `BearerAuth` instead of `SessionAuth`.
154    pub required_scopes: &'static [&'static str],
155    /// Optional runtime hook that lets a handler register any extra
156    /// component schemas with the generator.
157    pub register_schemas: Option<fn(&mut SchemaRegistry)>,
158    /// Optional API version associated with this route.
159    pub api_version: Option<&'static str>,
160    /// Whether this route opts out of sunset 410 responses.
161    pub sunset_opt_out: bool,
162    /// Whether this route uses dynamic policy authorization.
163    pub has_policy: bool,
164    /// Record-level authorization bindings declared by `#[authorize]` on the
165    /// handler, in source order — one entry per attribute, empty when the
166    /// handler declares none.
167    ///
168    /// This is the provable subset of [`Self::has_policy`], never a
169    /// replacement for it: that boolean is also `true` for a hand-written
170    /// `__check_policy` call in the body, which carries no binding a macro can
171    /// recover.
172    pub authorize_bindings: &'static [AuthorizeBinding],
173    /// Pool tags the handler's declared extractors prove it holds for the
174    /// length of the request (`"db"`, `"mail"`, …), sorted and deduplicated.
175    ///
176    /// The statically derived half of the capacity contract (issue #1733):
177    /// `autumn calibrate` folds these into `capacity.lock` so a contract says
178    /// *why* a route costs what it costs, not just what the aggregate
179    /// envelope was. Empty is the honest default — it means "no pool proven",
180    /// not "no pool touched" (see `route_listing::RouteInfo::pools`).
181    pub pools: &'static [&'static str],
182    /// True when the handler is explicitly declared public via `#[public]`.
183    ///
184    /// Populated by the route macros from the `#[public]` marker. Used by the
185    /// route-listing security classifier (`autumn routes audit`) to
186    /// distinguish a *deliberately* open route from one whose auth posture was
187    /// simply never declared.
188    pub public: bool,
189    /// Module path of the handler (`module_path!()` captured at the handler's
190    /// definition site), used to name a route in security-audit diagnostics.
191    /// Empty for routes constructed without the route macros.
192    pub module_path: &'static str,
193    /// Source file of the handler (`file!()` captured at the handler's
194    /// definition site), used alongside [`Self::source_line`] to point a
195    /// security-audit diagnostic straight at the offending handler. Empty for
196    /// routes constructed without the route macros.
197    pub source_file: &'static str,
198    /// Source line of the handler (`line!()` captured at the handler's
199    /// definition site). `0` when [`Self::source_file`] is empty.
200    pub source_line: u32,
201    /// True when the endpoint opts in to MCP tool exposure via
202    /// `#[api_doc(mcp)]`. Opt-in is per-endpoint and never implicit.
203    pub mcp_tool: bool,
204    /// True when the endpoint explicitly opts *out* of MCP exposure via
205    /// `#[api_doc(mcp = false)]`. Honored even under the whole-API hatch
206    /// (`AppBuilder::expose_all_as_mcp`). Not an intra-doc link: this field is
207    /// always compiled, but the builder method is gated behind the `mcp`
208    /// feature, so a hard link would break docs built without it.
209    pub mcp_exclude: bool,
210    /// True when the endpoint opts in to *streaming* MCP exposure via
211    /// `#[api_doc(mcp, stream)]`. A streaming tool returns an Autumn `Sse`
212    /// stream that the MCP endpoint projects onto the Streamable-HTTP SSE
213    /// channel as `notifications/progress` messages terminated by the final
214    /// `tools/call` result. Because an `Sse` handler has no JSON response
215    /// schema, this flag also exempts the tool from the JSON-out eligibility
216    /// gate that otherwise excludes schema-less routes.
217    pub mcp_stream: bool,
218    /// True when the endpoint opts in to the MCP Tasks extension via
219    /// `#[api_doc(mcp, task)]`. A client that declares the extension gets a
220    /// task handle and polls `tasks/get` for the result.
221    pub mcp_task: bool,
222    /// True for a core MCP tool, set by `#[api_doc(mcp, core)]`. The tool
223    /// carries `_meta["anthropic/alwaysLoad"]`, so a client that defers tools
224    /// loads it first. In the progressive catalog, `tools/list` holds the core
225    /// tools and the two meta-tools. The flag does not opt the endpoint in.
226    pub mcp_core: bool,
227    /// True when the endpoint opts in to `WebMCP` exposure via
228    /// `#[api_doc(webmcp)]`. `mcp` does not imply it: a page agent runs with
229    /// the session of the user.
230    pub webmcp_tool: bool,
231    /// True for `#[api_doc(webmcp = false)]`. It wins over the
232    /// `expose_all_as_webmcp` hatch.
233    pub webmcp_exclude: bool,
234    /// Build-time authority envelope proved for this handler by
235    /// `#[agent_operable(grant = ...)]` (issue #1691), or `None` for a handler
236    /// that declares none.
237    ///
238    /// Always compiled — the field is metadata about the *handler*, not about
239    /// any particular transport, so the agent-authority manifest and the
240    /// `autumn routes` listing can read it without the `mcp` feature. The MCP
241    /// endpoint copies it onto the derived tool so `tools/call` can record the
242    /// compile-known reversibility in its audit trail and derive
243    /// `destructiveHint` from the grant instead of guessing from the verb.
244    ///
245    /// A `Some` here is a *proved* envelope: the macro walked the handler body,
246    /// const-asserted every detected effect against the named grant, and
247    /// refused to expand when an effect could not be proven. `None` therefore
248    /// means "ungoverned", never "no effects".
249    pub agent_authority: Option<&'static crate::agent_authority::AgentAuthority>,
250}
251
252/// A record-level authorization binding declared by `#[authorize]`.
253///
254/// One entry per `#[authorize("action", resource = Type)]` attribute on a
255/// handler, recovered from the macro expansion, so a binding disappears from
256/// the metadata exactly when the attribute disappears from the source.
257///
258/// This is deliberately **not** the `Policy` implementation that serves the
259/// check. `#[authorize]` names only the resource `R`; the concrete
260/// `impl Policy<R>` is resolved from the `PolicyRegistry` at boot
261/// (`AppBuilder::policy::<R, _>(...)`) and is therefore not knowable at build
262/// time. [`Self::resource`] is likewise the identifier as written at the use
263/// site rather than a resolved path, so two same-named types in different
264/// modules are indistinguishable here.
265#[derive(Copy, Clone, Debug, PartialEq, Eq)]
266pub struct AuthorizeBinding {
267    /// Action verb passed to the policy check — the first `#[authorize]`
268    /// argument, recorded verbatim.
269    pub action: &'static str,
270    /// Resource type identifier exactly as written in `resource = Type`.
271    pub resource: &'static str,
272}
273
274/// Reference to a schema definition, produced by the route macros.
275#[derive(Copy, Clone, Debug)]
276pub struct SchemaEntry {
277    /// Short human-readable type name, used as the *default* component
278    /// display key (`#/components/schemas/Name`) when it does not collide.
279    pub name: &'static str,
280    /// Whether this is a primitive JSON type (string/number/bool/array) as
281    /// opposed to a named object ref.
282    pub kind: SchemaKind,
283    /// Globally-unique schema *identity* for a `Ref` entry, as a fn pointer to
284    /// [`type_name_of`] (i.e. `::core::any::type_name::<T>()`). This is what
285    /// matches a route reference to its producer (`#[derive(OpenApiSchema)]`
286    /// descriptor / registered schema) and disambiguates two distinct types
287    /// that share a last path segment (e.g. `create::Args` vs `update::Args`)
288    /// so neither silently shadows the other (issue #1972).
289    ///
290    /// A fn pointer (rather than the `&'static str` directly) keeps a nested
291    /// `SchemaEntry` const-promotable to `&'static` in `Array` / `Nullable`
292    /// wrappers, since `type_name` is not yet a stably-const fn.
293    ///
294    /// Set on the `Array` / `Nullable` wrapper entries too, where it names the
295    /// WRAPPER (`core::option::Option<T>`, `alloc::vec::Vec<T>`). The route
296    /// macros match those two on the last path segment, so an application's own
297    /// `domain::Option<T>` reaches the same arm; `wrapper_is_impostor` reads
298    /// this identity to tell the two apart and renders an impostor as an
299    /// ordinary `$ref` rather than as something nullable or array-shaped.
300    ///
301    /// `None` for primitives, and for legacy short-name refs (e.g. the
302    /// repository macro's model refs) which keep their last-segment display
303    /// key. A `None` wrapper is treated as genuine, so hand-built entries keep
304    /// their previous behaviour.
305    pub identity: Option<fn() -> &'static str>,
306}
307
308impl SchemaEntry {
309    /// The globally-unique identity key for this entry: its `type_name` when an
310    /// `identity` fn is present, otherwise the short `name` (legacy behavior).
311    #[must_use]
312    pub fn identity_key(&self) -> &'static str {
313        self.identity.map_or(self.name, |f| f())
314    }
315}
316
317// `PartialEq`/`Eq` are implemented by hand rather than derived: deriving them
318// would compare the `identity` field's fn *pointers*, which the
319// `unpredictable_function_pointer_comparisons` lint (rightly) flags as
320// meaningless. Comparing the *resolved* identity strings is both meaningful and
321// what callers actually want (two entries are equal iff they describe the same
322// type the same way).
323impl PartialEq for SchemaEntry {
324    fn eq(&self, other: &Self) -> bool {
325        self.name == other.name
326            && self.kind == other.kind
327            && self.identity_key() == other.identity_key()
328    }
329}
330
331impl Eq for SchemaEntry {}
332
333/// Monomorphized `::core::any::type_name::<T>()` behind a fn pointer.
334///
335/// The route macros emit `Some(type_name_of::<T>)` as a [`SchemaEntry::identity`]
336/// so producer and consumer agree on a globally-unique schema identity by
337/// construction (both are the `type_name` of the same `T`). Using a fn pointer
338/// keeps nested entries const-promotable to `&'static` (see
339/// [`SchemaEntry::identity`]).
340#[must_use]
341pub fn type_name_of<T: ?Sized>() -> &'static str {
342    core::any::type_name::<T>()
343}
344
345/// Sanitize a schema identity into a valid OpenAPI component key.
346///
347/// OpenAPI restricts component keys to `^[A-Za-z0-9._-]+$`, so a Rust
348/// `type_name` (`crate::module::Args`, `Vec<T>`) cannot be used verbatim. `::`
349/// collapses to `.` (utoipa-style) and any other out-of-range character maps to
350/// `_`. Centralizing this here means the registration side and the `$ref` side
351/// always derive the exact same key from the same identity.
352#[must_use]
353pub fn component_key(raw: &str) -> String {
354    let dotted = raw.replace("::", ".");
355    dotted
356        .chars()
357        .map(|c| {
358            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
359                c
360            } else {
361                '_'
362            }
363        })
364        .collect()
365}
366
367/// Classifier for how a type should appear in the spec.
368#[derive(Copy, Clone, Debug, PartialEq, Eq)]
369pub enum SchemaKind {
370    /// Refers to a named component schema.
371    Ref,
372    /// A primitive JSON type inlined at the reference site.
373    Primitive(&'static str),
374    /// A JSON array whose items follow the referenced sub-schema. Used
375    /// for handlers that return `Json<Vec<T>>` (or accept one as a
376    /// request body) — emitting `Ref` for those would produce an
377    /// object schema instead of the array the endpoint actually
378    /// serializes.
379    Array(&'static SchemaEntry),
380    /// A nullable schema — used when the handler wraps the payload in
381    /// `Option<T>`. The referenced sub-entry describes `T`.
382    Nullable(&'static SchemaEntry),
383}
384
385// ──────────────────────────────────────────────────────────────────
386// Configuration — users opt into OpenAPI generation explicitly.
387// ──────────────────────────────────────────────────────────────────
388
389/// User-facing configuration for OpenAPI generation.
390///
391/// Passed to [`AppBuilder::openapi`](crate::app::AppBuilder::openapi)
392/// to enable spec generation and mount the documentation endpoints.
393#[cfg(feature = "openapi")]
394#[derive(Clone)]
395pub struct OpenApiConfig {
396    /// API title that appears in the Swagger UI header.
397    pub title: String,
398    /// API version (e.g. `"1.0.0"`).
399    pub version: String,
400    /// Optional free-form API description (Markdown permitted in UI).
401    pub description: Option<String>,
402    /// Path serving the raw `openapi.json`. Defaults to `/openapi.json`.
403    pub openapi_json_path: String,
404    /// Path serving the Swagger UI HTML. Defaults to `/swagger-ui`. Set
405    /// to `None` to disable the UI while still exposing the JSON.
406    pub swagger_ui_path: Option<String>,
407    /// Session cookie name used by secured route security docs.
408    ///
409    /// Runtime OpenAPI mounting replaces this with `session.cookie_name`
410    /// from the loaded app config.
411    pub session_cookie_name: String,
412    /// User-registered component schemas keyed by schema name.
413    pub additional_schemas: BTreeMap<String, serde_json::Value>,
414    /// API versions registry.
415    pub api_versions: Vec<crate::app::ApiVersion>,
416}
417
418#[cfg(feature = "openapi")]
419impl OpenApiConfig {
420    /// Create a new config with the required `title` and `version`.
421    #[must_use]
422    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
423        Self {
424            title: title.into(),
425            version: version.into(),
426            description: None,
427            openapi_json_path: "/openapi.json".to_owned(),
428            swagger_ui_path: Some("/swagger-ui".to_owned()),
429            session_cookie_name: "autumn.sid".to_owned(),
430            additional_schemas: BTreeMap::new(),
431            api_versions: Vec::new(),
432        }
433    }
434
435    /// Set a free-form API description.
436    #[must_use]
437    pub fn description(mut self, description: impl Into<String>) -> Self {
438        self.description = Some(description.into());
439        self
440    }
441
442    /// Override the path serving `openapi.json`.
443    #[must_use]
444    pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
445        self.openapi_json_path = path.into();
446        self
447    }
448
449    /// Override the Swagger UI path (or `None` to disable it).
450    #[must_use]
451    pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
452        self.swagger_ui_path = path;
453        self
454    }
455
456    /// Override the session cookie name documented for secured routes.
457    #[must_use]
458    pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
459        self.session_cookie_name = name.into();
460        self
461    }
462
463    /// Register a custom component schema. Useful when a handler's
464    /// payload type does not implement `OpenApiSchema`.
465    #[must_use]
466    pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
467        self.additional_schemas.insert(name.into(), schema);
468        self
469    }
470}
471
472// ──────────────────────────────────────────────────────────────────
473// Schema trait + primitive impls (feature-gated)
474// ──────────────────────────────────────────────────────────────────
475
476/// Describes a type's JSON schema for OpenAPI generation.
477///
478/// Provide a manual implementation for complex types to expose rich
479/// schemas in the generated spec. A blanket default is not provided —
480/// routes whose types do not implement this trait simply emit a generic
481/// `object` placeholder referring to the type name.
482///
483/// This trait is always available (no feature gate) so that `#[model]`-generated
484/// types can implement it unconditionally. The spec generation machinery that
485/// consumes implementations is still gated behind the `openapi` feature.
486pub trait OpenApiSchema {
487    /// Component schema name (appears under `#/components/schemas/`).
488    fn schema_name() -> &'static str;
489
490    /// Produce the JSON schema for this type.
491    fn schema() -> serde_json::Value;
492}
493
494/// Derive a field-accurate [`OpenApiSchema`] impl for a plain struct with named
495/// fields (issue #1972), so a handler-arg struct used in `Query<T>` / `Json<T>`
496/// advertises its real fields in the OpenAPI spec and the MCP tool `inputSchema`
497/// instead of collapsing to a generic `{"type":"object"}` placeholder — with no
498/// hand-written impl or `OpenApiConfig::register_schema` call.
499///
500/// Each field becomes a JSON-schema property (nullable `Option<T>` via
501/// `oneOf [T, null]`, `Vec<T>` as an array, primitives inline, other named types
502/// as `$ref`s), and every non-`Option` field is listed as `required` — mirroring
503/// the schema `#[model]` already generates. The derive also registers the schema
504/// in the compile-time inventory the spec/MCP back-fill consults, so a
505/// `Query<MyArgs>` / `Json<MyArgs>` handler picks it up automatically.
506///
507/// Bring it into scope alongside the trait: `use autumn_web::openapi::OpenApiSchema;`.
508pub use autumn_macros::OpenApiSchema;
509
510macro_rules! impl_primitive_schema {
511    ($ty:ty, $name:literal, $json:literal) => {
512        impl OpenApiSchema for $ty {
513            fn schema_name() -> &'static str {
514                $name
515            }
516            fn schema() -> serde_json::Value {
517                serde_json::json!({ "type": $json })
518            }
519        }
520    };
521}
522
523impl_primitive_schema!(bool, "boolean", "boolean");
524impl_primitive_schema!(String, "string", "string");
525impl_primitive_schema!(&'static str, "string", "string");
526impl_primitive_schema!(i8, "integer", "integer");
527impl_primitive_schema!(i16, "integer", "integer");
528impl_primitive_schema!(i32, "integer", "integer");
529impl_primitive_schema!(i64, "integer", "integer");
530impl_primitive_schema!(u8, "integer", "integer");
531impl_primitive_schema!(u16, "integer", "integer");
532impl_primitive_schema!(u32, "integer", "integer");
533impl_primitive_schema!(u64, "integer", "integer");
534impl_primitive_schema!(f32, "number", "number");
535impl_primitive_schema!(f64, "number", "number");
536impl_primitive_schema!(serde_json::Value, "object", "object");
537
538macro_rules! impl_scalar_schema {
539    ($ty:ty, $name:literal, $schema:expr) => {
540        impl OpenApiSchema for $ty {
541            fn schema_name() -> &'static str {
542                $name
543            }
544            fn schema() -> serde_json::Value {
545                $schema
546            }
547        }
548    };
549}
550
551impl_scalar_schema!(
552    uuid::Uuid,
553    "uuid",
554    serde_json::json!({ "type": "string", "format": "uuid" })
555);
556impl_scalar_schema!(
557    chrono::DateTime<chrono::Utc>,
558    "date-time",
559    serde_json::json!({ "type": "string", "format": "date-time" })
560);
561impl_scalar_schema!(
562    chrono::NaiveDate,
563    "date",
564    serde_json::json!({ "type": "string", "format": "date" })
565);
566// No `format`. A naive value has no UTC offset, so `date-time` is wrong.
567impl_scalar_schema!(
568    chrono::NaiveDateTime,
569    "naive-date-time",
570    serde_json::json!({
571        "type": "string",
572        "description": "ISO 8601 date-time with no UTC offset, e.g. 2026-09-06T18:00:00",
573    })
574);
575impl_scalar_schema!(rust_decimal::Decimal, "decimal", decimal_scalar_schema());
576
577/// The schema of a [`rust_decimal::Decimal`], as serde writes it.
578///
579/// By default, `rust_decimal` writes a JSON string. With the `serde-float` or
580/// `serde-arbitrary-precision` feature, it writes a number. Cargo merges the
581/// features of all crates in a build, so this function reads the type from
582/// serde at run time. The schema always matches the wire.
583#[must_use]
584pub fn decimal_scalar_schema() -> serde_json::Value {
585    match serde_json::to_value(rust_decimal::Decimal::new(12345, 2)) {
586        Ok(serde_json::Value::String(_)) => {
587            serde_json::json!({ "type": "string", "format": "decimal" })
588        }
589        _ => serde_json::json!({ "type": "number" }),
590    }
591}
592
593// ──────────────────────────────────────────────────────────────────
594// Compile-time inventory of `#[derive(OpenApiSchema)]` component schemas.
595// ──────────────────────────────────────────────────────────────────
596
597/// Compile-time registration of a plain struct's derived `OpenApiSchema`,
598/// emitted by `#[derive(OpenApiSchema)]` (issue #1972).
599///
600/// The spec generator and the MCP tool-catalog builder both back-fill component
601/// schemas for referenced type names they did not otherwise register. Without a
602/// hand-written `OpenApiSchema` impl + `OpenApiConfig::register_schema`, a plain
603/// handler-arg struct (a `Query<T>` param struct or a non-`#[model]` `Json<T>`
604/// body) used to resolve to a generic `{"type":"object","title":"X"}`
605/// placeholder — so the argument's real fields lived only in prose. This
606/// descriptor lets a `#[derive(OpenApiSchema)]` struct advertise its
607/// field-accurate schema by name, which the back-fill loops pick up
608/// automatically (no manual registration).
609///
610/// This is deliberately not feature-gated: `#[derive(OpenApiSchema)]` submits an
611/// entry unconditionally, and the `openapi`-gated spec builder consults it only
612/// when that feature is compiled in.
613pub struct DerivedSchemaDescriptor {
614    /// Short display hint (the type's last path segment / `schema_name()`).
615    pub name: &'static str,
616    /// The type's globally-unique *identity* (`type_name`), behind a fn pointer.
617    /// The spec/MCP back-fill matches a route reference to this descriptor by
618    /// identity, so two distinct types sharing a last segment resolve to their
619    /// own schema instead of whichever inventory entry link-order hit first
620    /// (issue #1972).
621    pub identity: fn() -> &'static str,
622    /// Produces the JSON schema for the type (the type's `OpenApiSchema::schema`).
623    pub schema: fn() -> serde_json::Value,
624}
625
626inventory::collect!(DerivedSchemaDescriptor);
627
628/// Look up the derived component schema for a schema *identity* (`type_name`).
629///
630/// Returns the schema when a `#[derive(OpenApiSchema)]` type with that identity
631/// was linked into the binary, or `None` when no such derive exists (so callers
632/// fall back to the generic placeholder). Matching by identity — not the short
633/// last-segment name — is what keeps two distinct `Args` types from shadowing
634/// each other in the back-fill.
635#[must_use]
636pub fn registered_derived_schema(identity: &str) -> Option<serde_json::Value> {
637    inventory::iter::<DerivedSchemaDescriptor>
638        .into_iter()
639        .find(|descriptor| (descriptor.identity)() == identity)
640        .map(|descriptor| (descriptor.schema)())
641}
642
643/// The position of a schema reference in an operation (issue #2607).
644///
645/// The server reads a request body and query parameters, so they use the shape
646/// a type deserializes from. The server writes a response body, so it uses the
647/// shape the type serializes to. `#[private]` is one cause of a difference: it
648/// skips a field on serialize only.
649#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
650pub enum SchemaDirection {
651    /// The server reads the value: a request body or query parameters.
652    Request,
653    /// The server writes the value: a response body.
654    Response,
655}
656
657/// A schema for one [`SchemaDirection`] that replaces the derived schema
658/// (issue #2607).
659///
660/// `#[model]` submits one when the request and response shapes of a type can
661/// differ. A reference in [`Self::direction`] uses it. All other references
662/// use the [`DerivedSchemaDescriptor`].
663#[doc(hidden)]
664pub struct DirectionalSchemaDescriptor {
665    /// The type identity (`type_name`), as in [`DerivedSchemaDescriptor::identity`].
666    pub identity: fn() -> &'static str,
667    /// The direction this schema applies to.
668    pub direction: SchemaDirection,
669    /// The schema for that direction. `None` means that no accurate schema is
670    /// known. The spec then uses the opaque placeholder.
671    pub schema: Option<fn() -> serde_json::Value>,
672}
673
674inventory::collect!(DirectionalSchemaDescriptor);
675
676/// Look up the derived schema of `identity` for one direction (issue #2607).
677///
678/// A [`DirectionalSchemaDescriptor`] for that direction wins. Otherwise this
679/// is [`registered_derived_schema`]. `None` means that the spec uses the
680/// opaque placeholder.
681#[must_use]
682pub fn registered_derived_schema_for(
683    identity: &str,
684    direction: SchemaDirection,
685) -> Option<serde_json::Value> {
686    inventory::iter::<DirectionalSchemaDescriptor>
687        .into_iter()
688        .find(|descriptor| descriptor.direction == direction && (descriptor.identity)() == identity)
689        .map_or_else(
690            || registered_derived_schema(identity),
691            |descriptor| descriptor.schema.map(|schema| schema()),
692        )
693}
694
695/// Remove properties from an object schema, and from its `required` list.
696///
697/// `#[model]` calls this to make the response schema of `New*` (issue #2607).
698/// A `required` list that becomes empty is removed.
699#[doc(hidden)]
700pub fn remove_schema_properties(schema: &mut serde_json::Value, names: &[&str]) {
701    let Some(object) = schema.as_object_mut() else {
702        return;
703    };
704    if let Some(properties) = object
705        .get_mut("properties")
706        .and_then(serde_json::Value::as_object_mut)
707    {
708        for name in names {
709            properties.remove(*name);
710        }
711    }
712    if let Some(required) = object
713        .get_mut("required")
714        .and_then(serde_json::Value::as_array_mut)
715    {
716        required.retain(|entry| entry.as_str().is_none_or(|name| !names.contains(&name)));
717        if required.is_empty() {
718            object.remove("required");
719        }
720    }
721}
722
723// ──────────────────────────────────────────────────────────────────
724// Runtime registry of component schemas populated while building the spec.
725// ──────────────────────────────────────────────────────────────────
726
727/// Accumulates component schemas while a spec is being built.
728#[derive(Default)]
729pub struct SchemaRegistry {
730    schemas: BTreeMap<String, serde_json::Value>,
731}
732
733impl SchemaRegistry {
734    /// Register a type via its `OpenApiSchema` implementation. A
735    /// duplicate insertion is a no-op (the existing entry wins).
736    pub fn register<T: OpenApiSchema>(&mut self) {
737        let name = T::schema_name().to_owned();
738        self.schemas.entry(name).or_insert_with(T::schema);
739    }
740
741    /// Insert a raw pre-built schema by name.
742    pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
743        self.schemas.insert(name.into(), schema);
744    }
745
746    /// Drain the collected schemas, consuming the registry.
747    #[must_use]
748    pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
749        self.schemas
750    }
751
752    /// Peek at the collected schemas without consuming the registry.
753    #[must_use]
754    pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
755        &self.schemas
756    }
757}
758
759// ──────────────────────────────────────────────────────────────────
760// Serializable OpenAPI 3.1 document types.
761//
762// Only the fields Autumn actually populates are modelled — unused
763// OpenAPI keys (callbacks, links, discriminators…) are intentionally
764// omitted so the generated JSON stays clean. Gated behind the
765// `openapi` feature so the runtime spec builder doesn't add code
766// size / dependency pressure to apps that never serve a JSON spec.
767// ──────────────────────────────────────────────────────────────────
768
769#[cfg(feature = "openapi")]
770/// Represents a root OpenAPI 3.1 specification document.
771#[derive(Debug, Serialize, Deserialize)]
772pub struct OpenApiSpec {
773    /// The OpenAPI version string (e.g., `3.1.0`).
774    pub openapi: String,
775    /// General information about the API.
776    pub info: Info,
777    /// The available paths and operations for the API.
778    pub paths: BTreeMap<String, PathItem>,
779    /// Reusable schemas, parameters, and other components.
780    #[serde(skip_serializing_if = "Option::is_none")]
781    pub components: Option<Components>,
782}
783
784#[cfg(feature = "openapi")]
785/// Provides metadata about the API.
786#[derive(Debug, Serialize, Deserialize)]
787pub struct Info {
788    /// The title of the API.
789    pub title: String,
790    /// The version of the OpenAPI document.
791    pub version: String,
792    /// A description of the API.
793    #[serde(skip_serializing_if = "Option::is_none")]
794    pub description: Option<String>,
795}
796
797#[cfg(feature = "openapi")]
798/// Describes the operations available on a single path.
799#[derive(Default, Debug, Serialize, Deserialize)]
800pub struct PathItem {
801    /// A definition of a GET operation on this path.
802    #[serde(skip_serializing_if = "Option::is_none")]
803    pub get: Option<Operation>,
804    /// A definition of a POST operation on this path.
805    #[serde(skip_serializing_if = "Option::is_none")]
806    pub post: Option<Operation>,
807    /// A definition of a PUT operation on this path.
808    #[serde(skip_serializing_if = "Option::is_none")]
809    pub put: Option<Operation>,
810    /// A definition of a DELETE operation on this path.
811    #[serde(skip_serializing_if = "Option::is_none")]
812    pub delete: Option<Operation>,
813    /// A definition of a PATCH operation on this path.
814    #[serde(skip_serializing_if = "Option::is_none")]
815    pub patch: Option<Operation>,
816}
817
818#[cfg(feature = "openapi")]
819/// Describes a single API operation on a path.
820#[derive(Debug, Serialize, Deserialize)]
821pub struct Operation {
822    /// Unique string used to identify the operation.
823    #[serde(rename = "operationId")]
824    pub operation_id: String,
825    /// A short summary of what the operation does.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub summary: Option<String>,
828    /// A verbose explanation of the operation behavior.
829    #[serde(skip_serializing_if = "Option::is_none")]
830    pub description: Option<String>,
831    /// A list of tags for API documentation control.
832    #[serde(skip_serializing_if = "Vec::is_empty")]
833    pub tags: Vec<String>,
834    /// A list of parameters that are applicable for this operation.
835    #[serde(skip_serializing_if = "Vec::is_empty")]
836    pub parameters: Vec<Parameter>,
837    /// The request body applicable for this operation.
838    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
839    pub request_body: Option<RequestBody>,
840    /// The list of possible responses as they are returned from executing this operation.
841    pub responses: BTreeMap<String, Response>,
842    /// Security requirements for this operation. Non-empty when the route uses `#[secured]`.
843    #[serde(skip_serializing_if = "Vec::is_empty")]
844    pub security: Vec<BTreeMap<String, Vec<String>>>,
845    /// Declares this operation to be deprecated.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub deprecated: Option<bool>,
848    /// Vendor extension: bearer-token scope strings required by this operation.
849    /// Empty for session-only or unsecured routes.
850    #[serde(rename = "x-required-scopes", skip_serializing_if = "Vec::is_empty")]
851    pub x_required_scopes: Vec<String>,
852}
853
854#[cfg(feature = "openapi")]
855/// Describes a single operation parameter.
856///
857/// **Breaking (issue #2251):** carries a new `description` field. A
858/// struct-literal `Parameter { .. }` built outside this crate needs a
859/// `description: None`, or `..Default::default()` for every field it
860/// does not set.
861#[derive(Debug, Default, Serialize, Deserialize)]
862pub struct Parameter {
863    /// The name of the parameter.
864    pub name: String,
865    /// The location of the parameter. Possible values are "query", "header", "path" or "cookie".
866    #[serde(rename = "in")]
867    pub location: String,
868    /// Determines whether this parameter is mandatory.
869    pub required: bool,
870    /// The schema defining the type used for the parameter.
871    pub schema: serde_json::Value,
872    /// Serialization style. A `Query<T>` field gets one of two styles, or
873    /// none:
874    /// * `"form"` with `explode: true` — a scalar or scalar-array field
875    ///   (`?q=foo`, `?tags=a&tags=b`).
876    /// * `"deepObject"` with `explode: true` — a nested-object field
877    ///   (`?filter[status]=open`).
878    /// * `None` — an array-of-objects field (`?items[0][sku]=A-1`). No
879    ///   OpenAPI style expresses this; see [`Self::description`] and the
880    ///   "Known gaps" note in `docs/guide/openapi.md`.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub style: Option<String>,
883    /// When `true` with `style: "form"`, each schema property becomes an
884    /// independent query parameter (e.g. `?q=foo&page=2`). `true` with
885    /// `style: "deepObject"` expands one object level (`?filter[status]=open`).
886    #[serde(skip_serializing_if = "Option::is_none")]
887    pub explode: Option<bool>,
888    /// Free-text note for a shape no OpenAPI `style` can express (an
889    /// array-of-objects query field). Absent whenever `style` is set.
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub description: Option<String>,
892}
893
894#[cfg(feature = "openapi")]
895/// Describes a single request body.
896#[derive(Debug, Serialize, Deserialize)]
897pub struct RequestBody {
898    /// Determines if the request body is required in the request.
899    pub required: bool,
900    /// The content of the request body, keyed by media type.
901    pub content: BTreeMap<String, MediaType>,
902}
903
904#[cfg(feature = "openapi")]
905/// Describes a single response from an API Operation.
906#[derive(Debug, Serialize, Deserialize)]
907pub struct Response {
908    /// A short description of the response.
909    pub description: String,
910    /// A map containing descriptions of potential response payloads, keyed by media type.
911    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
912    pub content: BTreeMap<String, MediaType>,
913}
914
915#[cfg(feature = "openapi")]
916/// Provides schema and examples for the media type identified by its key.
917#[derive(Debug, Serialize, Deserialize)]
918pub struct MediaType {
919    /// The schema defining the content of the request, response, or parameter.
920    pub schema: serde_json::Value,
921}
922
923#[cfg(feature = "openapi")]
924/// Holds a set of reusable objects for different aspects of the OAS.
925#[derive(Debug, Serialize, Deserialize)]
926pub struct Components {
927    /// Reusable Schema Objects.
928    pub schemas: BTreeMap<String, serde_json::Value>,
929    /// Security scheme definitions (e.g. SessionAuth).
930    #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
931    pub security_schemes: BTreeMap<String, serde_json::Value>,
932}
933
934// ──────────────────────────────────────────────────────────────────
935// Spec generator
936// ──────────────────────────────────────────────────────────────────
937
938/// Write the generated OpenAPI spec to `dist/openapi.json` and
939/// `dist/openapi.yaml` inside `dist_dir`.
940///
941/// Called during `autumn build` (when `AUTUMN_BUILD_STATIC=1`) to emit
942/// a machine-readable API contract alongside the pre-rendered HTML pages.
943///
944/// # Errors
945///
946/// Returns an [`std::io::Error`] if the directory cannot be created or
947/// either file cannot be written.
948#[cfg(feature = "openapi")]
949pub fn write_openapi_spec_to_dist(
950    spec: &OpenApiSpec,
951    dist_dir: &std::path::Path,
952) -> std::io::Result<()> {
953    std::fs::create_dir_all(dist_dir)?;
954
955    let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
956    std::fs::write(dist_dir.join("openapi.json"), &json)?;
957
958    let yaml = serde_saphyr::to_string(spec).map_err(std::io::Error::other)?;
959    std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
960
961    Ok(())
962}
963
964/// Resolves each referenced schema *identity* (`type_name`) to a readable,
965/// collision-free OpenAPI component *display key* (issue #1972).
966///
967/// Built once at spec-finalize (and re-derivable purely from the routes so the
968/// MCP tool builder computes the identical mapping). A short last-segment key
969/// (`Args`) is used whenever it is unambiguous; only when two *distinct*
970/// identities would collide on the same last segment is each qualified with
971/// enough trailing module segments to disambiguate (`create.Args` /
972/// `update.Args`). Both the component registration and every `$ref` go through
973/// this map, so a route reference can never resolve to the wrong schema.
974#[cfg(feature = "openapi")]
975#[derive(Default, Debug, Clone)]
976pub struct SchemaComponentIndex {
977    /// identity key (`type_name` or legacy short name) → display component key.
978    by_identity: BTreeMap<String, String>,
979    /// identity → key of its request component, for an identity whose request
980    /// and response shapes differ (issue #2607). The response keeps `by_identity`.
981    request_by_identity: BTreeMap<String, String>,
982    /// Identities referenced only in the request direction. Their one component
983    /// holds the request schema.
984    request_only: std::collections::BTreeSet<String>,
985}
986
987#[cfg(feature = "openapi")]
988impl SchemaComponentIndex {
989    /// The component display key for a `Ref` entry — the value emitted in its
990    /// `#/components/schemas/{key}` `$ref`. Falls back to the sanitized short
991    /// name for an identity that was not part of the indexed route set (e.g. a
992    /// hand-built entry in a unit test).
993    #[must_use]
994    pub fn display_key(&self, entry: &SchemaEntry) -> String {
995        let identity = entry.identity_key();
996        self.by_identity
997            .get(identity)
998            .cloned()
999            .unwrap_or_else(|| component_key(entry.name))
1000    }
1001
1002    /// As [`Self::display_key`], for a reference in `direction` (issue #2607).
1003    ///
1004    /// The key is different only for a request reference to a type whose two
1005    /// shapes differ.
1006    #[must_use]
1007    pub fn display_key_in(&self, entry: &SchemaEntry, direction: SchemaDirection) -> String {
1008        match direction {
1009            SchemaDirection::Request => self
1010                .request_by_identity
1011                .get(entry.identity_key())
1012                .cloned()
1013                .unwrap_or_else(|| self.display_key(entry)),
1014            SchemaDirection::Response => self.display_key(entry),
1015        }
1016    }
1017
1018    /// Resolve a raw schema *identity* string (`type_name`) to its display key,
1019    /// or `None` when the identity is not part of this index. Used by the
1020    /// finalize body-ref rewrite to map a nested derived-schema `$ref` (emitted
1021    /// as the field type's full `type_name`) to its collision-resolved component
1022    /// key (issue #1972).
1023    fn display_key_for_identity(&self, identity: &str, direction: SchemaDirection) -> Option<&str> {
1024        if direction == SchemaDirection::Request
1025            && let Some(key) = self.request_by_identity.get(identity)
1026        {
1027            return Some(key);
1028        }
1029        self.by_identity.get(identity).map(String::as_str)
1030    }
1031
1032    /// Iterate `(identity, display_key, direction)` for every component the
1033    /// back-fill registers. `direction` selects the schema it holds.
1034    fn components(&self) -> impl Iterator<Item = (&String, &String, SchemaDirection)> {
1035        let main = self
1036            .by_identity
1037            .iter()
1038            .map(|(identity, key)| (identity, key, self.main_direction(identity)));
1039        let request = self
1040            .request_by_identity
1041            .iter()
1042            .map(|(identity, key)| (identity, key, SchemaDirection::Request));
1043        main.chain(request)
1044    }
1045
1046    /// The schema direction of the main component of `identity`.
1047    fn main_direction(&self, identity: &str) -> SchemaDirection {
1048        if self.request_only.contains(identity) {
1049            SchemaDirection::Request
1050        } else {
1051            SchemaDirection::Response
1052        }
1053    }
1054}
1055
1056/// The trailing `n` `::`-segments of a `type_name`, joined with `.` and
1057/// sanitized into a component key (`a::b::Args`, n=2 → `b.Args`).
1058#[cfg(feature = "openapi")]
1059fn qualified_suffix_key(identity: &str, depth: usize) -> String {
1060    let segments: Vec<&str> = identity.split("::").collect();
1061    let start = segments.len().saturating_sub(depth);
1062    component_key(&segments[start..].join("::"))
1063}
1064
1065/// Build the identity→display-key map for every schema referenced by `routes`.
1066///
1067/// Pure over the route set. This index does not see user or route
1068/// registrations, so a request key can be equal to one of their keys. Use
1069/// [`schema_component_index_for`] to get the index that [`generate_spec`]
1070/// uses.
1071#[cfg(feature = "openapi")]
1072#[must_use]
1073pub fn build_schema_component_index(routes: &[&ApiDoc]) -> SchemaComponentIndex {
1074    build_schema_component_index_reserving(routes, &BTreeMap::new())
1075}
1076
1077/// The component index [`generate_spec`] uses for `config` and `routes`.
1078///
1079/// The MCP tool builder uses it too, so its `$ref`s match the served spec.
1080#[cfg(feature = "openapi")]
1081#[must_use]
1082pub fn schema_component_index_for(
1083    config: &OpenApiConfig,
1084    routes: &[&ApiDoc],
1085) -> SchemaComponentIndex {
1086    seeded_registry_and_index(config, routes).1
1087}
1088
1089/// The registry before the back-fill (user and route registrations), and the
1090/// index built so that no request key takes one of those keys.
1091#[cfg(feature = "openapi")]
1092fn seeded_registry_and_index(
1093    config: &OpenApiConfig,
1094    routes: &[&ApiDoc],
1095) -> (SchemaRegistry, SchemaComponentIndex) {
1096    let mut registry = SchemaRegistry::default();
1097    for (name, schema) in &config.additional_schemas {
1098        registry.insert(name.clone(), schema.clone());
1099    }
1100    registry.insert("ProblemDetails", problem_details_schema());
1101    for api_doc in routes {
1102        if !api_doc.hidden
1103            && let Some(register) = api_doc.register_schemas
1104        {
1105            (register)(&mut registry);
1106        }
1107    }
1108    let index = build_schema_component_index_reserving(routes, registry.schemas());
1109    (registry, index)
1110}
1111
1112/// Build the index. `registered` holds the user and route registrations. Their
1113/// keys are reserved, and their refs are response uses.
1114#[cfg(feature = "openapi")]
1115fn build_schema_component_index_reserving(
1116    routes: &[&ApiDoc],
1117    registered: &BTreeMap<String, serde_json::Value>,
1118) -> SchemaComponentIndex {
1119    // Collect (identity, base-display) for every referenced Ref entry, deduped
1120    // by identity, and each direction it is referenced in (issue #2607).
1121    let mut graph = IdentityGraph::default();
1122    for api_doc in routes {
1123        if api_doc.hidden {
1124            continue;
1125        }
1126        for (entry, direction) in [
1127            (api_doc.request_body.as_ref(), SchemaDirection::Request),
1128            (api_doc.response.as_ref(), SchemaDirection::Response),
1129            (api_doc.query_schema.as_ref(), SchemaDirection::Request),
1130        ] {
1131            let Some(entry) = entry else { continue };
1132            for e in flatten_ref_entries(entry) {
1133                graph.add(e.identity_key(), component_key(e.name), direction);
1134            }
1135        }
1136    }
1137
1138    // A registered component is a response component: the rewrite treats it
1139    // as one. Thus a ref in it is a response use. An example is the short
1140    // `<Model>` ref of the `<Model>Page` envelope. Without this rule, a type
1141    // that only a list route returns is request-only, and its one component
1142    // gets the request shape.
1143    let mut registered_refs: Vec<String> = Vec::new();
1144    for body in registered.values() {
1145        collect_body_ref_identities(body, &mut registered_refs);
1146    }
1147    let by_identity = loop {
1148        graph.close();
1149        let by_identity = assign_display_keys(&graph.refs);
1150        let mut grew = false;
1151        for target in &registered_refs {
1152            let identity = if graph.seen.contains(target) {
1153                Some(target.clone())
1154            } else {
1155                by_identity
1156                    .iter()
1157                    .find(|(_, key)| *key == target)
1158                    .map(|(identity, _)| identity.clone())
1159            };
1160            if let Some(identity) = identity {
1161                grew |= graph.add_use(identity, SchemaDirection::Response);
1162            }
1163        }
1164        if !grew {
1165            break by_identity;
1166        }
1167    };
1168
1169    let request_only = graph
1170        .seen
1171        .iter()
1172        .filter(|identity| {
1173            !graph
1174                .uses
1175                .contains(&((*identity).clone(), SchemaDirection::Response))
1176        })
1177        .cloned()
1178        .collect();
1179    // A registration on the main key replaces the schema in both directions.
1180    // Such a type does not split, so its request refs use that registration.
1181    let fixed: std::collections::BTreeSet<String> = by_identity
1182        .iter()
1183        .filter(|(_, key)| registered.contains_key(*key))
1184        .map(|(identity, _)| identity.clone())
1185        .collect();
1186    let split = split_identities(&graph.uses, &graph.bodies, &fixed);
1187    let reserved: std::collections::BTreeSet<String> = registered.keys().cloned().collect();
1188    let request_by_identity = assign_request_keys(&split, &by_identity, &reserved);
1189
1190    SchemaComponentIndex {
1191        by_identity,
1192        request_by_identity,
1193        request_only,
1194    }
1195}
1196
1197/// The referenced identities, their directions, and their derived bodies.
1198#[cfg(feature = "openapi")]
1199#[derive(Default)]
1200struct IdentityGraph {
1201    /// `(identity, base display key)`, one entry for each identity.
1202    refs: Vec<(String, String)>,
1203    seen: std::collections::BTreeSet<String>,
1204    uses: std::collections::BTreeSet<(String, SchemaDirection)>,
1205    queue: Vec<(String, SchemaDirection)>,
1206    bodies: BTreeMap<(String, SchemaDirection), Option<serde_json::Value>>,
1207}
1208
1209#[cfg(feature = "openapi")]
1210impl IdentityGraph {
1211    /// Record a reference to `identity` in `direction`.
1212    fn add(&mut self, identity: &str, base: String, direction: SchemaDirection) {
1213        if self.seen.insert(identity.to_owned()) {
1214            self.refs.push((identity.to_owned(), base));
1215        }
1216        self.add_use(identity.to_owned(), direction);
1217    }
1218
1219    /// Record a use of a known identity. Returns `true` for a new use.
1220    fn add_use(&mut self, identity: String, direction: SchemaDirection) -> bool {
1221        let new = self.uses.insert((identity.clone(), direction));
1222        if new {
1223            self.queue.push((identity, direction));
1224        }
1225        new
1226    }
1227
1228    /// Close the identity graph: a `$ref` emitted *inside* a derived schema
1229    /// body (a nested named-struct field) can reference an identity that no
1230    /// route mentions directly. Fetch each derived body and recursively collect
1231    /// the identities it refers to, to a fixpoint, so nested-only types
1232    /// participate in collision detection and each earns its own component
1233    /// (issue #1972). A nested reference has the direction of the body it is
1234    /// in (issue #2607).
1235    fn close(&mut self) {
1236        while let Some((identity, direction)) = self.queue.pop() {
1237            let body = registered_derived_schema_for(&identity, direction);
1238            if let Some(body) = &body {
1239                let mut nested: Vec<String> = Vec::new();
1240                collect_body_ref_identities(body, &mut nested);
1241                for n in nested {
1242                    let base = base_display_for_identity(&n);
1243                    self.add(&n, base, direction);
1244                }
1245            }
1246            self.bodies.insert((identity, direction), body);
1247        }
1248    }
1249}
1250
1251/// The identities that need a separate request component (issue #2607).
1252///
1253/// An identity splits when both directions reference it and either its two
1254/// schemas differ, or a type it references splits. The second rule finds an
1255/// asymmetric type inside a symmetric wrapper. An identity in `fixed` never
1256/// splits.
1257#[cfg(feature = "openapi")]
1258fn split_identities(
1259    uses: &std::collections::BTreeSet<(String, SchemaDirection)>,
1260    bodies: &BTreeMap<(String, SchemaDirection), Option<serde_json::Value>>,
1261    fixed: &std::collections::BTreeSet<String>,
1262) -> std::collections::BTreeSet<String> {
1263    let body = |identity: &str, direction| {
1264        bodies
1265            .get(&(identity.to_owned(), direction))
1266            .and_then(Option::as_ref)
1267    };
1268    let both: Vec<&str> = uses
1269        .iter()
1270        .filter(|(identity, direction)| {
1271            *direction == SchemaDirection::Request
1272                && uses.contains(&(identity.clone(), SchemaDirection::Response))
1273                && !fixed.contains(identity)
1274        })
1275        .map(|(identity, _)| identity.as_str())
1276        .collect();
1277    let mut split: std::collections::BTreeSet<String> = both
1278        .iter()
1279        .filter(|identity| {
1280            body(identity, SchemaDirection::Request) != body(identity, SchemaDirection::Response)
1281        })
1282        .map(|identity| (*identity).to_owned())
1283        .collect();
1284    loop {
1285        let before = split.len();
1286        for identity in &both {
1287            if split.contains(*identity) {
1288                continue;
1289            }
1290            let mut nested: Vec<String> = Vec::new();
1291            if let Some(b) = body(identity, SchemaDirection::Request) {
1292                collect_body_ref_identities(b, &mut nested);
1293            }
1294            if nested.iter().any(|n| split.contains(n)) {
1295                split.insert((*identity).to_owned());
1296            }
1297        }
1298        if split.len() == before {
1299            return split;
1300        }
1301    }
1302}
1303
1304/// Give each split identity a request key: `<Key>Request`, or `<Key>Request-N`
1305/// when a component or `reserved` already has that key. Deterministic: the
1306/// identities are visited in sorted order.
1307#[cfg(feature = "openapi")]
1308fn assign_request_keys(
1309    split: &std::collections::BTreeSet<String>,
1310    by_identity: &BTreeMap<String, String>,
1311    reserved: &std::collections::BTreeSet<String>,
1312) -> BTreeMap<String, String> {
1313    let mut used: std::collections::BTreeSet<String> = by_identity
1314        .values()
1315        .cloned()
1316        .chain(reserved.iter().cloned())
1317        .collect();
1318    let mut out = BTreeMap::new();
1319    for identity in split {
1320        let Some(main) = by_identity.get(identity) else {
1321            continue;
1322        };
1323        let base = format!("{main}Request");
1324        let mut key = base.clone();
1325        let mut n = 2u32;
1326        while used.contains(&key) {
1327            key = format!("{base}-{n}");
1328            n += 1;
1329        }
1330        used.insert(key.clone());
1331        out.insert(identity.clone(), key);
1332    }
1333    out
1334}
1335
1336/// Assign a **unique, deterministic** display component key to every referenced
1337/// schema identity. `refs` is `(identity, base_display)` pairs; identities are
1338/// assumed already deduped. Pure over its input and independent of the order the
1339/// pairs are supplied (identities are qualified in a stable sorted order), so the
1340/// same route set always yields the same keys across runs and link orders.
1341///
1342/// Every distinct identity is guaranteed a distinct display key: the collision
1343/// ladder (fewest trailing `::`-segments that make the key unique → full
1344/// sanitized fallback) can be exhausted, and a colliding identity's fallback key
1345/// can equal a key another colliding identity already claimed (e.g. crate `app`
1346/// with `app::app::Args` claiming `app.Args` at depth 2 while `app::Args`
1347/// exhausts its ladder and falls back to the same `app.Args`). When the best
1348/// candidate is still taken, a deterministic `-N` disambiguator is appended until
1349/// the key is free. `-` never appears in a `component_key` output derived from a
1350/// real Rust `type_name` (Rust paths contain no `-`), so the disambiguator can
1351/// never collide with a naturally-produced key (issue #1972).
1352#[cfg(feature = "openapi")]
1353fn assign_display_keys(refs: &[(String, String)]) -> BTreeMap<String, String> {
1354    // Which base display keys are shared by more than one distinct identity?
1355    let mut by_base: BTreeMap<String, std::collections::BTreeSet<&str>> = BTreeMap::new();
1356    for (identity, base) in refs {
1357        by_base
1358            .entry(base.clone())
1359            .or_default()
1360            .insert(identity.as_str());
1361    }
1362
1363    let mut by_identity: BTreeMap<String, String> = BTreeMap::new();
1364    let mut used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1365
1366    // First pass: assign the plain last-segment key to every identity whose base
1367    // is unambiguous, and to legacy short-name identities (no `::` path — the
1368    // repository macro's model refs) which cannot be qualified anyway.
1369    for (identity, base) in refs {
1370        let collides = by_base[base].len() > 1 && identity.contains("::");
1371        if !collides {
1372            by_identity.entry(identity.clone()).or_insert_with(|| {
1373                used.insert(base.clone());
1374                base.clone()
1375            });
1376        }
1377    }
1378
1379    // Second pass: qualify each genuinely-colliding real type path with the
1380    // fewest trailing module segments that make its key unique. Iterate in a
1381    // stable sorted order so the assignment is deterministic regardless of the
1382    // order pairs were collected, and guarantee the final key is free even when
1383    // the suffix ladder and the full-sanitized fallback are both exhausted.
1384    let mut pending: Vec<&String> = refs
1385        .iter()
1386        .map(|(identity, _)| identity)
1387        .filter(|identity| !by_identity.contains_key(*identity))
1388        .collect();
1389    pending.sort_unstable();
1390    for identity in pending {
1391        let depth_max = identity.split("::").count();
1392        let mut display = (2..=depth_max)
1393            .map(|depth| qualified_suffix_key(identity, depth))
1394            .find(|candidate| !used.contains(candidate))
1395            .unwrap_or_else(|| component_key(identity));
1396        if used.contains(&display) {
1397            let base = display.clone();
1398            let mut n = 2u32;
1399            loop {
1400                let candidate = format!("{base}-{n}");
1401                if !used.contains(&candidate) {
1402                    display = candidate;
1403                    break;
1404                }
1405                n += 1;
1406            }
1407        }
1408        used.insert(display.clone());
1409        by_identity.insert(identity.clone(), display);
1410    }
1411
1412    by_identity
1413}
1414
1415/// The base (short) display key for a raw schema *identity* string: its last
1416/// `::`-segment (ignoring any generic argument list), sanitized into a component
1417/// key. Mirrors what the macros derive from `last_segment_name` for a top-level
1418/// route ref, so a nested-only identity groups under the same base as a route
1419/// ref of the same short name (issue #1972).
1420#[cfg(feature = "openapi")]
1421fn base_display_for_identity(identity: &str) -> String {
1422    let without_generics = identity.split('<').next().unwrap_or(identity);
1423    let last = without_generics
1424        .rsplit("::")
1425        .next()
1426        .unwrap_or(without_generics);
1427    component_key(last)
1428}
1429
1430/// Recursively collect every `#/components/schemas/<identity>` ref target found
1431/// inside a (derived) schema body, pushing each raw identity string into `out`.
1432///
1433/// The macro emits a nested named-struct field's `$ref` as the field type's full
1434/// `type_name` identity, so the strings gathered here are exactly the identity
1435/// keys the collision index resolves.
1436#[cfg(feature = "openapi")]
1437fn collect_body_ref_identities(value: &serde_json::Value, out: &mut Vec<String>) {
1438    match value {
1439        serde_json::Value::Object(map) => {
1440            if let Some(serde_json::Value::String(reference)) = map.get("$ref")
1441                && let Some(id) = reference.strip_prefix("#/components/schemas/")
1442            {
1443                out.push(id.to_owned());
1444            }
1445            for v in map.values() {
1446                collect_body_ref_identities(v, out);
1447            }
1448        }
1449        serde_json::Value::Array(items) => {
1450            for v in items {
1451                collect_body_ref_identities(v, out);
1452            }
1453        }
1454        _ => {}
1455    }
1456}
1457
1458/// Rewrite every body-internal `#/components/schemas/<identity>` ref in each
1459/// registered component to its collision-resolved display key.
1460///
1461/// A ref whose target is not a known identity (e.g. the pagination envelope's
1462/// short `#/components/schemas/<Model>` ref, which is already a display key) is
1463/// left untouched, so the common non-colliding case produces exactly the short
1464/// key it did before (no churn — issue #1972).
1465#[cfg(feature = "openapi")]
1466fn rewrite_component_body_refs(
1467    components: &mut BTreeMap<String, serde_json::Value>,
1468    index: &SchemaComponentIndex,
1469) {
1470    // A ref inside a request component is a request ref (issue #2607). A
1471    // component the index does not own is a response component.
1472    let directions: BTreeMap<String, SchemaDirection> = index
1473        .components()
1474        .map(|(_, key, direction)| (key.clone(), direction))
1475        .collect();
1476    for (key, schema) in components.iter_mut() {
1477        let direction = directions
1478            .get(key)
1479            .copied()
1480            .unwrap_or(SchemaDirection::Response);
1481        rewrite_identity_refs(schema, index, direction);
1482    }
1483}
1484
1485#[cfg(feature = "openapi")]
1486fn rewrite_identity_refs(
1487    value: &mut serde_json::Value,
1488    index: &SchemaComponentIndex,
1489    direction: SchemaDirection,
1490) {
1491    match value {
1492        serde_json::Value::Object(map) => {
1493            if let Some(serde_json::Value::String(reference)) = map.get_mut("$ref") {
1494                let replacement = reference
1495                    .strip_prefix("#/components/schemas/")
1496                    .and_then(|identity| index.display_key_for_identity(identity, direction))
1497                    .map(|display| format!("#/components/schemas/{display}"));
1498                if let Some(new_ref) = replacement {
1499                    *reference = new_ref;
1500                }
1501            }
1502            for v in map.values_mut() {
1503                rewrite_identity_refs(v, index, direction);
1504            }
1505        }
1506        serde_json::Value::Array(items) => {
1507            for v in items {
1508                rewrite_identity_refs(v, index, direction);
1509            }
1510        }
1511        _ => {}
1512    }
1513}
1514
1515/// `type_name` prefixes that identify `std`'s own `Option` / `Vec`.
1516///
1517/// Both spellings are accepted for each: `core::option::Option` /
1518/// `alloc::vec::Vec` are what today's rustc renders, while `std::option::` /
1519/// `std::vec::` are re-export paths a future rustc could plausibly print.
1520#[cfg(feature = "openapi")]
1521const OPTION_TYPE_NAME_PREFIXES: [&str; 2] = ["core::option::Option<", "std::option::Option<"];
1522#[cfg(feature = "openapi")]
1523const VEC_TYPE_NAME_PREFIXES: [&str; 2] = ["alloc::vec::Vec<", "std::vec::Vec<"];
1524
1525/// Does this `Array` / `Nullable` entry describe an application type that merely
1526/// SPELLS `Vec` / `Option` in its last path segment?
1527///
1528/// The route macros can only match a type by the tokens as written, so
1529/// `domain::Option<T>` — an ordinary struct — produces a `Nullable` entry just
1530/// as `std`'s `Option<T>` does. Rendering it as nullable (or as an array, for a
1531/// `domain::Vec<T>`) advertises a wire shape that type does not serialize.
1532/// The wrapper entry carries its own `type_name`, which settles it.
1533///
1534/// An entry with no identity is treated as genuine: hand-built entries and
1535/// anything emitted before wrapper identities existed keep their old shape.
1536#[cfg(feature = "openapi")]
1537fn wrapper_is_impostor(entry: &SchemaEntry) -> bool {
1538    let prefixes: &[&str] = match entry.kind {
1539        SchemaKind::Array(_) => &VEC_TYPE_NAME_PREFIXES,
1540        SchemaKind::Nullable(_) => &OPTION_TYPE_NAME_PREFIXES,
1541        SchemaKind::Ref | SchemaKind::Primitive(_) => return false,
1542    };
1543    entry.identity.is_some_and(|resolve| {
1544        let identity = resolve();
1545        !prefixes.iter().any(|prefix| identity.starts_with(prefix))
1546    })
1547}
1548
1549/// The `type_name` of `serde_json::Value`.
1550#[cfg(feature = "openapi")]
1551const SERDE_JSON_VALUE_IDENTITY: &str = "serde_json::value::Value";
1552
1553/// The schema for arbitrary JSON: no constraint at all.
1554///
1555/// Deliberately identical to what `#[derive(OpenApiSchema)]` emits for a
1556/// `serde_json::Value` FIELD, so a route-level `Json<Value>` and a model field
1557/// of the same type describe the same contract.
1558#[cfg(feature = "openapi")]
1559fn arbitrary_json_schema() -> serde_json::Value {
1560    serde_json::json!({
1561        "description": "Arbitrary JSON: an object, array, string, number, boolean or null.",
1562    })
1563}
1564
1565/// Does this entry describe a genuine `serde_json::Value`?
1566///
1567/// A handler taking or returning `Json<serde_json::Value>` produced an ordinary
1568/// named `Ref`. Nothing registers a schema for that external type, so the
1569/// back-fill gave it the opaque `{"type":"object"}` placeholder — which
1570/// misdescribes every array, scalar and null it legitimately carries, and made
1571/// `--strict` fail on a handler that is behaving correctly. The model-field path
1572/// already special-cases this; the route-level builder is the same rule one step
1573/// out.
1574///
1575/// Checked by full `type_name`, never by the last path segment: an application's
1576/// own `Value` is an ordinary type and keeps its `$ref`.
1577#[cfg(feature = "openapi")]
1578fn entry_is_arbitrary_json(entry: &SchemaEntry) -> bool {
1579    matches!(entry.kind, SchemaKind::Ref)
1580        && entry
1581            .identity
1582            .is_some_and(|resolve| resolve() == SERDE_JSON_VALUE_IDENTITY)
1583}
1584
1585/// Flatten an entry, yielding each leaf `Ref` entry reached through
1586/// `Array` / `Nullable` wrappers (so a `Json<Vec<User>>` contributes `User`).
1587#[cfg(feature = "openapi")]
1588fn flatten_ref_entries(entry: &SchemaEntry) -> Vec<&SchemaEntry> {
1589    // An impostor wrapper is a named type in its own right: it earns its own
1590    // component, rather than contributing the inner type it never wraps.
1591    if wrapper_is_impostor(entry) {
1592        return vec![entry];
1593    }
1594    // Arbitrary JSON is described inline, so it must NOT earn a component —
1595    // registering one is exactly how it became an opaque placeholder.
1596    if entry_is_arbitrary_json(entry) {
1597        return Vec::new();
1598    }
1599    match entry.kind {
1600        SchemaKind::Ref => vec![entry],
1601        SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => flatten_ref_entries(inner),
1602        SchemaKind::Primitive(_) => Vec::new(),
1603    }
1604}
1605
1606/// Build an [`OpenApiSpec`] from a collection of routes and user config.
1607///
1608/// This is the core of the auto-generation: every route's [`ApiDoc`] is
1609/// translated into an [`Operation`] under the matching [`PathItem`].
1610#[cfg(feature = "openapi")]
1611#[must_use]
1612pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
1613    generate_spec_at(config, routes, crate::time::ambient_now())
1614}
1615
1616#[cfg(feature = "openapi")]
1617#[must_use]
1618pub fn generate_spec_at(
1619    config: &OpenApiConfig,
1620    routes: &[&ApiDoc],
1621    now: chrono::DateTime<chrono::Utc>,
1622) -> OpenApiSpec {
1623    let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
1624
1625    // Seed the user and route registrations, then resolve every referenced
1626    // schema identity to a collision-free component display key up front, so
1627    // both the `$ref` sites (via `operation_for`) and the back-fill below
1628    // register/reference the exact same key (issue #1972).
1629    let (mut registry, index) = seeded_registry_and_index(config, routes);
1630
1631    let mut any_secured = false;
1632    let mut any_scoped = false;
1633
1634    for api_doc in routes {
1635        if api_doc.hidden {
1636            continue;
1637        }
1638        if api_doc.secured {
1639            any_secured = true;
1640        }
1641        if !api_doc.required_scopes.is_empty() {
1642            any_scoped = true;
1643        }
1644
1645        let operation = operation_for(api_doc, &config.api_versions, now, &index);
1646        let entry = paths.entry(api_doc.path.to_owned()).or_default();
1647        match api_doc.method {
1648            "GET" => entry.get = Some(operation),
1649            "POST" => entry.post = Some(operation),
1650            "PUT" => entry.put = Some(operation),
1651            "DELETE" => entry.delete = Some(operation),
1652            "PATCH" => entry.patch = Some(operation),
1653            // Unknown methods are silently skipped; Autumn's route macros
1654            // only emit the five verbs above today.
1655            _ => {}
1656        }
1657    }
1658
1659    // Back-fill a schema for every referenced identity the user didn't already
1660    // register, under its resolved display key. A `#[derive(OpenApiSchema)]`
1661    // type advertises a real field-accurate schema through the compile-time
1662    // inventory (matched by identity, so two same-named types never shadow each
1663    // other — issue #1972); only an identity with no derived schema (and no
1664    // explicit `OpenApiConfig::register_schema`) falls back to the minimal
1665    // `{"type": "object", "title": "X"}` placeholder. Each component holds the
1666    // schema of its direction (issue #2607).
1667    for (identity, display_key, direction) in index.components() {
1668        if !registry.schemas().contains_key(display_key) {
1669            let schema = registered_derived_schema_for(identity, direction).unwrap_or_else(|| {
1670                serde_json::json!({
1671                    "type": "object",
1672                    "title": display_key,
1673                })
1674            });
1675            registry.insert(display_key.clone(), schema);
1676        }
1677    }
1678
1679    // Register auth security schemes used by secured routes.
1680    let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1681    if any_secured {
1682        security_schemes.insert(
1683            "SessionAuth".to_owned(),
1684            serde_json::json!({
1685                "type": "apiKey",
1686                "in": "cookie",
1687                "name": config.session_cookie_name.clone(),
1688                "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
1689            }),
1690        );
1691    }
1692    if any_scoped {
1693        security_schemes.insert(
1694            "BearerAuth".to_owned(),
1695            serde_json::json!({
1696                "type": "http",
1697                "scheme": "bearer",
1698                "description": "API bearer token. Scope-secured routes require a valid token whose scopes include all required values.",
1699            }),
1700        );
1701    }
1702
1703    let mut components_map = registry.into_map();
1704    // Rewrite every `$ref` that appears *inside* a component body from its raw
1705    // schema identity (`type_name`) to its collision-resolved display key, so a
1706    // nested derived-schema ref resolves to the same component the top-level
1707    // route refs use (issue #1972). Top-level operation refs are already emitted
1708    // as display keys by `operation_for`/`schema_value_for`; this closes the
1709    // body-internal half of the `$ref` graph.
1710    rewrite_component_body_refs(&mut components_map, &index);
1711    let components = if !components_map.is_empty() || !security_schemes.is_empty() {
1712        Some(Components {
1713            schemas: components_map,
1714            security_schemes,
1715        })
1716    } else {
1717        None
1718    };
1719
1720    OpenApiSpec {
1721        openapi: "3.1.0".to_owned(),
1722        info: Info {
1723            title: config.title.clone(),
1724            version: config.version.clone(),
1725            description: config.description.clone(),
1726        },
1727        paths,
1728        components,
1729    }
1730}
1731
1732#[cfg(feature = "openapi")]
1733#[allow(clippy::too_many_lines)]
1734fn operation_for(
1735    api_doc: &ApiDoc,
1736    api_versions: &[crate::app::ApiVersion],
1737    now: chrono::DateTime<chrono::Utc>,
1738    index: &SchemaComponentIndex,
1739) -> Operation {
1740    let mut tags = if api_doc.tags.is_empty() {
1741        default_tag(api_doc.path)
1742            .map(|t| vec![t.to_owned()])
1743            .unwrap_or_default()
1744    } else {
1745        api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
1746    };
1747
1748    if let Some(version) = api_doc.api_version {
1749        tags.push(version.to_string());
1750    }
1751
1752    let is_deprecated = api_doc.api_version.is_some_and(|version| {
1753        api_versions
1754            .iter()
1755            .find(|av| av.version == version)
1756            .is_some_and(|av| {
1757                let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
1758                let is_sun = av.sunset_at.is_some_and(|s| now >= s);
1759                is_dep || is_sun
1760            })
1761    });
1762    let deprecated = if is_deprecated { Some(true) } else { None };
1763
1764    // Path parameters — always required.
1765    let mut parameters: Vec<Parameter> = api_doc
1766        .path_params
1767        .iter()
1768        .map(|name| Parameter {
1769            name: (*name).to_owned(),
1770            location: "path".to_owned(),
1771            required: true,
1772            schema: serde_json::json!({ "type": "string" }),
1773            style: None,
1774            explode: None,
1775            description: None,
1776        })
1777        .collect();
1778
1779    // Query parameters from `Query<T>` extractor — one per struct field when
1780    // the field shapes are known (issue #2251), else the old single
1781    // struct-level parameter. See `query_parameters_for`.
1782    if let Some(query_entry) = &api_doc.query_schema {
1783        parameters.extend(query_parameters_for(query_entry, index));
1784    }
1785
1786    let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
1787        required: true,
1788        content: std::iter::once((
1789            "application/json".to_owned(),
1790            MediaType {
1791                schema: schema_value_for(entry, index, SchemaDirection::Request),
1792            },
1793        ))
1794        .collect(),
1795    });
1796
1797    let mut responses: BTreeMap<String, Response> = BTreeMap::new();
1798    let status = if api_doc.success_status == 0 {
1799        200
1800    } else {
1801        api_doc.success_status
1802    };
1803    let response_content = api_doc
1804        .response
1805        .as_ref()
1806        .map(|entry| {
1807            let mut content = BTreeMap::new();
1808            content.insert(
1809                "application/json".to_owned(),
1810                MediaType {
1811                    schema: schema_value_for(entry, index, SchemaDirection::Response),
1812                },
1813            );
1814            content
1815        })
1816        .unwrap_or_default();
1817    responses.insert(
1818        status.to_string(),
1819        Response {
1820            description: status_description(status).to_owned(),
1821            content: response_content,
1822        },
1823    );
1824    insert_problem_responses(&mut responses);
1825
1826    // If this route version has a sunset schedule and is not opted out, document 410 Gone
1827    let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
1828        api_versions
1829            .iter()
1830            .find(|av| av.version == version)
1831            .is_some_and(|av| av.sunset_at.is_some())
1832            && !api_doc.sunset_opt_out
1833    });
1834
1835    if is_subject_to_sunset {
1836        responses.entry("410".to_owned()).or_insert_with(|| {
1837            let mut content = BTreeMap::new();
1838            content.insert(
1839                "application/problem+json".to_owned(),
1840                MediaType {
1841                    schema: serde_json::json!({
1842                        "$ref": "#/components/schemas/ProblemDetails",
1843                    }),
1844                },
1845            );
1846            Response {
1847                description: status_description(410).to_owned(),
1848                content,
1849            }
1850        });
1851    }
1852
1853    // Security requirements:
1854    //   - scopes-only  (#[secured(scopes=[…])])            → BearerAuth
1855    //   - roles+scopes (#[secured("r", scopes=[…])])       → SessionAuth AND BearerAuth
1856    //   - roles-only / bare #[secured]                     → SessionAuth
1857    // Both entries in one BTreeMap object means AND per the OpenAPI spec.
1858    // HTTP-bearer scheme value arrays must be empty (non-empty arrays are OAuth2 scopes).
1859    let security = if api_doc.secured {
1860        let mut req = BTreeMap::new();
1861        if !api_doc.required_scopes.is_empty() {
1862            req.insert("BearerAuth".to_owned(), Vec::<String>::new());
1863        }
1864        if api_doc.required_scopes.is_empty() || !api_doc.required_roles.is_empty() {
1865            req.insert("SessionAuth".to_owned(), Vec::<String>::new());
1866        }
1867        vec![req]
1868    } else {
1869        Vec::new()
1870    };
1871
1872    Operation {
1873        operation_id: api_doc.operation_id.to_owned(),
1874        summary: api_doc.summary.map(str::to_owned),
1875        description: api_doc.description.map(str::to_owned),
1876        tags,
1877        parameters,
1878        request_body,
1879        responses,
1880        security,
1881        deprecated,
1882        x_required_scopes: api_doc
1883            .required_scopes
1884            .iter()
1885            .map(ToString::to_string)
1886            .collect(),
1887    }
1888}
1889
1890/// Build the `Query<T>` parameters for one operation (issue #2251).
1891///
1892/// One [`Parameter`] per field of `T` when `T`'s shape is known (an
1893/// `OpenApiSchema` back-fill match) — each field then gets the `style` that
1894/// actually round-trips it through [`crate::query_string`]. Falls back to the
1895/// old single struct-level parameter when `T`'s fields cannot be read (no
1896/// churn for a `Query<T>` that never opted into `#[derive(OpenApiSchema)]`).
1897#[cfg(feature = "openapi")]
1898fn query_parameters_for(query_entry: &SchemaEntry, index: &SchemaComponentIndex) -> Vec<Parameter> {
1899    if matches!(query_entry.kind, SchemaKind::Ref)
1900        && let Some(body) =
1901            registered_derived_schema_for(query_entry.identity_key(), SchemaDirection::Request)
1902        && let Some(fields) = query_parameters_from_body(&body, index)
1903    {
1904        return fields;
1905    }
1906    vec![Parameter {
1907        name: query_entry.name.to_owned(),
1908        location: "query".to_owned(),
1909        required: false,
1910        schema: schema_value_for(query_entry, index, SchemaDirection::Request),
1911        style: Some("form".to_owned()),
1912        explode: Some(true),
1913        description: None,
1914    }]
1915}
1916
1917/// Split a query struct's own JSON Schema body into one [`Parameter`] per
1918/// property. `None` when `body` is not a plain object schema with
1919/// `properties` — the caller then keeps the old whole-struct parameter.
1920#[cfg(feature = "openapi")]
1921fn query_parameters_from_body(
1922    body: &serde_json::Value,
1923    index: &SchemaComponentIndex,
1924) -> Option<Vec<Parameter>> {
1925    let object = body.as_object()?;
1926    if object.get("type").and_then(serde_json::Value::as_str) != Some("object") {
1927        return None;
1928    }
1929    let properties = object.get("properties")?.as_object()?;
1930    let required = object.get("required").and_then(serde_json::Value::as_array);
1931    let is_required =
1932        |name: &str| required.is_some_and(|r| r.iter().any(|v| v.as_str() == Some(name)));
1933
1934    Some(
1935        properties
1936            .iter()
1937            .map(|(name, field_schema)| {
1938                let (style, explode, description) = query_field_style(field_schema);
1939                // A field's $ref here still names the raw type_name, not the
1940                // display key. The finalize pass fixes that up everywhere it
1941                // tracks — but not in this parameter's schema, since it lives
1942                // outside components_map. Rewrite it here instead.
1943                let mut schema = field_schema.clone();
1944                rewrite_identity_refs(&mut schema, index, SchemaDirection::Request);
1945                Parameter {
1946                    name: name.clone(),
1947                    location: "query".to_owned(),
1948                    required: is_required(name),
1949                    schema,
1950                    style,
1951                    explode,
1952                    description,
1953                }
1954            })
1955            .collect(),
1956    )
1957}
1958
1959/// How one query field's shape decodes through [`crate::query_string`], and
1960/// the `(style, explode, description)` that documents it.
1961#[cfg(feature = "openapi")]
1962fn query_field_style(schema: &serde_json::Value) -> (Option<String>, Option<bool>, Option<String>) {
1963    match query_field_shape(schema) {
1964        QueryFieldShape::Flat => (Some("form".to_owned()), Some(true), None),
1965        QueryFieldShape::Object => (Some("deepObject".to_owned()), Some(true), None),
1966        QueryFieldShape::ObjectArray => (
1967            None,
1968            None,
1969            Some(
1970                "Bracketed nested-array query encoding, e.g. \
1971                 ?field[0][prop]=value for an array of objects, or \
1972                 ?field[0][0]=value for an array of arrays. No OpenAPI \
1973                 style expresses this; see the query-string guide."
1974                    .to_owned(),
1975            ),
1976        ),
1977    }
1978}
1979
1980/// A query field's shape, as far as it changes how it decodes off the wire.
1981#[cfg(feature = "openapi")]
1982enum QueryFieldShape {
1983    /// A scalar, or an array of scalars — `form`/`explode` is exact.
1984    Flat,
1985    /// A nested object — needs `deepObject`.
1986    Object,
1987    /// An array of objects — no OpenAPI `style` expresses this.
1988    ObjectArray,
1989}
1990
1991#[cfg(feature = "openapi")]
1992fn query_field_shape(schema: &serde_json::Value) -> QueryFieldShape {
1993    let effective = unwrap_nullable_schema(schema);
1994    if let Some(identity) = ref_target(effective) {
1995        return if ref_is_object(identity) {
1996            QueryFieldShape::Object
1997        } else {
1998            QueryFieldShape::Flat
1999        };
2000    }
2001    // An object schema inlined at the field site, not behind a $ref — a
2002    // #[translatable] field is the one case the macro emits today
2003    // (autumn-macros/src/schema.rs, emit_json_schema_tokens_for_field).
2004    if effective.get("type").and_then(serde_json::Value::as_str) == Some("object") {
2005        return QueryFieldShape::Object;
2006    }
2007    if effective.get("type").and_then(serde_json::Value::as_str) == Some("array")
2008        && let Some(items) = effective.get("items")
2009    {
2010        let items = unwrap_nullable_schema(items);
2011        let items_are_objects = ref_target(items).map_or_else(
2012            || items.get("type").and_then(serde_json::Value::as_str) == Some("array"),
2013            ref_is_object,
2014        );
2015        if items_are_objects {
2016            return QueryFieldShape::ObjectArray;
2017        }
2018    }
2019    QueryFieldShape::Flat
2020}
2021
2022/// Unwrap `{"oneOf": [<real>, {"type": "null"}]}` down to `<real>`, repeating
2023/// for a doubly-wrapped `Option<Option<T>>`. `emit_json_schema_tokens_for_field`
2024/// emits this shape for every `Option<T>` field. Returns `schema` unchanged
2025/// once nothing more unwraps.
2026#[cfg(feature = "openapi")]
2027fn unwrap_nullable_schema(schema: &serde_json::Value) -> &serde_json::Value {
2028    let mut current = schema;
2029    while let Some(real) = current
2030        .get("oneOf")
2031        .and_then(serde_json::Value::as_array)
2032        .and_then(|branches| branches.first())
2033    {
2034        current = real;
2035    }
2036    current
2037}
2038
2039/// The raw `$ref` target identity, when `schema` is a bare reference.
2040#[cfg(feature = "openapi")]
2041fn ref_target(schema: &serde_json::Value) -> Option<&str> {
2042    schema
2043        .get("$ref")
2044        .and_then(serde_json::Value::as_str)
2045        .and_then(|r| r.strip_prefix("#/components/schemas/"))
2046}
2047
2048/// Does the schema `identity` names describe a JSON object?
2049///
2050/// An identity with no registered schema defaults to `false` (flat). Most
2051/// unregistered `$ref` targets are a plain enum or newtype that never opted
2052/// into `#[derive(OpenApiSchema)]` — describing one as `deepObject` would
2053/// send `?dir[...]=...` for a field the handler reads as `?dir=asc`. This
2054/// matches what the OLD whole-struct fallback did for every field, so an
2055/// unregistered type is never worse off than before this per-field split
2056/// (issue #2251). The cost falls on an unregistered field that genuinely IS
2057/// nested (e.g. `HashMap<String, V>`) — add `#[derive(OpenApiSchema)]` to a
2058/// wrapping type, or accept the field as `form`-styled and document the real
2059/// shape by hand.
2060#[cfg(feature = "openapi")]
2061fn ref_is_object(identity: &str) -> bool {
2062    registered_derived_schema(identity).is_some_and(|schema| {
2063        schema.get("type").and_then(serde_json::Value::as_str) == Some("object")
2064    })
2065}
2066
2067/// Render a [`SchemaEntry`] into its JSON Schema value.
2068///
2069/// Produces the same shape the OpenAPI generator emits. Exposed so the MCP
2070/// projection can derive a tool's `inputSchema` from the exact same typed
2071/// contract — guaranteeing the tool schema cannot drift from the handler.
2072///
2073/// `index` resolves each `Ref` to its collision-free component display key
2074/// (issue #1972); build it once with [`schema_component_index_for`] over the
2075/// same route set so tool `$ref`s match the served OpenAPI components exactly.
2076///
2077/// This renders a response reference. For a request body or query, use
2078/// [`schema_entry_to_value_in`] with [`SchemaDirection::Request`].
2079#[cfg(feature = "openapi")]
2080#[must_use]
2081pub fn schema_entry_to_value(
2082    entry: &SchemaEntry,
2083    index: &SchemaComponentIndex,
2084) -> serde_json::Value {
2085    schema_value_for(entry, index, SchemaDirection::Response)
2086}
2087
2088/// As [`schema_entry_to_value`], for a reference in `direction` (issue #2607).
2089#[cfg(feature = "openapi")]
2090#[must_use]
2091pub fn schema_entry_to_value_in(
2092    entry: &SchemaEntry,
2093    index: &SchemaComponentIndex,
2094    direction: SchemaDirection,
2095) -> serde_json::Value {
2096    schema_value_for(entry, index, direction)
2097}
2098
2099#[cfg(feature = "openapi")]
2100fn schema_value_for(
2101    entry: &SchemaEntry,
2102    index: &SchemaComponentIndex,
2103    direction: SchemaDirection,
2104) -> serde_json::Value {
2105    // `domain::Option<T>` / `domain::Vec<T>` reach the wrapper arms by last-path
2106    // -segment match but are ordinary named types: describe them as such.
2107    if wrapper_is_impostor(entry) {
2108        return serde_json::json!({
2109            "$ref": format!("#/components/schemas/{}", index.display_key_in(entry, direction))
2110        });
2111    }
2112    if entry_is_arbitrary_json(entry) {
2113        return arbitrary_json_schema();
2114    }
2115    match entry.kind {
2116        SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
2117        SchemaKind::Ref => {
2118            serde_json::json!({
2119                "$ref": format!("#/components/schemas/{}", index.display_key_in(entry, direction))
2120            })
2121        }
2122        SchemaKind::Array(items) => serde_json::json!({
2123            "type": "array",
2124            "items": schema_value_for(items, index, direction),
2125        }),
2126        SchemaKind::Nullable(inner) => {
2127            // OpenAPI 3.1 aligns with JSON Schema 2020-12, which supports
2128            // `type: "null"` natively:
2129            //   * For a `$ref`, use `oneOf: [{$ref: ...}, {type: "null"}]`
2130            //     so the ref can stand alone without `allOf` workarounds.
2131            //   * For primitives, use the compact type-array form: `type: ["T", "null"]`.
2132            //   * For all other schemas (arrays, nested nullable, etc.), use `oneOf`
2133            //     so the full inner schema (e.g. `items`) is preserved.
2134            // `Option<serde_json::Value>` must NOT be wrapped: the
2135            // unconstrained schema already admits null, and `oneOf` demands that
2136            // EXACTLY ONE branch match — so `oneOf [{unconstrained}, {null}]`
2137            // would reject the very null it is meant to permit.
2138            if entry_is_arbitrary_json(inner) {
2139                return arbitrary_json_schema();
2140            }
2141            match inner.kind {
2142                SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
2143                    serde_json::json!({
2144                        "oneOf": [
2145                            schema_value_for(inner, index, direction),
2146                            { "type": "null" },
2147                        ],
2148                    })
2149                }
2150                SchemaKind::Primitive(base_type) => {
2151                    serde_json::json!({ "type": [base_type, "null"] })
2152                }
2153            }
2154        }
2155    }
2156}
2157
2158#[cfg(feature = "openapi")]
2159fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
2160    for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
2161        responses.entry(status.to_string()).or_insert_with(|| {
2162            let mut content = BTreeMap::new();
2163            content.insert(
2164                "application/problem+json".to_owned(),
2165                MediaType {
2166                    schema: serde_json::json!({
2167                        "$ref": "#/components/schemas/ProblemDetails",
2168                    }),
2169                },
2170            );
2171            Response {
2172                description: status_description(status).to_owned(),
2173                content,
2174            }
2175        });
2176    }
2177}
2178
2179#[cfg(feature = "openapi")]
2180fn problem_details_schema() -> serde_json::Value {
2181    serde_json::json!({
2182        "type": "object",
2183        "additionalProperties": false,
2184        "required": [
2185            "type",
2186            "title",
2187            "status",
2188            "detail",
2189            "instance",
2190            "code",
2191            "request_id",
2192            "errors",
2193        ],
2194        "properties": {
2195            "type": {
2196                "type": "string",
2197                "format": "uri-reference",
2198            },
2199            "title": {
2200                "type": "string",
2201            },
2202            "status": {
2203                "type": "integer",
2204                "minimum": 400,
2205                "maximum": 599,
2206            },
2207            "detail": {
2208                "type": "string",
2209            },
2210            "instance": {
2211                "type": ["string", "null"],
2212            },
2213            "code": {
2214                "type": "string",
2215                "pattern": "^autumn\\.[a-z0-9_]+$",
2216            },
2217            "request_id": {
2218                "type": ["string", "null"],
2219            },
2220            "errors": {
2221                "type": "array",
2222                "items": {
2223                    "type": "object",
2224                    "additionalProperties": false,
2225                    "required": ["field", "messages"],
2226                    "properties": {
2227                        "field": {
2228                            "type": "string",
2229                        },
2230                        "messages": {
2231                            "type": "array",
2232                            "items": {
2233                                "type": "string",
2234                            },
2235                        },
2236                    },
2237                },
2238            },
2239        },
2240    })
2241}
2242
2243#[cfg(feature = "openapi")]
2244fn default_tag(path: &str) -> Option<&str> {
2245    path.trim_start_matches('/')
2246        .split('/')
2247        .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
2248}
2249
2250#[cfg(feature = "openapi")]
2251const fn status_description(status: u16) -> &'static str {
2252    match status {
2253        200 => "OK",
2254        201 => "Created",
2255        202 => "Accepted",
2256        204 => "No Content",
2257        301 => "Moved Permanently",
2258        302 => "Found",
2259        400 => "Bad Request",
2260        401 => "Unauthorized",
2261        403 => "Forbidden",
2262        404 => "Not Found",
2263        409 => "Conflict",
2264        413 => "Payload Too Large",
2265        415 => "Unsupported Media Type",
2266        422 => "Unprocessable Entity",
2267        500 => "Internal Server Error",
2268        503 => "Service Unavailable",
2269        _ => "Response",
2270    }
2271}
2272
2273// ──────────────────────────────────────────────────────────────────
2274// Swagger UI HTML
2275// ──────────────────────────────────────────────────────────────────
2276
2277#[cfg(feature = "openapi")]
2278pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
2279#[cfg(feature = "openapi")]
2280pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
2281#[cfg(feature = "openapi")]
2282pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
2283    include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
2284#[cfg(feature = "openapi")]
2285const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
2286#[cfg(feature = "openapi")]
2287const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
2288#[cfg(feature = "openapi")]
2289const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
2290
2291/// Compute the same-origin asset URLs mounted beneath the Swagger UI HTML path.
2292#[cfg(feature = "openapi")]
2293#[must_use]
2294pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
2295    [
2296        swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
2297        swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
2298        swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
2299    ]
2300}
2301
2302#[cfg(feature = "openapi")]
2303#[must_use]
2304fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
2305    let base = swagger_path.trim_end_matches('/');
2306    if base.is_empty() || base == "/" {
2307        format!("/{asset_file}")
2308    } else {
2309        format!("{base}/{asset_file}")
2310    }
2311}
2312
2313/// Minimal Swagger UI bootstrap HTML that loads same-origin vendored assets.
2314#[cfg(feature = "openapi")]
2315#[must_use]
2316pub fn swagger_ui_html(
2317    title: &str,
2318    css_url: &str,
2319    bundle_url: &str,
2320    initializer_url: &str,
2321) -> String {
2322    let title = html_escape(title);
2323    let css_url = html_escape(css_url);
2324    let bundle_url = html_escape(bundle_url);
2325    let initializer_url = html_escape(initializer_url);
2326    let mut out = String::with_capacity(1024);
2327    out.push_str("<!DOCTYPE html>\n");
2328    out.push_str("<html lang=\"en\">\n");
2329    out.push_str("  <head>\n");
2330    out.push_str("    <meta charset=\"utf-8\" />\n");
2331    out.push_str("    <title>");
2332    out.push_str(&title);
2333    out.push_str("</title>\n");
2334    out.push_str("    <link rel=\"stylesheet\" href=\"");
2335    out.push_str(&css_url);
2336    out.push_str("\" />\n");
2337    out.push_str("  </head>\n");
2338    out.push_str("  <body>\n");
2339    out.push_str("    <div id=\"swagger-ui\"></div>\n");
2340    out.push_str("    <script src=\"");
2341    out.push_str(&bundle_url);
2342    out.push_str("\" charset=\"UTF-8\"></script>\n");
2343    out.push_str("    <script src=\"");
2344    out.push_str(&initializer_url);
2345    out.push_str("\" charset=\"UTF-8\"></script>\n");
2346    out.push_str("  </body>\n");
2347    out.push_str("</html>\n");
2348    out
2349}
2350
2351/// External Swagger UI initializer script so the default `script-src 'self'`
2352/// CSP can boot the docs UI without permitting inline JavaScript.
2353#[cfg(feature = "openapi")]
2354#[must_use]
2355pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
2356    let spec_url = serde_json::to_string(spec_url)
2357        .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
2358    let mut out = String::with_capacity(256);
2359    out.push_str("window.onload = function() {\n");
2360    out.push_str("  window.ui = SwaggerUIBundle({\n");
2361    out.push_str("    url: ");
2362    out.push_str(&spec_url);
2363    out.push_str(",\n");
2364    out.push_str("    dom_id: \"#swagger-ui\",\n");
2365    out.push_str("    deepLinking: true\n");
2366    out.push_str("  });\n");
2367    out.push_str("};\n");
2368    out
2369}
2370
2371#[cfg(feature = "openapi")]
2372fn html_escape(s: &str) -> String {
2373    s.replace('&', "&amp;")
2374        .replace('<', "&lt;")
2375        .replace('>', "&gt;")
2376        .replace('"', "&quot;")
2377}
2378
2379// ──────────────────────────────────────────────────────────────────
2380// Opaque-schema (placeholder) detection
2381// ──────────────────────────────────────────────────────────────────
2382
2383/// Keys the generated placeholder may carry. Anything else means the schema
2384/// says something real about its instances, so it is not a placeholder.
2385#[cfg(feature = "openapi")]
2386const PLACEHOLDER_KEYS: [&str; 3] = ["type", "title", "description"];
2387
2388/// True when `schema` is exactly the opaque object placeholder
2389/// [`generate_spec`] emits for a referenced type that has no `OpenApiSchema`:
2390/// `{"type":"object","title":…}` and nothing else.
2391///
2392/// Matched by *shape*, not by the absence of `properties` alone. An object can
2393/// describe its instances without that key — `additionalProperties` (a map),
2394/// `oneOf`/`allOf`/`anyOf`, `patternProperties`, a bare `$ref` — and a schema
2395/// somebody registered deliberately through
2396/// [`OpenApiConfig::register_schema`] in one of those forms is a real contract
2397/// a client generator can render. Flagging it would make
2398/// `autumn openapi export --strict` fail CI over a fully typed map. So a
2399/// placeholder is recognised as an object carrying no key beyond `title` /
2400/// `description`, which is precisely what the back-fill emits.
2401///
2402/// This is the single canonical predicate: the MCP tool-catalog builder
2403/// ([`crate::mcp`]) applies it to a tool's `inputSchema`, and
2404/// [`opaque_component_schemas`] applies it to a built spec's components.
2405#[cfg(feature = "openapi")]
2406#[must_use]
2407pub fn is_opaque_object_schema(schema: &serde_json::Value) -> bool {
2408    if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
2409        return false;
2410    }
2411    schema.as_object().is_none_or(|map| {
2412        map.keys()
2413            .all(|key| PLACEHOLDER_KEYS.contains(&key.as_str()))
2414    })
2415}
2416
2417/// One component schema that degraded to the opaque object placeholder, with
2418/// the operations that reference it.
2419///
2420/// Produced by [`opaque_component_schemas`]. A consumer of the spec (a client
2421/// generator, Swagger UI, the MCP projection) can only render such a schema as
2422/// an untyped blob — `unknown` in TypeScript, `serde_json::Value` in Rust — so
2423/// `autumn openapi export` reports these rather than letting the contract
2424/// degrade silently.
2425#[cfg(feature = "openapi")]
2426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2427pub struct OpaqueSchema {
2428    /// Component key under `#/components/schemas/`.
2429    pub schema: String,
2430    /// `"METHOD /path"` for every operation referencing it, sorted and deduped.
2431    /// Empty when the component is registered but unreferenced.
2432    pub referenced_by: Vec<String>,
2433}
2434
2435/// Report every component schema in `spec` that degraded to the opaque
2436/// placeholder, sorted by component name.
2437///
2438/// The fix for each entry is to add `#[derive(OpenApiSchema)]` to the offending
2439/// type (or register a hand-written schema via
2440/// [`OpenApiConfig::register_schema`]), which makes the back-fill resolve the
2441/// real field-accurate schema instead of the placeholder.
2442#[cfg(feature = "openapi")]
2443#[must_use]
2444pub fn opaque_component_schemas(spec: &OpenApiSpec) -> Vec<OpaqueSchema> {
2445    let Some(components) = spec.components.as_ref() else {
2446        return Vec::new();
2447    };
2448
2449    let opaque: std::collections::BTreeSet<&str> = components
2450        .schemas
2451        .iter()
2452        .filter(|(_, schema)| is_opaque_object_schema(schema))
2453        .map(|(name, _)| name.as_str())
2454        .collect();
2455    if opaque.is_empty() {
2456        return Vec::new();
2457    }
2458
2459    // Pre-compute each component's own outgoing refs, so attribution can follow
2460    // the component graph rather than stopping at an operation's direct refs.
2461    // An operation usually reaches an opaque type *indirectly* — `POST /orders`
2462    // takes a derived `Order` whose `address` field `$ref`s an underived
2463    // `Address` — and reporting `Address` with an empty `referenced_by` would
2464    // hide the very operation whose contract is degraded.
2465    let component_refs: BTreeMap<&str, Vec<String>> = components
2466        .schemas
2467        .iter()
2468        .map(|(name, schema)| {
2469            let mut out = Vec::new();
2470            collect_body_ref_identities(schema, &mut out);
2471            (name.as_str(), out)
2472        })
2473        .collect();
2474
2475    // Map each opaque component to the operations that reach it. A reference
2476    // can sit anywhere in an operation (body, response, parameter schema, or
2477    // nested inside an array/nullable wrapper), so walk the serialized
2478    // operation wholesale rather than probing known slots, then close over the
2479    // component graph from whatever that turns up.
2480    let mut refs: BTreeMap<&str, std::collections::BTreeSet<String>> = BTreeMap::new();
2481    for (path, item) in &spec.paths {
2482        for (method, operation) in path_item_operations(item) {
2483            let Ok(value) = serde_json::to_value(operation) else {
2484                continue;
2485            };
2486            let mut frontier = Vec::new();
2487            collect_body_ref_identities(&value, &mut frontier);
2488
2489            // Breadth-first over components, `seen` guarding the cycles a
2490            // self- or mutually-recursive schema creates.
2491            let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2492            while let Some(identity) = frontier.pop() {
2493                if !seen.insert(identity.clone()) {
2494                    continue;
2495                }
2496                if let Some(name) = opaque.get(identity.as_str()) {
2497                    refs.entry(name)
2498                        .or_default()
2499                        .insert(format!("{method} {path}"));
2500                }
2501                if let Some(nested) = component_refs.get(identity.as_str()) {
2502                    frontier.extend(nested.iter().cloned());
2503                }
2504            }
2505        }
2506    }
2507
2508    opaque
2509        .into_iter()
2510        .map(|schema| OpaqueSchema {
2511            schema: schema.to_owned(),
2512            referenced_by: refs
2513                .get(schema)
2514                .map(|set| set.iter().cloned().collect())
2515                .unwrap_or_default(),
2516        })
2517        .collect()
2518}
2519
2520/// Yield each `(METHOD, operation)` pair present on a [`PathItem`].
2521#[cfg(feature = "openapi")]
2522fn path_item_operations(item: &PathItem) -> Vec<(&'static str, &Operation)> {
2523    [
2524        ("GET", item.get.as_ref()),
2525        ("POST", item.post.as_ref()),
2526        ("PUT", item.put.as_ref()),
2527        ("PATCH", item.patch.as_ref()),
2528        ("DELETE", item.delete.as_ref()),
2529    ]
2530    .into_iter()
2531    .filter_map(|(method, op)| op.map(|op| (method, op)))
2532    .collect()
2533}
2534
2535// ──────────────────────────────────────────────────────────────────
2536// Tests
2537// ──────────────────────────────────────────────────────────────────
2538
2539#[cfg(all(test, feature = "openapi"))]
2540mod tests {
2541    use super::*;
2542
2543    // ── issue #2607: direction helpers ─────────────────────────────────
2544
2545    #[test]
2546    fn remove_schema_properties_drops_the_property_and_its_required_entry() {
2547        let mut schema = serde_json::json!({
2548            "type": "object",
2549            "properties": { "a": {}, "b": {} },
2550            "required": ["a", "b"],
2551        });
2552        remove_schema_properties(&mut schema, &["b"]);
2553        assert_eq!(
2554            schema,
2555            serde_json::json!({
2556                "type": "object",
2557                "properties": { "a": {} },
2558                "required": ["a"],
2559            })
2560        );
2561        remove_schema_properties(&mut schema, &["a"]);
2562        assert!(schema.get("required").is_none(), "{schema}");
2563    }
2564
2565    #[test]
2566    fn request_key_skips_taken_and_reserved_keys() {
2567        let by_identity: BTreeMap<String, String> = [
2568            ("app::Widget".to_owned(), "Widget".to_owned()),
2569            ("app::WidgetRequest".to_owned(), "WidgetRequest".to_owned()),
2570        ]
2571        .into_iter()
2572        .collect();
2573        let split = std::iter::once("app::Widget".to_owned()).collect();
2574        let reserved = std::iter::once("WidgetRequest-2".to_owned()).collect();
2575        let keys = assign_request_keys(&split, &by_identity, &reserved);
2576        assert_eq!(keys["app::Widget"], "WidgetRequest-3");
2577    }
2578
2579    #[test]
2580    fn split_follows_a_nested_split() {
2581        let both = |id: &str| {
2582            [
2583                (id.to_owned(), SchemaDirection::Request),
2584                (id.to_owned(), SchemaDirection::Response),
2585            ]
2586        };
2587        let uses: std::collections::BTreeSet<(String, SchemaDirection)> = both("app::Outer")
2588            .into_iter()
2589            .chain(both("app::Inner"))
2590            .chain(both("app::Plain"))
2591            .collect();
2592        let outer = serde_json::json!({
2593            "properties": { "inner": { "$ref": "#/components/schemas/app::Inner" } }
2594        });
2595        let plain = serde_json::json!({ "type": "object" });
2596        let bodies: BTreeMap<(String, SchemaDirection), Option<serde_json::Value>> = [
2597            (
2598                ("app::Outer".to_owned(), SchemaDirection::Request),
2599                Some(outer.clone()),
2600            ),
2601            (
2602                ("app::Outer".to_owned(), SchemaDirection::Response),
2603                Some(outer),
2604            ),
2605            (
2606                ("app::Inner".to_owned(), SchemaDirection::Request),
2607                Some(serde_json::json!({ "required": ["secret"] })),
2608            ),
2609            (
2610                ("app::Inner".to_owned(), SchemaDirection::Response),
2611                Some(serde_json::json!({})),
2612            ),
2613            (
2614                ("app::Plain".to_owned(), SchemaDirection::Request),
2615                Some(plain.clone()),
2616            ),
2617            (
2618                ("app::Plain".to_owned(), SchemaDirection::Response),
2619                Some(plain),
2620            ),
2621        ]
2622        .into_iter()
2623        .collect();
2624        let split = split_identities(&uses, &bodies, &std::collections::BTreeSet::new());
2625        let split: Vec<&str> = split.iter().map(String::as_str).collect();
2626        assert_eq!(split, vec!["app::Inner", "app::Outer"]);
2627    }
2628
2629    fn make_doc() -> ApiDoc {
2630        ApiDoc {
2631            method: "GET",
2632            path: "/users/{id}",
2633            operation_id: "get_user",
2634            summary: Some("Fetch a user"),
2635            description: None,
2636            tags: &[],
2637            path_params: &["id"],
2638            request_body: None,
2639            response: None,
2640            success_status: 200,
2641            hidden: false,
2642            query_schema: None,
2643            secured: false,
2644            required_roles: &[],
2645            register_schemas: None,
2646            api_version: None,
2647            ..Default::default()
2648        }
2649    }
2650
2651    #[test]
2652    fn config_builder_methods_work() {
2653        let config = OpenApiConfig::new("Demo", "1.0.0")
2654            .description("A cool API")
2655            .openapi_json_path("/api.json")
2656            .swagger_ui_path(None)
2657            .session_cookie_name("demo.sid");
2658
2659        assert_eq!(config.title, "Demo");
2660        assert_eq!(config.version, "1.0.0");
2661        assert_eq!(config.description.unwrap(), "A cool API");
2662        assert_eq!(config.openapi_json_path, "/api.json");
2663        assert_eq!(config.swagger_ui_path, None);
2664        assert_eq!(config.session_cookie_name, "demo.sid");
2665    }
2666
2667    #[test]
2668    fn secured_spec_uses_configured_session_cookie_name() {
2669        let mut doc = make_doc();
2670        doc.path = "/protected";
2671        doc.operation_id = "protected";
2672        doc.path_params = &[];
2673        doc.secured = true;
2674
2675        let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
2676        let spec = generate_spec(&config, &[&doc]);
2677        let scheme = &spec
2678            .components
2679            .as_ref()
2680            .expect("secured routes emit security components")
2681            .security_schemes["SessionAuth"];
2682
2683        assert_eq!(scheme["type"], "apiKey");
2684        assert_eq!(scheme["in"], "cookie");
2685        assert_eq!(scheme["name"], "demo.sid");
2686    }
2687
2688    #[test]
2689    fn generate_spec_builds_path_with_parameters() {
2690        let doc = make_doc();
2691        let config = OpenApiConfig::new("Demo", "1.0.0");
2692        let spec = generate_spec(&config, &[&doc]);
2693
2694        assert_eq!(spec.openapi, "3.1.0");
2695        assert_eq!(spec.info.title, "Demo");
2696        assert!(spec.paths.contains_key("/users/{id}"));
2697
2698        let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
2699        assert_eq!(op.operation_id, "get_user");
2700        assert_eq!(op.parameters.len(), 1);
2701        assert_eq!(op.parameters[0].name, "id");
2702        assert_eq!(op.parameters[0].location, "path");
2703        assert_eq!(op.tags, vec!["users".to_owned()]);
2704    }
2705
2706    #[test]
2707    fn generate_spec_skips_hidden_routes() {
2708        let mut doc = make_doc();
2709        doc.hidden = true;
2710        let config = OpenApiConfig::new("Demo", "1.0.0");
2711        let spec = generate_spec(&config, &[&doc]);
2712        assert!(spec.paths.is_empty());
2713    }
2714
2715    #[test]
2716    fn generate_spec_writes_request_body_ref() {
2717        let mut doc = make_doc();
2718        doc.method = "POST";
2719        doc.path = "/users";
2720        doc.operation_id = "create_user";
2721        doc.path_params = &[];
2722        doc.request_body = Some(SchemaEntry {
2723            name: "CreateUser",
2724            kind: SchemaKind::Ref,
2725            identity: None,
2726        });
2727        doc.success_status = 201;
2728
2729        let config = OpenApiConfig::new("Demo", "1.0.0");
2730        let spec = generate_spec(&config, &[&doc]);
2731        let op = spec.paths["/users"].post.as_ref().unwrap();
2732        let body = op.request_body.as_ref().unwrap();
2733        assert!(body.required);
2734        let media = body.content.get("application/json").unwrap();
2735        assert_eq!(
2736            media.schema,
2737            serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
2738        );
2739        assert!(op.responses.contains_key("201"));
2740    }
2741
2742    #[test]
2743    fn generate_spec_inlines_primitive_response() {
2744        let mut doc = make_doc();
2745        doc.response = Some(SchemaEntry {
2746            name: "string",
2747            kind: SchemaKind::Primitive("string"),
2748            identity: None,
2749        });
2750        let config = OpenApiConfig::new("Demo", "1.0.0");
2751        let spec = generate_spec(&config, &[&doc]);
2752        let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
2753        let media = op.responses["200"].content.get("application/json").unwrap();
2754        assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
2755    }
2756
2757    #[test]
2758    fn swagger_ui_html_uses_same_origin_assets() {
2759        let html = swagger_ui_html(
2760            "Demo",
2761            "/swagger-ui/swagger-ui.css",
2762            "/swagger-ui/swagger-ui-bundle.js",
2763            "/swagger-ui/swagger-initializer.js",
2764        );
2765        assert!(html.contains("/swagger-ui/swagger-ui.css"));
2766        assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
2767        assert!(html.contains("/swagger-ui/swagger-initializer.js"));
2768        assert!(!html.contains("unpkg.com"));
2769        assert!(!html.contains("window.onload = function()"));
2770    }
2771
2772    #[test]
2773    fn swagger_ui_initializer_js_references_spec_url() {
2774        let js = swagger_ui_initializer_js("/openapi.json");
2775        assert!(js.contains("SwaggerUIBundle"));
2776        assert!(js.contains(r#""/openapi.json""#));
2777    }
2778
2779    #[test]
2780    fn generate_spec_includes_additional_schemas() {
2781        let doc = make_doc();
2782        let config = OpenApiConfig::new("Demo", "1.0.0")
2783            .register_schema("Foo", serde_json::json!({ "type": "object" }));
2784        let spec = generate_spec(&config, &[&doc]);
2785        let components = spec.components.unwrap();
2786        assert!(components.schemas.contains_key("Foo"));
2787    }
2788
2789    #[test]
2790    fn generate_spec_back_fills_unregistered_ref_schemas() {
2791        // A Json<CreateUser> handler emits a `$ref` with no component
2792        // schema registered. The generator must back-fill a placeholder
2793        // schema so the resulting OpenAPI document is valid.
2794        let mut doc = make_doc();
2795        doc.method = "POST";
2796        doc.path = "/users";
2797        doc.path_params = &[];
2798        doc.request_body = Some(SchemaEntry {
2799            name: "CreateUser",
2800            kind: SchemaKind::Ref,
2801            identity: None,
2802        });
2803        doc.response = Some(SchemaEntry {
2804            name: "User",
2805            kind: SchemaKind::Ref,
2806            identity: None,
2807        });
2808
2809        let config = OpenApiConfig::new("Demo", "1.0.0");
2810        let spec = generate_spec(&config, &[&doc]);
2811        let components = spec.components.expect("components must be emitted");
2812        let create = components
2813            .schemas
2814            .get("CreateUser")
2815            .expect("CreateUser should be back-filled");
2816        let user = components
2817            .schemas
2818            .get("User")
2819            .expect("User should be back-filled");
2820        assert_eq!(create["type"], "object");
2821        assert_eq!(create["title"], "CreateUser");
2822        assert_eq!(user["type"], "object");
2823        assert_eq!(user["title"], "User");
2824    }
2825
2826    #[test]
2827    fn generate_spec_preserves_user_registered_schemas_over_backfill() {
2828        let mut doc = make_doc();
2829        doc.response = Some(SchemaEntry {
2830            name: "User",
2831            kind: SchemaKind::Ref,
2832            identity: None,
2833        });
2834
2835        let user_schema = serde_json::json!({
2836            "type": "object",
2837            "properties": {"id": {"type": "integer"}},
2838        });
2839        let config =
2840            OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
2841        let spec = generate_spec(&config, &[&doc]);
2842        let components = spec.components.unwrap();
2843        let stored = components.schemas.get("User").unwrap();
2844        assert_eq!(stored, &user_schema, "user schema must not be overwritten");
2845    }
2846
2847    #[test]
2848    fn status_description_returns_correct_strings() {
2849        assert_eq!(status_description(200), "OK");
2850        assert_eq!(status_description(201), "Created");
2851        assert_eq!(status_description(202), "Accepted");
2852        assert_eq!(status_description(204), "No Content");
2853        assert_eq!(status_description(301), "Moved Permanently");
2854        assert_eq!(status_description(302), "Found");
2855        assert_eq!(status_description(400), "Bad Request");
2856        assert_eq!(status_description(401), "Unauthorized");
2857        assert_eq!(status_description(403), "Forbidden");
2858        assert_eq!(status_description(404), "Not Found");
2859        assert_eq!(status_description(409), "Conflict");
2860        assert_eq!(status_description(413), "Payload Too Large");
2861        assert_eq!(status_description(415), "Unsupported Media Type");
2862        assert_eq!(status_description(422), "Unprocessable Entity");
2863        assert_eq!(status_description(500), "Internal Server Error");
2864        assert_eq!(status_description(503), "Service Unavailable");
2865        assert_eq!(status_description(418), "Response");
2866    }
2867
2868    #[test]
2869    fn default_tag_picks_first_static_segment() {
2870        assert_eq!(default_tag("/users/{id}"), Some("users"));
2871        assert_eq!(default_tag("/api/v1/users"), Some("api"));
2872        assert_eq!(default_tag("/"), None);
2873        assert_eq!(default_tag("/{id}"), None);
2874    }
2875
2876    // ── OpenAPI 3.1 compliance tests (RED phase) ───────────────────────────
2877
2878    #[test]
2879    fn spec_version_is_3_1_0() {
2880        let config = OpenApiConfig::new("Demo", "1.0.0");
2881        let spec = generate_spec(&config, &[]);
2882        assert_eq!(
2883            spec.openapi, "3.1.0",
2884            "Autumn must emit OpenAPI 3.1.0, not {}",
2885            spec.openapi
2886        );
2887    }
2888
2889    #[test]
2890    fn nullable_ref_uses_openapi_3_1_one_of() {
2891        // OpenAPI 3.1 aligns with JSON Schema 2020-12: nullable refs use
2892        // `oneOf: [{$ref: ...}, {type: "null"}]` instead of 3.0's
2893        // `nullable: true` + `allOf` workaround.
2894        static INNER: SchemaEntry = SchemaEntry {
2895            name: "User",
2896            kind: SchemaKind::Ref,
2897            identity: None,
2898        };
2899        let entry = SchemaEntry {
2900            name: "nullable",
2901            kind: SchemaKind::Nullable(&INNER),
2902            identity: None,
2903        };
2904        let value = schema_value_for(
2905            &entry,
2906            &SchemaComponentIndex::default(),
2907            SchemaDirection::Response,
2908        );
2909        assert!(
2910            value.get("nullable").is_none(),
2911            "3.1 must not emit `nullable: true` (that is 3.0 only)"
2912        );
2913        assert!(
2914            value.get("allOf").is_none(),
2915            "3.1 must not use allOf for nullable refs"
2916        );
2917        let one_of = value["oneOf"]
2918            .as_array()
2919            .expect("3.1 nullable ref must use oneOf");
2920        assert_eq!(one_of.len(), 2);
2921        assert_eq!(
2922            one_of[0]["$ref"], "#/components/schemas/User",
2923            "first oneOf branch must be the $ref"
2924        );
2925        assert_eq!(
2926            one_of[1]["type"], "null",
2927            "second oneOf branch must be {{type: null}}"
2928        );
2929    }
2930
2931    #[test]
2932    fn nullable_primitive_uses_type_array() {
2933        // OpenAPI 3.1 uses `type: ["integer", "null"]` for nullable
2934        // primitives instead of the 3.0 `nullable: true` flag.
2935        static INNER: SchemaEntry = SchemaEntry {
2936            name: "integer",
2937            kind: SchemaKind::Primitive("integer"),
2938            identity: None,
2939        };
2940        let entry = SchemaEntry {
2941            name: "nullable",
2942            kind: SchemaKind::Nullable(&INNER),
2943            identity: None,
2944        };
2945        let value = schema_value_for(
2946            &entry,
2947            &SchemaComponentIndex::default(),
2948            SchemaDirection::Response,
2949        );
2950        assert!(
2951            value.get("nullable").is_none(),
2952            "3.1 must not emit `nullable: true`"
2953        );
2954        let types = value["type"]
2955            .as_array()
2956            .expect("3.1 nullable primitive must use a type array");
2957        assert!(
2958            types.contains(&serde_json::Value::String("integer".to_owned())),
2959            "type array must include the base type"
2960        );
2961        assert!(
2962            types.contains(&serde_json::Value::String("null".to_owned())),
2963            "type array must include null"
2964        );
2965    }
2966
2967    #[test]
2968    fn write_openapi_spec_to_dist_creates_json_file() {
2969        let tmp = tempfile::TempDir::new().unwrap();
2970        let dist = tmp.path().join("dist");
2971        std::fs::create_dir_all(&dist).unwrap();
2972
2973        let config = OpenApiConfig::new("TestAPI", "2.0.0");
2974        let spec = generate_spec(&config, &[]);
2975
2976        write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
2977
2978        let json_path = dist.join("openapi.json");
2979        assert!(json_path.exists(), "dist/openapi.json must be written");
2980
2981        let content = std::fs::read_to_string(&json_path).unwrap();
2982        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2983        assert_eq!(parsed["openapi"], "3.1.0");
2984        assert_eq!(parsed["info"]["title"], "TestAPI");
2985    }
2986
2987    #[test]
2988    fn write_openapi_spec_to_dist_creates_yaml_file() {
2989        let tmp = tempfile::TempDir::new().unwrap();
2990        let dist = tmp.path().join("dist");
2991        std::fs::create_dir_all(&dist).unwrap();
2992
2993        let config = OpenApiConfig::new("TestAPI", "2.0.0");
2994        let spec = generate_spec(&config, &[]);
2995
2996        write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
2997
2998        let yaml_path = dist.join("openapi.yaml");
2999        assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
3000
3001        let content = std::fs::read_to_string(&yaml_path).unwrap();
3002        assert!(
3003            content.contains("openapi:"),
3004            "YAML must include the openapi field"
3005        );
3006        assert!(content.contains("3.1.0"), "YAML must include the version");
3007        assert!(content.contains("TestAPI"), "YAML must include the title");
3008    }
3009
3010    #[test]
3011    fn schema_registry_into_map_returns_all_schemas() {
3012        let mut registry = SchemaRegistry::default();
3013        registry.insert("Foo", serde_json::json!({ "type": "string" }));
3014        registry.insert("Bar", serde_json::json!({ "type": "integer" }));
3015
3016        let map = registry.into_map();
3017        assert_eq!(map.len(), 2);
3018        assert_eq!(
3019            map.get("Foo").unwrap(),
3020            &serde_json::json!({ "type": "string" })
3021        );
3022        assert_eq!(
3023            map.get("Bar").unwrap(),
3024            &serde_json::json!({ "type": "integer" })
3025        );
3026    }
3027
3028    #[test]
3029    fn schema_registry_deduplicates() {
3030        struct Foo;
3031        impl OpenApiSchema for Foo {
3032            fn schema_name() -> &'static str {
3033                "Foo"
3034            }
3035            fn schema() -> serde_json::Value {
3036                serde_json::json!({ "type": "object", "title": "Foo" })
3037            }
3038        }
3039
3040        let mut registry = SchemaRegistry::default();
3041        registry.register::<Foo>();
3042        registry.register::<Foo>();
3043        assert_eq!(registry.schemas().len(), 1);
3044    }
3045
3046    #[test]
3047    fn primitive_impls_cover_common_types() {
3048        assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
3049        assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
3050        assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
3051        assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
3052    }
3053
3054    #[test]
3055    fn swagger_ui_html_embeds_spec_url() {
3056        let html = swagger_ui_html(
3057            "My API",
3058            "/swagger-ui/swagger-ui.css",
3059            "/swagger-ui/swagger-ui-bundle.js",
3060            "/swagger-ui/swagger-initializer.js",
3061        );
3062        assert!(html.contains("/swagger-ui/swagger-ui.css"));
3063        assert!(html.contains("My API"));
3064    }
3065
3066    #[test]
3067    fn swagger_ui_html_escapes_attributes() {
3068        let html = swagger_ui_html(
3069            "A \"cool\" & fun API",
3070            "/swagger-ui/swagger-ui.css?x=<y>",
3071            "/swagger-ui/swagger-ui-bundle.js",
3072            "/swagger-ui/swagger-initializer.js",
3073        );
3074        assert!(html.contains("/swagger-ui/swagger-ui.css?x=&lt;y&gt;"));
3075        assert!(html.contains("A &quot;cool&quot; &amp; fun API"));
3076    }
3077
3078    // ── Security requirement generation (#1158) ──────────────────────────────
3079
3080    fn make_secured_doc(
3081        secured: bool,
3082        required_roles: &'static [&'static str],
3083        required_scopes: &'static [&'static str],
3084    ) -> ApiDoc {
3085        let mut doc = make_doc();
3086        doc.path = "/secured";
3087        doc.operation_id = "secured_op";
3088        doc.path_params = &[];
3089        doc.secured = secured;
3090        doc.required_roles = required_roles;
3091        doc.required_scopes = required_scopes;
3092        doc
3093    }
3094
3095    #[test]
3096    fn unsecured_route_has_no_security_requirement() {
3097        let doc = make_secured_doc(false, &[], &[]);
3098        let config = OpenApiConfig::new("Demo", "1.0.0");
3099        let spec = generate_spec(&config, &[&doc]);
3100        let op = spec.paths["/secured"].get.as_ref().unwrap();
3101        assert!(op.security.is_empty());
3102    }
3103
3104    #[test]
3105    fn bare_secured_uses_session_auth() {
3106        let doc = make_secured_doc(true, &[], &[]);
3107        let config = OpenApiConfig::new("Demo", "1.0.0");
3108        let spec = generate_spec(&config, &[&doc]);
3109        let op = spec.paths["/secured"].get.as_ref().unwrap();
3110        assert_eq!(op.security.len(), 1);
3111        assert!(op.security[0].contains_key("SessionAuth"));
3112        assert!(!op.security[0].contains_key("BearerAuth"));
3113    }
3114
3115    #[test]
3116    fn role_only_uses_session_auth() {
3117        let doc = make_secured_doc(true, &["admin"], &[]);
3118        let config = OpenApiConfig::new("Demo", "1.0.0");
3119        let spec = generate_spec(&config, &[&doc]);
3120        let op = spec.paths["/secured"].get.as_ref().unwrap();
3121        assert_eq!(op.security.len(), 1);
3122        assert!(op.security[0].contains_key("SessionAuth"));
3123        assert!(!op.security[0].contains_key("BearerAuth"));
3124    }
3125
3126    #[test]
3127    fn scope_only_uses_bearer_auth_with_empty_array() {
3128        let doc = make_secured_doc(true, &[], &["posts:write"]);
3129        let config = OpenApiConfig::new("Demo", "1.0.0");
3130        let spec = generate_spec(&config, &[&doc]);
3131        let op = spec.paths["/secured"].get.as_ref().unwrap();
3132        assert_eq!(op.security.len(), 1);
3133        assert!(op.security[0].contains_key("BearerAuth"));
3134        assert!(!op.security[0].contains_key("SessionAuth"));
3135        // OpenAPI spec: HTTP bearer value array must be empty (not scope names).
3136        assert!(op.security[0]["BearerAuth"].is_empty());
3137        // BearerAuth scheme is registered in components.
3138        let schemes = &spec.components.as_ref().unwrap().security_schemes;
3139        assert!(schemes.contains_key("BearerAuth"));
3140        assert_eq!(schemes["BearerAuth"]["scheme"], "bearer");
3141    }
3142
3143    #[test]
3144    fn mixed_role_and_scope_uses_both_auth_schemes() {
3145        let doc = make_secured_doc(true, &["admin"], &["posts:write"]);
3146        let config = OpenApiConfig::new("Demo", "1.0.0");
3147        let spec = generate_spec(&config, &[&doc]);
3148        let op = spec.paths["/secured"].get.as_ref().unwrap();
3149        assert_eq!(op.security.len(), 1);
3150        // Both in the same object = AND semantics.
3151        assert!(op.security[0].contains_key("SessionAuth"));
3152        assert!(op.security[0].contains_key("BearerAuth"));
3153    }
3154
3155    #[test]
3156    fn bearer_auth_scheme_registered_only_for_scoped_routes() {
3157        let unscoped = make_secured_doc(true, &["admin"], &[]);
3158        let config = OpenApiConfig::new("Demo", "1.0.0");
3159        let spec = generate_spec(&config, &[&unscoped]);
3160        let schemes = &spec.components.as_ref().unwrap().security_schemes;
3161        assert!(!schemes.contains_key("BearerAuth"));
3162    }
3163
3164    // Regression (issue #1972): the fallback display-key assignment must be
3165    // collision-proof. This generalizes the reviewer's `app::app::Args` /
3166    // `app::Args` example to a pair that still clashes under the deterministic
3167    // sorted assignment order: `a::x::Args` sorts first and claims the 2-segment
3168    // suffix `x.Args`, then `x::Args` exhausts its suffix ladder (its only
3169    // qualified candidate IS `x.Args`) and falls back to
3170    // `component_key("x::Args")` == `x.Args` — the exact key `a::x::Args` already
3171    // took. Without the disambiguator both identities would map to `x.Args`, so
3172    // the second schema would silently overwrite the first.
3173    #[test]
3174    fn colliding_fallback_keys_are_disambiguated() {
3175        let refs = vec![
3176            ("a::x::Args".to_owned(), "Args".to_owned()),
3177            ("x::Args".to_owned(), "Args".to_owned()),
3178        ];
3179        let by_identity = assign_display_keys(&refs);
3180
3181        let a = by_identity.get("a::x::Args").expect("a::x::Args assigned");
3182        let b = by_identity.get("x::Args").expect("x::Args assigned");
3183        assert_ne!(
3184            a, b,
3185            "distinct identities must map to distinct display keys, got {a} == {b}"
3186        );
3187        // `a::x::Args` claims the suffix key; `x::Args` must be pushed onto the
3188        // deterministic `-N` disambiguator rather than overwriting it — pinning
3189        // this proves the disambiguator branch actually ran.
3190        assert_eq!(a, "x.Args");
3191        assert_eq!(b, "x.Args-2");
3192        // Both keys are valid OpenAPI component keys.
3193        for key in [a, b] {
3194            assert!(
3195                key.chars()
3196                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
3197                "display key {key} is not a valid component key"
3198            );
3199        }
3200    }
3201
3202    // Determinism: the same identity set must yield the same keys regardless of
3203    // the order the `(identity, base)` pairs are supplied.
3204    #[test]
3205    fn assign_display_keys_is_order_independent() {
3206        let forward = vec![
3207            ("app::app::Args".to_owned(), "Args".to_owned()),
3208            ("app::Args".to_owned(), "Args".to_owned()),
3209            ("other::mod::Args".to_owned(), "Args".to_owned()),
3210        ];
3211        let mut reversed = forward.clone();
3212        reversed.reverse();
3213        assert_eq!(
3214            assign_display_keys(&forward),
3215            assign_display_keys(&reversed),
3216            "display-key assignment must not depend on input order"
3217        );
3218    }
3219}
3220
3221/// The scalar impls compile without the `openapi` feature, so their tests do too.
3222#[cfg(test)]
3223mod scalar_schema_tests {
3224    use super::*;
3225
3226    #[test]
3227    fn scalar_impls_document_strings_with_a_format() {
3228        use serde_json::json;
3229        let cases = [
3230            (
3231                <uuid::Uuid as OpenApiSchema>::schema(),
3232                json!({"type": "string", "format": "uuid"}),
3233            ),
3234            (
3235                <chrono::DateTime<chrono::Utc> as OpenApiSchema>::schema(),
3236                json!({"type": "string", "format": "date-time"}),
3237            ),
3238            (
3239                <chrono::NaiveDate as OpenApiSchema>::schema(),
3240                json!({"type": "string", "format": "date"}),
3241            ),
3242        ];
3243        for (got, want) in cases {
3244            assert_eq!(got, want);
3245        }
3246        let naive = <chrono::NaiveDateTime as OpenApiSchema>::schema();
3247        assert_eq!(naive["type"], "string");
3248        assert!(
3249            naive.get("format").is_none(),
3250            "naive has no offset: {naive}"
3251        );
3252    }
3253
3254    #[test]
3255    fn decimal_schema_matches_its_serde_output() {
3256        let wire = serde_json::to_value(rust_decimal::Decimal::new(12345, 2)).unwrap();
3257        let schema = <rust_decimal::Decimal as OpenApiSchema>::schema();
3258        if wire.is_string() {
3259            assert_eq!(
3260                schema,
3261                serde_json::json!({"type": "string", "format": "decimal"})
3262            );
3263        } else {
3264            assert_eq!(schema, serde_json::json!({"type": "number"}));
3265        }
3266    }
3267}