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.0 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 `/v3/api-docs` and
12//! a Swagger UI is served at `/swagger-ui` when the feature is enabled.
13//!
14//! # Quick start
15//!
16//! Enable the `openapi` feature in `Cargo.toml`, then:
17//!
18//! ```toml
19//! [dependencies]
20//! autumn-web = { version = "0.2", features = ["openapi"] }
21//! ```
22//!
23//! ```rust,ignore
24//! use autumn_web::prelude::*;
25//!
26//! #[get("/hello")]
27//! async fn hello() -> &'static str { "hi" }
28//!
29//! #[autumn_web::main]
30//! async fn main() {
31//!     autumn_web::app()
32//!         .routes(routes![hello])
33//!         .openapi(autumn_web::openapi::OpenApiConfig::new("My API", "1.0.0"))
34//!         .run()
35//!         .await;
36//! }
37//! ```
38//!
39//! With `.openapi(...)` enabled, the following endpoints are mounted:
40//! * `GET /v3/api-docs` — serves the generated `openapi.json`.
41//! * `GET /swagger-ui` — serves a Swagger UI HTML page loading the JSON
42//!   above.
43//!
44//! # Enriching the auto-generated docs
45//!
46//! Decorate handlers with [`#[api_doc(...)]`](crate::api_doc) to override
47//! or add documentation fields that cannot be inferred from the signature
48//! (summaries, descriptions, tags, custom status codes, etc.):
49//!
50//! ```rust,no_run
51//! use autumn_web::prelude::*;
52//!
53//! #[get("/users/{id}")]
54//! #[api_doc(summary = "Fetch a user by id", tag = "users")]
55//! async fn get_user(_id: Path<i32>) -> &'static str { "user" }
56//! ```
57//!
58//! # Custom schemas
59//!
60//! Types that need rich schemas (beyond the generic "object" fallback)
61//! implement the `OpenApiSchema` trait and are registered with
62//! `OpenApiConfig::register_schema`.
63
64use std::collections::BTreeMap;
65
66#[cfg(feature = "openapi")]
67use serde::{Deserialize, Serialize};
68
69// ──────────────────────────────────────────────────────────────────
70// Public metadata attached to each Route
71// ──────────────────────────────────────────────────────────────────
72
73/// OpenAPI metadata emitted alongside every annotated route.
74///
75/// Populated by the route macros ([`get`](crate::get),
76/// [`post`](crate::post), etc.) from the handler's path, signature, and
77/// any [`#[api_doc(...)]`](crate::api_doc) overrides.
78#[derive(Clone, Debug, Default)]
79// A flat, generated metadata descriptor; the independent boolean flags
80// (hidden, secured, sunset_opt_out, has_policy, public, mcp_tool, mcp_exclude)
81// each model a distinct, orthogonal route property, so grouping them into a
82// sub-struct would obscure rather than clarify.
83#[allow(clippy::struct_excessive_bools)]
84pub struct ApiDoc {
85    /// HTTP method as an uppercase string (e.g. `"GET"`).
86    pub method: &'static str,
87    /// Raw route path with `{param}` placeholders (e.g. `"/users/{id}"`).
88    pub path: &'static str,
89    /// Handler function name — used as the default `operationId`.
90    pub operation_id: &'static str,
91    /// Short human-readable summary (from `#[api_doc(summary = ...)]`).
92    pub summary: Option<&'static str>,
93    /// Longer free-form description.
94    pub description: Option<&'static str>,
95    /// Grouping tags. Defaults to the first path segment when unset.
96    pub tags: &'static [&'static str],
97    /// Path parameter names extracted from the URL template.
98    ///
99    /// Built at compile time from `{...}` segments in the route path.
100    pub path_params: &'static [&'static str],
101    /// Optional schema for the request body (typically the inner type of
102    /// a `Json<T>` extractor).
103    pub request_body: Option<SchemaEntry>,
104    /// Optional schema for the success response (typically the inner type
105    /// of a `Json<T>` return value).
106    pub response: Option<SchemaEntry>,
107    /// Success HTTP status code, defaults to `200`.
108    pub success_status: u16,
109    /// When `true`, the route is excluded from the generated spec.
110    pub hidden: bool,
111    /// Optional query-parameter schema inferred from `Query<T>` extractors.
112    pub query_schema: Option<SchemaEntry>,
113    /// True when the route requires authentication (`#[secured]`).
114    pub secured: bool,
115    /// Roles required by `#[secured("role1")]`. Empty means any authenticated user.
116    pub required_roles: &'static [&'static str],
117    /// Scopes required by `#[secured(scopes = ["scope"])]`. When non-empty the
118    /// route is documented as `BearerAuth` instead of `SessionAuth`.
119    pub required_scopes: &'static [&'static str],
120    /// Optional runtime hook that lets a handler register any extra
121    /// component schemas with the generator.
122    pub register_schemas: Option<fn(&mut SchemaRegistry)>,
123    /// Optional API version associated with this route.
124    pub api_version: Option<&'static str>,
125    /// Whether this route opts out of sunset 410 responses.
126    pub sunset_opt_out: bool,
127    /// Whether this route uses dynamic policy authorization.
128    pub has_policy: bool,
129    /// True when the handler is explicitly declared public via `#[public]`.
130    ///
131    /// Populated by the route macros from the `#[public]` marker. Used by the
132    /// route-listing security classifier (`autumn routes audit`) to
133    /// distinguish a *deliberately* open route from one whose auth posture was
134    /// simply never declared.
135    pub public: bool,
136    /// Module path of the handler (`module_path!()` captured at the handler's
137    /// definition site), used to name a route in security-audit diagnostics.
138    /// Empty for routes constructed without the route macros.
139    pub module_path: &'static str,
140    /// True when the endpoint opts in to MCP tool exposure via
141    /// `#[api_doc(mcp)]`. Opt-in is per-endpoint and never implicit.
142    pub mcp_tool: bool,
143    /// True when the endpoint explicitly opts *out* of MCP exposure via
144    /// `#[api_doc(mcp = false)]`. Honored even under the whole-API hatch
145    /// (`AppBuilder::expose_all_as_mcp`). Not an intra-doc link: this field is
146    /// always compiled, but the builder method is gated behind the `mcp`
147    /// feature, so a hard link would break docs built without it.
148    pub mcp_exclude: bool,
149    /// True when the endpoint opts in to *streaming* MCP exposure via
150    /// `#[api_doc(mcp, stream)]`. A streaming tool returns an Autumn `Sse`
151    /// stream that the MCP endpoint projects onto the Streamable-HTTP SSE
152    /// channel as `notifications/progress` messages terminated by the final
153    /// `tools/call` result. Because an `Sse` handler has no JSON response
154    /// schema, this flag also exempts the tool from the JSON-out eligibility
155    /// gate that otherwise excludes schema-less routes.
156    pub mcp_stream: bool,
157}
158
159/// Reference to a schema definition, produced by the route macros.
160#[derive(Copy, Clone, Debug)]
161pub struct SchemaEntry {
162    /// Short human-readable type name, used as the *default* component
163    /// display key (`#/components/schemas/Name`) when it does not collide.
164    pub name: &'static str,
165    /// Whether this is a primitive JSON type (string/number/bool/array) as
166    /// opposed to a named object ref.
167    pub kind: SchemaKind,
168    /// Globally-unique schema *identity* for a `Ref` entry, as a fn pointer to
169    /// [`type_name_of`] (i.e. `::core::any::type_name::<T>()`). This is what
170    /// matches a route reference to its producer (`#[derive(OpenApiSchema)]`
171    /// descriptor / registered schema) and disambiguates two distinct types
172    /// that share a last path segment (e.g. `create::Args` vs `update::Args`)
173    /// so neither silently shadows the other (issue #1972).
174    ///
175    /// A fn pointer (rather than the `&'static str` directly) keeps a nested
176    /// `SchemaEntry` const-promotable to `&'static` in `Array` / `Nullable`
177    /// wrappers, since `type_name` is not yet a stably-const fn.
178    ///
179    /// `None` for primitives and for the `Array` / `Nullable` wrapper entries
180    /// (whose `name` is the sentinel `"array"` / `"nullable"`), and for legacy
181    /// short-name refs (e.g. the repository macro's model refs) which keep their
182    /// last-segment display key.
183    pub identity: Option<fn() -> &'static str>,
184}
185
186impl SchemaEntry {
187    /// The globally-unique identity key for this entry: its `type_name` when an
188    /// `identity` fn is present, otherwise the short `name` (legacy behavior).
189    #[must_use]
190    pub fn identity_key(&self) -> &'static str {
191        self.identity.map_or(self.name, |f| f())
192    }
193}
194
195// `PartialEq`/`Eq` are implemented by hand rather than derived: deriving them
196// would compare the `identity` field's fn *pointers*, which the
197// `unpredictable_function_pointer_comparisons` lint (rightly) flags as
198// meaningless. Comparing the *resolved* identity strings is both meaningful and
199// what callers actually want (two entries are equal iff they describe the same
200// type the same way).
201impl PartialEq for SchemaEntry {
202    fn eq(&self, other: &Self) -> bool {
203        self.name == other.name
204            && self.kind == other.kind
205            && self.identity_key() == other.identity_key()
206    }
207}
208
209impl Eq for SchemaEntry {}
210
211/// Monomorphized `::core::any::type_name::<T>()` behind a fn pointer.
212///
213/// The route macros emit `Some(type_name_of::<T>)` as a [`SchemaEntry::identity`]
214/// so producer and consumer agree on a globally-unique schema identity by
215/// construction (both are the `type_name` of the same `T`). Using a fn pointer
216/// keeps nested entries const-promotable to `&'static` (see
217/// [`SchemaEntry::identity`]).
218#[must_use]
219pub fn type_name_of<T: ?Sized>() -> &'static str {
220    core::any::type_name::<T>()
221}
222
223/// Sanitize a schema identity into a valid OpenAPI component key.
224///
225/// OpenAPI restricts component keys to `^[A-Za-z0-9._-]+$`, so a Rust
226/// `type_name` (`crate::module::Args`, `Vec<T>`) cannot be used verbatim. `::`
227/// collapses to `.` (utoipa-style) and any other out-of-range character maps to
228/// `_`. Centralizing this here means the registration side and the `$ref` side
229/// always derive the exact same key from the same identity.
230#[must_use]
231pub fn component_key(raw: &str) -> String {
232    let dotted = raw.replace("::", ".");
233    dotted
234        .chars()
235        .map(|c| {
236            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
237                c
238            } else {
239                '_'
240            }
241        })
242        .collect()
243}
244
245/// Classifier for how a type should appear in the spec.
246#[derive(Copy, Clone, Debug, PartialEq, Eq)]
247pub enum SchemaKind {
248    /// Refers to a named component schema.
249    Ref,
250    /// A primitive JSON type inlined at the reference site.
251    Primitive(&'static str),
252    /// A JSON array whose items follow the referenced sub-schema. Used
253    /// for handlers that return `Json<Vec<T>>` (or accept one as a
254    /// request body) — emitting `Ref` for those would produce an
255    /// object schema instead of the array the endpoint actually
256    /// serializes.
257    Array(&'static SchemaEntry),
258    /// A nullable schema — used when the handler wraps the payload in
259    /// `Option<T>`. The referenced sub-entry describes `T`.
260    Nullable(&'static SchemaEntry),
261}
262
263// ──────────────────────────────────────────────────────────────────
264// Configuration — users opt into OpenAPI generation explicitly.
265// ──────────────────────────────────────────────────────────────────
266
267/// User-facing configuration for OpenAPI generation.
268///
269/// Passed to [`AppBuilder::openapi`](crate::app::AppBuilder::openapi)
270/// to enable spec generation and mount the documentation endpoints.
271#[cfg(feature = "openapi")]
272#[derive(Clone)]
273pub struct OpenApiConfig {
274    /// API title that appears in the Swagger UI header.
275    pub title: String,
276    /// API version (e.g. `"1.0.0"`).
277    pub version: String,
278    /// Optional free-form API description (Markdown permitted in UI).
279    pub description: Option<String>,
280    /// Path serving the raw `openapi.json`. Defaults to `/v3/api-docs`.
281    pub openapi_json_path: String,
282    /// Path serving the Swagger UI HTML. Defaults to `/swagger-ui`. Set
283    /// to `None` to disable the UI while still exposing the JSON.
284    pub swagger_ui_path: Option<String>,
285    /// Session cookie name used by secured route security docs.
286    ///
287    /// Runtime OpenAPI mounting replaces this with `session.cookie_name`
288    /// from the loaded app config.
289    pub session_cookie_name: String,
290    /// User-registered component schemas keyed by schema name.
291    pub additional_schemas: BTreeMap<String, serde_json::Value>,
292    /// API versions registry.
293    pub api_versions: Vec<crate::app::ApiVersion>,
294}
295
296#[cfg(feature = "openapi")]
297impl OpenApiConfig {
298    /// Create a new config with the required `title` and `version`.
299    #[must_use]
300    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
301        Self {
302            title: title.into(),
303            version: version.into(),
304            description: None,
305            openapi_json_path: "/openapi.json".to_owned(),
306            swagger_ui_path: Some("/swagger-ui".to_owned()),
307            session_cookie_name: "autumn.sid".to_owned(),
308            additional_schemas: BTreeMap::new(),
309            api_versions: Vec::new(),
310        }
311    }
312
313    /// Set a free-form API description.
314    #[must_use]
315    pub fn description(mut self, description: impl Into<String>) -> Self {
316        self.description = Some(description.into());
317        self
318    }
319
320    /// Override the path serving `openapi.json`.
321    #[must_use]
322    pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
323        self.openapi_json_path = path.into();
324        self
325    }
326
327    /// Override the Swagger UI path (or `None` to disable it).
328    #[must_use]
329    pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
330        self.swagger_ui_path = path;
331        self
332    }
333
334    /// Override the session cookie name documented for secured routes.
335    #[must_use]
336    pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
337        self.session_cookie_name = name.into();
338        self
339    }
340
341    /// Register a custom component schema. Useful when a handler's
342    /// payload type does not implement `OpenApiSchema`.
343    #[must_use]
344    pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
345        self.additional_schemas.insert(name.into(), schema);
346        self
347    }
348}
349
350// ──────────────────────────────────────────────────────────────────
351// Schema trait + primitive impls (feature-gated)
352// ──────────────────────────────────────────────────────────────────
353
354/// Describes a type's JSON schema for OpenAPI generation.
355///
356/// Provide a manual implementation for complex types to expose rich
357/// schemas in the generated spec. A blanket default is not provided —
358/// routes whose types do not implement this trait simply emit a generic
359/// `object` placeholder referring to the type name.
360///
361/// This trait is always available (no feature gate) so that `#[model]`-generated
362/// types can implement it unconditionally. The spec generation machinery that
363/// consumes implementations is still gated behind the `openapi` feature.
364pub trait OpenApiSchema {
365    /// Component schema name (appears under `#/components/schemas/`).
366    fn schema_name() -> &'static str;
367
368    /// Produce the JSON schema for this type.
369    fn schema() -> serde_json::Value;
370}
371
372/// Derive a field-accurate [`OpenApiSchema`] impl for a plain struct with named
373/// fields (issue #1972), so a handler-arg struct used in `Query<T>` / `Json<T>`
374/// advertises its real fields in the OpenAPI spec and the MCP tool `inputSchema`
375/// instead of collapsing to a generic `{"type":"object"}` placeholder — with no
376/// hand-written impl or `OpenApiConfig::register_schema` call.
377///
378/// Each field becomes a JSON-schema property (nullable `Option<T>` via
379/// `oneOf [T, null]`, `Vec<T>` as an array, primitives inline, other named types
380/// as `$ref`s), and every non-`Option` field is listed as `required` — mirroring
381/// the schema `#[model]` already generates. The derive also registers the schema
382/// in the compile-time inventory the spec/MCP back-fill consults, so a
383/// `Query<MyArgs>` / `Json<MyArgs>` handler picks it up automatically.
384///
385/// Bring it into scope alongside the trait: `use autumn_web::openapi::OpenApiSchema;`.
386pub use autumn_macros::OpenApiSchema;
387
388macro_rules! impl_primitive_schema {
389    ($ty:ty, $name:literal, $json:literal) => {
390        impl OpenApiSchema for $ty {
391            fn schema_name() -> &'static str {
392                $name
393            }
394            fn schema() -> serde_json::Value {
395                serde_json::json!({ "type": $json })
396            }
397        }
398    };
399}
400
401impl_primitive_schema!(bool, "boolean", "boolean");
402impl_primitive_schema!(String, "string", "string");
403impl_primitive_schema!(&'static str, "string", "string");
404impl_primitive_schema!(i8, "integer", "integer");
405impl_primitive_schema!(i16, "integer", "integer");
406impl_primitive_schema!(i32, "integer", "integer");
407impl_primitive_schema!(i64, "integer", "integer");
408impl_primitive_schema!(u8, "integer", "integer");
409impl_primitive_schema!(u16, "integer", "integer");
410impl_primitive_schema!(u32, "integer", "integer");
411impl_primitive_schema!(u64, "integer", "integer");
412impl_primitive_schema!(f32, "number", "number");
413impl_primitive_schema!(f64, "number", "number");
414impl_primitive_schema!(serde_json::Value, "object", "object");
415
416// ──────────────────────────────────────────────────────────────────
417// Compile-time inventory of `#[derive(OpenApiSchema)]` component schemas.
418// ──────────────────────────────────────────────────────────────────
419
420/// Compile-time registration of a plain struct's derived `OpenApiSchema`,
421/// emitted by `#[derive(OpenApiSchema)]` (issue #1972).
422///
423/// The spec generator and the MCP tool-catalog builder both back-fill component
424/// schemas for referenced type names they did not otherwise register. Without a
425/// hand-written `OpenApiSchema` impl + `OpenApiConfig::register_schema`, a plain
426/// handler-arg struct (a `Query<T>` param struct or a non-`#[model]` `Json<T>`
427/// body) used to resolve to a generic `{"type":"object","title":"X"}`
428/// placeholder — so the argument's real fields lived only in prose. This
429/// descriptor lets a `#[derive(OpenApiSchema)]` struct advertise its
430/// field-accurate schema by name, which the back-fill loops pick up
431/// automatically (no manual registration).
432///
433/// This is deliberately not feature-gated: `#[derive(OpenApiSchema)]` submits an
434/// entry unconditionally, and the `openapi`-gated spec builder consults it only
435/// when that feature is compiled in.
436pub struct DerivedSchemaDescriptor {
437    /// Short display hint (the type's last path segment / `schema_name()`).
438    pub name: &'static str,
439    /// The type's globally-unique *identity* (`type_name`), behind a fn pointer.
440    /// The spec/MCP back-fill matches a route reference to this descriptor by
441    /// identity, so two distinct types sharing a last segment resolve to their
442    /// own schema instead of whichever inventory entry link-order hit first
443    /// (issue #1972).
444    pub identity: fn() -> &'static str,
445    /// Produces the JSON schema for the type (the type's `OpenApiSchema::schema`).
446    pub schema: fn() -> serde_json::Value,
447}
448
449inventory::collect!(DerivedSchemaDescriptor);
450
451/// Look up the derived component schema for a schema *identity* (`type_name`).
452///
453/// Returns the schema when a `#[derive(OpenApiSchema)]` type with that identity
454/// was linked into the binary, or `None` when no such derive exists (so callers
455/// fall back to the generic placeholder). Matching by identity — not the short
456/// last-segment name — is what keeps two distinct `Args` types from shadowing
457/// each other in the back-fill.
458#[must_use]
459pub fn registered_derived_schema(identity: &str) -> Option<serde_json::Value> {
460    inventory::iter::<DerivedSchemaDescriptor>
461        .into_iter()
462        .find(|descriptor| (descriptor.identity)() == identity)
463        .map(|descriptor| (descriptor.schema)())
464}
465
466// ──────────────────────────────────────────────────────────────────
467// Runtime registry of component schemas populated while building the spec.
468// ──────────────────────────────────────────────────────────────────
469
470/// Accumulates component schemas while a spec is being built.
471#[derive(Default)]
472pub struct SchemaRegistry {
473    schemas: BTreeMap<String, serde_json::Value>,
474}
475
476impl SchemaRegistry {
477    /// Register a type via its `OpenApiSchema` implementation. A
478    /// duplicate insertion is a no-op (the existing entry wins).
479    pub fn register<T: OpenApiSchema>(&mut self) {
480        let name = T::schema_name().to_owned();
481        self.schemas.entry(name).or_insert_with(T::schema);
482    }
483
484    /// Insert a raw pre-built schema by name.
485    pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
486        self.schemas.insert(name.into(), schema);
487    }
488
489    /// Drain the collected schemas, consuming the registry.
490    #[must_use]
491    pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
492        self.schemas
493    }
494
495    /// Peek at the collected schemas without consuming the registry.
496    #[must_use]
497    pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
498        &self.schemas
499    }
500}
501
502// ──────────────────────────────────────────────────────────────────
503// Serializable OpenAPI 3.0 document types.
504//
505// Only the fields Autumn actually populates are modelled — unused
506// OpenAPI keys (callbacks, links, discriminators…) are intentionally
507// omitted so the generated JSON stays clean. Gated behind the
508// `openapi` feature so the runtime spec builder doesn't add code
509// size / dependency pressure to apps that never serve a JSON spec.
510// ──────────────────────────────────────────────────────────────────
511
512#[cfg(feature = "openapi")]
513/// Represents a root OpenAPI 3.0 specification document.
514#[derive(Debug, Serialize, Deserialize)]
515pub struct OpenApiSpec {
516    /// The OpenAPI version string (e.g., `3.0.3`).
517    pub openapi: String,
518    /// General information about the API.
519    pub info: Info,
520    /// The available paths and operations for the API.
521    pub paths: BTreeMap<String, PathItem>,
522    /// Reusable schemas, parameters, and other components.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub components: Option<Components>,
525}
526
527#[cfg(feature = "openapi")]
528/// Provides metadata about the API.
529#[derive(Debug, Serialize, Deserialize)]
530pub struct Info {
531    /// The title of the API.
532    pub title: String,
533    /// The version of the OpenAPI document.
534    pub version: String,
535    /// A description of the API.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub description: Option<String>,
538}
539
540#[cfg(feature = "openapi")]
541/// Describes the operations available on a single path.
542#[derive(Default, Debug, Serialize, Deserialize)]
543pub struct PathItem {
544    /// A definition of a GET operation on this path.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub get: Option<Operation>,
547    /// A definition of a POST operation on this path.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub post: Option<Operation>,
550    /// A definition of a PUT operation on this path.
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub put: Option<Operation>,
553    /// A definition of a DELETE operation on this path.
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub delete: Option<Operation>,
556    /// A definition of a PATCH operation on this path.
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub patch: Option<Operation>,
559}
560
561#[cfg(feature = "openapi")]
562/// Describes a single API operation on a path.
563#[derive(Debug, Serialize, Deserialize)]
564pub struct Operation {
565    /// Unique string used to identify the operation.
566    #[serde(rename = "operationId")]
567    pub operation_id: String,
568    /// A short summary of what the operation does.
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub summary: Option<String>,
571    /// A verbose explanation of the operation behavior.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub description: Option<String>,
574    /// A list of tags for API documentation control.
575    #[serde(skip_serializing_if = "Vec::is_empty")]
576    pub tags: Vec<String>,
577    /// A list of parameters that are applicable for this operation.
578    #[serde(skip_serializing_if = "Vec::is_empty")]
579    pub parameters: Vec<Parameter>,
580    /// The request body applicable for this operation.
581    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
582    pub request_body: Option<RequestBody>,
583    /// The list of possible responses as they are returned from executing this operation.
584    pub responses: BTreeMap<String, Response>,
585    /// Security requirements for this operation. Non-empty when the route uses `#[secured]`.
586    #[serde(skip_serializing_if = "Vec::is_empty")]
587    pub security: Vec<BTreeMap<String, Vec<String>>>,
588    /// Declares this operation to be deprecated.
589    #[serde(skip_serializing_if = "Option::is_none")]
590    pub deprecated: Option<bool>,
591    /// Vendor extension: bearer-token scope strings required by this operation.
592    /// Empty for session-only or unsecured routes.
593    #[serde(rename = "x-required-scopes", skip_serializing_if = "Vec::is_empty")]
594    pub x_required_scopes: Vec<String>,
595}
596
597#[cfg(feature = "openapi")]
598/// Describes a single operation parameter.
599#[derive(Debug, Serialize, Deserialize)]
600pub struct Parameter {
601    /// The name of the parameter.
602    pub name: String,
603    /// The location of the parameter. Possible values are "query", "header", "path" or "cookie".
604    #[serde(rename = "in")]
605    pub location: String,
606    /// Determines whether this parameter is mandatory.
607    pub required: bool,
608    /// The schema defining the type used for the parameter.
609    pub schema: serde_json::Value,
610    /// Serialization style. `"form"` with `explode: true` makes each object
611    /// property a separate query key — the correct mapping for `Query<T>`.
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub style: Option<String>,
614    /// When `true` with `style: "form"`, each schema property becomes an
615    /// independent query parameter (e.g. `?q=foo&page=2`).
616    #[serde(skip_serializing_if = "Option::is_none")]
617    pub explode: Option<bool>,
618}
619
620#[cfg(feature = "openapi")]
621/// Describes a single request body.
622#[derive(Debug, Serialize, Deserialize)]
623pub struct RequestBody {
624    /// Determines if the request body is required in the request.
625    pub required: bool,
626    /// The content of the request body, keyed by media type.
627    pub content: BTreeMap<String, MediaType>,
628}
629
630#[cfg(feature = "openapi")]
631/// Describes a single response from an API Operation.
632#[derive(Debug, Serialize, Deserialize)]
633pub struct Response {
634    /// A short description of the response.
635    pub description: String,
636    /// A map containing descriptions of potential response payloads, keyed by media type.
637    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
638    pub content: BTreeMap<String, MediaType>,
639}
640
641#[cfg(feature = "openapi")]
642/// Provides schema and examples for the media type identified by its key.
643#[derive(Debug, Serialize, Deserialize)]
644pub struct MediaType {
645    /// The schema defining the content of the request, response, or parameter.
646    pub schema: serde_json::Value,
647}
648
649#[cfg(feature = "openapi")]
650/// Holds a set of reusable objects for different aspects of the OAS.
651#[derive(Debug, Serialize, Deserialize)]
652pub struct Components {
653    /// Reusable Schema Objects.
654    pub schemas: BTreeMap<String, serde_json::Value>,
655    /// Security scheme definitions (e.g. SessionAuth).
656    #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
657    pub security_schemes: BTreeMap<String, serde_json::Value>,
658}
659
660// ──────────────────────────────────────────────────────────────────
661// Spec generator
662// ──────────────────────────────────────────────────────────────────
663
664/// Write the generated OpenAPI spec to `dist/openapi.json` and
665/// `dist/openapi.yaml` inside `dist_dir`.
666///
667/// Called during `autumn build` (when `AUTUMN_BUILD_STATIC=1`) to emit
668/// a machine-readable API contract alongside the pre-rendered HTML pages.
669///
670/// # Errors
671///
672/// Returns an [`std::io::Error`] if the directory cannot be created or
673/// either file cannot be written.
674#[cfg(feature = "openapi")]
675pub fn write_openapi_spec_to_dist(
676    spec: &OpenApiSpec,
677    dist_dir: &std::path::Path,
678) -> std::io::Result<()> {
679    std::fs::create_dir_all(dist_dir)?;
680
681    let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
682    std::fs::write(dist_dir.join("openapi.json"), &json)?;
683
684    let yaml = serde_yaml::to_string(spec).map_err(std::io::Error::other)?;
685    std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
686
687    Ok(())
688}
689
690/// Resolves each referenced schema *identity* (`type_name`) to a readable,
691/// collision-free OpenAPI component *display key* (issue #1972).
692///
693/// Built once at spec-finalize (and re-derivable purely from the routes so the
694/// MCP tool builder computes the identical mapping). A short last-segment key
695/// (`Args`) is used whenever it is unambiguous; only when two *distinct*
696/// identities would collide on the same last segment is each qualified with
697/// enough trailing module segments to disambiguate (`create.Args` /
698/// `update.Args`). Both the component registration and every `$ref` go through
699/// this map, so a route reference can never resolve to the wrong schema.
700#[cfg(feature = "openapi")]
701#[derive(Default, Debug, Clone)]
702pub struct SchemaComponentIndex {
703    /// identity key (`type_name` or legacy short name) → display component key.
704    by_identity: BTreeMap<String, String>,
705}
706
707#[cfg(feature = "openapi")]
708impl SchemaComponentIndex {
709    /// The component display key for a `Ref` entry — the value emitted in its
710    /// `#/components/schemas/{key}` `$ref`. Falls back to the sanitized short
711    /// name for an identity that was not part of the indexed route set (e.g. a
712    /// hand-built entry in a unit test).
713    #[must_use]
714    pub fn display_key(&self, entry: &SchemaEntry) -> String {
715        let identity = entry.identity_key();
716        self.by_identity
717            .get(identity)
718            .cloned()
719            .unwrap_or_else(|| component_key(entry.name))
720    }
721
722    /// Resolve a raw schema *identity* string (`type_name`) to its display key,
723    /// or `None` when the identity is not part of this index. Used by the
724    /// finalize body-ref rewrite to map a nested derived-schema `$ref` (emitted
725    /// as the field type's full `type_name`) to its collision-resolved component
726    /// key (issue #1972).
727    fn display_key_for_identity(&self, identity: &str) -> Option<&str> {
728        self.by_identity.get(identity).map(String::as_str)
729    }
730
731    /// Iterate `(identity, display_key)` pairs — used by the back-fill to
732    /// register a component under its display key keyed by identity.
733    fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
734        self.by_identity.iter()
735    }
736}
737
738/// The trailing `n` `::`-segments of a `type_name`, joined with `.` and
739/// sanitized into a component key (`a::b::Args`, n=2 → `b.Args`).
740#[cfg(feature = "openapi")]
741fn qualified_suffix_key(identity: &str, depth: usize) -> String {
742    let segments: Vec<&str> = identity.split("::").collect();
743    let start = segments.len().saturating_sub(depth);
744    component_key(&segments[start..].join("::"))
745}
746
747/// Build the identity→display-key map for every schema referenced by `routes`.
748///
749/// Pure over the route set, so [`generate_spec_at`] and the MCP tool builder
750/// derive the exact same keys.
751#[cfg(feature = "openapi")]
752#[must_use]
753pub fn build_schema_component_index(routes: &[&ApiDoc]) -> SchemaComponentIndex {
754    // Collect (identity, base-display) for every referenced Ref entry, deduped
755    // by identity. `seen` gives the identity-graph closure below an O(1) "already
756    // queued?" check.
757    let mut refs: Vec<(String, String)> = Vec::new();
758    let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
759    for api_doc in routes {
760        if api_doc.hidden {
761            continue;
762        }
763        for entry in [
764            api_doc.request_body.as_ref(),
765            api_doc.response.as_ref(),
766            api_doc.query_schema.as_ref(),
767        ]
768        .into_iter()
769        .flatten()
770        {
771            for e in flatten_ref_entries(entry) {
772                let identity = e.identity_key().to_owned();
773                if seen.insert(identity.clone()) {
774                    refs.push((identity, component_key(e.name)));
775                }
776            }
777        }
778    }
779
780    // Close the identity graph: a `$ref` emitted *inside* a derived schema body
781    // (a nested named-struct field) can reference an identity that no route
782    // mentions directly. Fetch each derived body and recursively collect the
783    // identities it refers to, to a fixpoint, so nested-only types participate
784    // in collision detection and each earns its own component (issue #1972).
785    let mut queue: Vec<String> = refs.iter().map(|(id, _)| id.clone()).collect();
786    while let Some(identity) = queue.pop() {
787        let Some(body) = registered_derived_schema(&identity) else {
788            continue;
789        };
790        let mut nested: Vec<String> = Vec::new();
791        collect_body_ref_identities(&body, &mut nested);
792        for n in nested {
793            if seen.insert(n.clone()) {
794                let base = base_display_for_identity(&n);
795                refs.push((n.clone(), base));
796                queue.push(n);
797            }
798        }
799    }
800
801    SchemaComponentIndex {
802        by_identity: assign_display_keys(&refs),
803    }
804}
805
806/// Assign a **unique, deterministic** display component key to every referenced
807/// schema identity. `refs` is `(identity, base_display)` pairs; identities are
808/// assumed already deduped. Pure over its input and independent of the order the
809/// pairs are supplied (identities are qualified in a stable sorted order), so the
810/// same route set always yields the same keys across runs and link orders.
811///
812/// Every distinct identity is guaranteed a distinct display key: the collision
813/// ladder (fewest trailing `::`-segments that make the key unique → full
814/// sanitized fallback) can be exhausted, and a colliding identity's fallback key
815/// can equal a key another colliding identity already claimed (e.g. crate `app`
816/// with `app::app::Args` claiming `app.Args` at depth 2 while `app::Args`
817/// exhausts its ladder and falls back to the same `app.Args`). When the best
818/// candidate is still taken, a deterministic `-N` disambiguator is appended until
819/// the key is free. `-` never appears in a `component_key` output derived from a
820/// real Rust `type_name` (Rust paths contain no `-`), so the disambiguator can
821/// never collide with a naturally-produced key (issue #1972).
822#[cfg(feature = "openapi")]
823fn assign_display_keys(refs: &[(String, String)]) -> BTreeMap<String, String> {
824    // Which base display keys are shared by more than one distinct identity?
825    let mut by_base: BTreeMap<String, std::collections::BTreeSet<&str>> = BTreeMap::new();
826    for (identity, base) in refs {
827        by_base
828            .entry(base.clone())
829            .or_default()
830            .insert(identity.as_str());
831    }
832
833    let mut by_identity: BTreeMap<String, String> = BTreeMap::new();
834    let mut used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
835
836    // First pass: assign the plain last-segment key to every identity whose base
837    // is unambiguous, and to legacy short-name identities (no `::` path — the
838    // repository macro's model refs) which cannot be qualified anyway.
839    for (identity, base) in refs {
840        let collides = by_base[base].len() > 1 && identity.contains("::");
841        if !collides {
842            by_identity.entry(identity.clone()).or_insert_with(|| {
843                used.insert(base.clone());
844                base.clone()
845            });
846        }
847    }
848
849    // Second pass: qualify each genuinely-colliding real type path with the
850    // fewest trailing module segments that make its key unique. Iterate in a
851    // stable sorted order so the assignment is deterministic regardless of the
852    // order pairs were collected, and guarantee the final key is free even when
853    // the suffix ladder and the full-sanitized fallback are both exhausted.
854    let mut pending: Vec<&String> = refs
855        .iter()
856        .map(|(identity, _)| identity)
857        .filter(|identity| !by_identity.contains_key(*identity))
858        .collect();
859    pending.sort_unstable();
860    for identity in pending {
861        let depth_max = identity.split("::").count();
862        let mut display = (2..=depth_max)
863            .map(|depth| qualified_suffix_key(identity, depth))
864            .find(|candidate| !used.contains(candidate))
865            .unwrap_or_else(|| component_key(identity));
866        if used.contains(&display) {
867            let base = display.clone();
868            let mut n = 2u32;
869            loop {
870                let candidate = format!("{base}-{n}");
871                if !used.contains(&candidate) {
872                    display = candidate;
873                    break;
874                }
875                n += 1;
876            }
877        }
878        used.insert(display.clone());
879        by_identity.insert(identity.clone(), display);
880    }
881
882    by_identity
883}
884
885/// The base (short) display key for a raw schema *identity* string: its last
886/// `::`-segment (ignoring any generic argument list), sanitized into a component
887/// key. Mirrors what the macros derive from `last_segment_name` for a top-level
888/// route ref, so a nested-only identity groups under the same base as a route
889/// ref of the same short name (issue #1972).
890#[cfg(feature = "openapi")]
891fn base_display_for_identity(identity: &str) -> String {
892    let without_generics = identity.split('<').next().unwrap_or(identity);
893    let last = without_generics
894        .rsplit("::")
895        .next()
896        .unwrap_or(without_generics);
897    component_key(last)
898}
899
900/// Recursively collect every `#/components/schemas/<identity>` ref target found
901/// inside a (derived) schema body, pushing each raw identity string into `out`.
902///
903/// The macro emits a nested named-struct field's `$ref` as the field type's full
904/// `type_name` identity, so the strings gathered here are exactly the identity
905/// keys the collision index resolves.
906#[cfg(feature = "openapi")]
907fn collect_body_ref_identities(value: &serde_json::Value, out: &mut Vec<String>) {
908    match value {
909        serde_json::Value::Object(map) => {
910            if let Some(serde_json::Value::String(reference)) = map.get("$ref")
911                && let Some(id) = reference.strip_prefix("#/components/schemas/")
912            {
913                out.push(id.to_owned());
914            }
915            for v in map.values() {
916                collect_body_ref_identities(v, out);
917            }
918        }
919        serde_json::Value::Array(items) => {
920            for v in items {
921                collect_body_ref_identities(v, out);
922            }
923        }
924        _ => {}
925    }
926}
927
928/// Rewrite every body-internal `#/components/schemas/<identity>` ref in each
929/// registered component to its collision-resolved display key.
930///
931/// A ref whose target is not a known identity (e.g. the pagination envelope's
932/// short `#/components/schemas/<Model>` ref, which is already a display key) is
933/// left untouched, so the common non-colliding case produces exactly the short
934/// key it did before (no churn — issue #1972).
935#[cfg(feature = "openapi")]
936fn rewrite_component_body_refs(
937    components: &mut BTreeMap<String, serde_json::Value>,
938    index: &SchemaComponentIndex,
939) {
940    for schema in components.values_mut() {
941        rewrite_identity_refs(schema, index);
942    }
943}
944
945#[cfg(feature = "openapi")]
946fn rewrite_identity_refs(value: &mut serde_json::Value, index: &SchemaComponentIndex) {
947    match value {
948        serde_json::Value::Object(map) => {
949            if let Some(serde_json::Value::String(reference)) = map.get_mut("$ref") {
950                let replacement = reference
951                    .strip_prefix("#/components/schemas/")
952                    .and_then(|identity| index.display_key_for_identity(identity))
953                    .map(|display| format!("#/components/schemas/{display}"));
954                if let Some(new_ref) = replacement {
955                    *reference = new_ref;
956                }
957            }
958            for v in map.values_mut() {
959                rewrite_identity_refs(v, index);
960            }
961        }
962        serde_json::Value::Array(items) => {
963            for v in items {
964                rewrite_identity_refs(v, index);
965            }
966        }
967        _ => {}
968    }
969}
970
971/// Flatten an entry, yielding each leaf `Ref` entry reached through
972/// `Array` / `Nullable` wrappers (so a `Json<Vec<User>>` contributes `User`).
973#[cfg(feature = "openapi")]
974fn flatten_ref_entries(entry: &SchemaEntry) -> Vec<&SchemaEntry> {
975    match entry.kind {
976        SchemaKind::Ref => vec![entry],
977        SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => flatten_ref_entries(inner),
978        SchemaKind::Primitive(_) => Vec::new(),
979    }
980}
981
982/// Build an [`OpenApiSpec`] from a collection of routes and user config.
983///
984/// This is the core of the auto-generation: every route's [`ApiDoc`] is
985/// translated into an [`Operation`] under the matching [`PathItem`].
986#[cfg(feature = "openapi")]
987#[must_use]
988pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
989    generate_spec_at(config, routes, chrono::Utc::now())
990}
991
992#[cfg(feature = "openapi")]
993#[must_use]
994pub fn generate_spec_at(
995    config: &OpenApiConfig,
996    routes: &[&ApiDoc],
997    now: chrono::DateTime<chrono::Utc>,
998) -> OpenApiSpec {
999    let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
1000    let mut registry = SchemaRegistry::default();
1001
1002    for (name, schema) in &config.additional_schemas {
1003        registry.insert(name.clone(), schema.clone());
1004    }
1005    registry.insert("ProblemDetails", problem_details_schema());
1006
1007    // Resolve every referenced schema identity to a collision-free component
1008    // display key up front, so both the `$ref` sites (via `operation_for`) and
1009    // the back-fill below register/reference the exact same key (issue #1972).
1010    let index = build_schema_component_index(routes);
1011
1012    let mut any_secured = false;
1013    let mut any_scoped = false;
1014
1015    for api_doc in routes {
1016        if api_doc.hidden {
1017            continue;
1018        }
1019        if api_doc.secured {
1020            any_secured = true;
1021        }
1022        if !api_doc.required_scopes.is_empty() {
1023            any_scoped = true;
1024        }
1025        if let Some(register) = api_doc.register_schemas {
1026            (register)(&mut registry);
1027        }
1028
1029        let operation = operation_for(api_doc, &config.api_versions, now, &index);
1030        let entry = paths.entry(api_doc.path.to_owned()).or_default();
1031        match api_doc.method {
1032            "GET" => entry.get = Some(operation),
1033            "POST" => entry.post = Some(operation),
1034            "PUT" => entry.put = Some(operation),
1035            "DELETE" => entry.delete = Some(operation),
1036            "PATCH" => entry.patch = Some(operation),
1037            // Unknown methods are silently skipped; Autumn's route macros
1038            // only emit the five verbs above today.
1039            _ => {}
1040        }
1041    }
1042
1043    // Back-fill a schema for every referenced identity the user didn't already
1044    // register, under its resolved display key. A `#[derive(OpenApiSchema)]`
1045    // type advertises a real field-accurate schema through the compile-time
1046    // inventory (matched by identity, so two same-named types never shadow each
1047    // other — issue #1972); only an identity with no derived schema (and no
1048    // explicit `OpenApiConfig::register_schema`) falls back to the minimal
1049    // `{"type": "object", "title": "X"}` placeholder.
1050    for (identity, display_key) in index.iter() {
1051        if !registry.schemas().contains_key(display_key) {
1052            let schema = registered_derived_schema(identity).unwrap_or_else(|| {
1053                serde_json::json!({
1054                    "type": "object",
1055                    "title": display_key,
1056                })
1057            });
1058            registry.insert(display_key.clone(), schema);
1059        }
1060    }
1061
1062    // Register auth security schemes used by secured routes.
1063    let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1064    if any_secured {
1065        security_schemes.insert(
1066            "SessionAuth".to_owned(),
1067            serde_json::json!({
1068                "type": "apiKey",
1069                "in": "cookie",
1070                "name": config.session_cookie_name.clone(),
1071                "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
1072            }),
1073        );
1074    }
1075    if any_scoped {
1076        security_schemes.insert(
1077            "BearerAuth".to_owned(),
1078            serde_json::json!({
1079                "type": "http",
1080                "scheme": "bearer",
1081                "description": "API bearer token. Scope-secured routes require a valid token whose scopes include all required values.",
1082            }),
1083        );
1084    }
1085
1086    let mut components_map = registry.into_map();
1087    // Rewrite every `$ref` that appears *inside* a component body from its raw
1088    // schema identity (`type_name`) to its collision-resolved display key, so a
1089    // nested derived-schema ref resolves to the same component the top-level
1090    // route refs use (issue #1972). Top-level operation refs are already emitted
1091    // as display keys by `operation_for`/`schema_value_for`; this closes the
1092    // body-internal half of the `$ref` graph.
1093    rewrite_component_body_refs(&mut components_map, &index);
1094    let components = if !components_map.is_empty() || !security_schemes.is_empty() {
1095        Some(Components {
1096            schemas: components_map,
1097            security_schemes,
1098        })
1099    } else {
1100        None
1101    };
1102
1103    OpenApiSpec {
1104        openapi: "3.1.0".to_owned(),
1105        info: Info {
1106            title: config.title.clone(),
1107            version: config.version.clone(),
1108            description: config.description.clone(),
1109        },
1110        paths,
1111        components,
1112    }
1113}
1114
1115#[cfg(feature = "openapi")]
1116#[allow(clippy::too_many_lines)]
1117fn operation_for(
1118    api_doc: &ApiDoc,
1119    api_versions: &[crate::app::ApiVersion],
1120    now: chrono::DateTime<chrono::Utc>,
1121    index: &SchemaComponentIndex,
1122) -> Operation {
1123    let mut tags = if api_doc.tags.is_empty() {
1124        default_tag(api_doc.path)
1125            .map(|t| vec![t.to_owned()])
1126            .unwrap_or_default()
1127    } else {
1128        api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
1129    };
1130
1131    if let Some(version) = api_doc.api_version {
1132        tags.push(version.to_string());
1133    }
1134
1135    let is_deprecated = api_doc.api_version.is_some_and(|version| {
1136        api_versions
1137            .iter()
1138            .find(|av| av.version == version)
1139            .is_some_and(|av| {
1140                let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
1141                let is_sun = av.sunset_at.is_some_and(|s| now >= s);
1142                is_dep || is_sun
1143            })
1144    });
1145    let deprecated = if is_deprecated { Some(true) } else { None };
1146
1147    // Path parameters — always required.
1148    let mut parameters: Vec<Parameter> = api_doc
1149        .path_params
1150        .iter()
1151        .map(|name| Parameter {
1152            name: (*name).to_owned(),
1153            location: "path".to_owned(),
1154            required: true,
1155            schema: serde_json::json!({ "type": "string" }),
1156            style: None,
1157            explode: None,
1158        })
1159        .collect();
1160
1161    // Query parameters from `Query<T>` extractor.
1162    // Use `style: form, explode: true` so each field of the query struct
1163    // is serialized as an independent query key (e.g. `?q=foo&page=2`),
1164    // which matches what the server's `Query<T>` deserialization expects.
1165    if let Some(query_entry) = &api_doc.query_schema {
1166        parameters.push(Parameter {
1167            name: query_entry.name.to_owned(),
1168            location: "query".to_owned(),
1169            required: false,
1170            schema: schema_value_for(query_entry, index),
1171            style: Some("form".to_owned()),
1172            explode: Some(true),
1173        });
1174    }
1175
1176    let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
1177        required: true,
1178        content: std::iter::once((
1179            "application/json".to_owned(),
1180            MediaType {
1181                schema: schema_value_for(entry, index),
1182            },
1183        ))
1184        .collect(),
1185    });
1186
1187    let mut responses: BTreeMap<String, Response> = BTreeMap::new();
1188    let status = if api_doc.success_status == 0 {
1189        200
1190    } else {
1191        api_doc.success_status
1192    };
1193    let response_content = api_doc
1194        .response
1195        .as_ref()
1196        .map(|entry| {
1197            let mut content = BTreeMap::new();
1198            content.insert(
1199                "application/json".to_owned(),
1200                MediaType {
1201                    schema: schema_value_for(entry, index),
1202                },
1203            );
1204            content
1205        })
1206        .unwrap_or_default();
1207    responses.insert(
1208        status.to_string(),
1209        Response {
1210            description: status_description(status).to_owned(),
1211            content: response_content,
1212        },
1213    );
1214    insert_problem_responses(&mut responses);
1215
1216    // If this route version has a sunset schedule and is not opted out, document 410 Gone
1217    let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
1218        api_versions
1219            .iter()
1220            .find(|av| av.version == version)
1221            .is_some_and(|av| av.sunset_at.is_some())
1222            && !api_doc.sunset_opt_out
1223    });
1224
1225    if is_subject_to_sunset {
1226        responses.entry("410".to_owned()).or_insert_with(|| {
1227            let mut content = BTreeMap::new();
1228            content.insert(
1229                "application/problem+json".to_owned(),
1230                MediaType {
1231                    schema: serde_json::json!({
1232                        "$ref": "#/components/schemas/ProblemDetails",
1233                    }),
1234                },
1235            );
1236            Response {
1237                description: status_description(410).to_owned(),
1238                content,
1239            }
1240        });
1241    }
1242
1243    // Security requirements:
1244    //   - scopes-only  (#[secured(scopes=[…])])            → BearerAuth
1245    //   - roles+scopes (#[secured("r", scopes=[…])])       → SessionAuth AND BearerAuth
1246    //   - roles-only / bare #[secured]                     → SessionAuth
1247    // Both entries in one BTreeMap object means AND per the OpenAPI spec.
1248    // HTTP-bearer scheme value arrays must be empty (non-empty arrays are OAuth2 scopes).
1249    let security = if api_doc.secured {
1250        let mut req = BTreeMap::new();
1251        if !api_doc.required_scopes.is_empty() {
1252            req.insert("BearerAuth".to_owned(), Vec::<String>::new());
1253        }
1254        if api_doc.required_scopes.is_empty() || !api_doc.required_roles.is_empty() {
1255            req.insert("SessionAuth".to_owned(), Vec::<String>::new());
1256        }
1257        vec![req]
1258    } else {
1259        Vec::new()
1260    };
1261
1262    Operation {
1263        operation_id: api_doc.operation_id.to_owned(),
1264        summary: api_doc.summary.map(str::to_owned),
1265        description: api_doc.description.map(str::to_owned),
1266        tags,
1267        parameters,
1268        request_body,
1269        responses,
1270        security,
1271        deprecated,
1272        x_required_scopes: api_doc
1273            .required_scopes
1274            .iter()
1275            .map(ToString::to_string)
1276            .collect(),
1277    }
1278}
1279
1280/// Render a [`SchemaEntry`] into its JSON Schema value.
1281///
1282/// Produces the same shape the OpenAPI generator emits. Exposed so the MCP
1283/// projection can derive a tool's `inputSchema` from the exact same typed
1284/// contract — guaranteeing the tool schema cannot drift from the handler.
1285///
1286/// `index` resolves each `Ref` to its collision-free component display key
1287/// (issue #1972); build it once with [`build_schema_component_index`] over the
1288/// same route set so tool `$ref`s match the served OpenAPI components exactly.
1289#[cfg(feature = "openapi")]
1290#[must_use]
1291pub fn schema_entry_to_value(
1292    entry: &SchemaEntry,
1293    index: &SchemaComponentIndex,
1294) -> serde_json::Value {
1295    schema_value_for(entry, index)
1296}
1297
1298#[cfg(feature = "openapi")]
1299fn schema_value_for(entry: &SchemaEntry, index: &SchemaComponentIndex) -> serde_json::Value {
1300    match entry.kind {
1301        SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
1302        SchemaKind::Ref => {
1303            serde_json::json!({ "$ref": format!("#/components/schemas/{}", index.display_key(entry)) })
1304        }
1305        SchemaKind::Array(items) => serde_json::json!({
1306            "type": "array",
1307            "items": schema_value_for(items, index),
1308        }),
1309        SchemaKind::Nullable(inner) => {
1310            // OpenAPI 3.1 aligns with JSON Schema 2020-12, which supports
1311            // `type: "null"` natively:
1312            //   * For a `$ref`, use `oneOf: [{$ref: ...}, {type: "null"}]`
1313            //     so the ref can stand alone without `allOf` workarounds.
1314            //   * For primitives, use the compact type-array form: `type: ["T", "null"]`.
1315            //   * For all other schemas (arrays, nested nullable, etc.), use `oneOf`
1316            //     so the full inner schema (e.g. `items`) is preserved.
1317            match inner.kind {
1318                SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
1319                    serde_json::json!({
1320                        "oneOf": [
1321                            schema_value_for(inner, index),
1322                            { "type": "null" },
1323                        ],
1324                    })
1325                }
1326                SchemaKind::Primitive(base_type) => {
1327                    serde_json::json!({ "type": [base_type, "null"] })
1328                }
1329            }
1330        }
1331    }
1332}
1333
1334#[cfg(feature = "openapi")]
1335fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
1336    for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
1337        responses.entry(status.to_string()).or_insert_with(|| {
1338            let mut content = BTreeMap::new();
1339            content.insert(
1340                "application/problem+json".to_owned(),
1341                MediaType {
1342                    schema: serde_json::json!({
1343                        "$ref": "#/components/schemas/ProblemDetails",
1344                    }),
1345                },
1346            );
1347            Response {
1348                description: status_description(status).to_owned(),
1349                content,
1350            }
1351        });
1352    }
1353}
1354
1355#[cfg(feature = "openapi")]
1356fn problem_details_schema() -> serde_json::Value {
1357    serde_json::json!({
1358        "type": "object",
1359        "additionalProperties": false,
1360        "required": [
1361            "type",
1362            "title",
1363            "status",
1364            "detail",
1365            "instance",
1366            "code",
1367            "request_id",
1368            "errors",
1369        ],
1370        "properties": {
1371            "type": {
1372                "type": "string",
1373                "format": "uri-reference",
1374            },
1375            "title": {
1376                "type": "string",
1377            },
1378            "status": {
1379                "type": "integer",
1380                "minimum": 400,
1381                "maximum": 599,
1382            },
1383            "detail": {
1384                "type": "string",
1385            },
1386            "instance": {
1387                "type": ["string", "null"],
1388            },
1389            "code": {
1390                "type": "string",
1391                "pattern": "^autumn\\.[a-z0-9_]+$",
1392            },
1393            "request_id": {
1394                "type": ["string", "null"],
1395            },
1396            "errors": {
1397                "type": "array",
1398                "items": {
1399                    "type": "object",
1400                    "additionalProperties": false,
1401                    "required": ["field", "messages"],
1402                    "properties": {
1403                        "field": {
1404                            "type": "string",
1405                        },
1406                        "messages": {
1407                            "type": "array",
1408                            "items": {
1409                                "type": "string",
1410                            },
1411                        },
1412                    },
1413                },
1414            },
1415        },
1416    })
1417}
1418
1419#[cfg(feature = "openapi")]
1420fn default_tag(path: &str) -> Option<&str> {
1421    path.trim_start_matches('/')
1422        .split('/')
1423        .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
1424}
1425
1426#[cfg(feature = "openapi")]
1427const fn status_description(status: u16) -> &'static str {
1428    match status {
1429        200 => "OK",
1430        201 => "Created",
1431        202 => "Accepted",
1432        204 => "No Content",
1433        301 => "Moved Permanently",
1434        302 => "Found",
1435        400 => "Bad Request",
1436        401 => "Unauthorized",
1437        403 => "Forbidden",
1438        404 => "Not Found",
1439        409 => "Conflict",
1440        413 => "Payload Too Large",
1441        415 => "Unsupported Media Type",
1442        422 => "Unprocessable Entity",
1443        500 => "Internal Server Error",
1444        503 => "Service Unavailable",
1445        _ => "Response",
1446    }
1447}
1448
1449// ──────────────────────────────────────────────────────────────────
1450// Swagger UI HTML
1451// ──────────────────────────────────────────────────────────────────
1452
1453#[cfg(feature = "openapi")]
1454pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
1455#[cfg(feature = "openapi")]
1456pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
1457#[cfg(feature = "openapi")]
1458pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
1459    include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
1460#[cfg(feature = "openapi")]
1461const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
1462#[cfg(feature = "openapi")]
1463const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
1464#[cfg(feature = "openapi")]
1465const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
1466
1467/// Compute the same-origin asset URLs mounted beneath the Swagger UI HTML path.
1468#[cfg(feature = "openapi")]
1469#[must_use]
1470pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
1471    [
1472        swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
1473        swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
1474        swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
1475    ]
1476}
1477
1478#[cfg(feature = "openapi")]
1479#[must_use]
1480fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
1481    let base = swagger_path.trim_end_matches('/');
1482    if base.is_empty() || base == "/" {
1483        format!("/{asset_file}")
1484    } else {
1485        format!("{base}/{asset_file}")
1486    }
1487}
1488
1489/// Minimal Swagger UI bootstrap HTML that loads same-origin vendored assets.
1490#[cfg(feature = "openapi")]
1491#[must_use]
1492pub fn swagger_ui_html(
1493    title: &str,
1494    css_url: &str,
1495    bundle_url: &str,
1496    initializer_url: &str,
1497) -> String {
1498    let title = html_escape(title);
1499    let css_url = html_escape(css_url);
1500    let bundle_url = html_escape(bundle_url);
1501    let initializer_url = html_escape(initializer_url);
1502    let mut out = String::with_capacity(1024);
1503    out.push_str("<!DOCTYPE html>\n");
1504    out.push_str("<html lang=\"en\">\n");
1505    out.push_str("  <head>\n");
1506    out.push_str("    <meta charset=\"utf-8\" />\n");
1507    out.push_str("    <title>");
1508    out.push_str(&title);
1509    out.push_str("</title>\n");
1510    out.push_str("    <link rel=\"stylesheet\" href=\"");
1511    out.push_str(&css_url);
1512    out.push_str("\" />\n");
1513    out.push_str("  </head>\n");
1514    out.push_str("  <body>\n");
1515    out.push_str("    <div id=\"swagger-ui\"></div>\n");
1516    out.push_str("    <script src=\"");
1517    out.push_str(&bundle_url);
1518    out.push_str("\" charset=\"UTF-8\"></script>\n");
1519    out.push_str("    <script src=\"");
1520    out.push_str(&initializer_url);
1521    out.push_str("\" charset=\"UTF-8\"></script>\n");
1522    out.push_str("  </body>\n");
1523    out.push_str("</html>\n");
1524    out
1525}
1526
1527/// External Swagger UI initializer script so the default `script-src 'self'`
1528/// CSP can boot the docs UI without permitting inline JavaScript.
1529#[cfg(feature = "openapi")]
1530#[must_use]
1531pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
1532    let spec_url = serde_json::to_string(spec_url)
1533        .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
1534    let mut out = String::with_capacity(256);
1535    out.push_str("window.onload = function() {\n");
1536    out.push_str("  window.ui = SwaggerUIBundle({\n");
1537    out.push_str("    url: ");
1538    out.push_str(&spec_url);
1539    out.push_str(",\n");
1540    out.push_str("    dom_id: \"#swagger-ui\",\n");
1541    out.push_str("    deepLinking: true\n");
1542    out.push_str("  });\n");
1543    out.push_str("};\n");
1544    out
1545}
1546
1547#[cfg(feature = "openapi")]
1548fn html_escape(s: &str) -> String {
1549    s.replace('&', "&amp;")
1550        .replace('<', "&lt;")
1551        .replace('>', "&gt;")
1552        .replace('"', "&quot;")
1553}
1554
1555// ──────────────────────────────────────────────────────────────────
1556// Tests
1557// ──────────────────────────────────────────────────────────────────
1558
1559#[cfg(all(test, feature = "openapi"))]
1560mod tests {
1561    use super::*;
1562
1563    fn make_doc() -> ApiDoc {
1564        ApiDoc {
1565            method: "GET",
1566            path: "/users/{id}",
1567            operation_id: "get_user",
1568            summary: Some("Fetch a user"),
1569            description: None,
1570            tags: &[],
1571            path_params: &["id"],
1572            request_body: None,
1573            response: None,
1574            success_status: 200,
1575            hidden: false,
1576            query_schema: None,
1577            secured: false,
1578            required_roles: &[],
1579            register_schemas: None,
1580            api_version: None,
1581            ..Default::default()
1582        }
1583    }
1584
1585    #[test]
1586    fn config_builder_methods_work() {
1587        let config = OpenApiConfig::new("Demo", "1.0.0")
1588            .description("A cool API")
1589            .openapi_json_path("/api.json")
1590            .swagger_ui_path(None)
1591            .session_cookie_name("demo.sid");
1592
1593        assert_eq!(config.title, "Demo");
1594        assert_eq!(config.version, "1.0.0");
1595        assert_eq!(config.description.unwrap(), "A cool API");
1596        assert_eq!(config.openapi_json_path, "/api.json");
1597        assert_eq!(config.swagger_ui_path, None);
1598        assert_eq!(config.session_cookie_name, "demo.sid");
1599    }
1600
1601    #[test]
1602    fn secured_spec_uses_configured_session_cookie_name() {
1603        let mut doc = make_doc();
1604        doc.path = "/protected";
1605        doc.operation_id = "protected";
1606        doc.path_params = &[];
1607        doc.secured = true;
1608
1609        let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
1610        let spec = generate_spec(&config, &[&doc]);
1611        let scheme = &spec
1612            .components
1613            .as_ref()
1614            .expect("secured routes emit security components")
1615            .security_schemes["SessionAuth"];
1616
1617        assert_eq!(scheme["type"], "apiKey");
1618        assert_eq!(scheme["in"], "cookie");
1619        assert_eq!(scheme["name"], "demo.sid");
1620    }
1621
1622    #[test]
1623    fn generate_spec_builds_path_with_parameters() {
1624        let doc = make_doc();
1625        let config = OpenApiConfig::new("Demo", "1.0.0");
1626        let spec = generate_spec(&config, &[&doc]);
1627
1628        assert_eq!(spec.openapi, "3.1.0");
1629        assert_eq!(spec.info.title, "Demo");
1630        assert!(spec.paths.contains_key("/users/{id}"));
1631
1632        let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1633        assert_eq!(op.operation_id, "get_user");
1634        assert_eq!(op.parameters.len(), 1);
1635        assert_eq!(op.parameters[0].name, "id");
1636        assert_eq!(op.parameters[0].location, "path");
1637        assert_eq!(op.tags, vec!["users".to_owned()]);
1638    }
1639
1640    #[test]
1641    fn generate_spec_skips_hidden_routes() {
1642        let mut doc = make_doc();
1643        doc.hidden = true;
1644        let config = OpenApiConfig::new("Demo", "1.0.0");
1645        let spec = generate_spec(&config, &[&doc]);
1646        assert!(spec.paths.is_empty());
1647    }
1648
1649    #[test]
1650    fn generate_spec_writes_request_body_ref() {
1651        let mut doc = make_doc();
1652        doc.method = "POST";
1653        doc.path = "/users";
1654        doc.operation_id = "create_user";
1655        doc.path_params = &[];
1656        doc.request_body = Some(SchemaEntry {
1657            name: "CreateUser",
1658            kind: SchemaKind::Ref,
1659            identity: None,
1660        });
1661        doc.success_status = 201;
1662
1663        let config = OpenApiConfig::new("Demo", "1.0.0");
1664        let spec = generate_spec(&config, &[&doc]);
1665        let op = spec.paths["/users"].post.as_ref().unwrap();
1666        let body = op.request_body.as_ref().unwrap();
1667        assert!(body.required);
1668        let media = body.content.get("application/json").unwrap();
1669        assert_eq!(
1670            media.schema,
1671            serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
1672        );
1673        assert!(op.responses.contains_key("201"));
1674    }
1675
1676    #[test]
1677    fn generate_spec_inlines_primitive_response() {
1678        let mut doc = make_doc();
1679        doc.response = Some(SchemaEntry {
1680            name: "string",
1681            kind: SchemaKind::Primitive("string"),
1682            identity: None,
1683        });
1684        let config = OpenApiConfig::new("Demo", "1.0.0");
1685        let spec = generate_spec(&config, &[&doc]);
1686        let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1687        let media = op.responses["200"].content.get("application/json").unwrap();
1688        assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
1689    }
1690
1691    #[test]
1692    fn swagger_ui_html_uses_same_origin_assets() {
1693        let html = swagger_ui_html(
1694            "Demo",
1695            "/swagger-ui/swagger-ui.css",
1696            "/swagger-ui/swagger-ui-bundle.js",
1697            "/swagger-ui/swagger-initializer.js",
1698        );
1699        assert!(html.contains("/swagger-ui/swagger-ui.css"));
1700        assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
1701        assert!(html.contains("/swagger-ui/swagger-initializer.js"));
1702        assert!(!html.contains("unpkg.com"));
1703        assert!(!html.contains("window.onload = function()"));
1704    }
1705
1706    #[test]
1707    fn swagger_ui_initializer_js_references_spec_url() {
1708        let js = swagger_ui_initializer_js("/openapi.json");
1709        assert!(js.contains("SwaggerUIBundle"));
1710        assert!(js.contains(r#""/openapi.json""#));
1711    }
1712
1713    #[test]
1714    fn generate_spec_includes_additional_schemas() {
1715        let doc = make_doc();
1716        let config = OpenApiConfig::new("Demo", "1.0.0")
1717            .register_schema("Foo", serde_json::json!({ "type": "object" }));
1718        let spec = generate_spec(&config, &[&doc]);
1719        let components = spec.components.unwrap();
1720        assert!(components.schemas.contains_key("Foo"));
1721    }
1722
1723    #[test]
1724    fn generate_spec_back_fills_unregistered_ref_schemas() {
1725        // A Json<CreateUser> handler emits a `$ref` with no component
1726        // schema registered. The generator must back-fill a placeholder
1727        // schema so the resulting OpenAPI document is valid.
1728        let mut doc = make_doc();
1729        doc.method = "POST";
1730        doc.path = "/users";
1731        doc.path_params = &[];
1732        doc.request_body = Some(SchemaEntry {
1733            name: "CreateUser",
1734            kind: SchemaKind::Ref,
1735            identity: None,
1736        });
1737        doc.response = Some(SchemaEntry {
1738            name: "User",
1739            kind: SchemaKind::Ref,
1740            identity: None,
1741        });
1742
1743        let config = OpenApiConfig::new("Demo", "1.0.0");
1744        let spec = generate_spec(&config, &[&doc]);
1745        let components = spec.components.expect("components must be emitted");
1746        let create = components
1747            .schemas
1748            .get("CreateUser")
1749            .expect("CreateUser should be back-filled");
1750        let user = components
1751            .schemas
1752            .get("User")
1753            .expect("User should be back-filled");
1754        assert_eq!(create["type"], "object");
1755        assert_eq!(create["title"], "CreateUser");
1756        assert_eq!(user["type"], "object");
1757        assert_eq!(user["title"], "User");
1758    }
1759
1760    #[test]
1761    fn generate_spec_preserves_user_registered_schemas_over_backfill() {
1762        let mut doc = make_doc();
1763        doc.response = Some(SchemaEntry {
1764            name: "User",
1765            kind: SchemaKind::Ref,
1766            identity: None,
1767        });
1768
1769        let user_schema = serde_json::json!({
1770            "type": "object",
1771            "properties": {"id": {"type": "integer"}},
1772        });
1773        let config =
1774            OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
1775        let spec = generate_spec(&config, &[&doc]);
1776        let components = spec.components.unwrap();
1777        let stored = components.schemas.get("User").unwrap();
1778        assert_eq!(stored, &user_schema, "user schema must not be overwritten");
1779    }
1780
1781    #[test]
1782    fn status_description_returns_correct_strings() {
1783        assert_eq!(status_description(200), "OK");
1784        assert_eq!(status_description(201), "Created");
1785        assert_eq!(status_description(202), "Accepted");
1786        assert_eq!(status_description(204), "No Content");
1787        assert_eq!(status_description(301), "Moved Permanently");
1788        assert_eq!(status_description(302), "Found");
1789        assert_eq!(status_description(400), "Bad Request");
1790        assert_eq!(status_description(401), "Unauthorized");
1791        assert_eq!(status_description(403), "Forbidden");
1792        assert_eq!(status_description(404), "Not Found");
1793        assert_eq!(status_description(409), "Conflict");
1794        assert_eq!(status_description(413), "Payload Too Large");
1795        assert_eq!(status_description(415), "Unsupported Media Type");
1796        assert_eq!(status_description(422), "Unprocessable Entity");
1797        assert_eq!(status_description(500), "Internal Server Error");
1798        assert_eq!(status_description(503), "Service Unavailable");
1799        assert_eq!(status_description(418), "Response");
1800    }
1801
1802    #[test]
1803    fn default_tag_picks_first_static_segment() {
1804        assert_eq!(default_tag("/users/{id}"), Some("users"));
1805        assert_eq!(default_tag("/api/v1/users"), Some("api"));
1806        assert_eq!(default_tag("/"), None);
1807        assert_eq!(default_tag("/{id}"), None);
1808    }
1809
1810    // ── OpenAPI 3.1 compliance tests (RED phase) ───────────────────────────
1811
1812    #[test]
1813    fn spec_version_is_3_1_0() {
1814        let config = OpenApiConfig::new("Demo", "1.0.0");
1815        let spec = generate_spec(&config, &[]);
1816        assert_eq!(
1817            spec.openapi, "3.1.0",
1818            "Autumn must emit OpenAPI 3.1.0, not {}",
1819            spec.openapi
1820        );
1821    }
1822
1823    #[test]
1824    fn nullable_ref_uses_openapi_3_1_one_of() {
1825        // OpenAPI 3.1 aligns with JSON Schema 2020-12: nullable refs use
1826        // `oneOf: [{$ref: ...}, {type: "null"}]` instead of 3.0's
1827        // `nullable: true` + `allOf` workaround.
1828        static INNER: SchemaEntry = SchemaEntry {
1829            name: "User",
1830            kind: SchemaKind::Ref,
1831            identity: None,
1832        };
1833        let entry = SchemaEntry {
1834            name: "nullable",
1835            kind: SchemaKind::Nullable(&INNER),
1836            identity: None,
1837        };
1838        let value = schema_value_for(&entry, &SchemaComponentIndex::default());
1839        assert!(
1840            value.get("nullable").is_none(),
1841            "3.1 must not emit `nullable: true` (that is 3.0 only)"
1842        );
1843        assert!(
1844            value.get("allOf").is_none(),
1845            "3.1 must not use allOf for nullable refs"
1846        );
1847        let one_of = value["oneOf"]
1848            .as_array()
1849            .expect("3.1 nullable ref must use oneOf");
1850        assert_eq!(one_of.len(), 2);
1851        assert_eq!(
1852            one_of[0]["$ref"], "#/components/schemas/User",
1853            "first oneOf branch must be the $ref"
1854        );
1855        assert_eq!(
1856            one_of[1]["type"], "null",
1857            "second oneOf branch must be {{type: null}}"
1858        );
1859    }
1860
1861    #[test]
1862    fn nullable_primitive_uses_type_array() {
1863        // OpenAPI 3.1 uses `type: ["integer", "null"]` for nullable
1864        // primitives instead of the 3.0 `nullable: true` flag.
1865        static INNER: SchemaEntry = SchemaEntry {
1866            name: "integer",
1867            kind: SchemaKind::Primitive("integer"),
1868            identity: None,
1869        };
1870        let entry = SchemaEntry {
1871            name: "nullable",
1872            kind: SchemaKind::Nullable(&INNER),
1873            identity: None,
1874        };
1875        let value = schema_value_for(&entry, &SchemaComponentIndex::default());
1876        assert!(
1877            value.get("nullable").is_none(),
1878            "3.1 must not emit `nullable: true`"
1879        );
1880        let types = value["type"]
1881            .as_array()
1882            .expect("3.1 nullable primitive must use a type array");
1883        assert!(
1884            types.contains(&serde_json::Value::String("integer".to_owned())),
1885            "type array must include the base type"
1886        );
1887        assert!(
1888            types.contains(&serde_json::Value::String("null".to_owned())),
1889            "type array must include null"
1890        );
1891    }
1892
1893    #[test]
1894    fn write_openapi_spec_to_dist_creates_json_file() {
1895        let tmp = tempfile::TempDir::new().unwrap();
1896        let dist = tmp.path().join("dist");
1897        std::fs::create_dir_all(&dist).unwrap();
1898
1899        let config = OpenApiConfig::new("TestAPI", "2.0.0");
1900        let spec = generate_spec(&config, &[]);
1901
1902        write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1903
1904        let json_path = dist.join("openapi.json");
1905        assert!(json_path.exists(), "dist/openapi.json must be written");
1906
1907        let content = std::fs::read_to_string(&json_path).unwrap();
1908        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1909        assert_eq!(parsed["openapi"], "3.1.0");
1910        assert_eq!(parsed["info"]["title"], "TestAPI");
1911    }
1912
1913    #[test]
1914    fn write_openapi_spec_to_dist_creates_yaml_file() {
1915        let tmp = tempfile::TempDir::new().unwrap();
1916        let dist = tmp.path().join("dist");
1917        std::fs::create_dir_all(&dist).unwrap();
1918
1919        let config = OpenApiConfig::new("TestAPI", "2.0.0");
1920        let spec = generate_spec(&config, &[]);
1921
1922        write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1923
1924        let yaml_path = dist.join("openapi.yaml");
1925        assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
1926
1927        let content = std::fs::read_to_string(&yaml_path).unwrap();
1928        assert!(
1929            content.contains("openapi:"),
1930            "YAML must include the openapi field"
1931        );
1932        assert!(content.contains("3.1.0"), "YAML must include the version");
1933        assert!(content.contains("TestAPI"), "YAML must include the title");
1934    }
1935
1936    #[test]
1937    fn schema_registry_into_map_returns_all_schemas() {
1938        let mut registry = SchemaRegistry::default();
1939        registry.insert("Foo", serde_json::json!({ "type": "string" }));
1940        registry.insert("Bar", serde_json::json!({ "type": "integer" }));
1941
1942        let map = registry.into_map();
1943        assert_eq!(map.len(), 2);
1944        assert_eq!(
1945            map.get("Foo").unwrap(),
1946            &serde_json::json!({ "type": "string" })
1947        );
1948        assert_eq!(
1949            map.get("Bar").unwrap(),
1950            &serde_json::json!({ "type": "integer" })
1951        );
1952    }
1953
1954    #[test]
1955    fn schema_registry_deduplicates() {
1956        struct Foo;
1957        impl OpenApiSchema for Foo {
1958            fn schema_name() -> &'static str {
1959                "Foo"
1960            }
1961            fn schema() -> serde_json::Value {
1962                serde_json::json!({ "type": "object", "title": "Foo" })
1963            }
1964        }
1965
1966        let mut registry = SchemaRegistry::default();
1967        registry.register::<Foo>();
1968        registry.register::<Foo>();
1969        assert_eq!(registry.schemas().len(), 1);
1970    }
1971
1972    #[test]
1973    fn primitive_impls_cover_common_types() {
1974        assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
1975        assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
1976        assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
1977        assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
1978    }
1979
1980    #[test]
1981    fn swagger_ui_html_embeds_spec_url() {
1982        let html = swagger_ui_html(
1983            "My API",
1984            "/swagger-ui/swagger-ui.css",
1985            "/swagger-ui/swagger-ui-bundle.js",
1986            "/swagger-ui/swagger-initializer.js",
1987        );
1988        assert!(html.contains("/swagger-ui/swagger-ui.css"));
1989        assert!(html.contains("My API"));
1990    }
1991
1992    #[test]
1993    fn swagger_ui_html_escapes_attributes() {
1994        let html = swagger_ui_html(
1995            "A \"cool\" & fun API",
1996            "/swagger-ui/swagger-ui.css?x=<y>",
1997            "/swagger-ui/swagger-ui-bundle.js",
1998            "/swagger-ui/swagger-initializer.js",
1999        );
2000        assert!(html.contains("/swagger-ui/swagger-ui.css?x=&lt;y&gt;"));
2001        assert!(html.contains("A &quot;cool&quot; &amp; fun API"));
2002    }
2003
2004    // ── Security requirement generation (#1158) ──────────────────────────────
2005
2006    fn make_secured_doc(
2007        secured: bool,
2008        required_roles: &'static [&'static str],
2009        required_scopes: &'static [&'static str],
2010    ) -> ApiDoc {
2011        let mut doc = make_doc();
2012        doc.path = "/secured";
2013        doc.operation_id = "secured_op";
2014        doc.path_params = &[];
2015        doc.secured = secured;
2016        doc.required_roles = required_roles;
2017        doc.required_scopes = required_scopes;
2018        doc
2019    }
2020
2021    #[test]
2022    fn unsecured_route_has_no_security_requirement() {
2023        let doc = make_secured_doc(false, &[], &[]);
2024        let config = OpenApiConfig::new("Demo", "1.0.0");
2025        let spec = generate_spec(&config, &[&doc]);
2026        let op = spec.paths["/secured"].get.as_ref().unwrap();
2027        assert!(op.security.is_empty());
2028    }
2029
2030    #[test]
2031    fn bare_secured_uses_session_auth() {
2032        let doc = make_secured_doc(true, &[], &[]);
2033        let config = OpenApiConfig::new("Demo", "1.0.0");
2034        let spec = generate_spec(&config, &[&doc]);
2035        let op = spec.paths["/secured"].get.as_ref().unwrap();
2036        assert_eq!(op.security.len(), 1);
2037        assert!(op.security[0].contains_key("SessionAuth"));
2038        assert!(!op.security[0].contains_key("BearerAuth"));
2039    }
2040
2041    #[test]
2042    fn role_only_uses_session_auth() {
2043        let doc = make_secured_doc(true, &["admin"], &[]);
2044        let config = OpenApiConfig::new("Demo", "1.0.0");
2045        let spec = generate_spec(&config, &[&doc]);
2046        let op = spec.paths["/secured"].get.as_ref().unwrap();
2047        assert_eq!(op.security.len(), 1);
2048        assert!(op.security[0].contains_key("SessionAuth"));
2049        assert!(!op.security[0].contains_key("BearerAuth"));
2050    }
2051
2052    #[test]
2053    fn scope_only_uses_bearer_auth_with_empty_array() {
2054        let doc = make_secured_doc(true, &[], &["posts:write"]);
2055        let config = OpenApiConfig::new("Demo", "1.0.0");
2056        let spec = generate_spec(&config, &[&doc]);
2057        let op = spec.paths["/secured"].get.as_ref().unwrap();
2058        assert_eq!(op.security.len(), 1);
2059        assert!(op.security[0].contains_key("BearerAuth"));
2060        assert!(!op.security[0].contains_key("SessionAuth"));
2061        // OpenAPI spec: HTTP bearer value array must be empty (not scope names).
2062        assert!(op.security[0]["BearerAuth"].is_empty());
2063        // BearerAuth scheme is registered in components.
2064        let schemes = &spec.components.as_ref().unwrap().security_schemes;
2065        assert!(schemes.contains_key("BearerAuth"));
2066        assert_eq!(schemes["BearerAuth"]["scheme"], "bearer");
2067    }
2068
2069    #[test]
2070    fn mixed_role_and_scope_uses_both_auth_schemes() {
2071        let doc = make_secured_doc(true, &["admin"], &["posts:write"]);
2072        let config = OpenApiConfig::new("Demo", "1.0.0");
2073        let spec = generate_spec(&config, &[&doc]);
2074        let op = spec.paths["/secured"].get.as_ref().unwrap();
2075        assert_eq!(op.security.len(), 1);
2076        // Both in the same object = AND semantics.
2077        assert!(op.security[0].contains_key("SessionAuth"));
2078        assert!(op.security[0].contains_key("BearerAuth"));
2079    }
2080
2081    #[test]
2082    fn bearer_auth_scheme_registered_only_for_scoped_routes() {
2083        let unscoped = make_secured_doc(true, &["admin"], &[]);
2084        let config = OpenApiConfig::new("Demo", "1.0.0");
2085        let spec = generate_spec(&config, &[&unscoped]);
2086        let schemes = &spec.components.as_ref().unwrap().security_schemes;
2087        assert!(!schemes.contains_key("BearerAuth"));
2088    }
2089
2090    // Regression (issue #1972): the fallback display-key assignment must be
2091    // collision-proof. This generalizes the reviewer's `app::app::Args` /
2092    // `app::Args` example to a pair that still clashes under the deterministic
2093    // sorted assignment order: `a::x::Args` sorts first and claims the 2-segment
2094    // suffix `x.Args`, then `x::Args` exhausts its suffix ladder (its only
2095    // qualified candidate IS `x.Args`) and falls back to
2096    // `component_key("x::Args")` == `x.Args` — the exact key `a::x::Args` already
2097    // took. Without the disambiguator both identities would map to `x.Args`, so
2098    // the second schema would silently overwrite the first.
2099    #[test]
2100    fn colliding_fallback_keys_are_disambiguated() {
2101        let refs = vec![
2102            ("a::x::Args".to_owned(), "Args".to_owned()),
2103            ("x::Args".to_owned(), "Args".to_owned()),
2104        ];
2105        let by_identity = assign_display_keys(&refs);
2106
2107        let a = by_identity.get("a::x::Args").expect("a::x::Args assigned");
2108        let b = by_identity.get("x::Args").expect("x::Args assigned");
2109        assert_ne!(
2110            a, b,
2111            "distinct identities must map to distinct display keys, got {a} == {b}"
2112        );
2113        // `a::x::Args` claims the suffix key; `x::Args` must be pushed onto the
2114        // deterministic `-N` disambiguator rather than overwriting it — pinning
2115        // this proves the disambiguator branch actually ran.
2116        assert_eq!(a, "x.Args");
2117        assert_eq!(b, "x.Args-2");
2118        // Both keys are valid OpenAPI component keys.
2119        for key in [a, b] {
2120            assert!(
2121                key.chars()
2122                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
2123                "display key {key} is not a valid component key"
2124            );
2125        }
2126    }
2127
2128    // Determinism: the same identity set must yield the same keys regardless of
2129    // the order the `(identity, base)` pairs are supplied.
2130    #[test]
2131    fn assign_display_keys_is_order_independent() {
2132        let forward = vec![
2133            ("app::app::Args".to_owned(), "Args".to_owned()),
2134            ("app::Args".to_owned(), "Args".to_owned()),
2135            ("other::mod::Args".to_owned(), "Args".to_owned()),
2136        ];
2137        let mut reversed = forward.clone();
2138        reversed.reverse();
2139        assert_eq!(
2140            assign_display_keys(&forward),
2141            assign_display_keys(&reversed),
2142            "display-key assignment must not depend on input order"
2143        );
2144    }
2145}