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, mcp_tool, mcp_exclude) each
81// 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    /// Optional runtime hook that lets a handler register any extra
118    /// component schemas with the generator.
119    pub register_schemas: Option<fn(&mut SchemaRegistry)>,
120    /// Optional API version associated with this route.
121    pub api_version: Option<&'static str>,
122    /// Whether this route opts out of sunset 410 responses.
123    pub sunset_opt_out: bool,
124    /// Whether this route uses dynamic policy authorization.
125    pub has_policy: bool,
126    /// True when the endpoint opts in to MCP tool exposure via
127    /// `#[api_doc(mcp)]`. Opt-in is per-endpoint and never implicit.
128    pub mcp_tool: bool,
129    /// True when the endpoint explicitly opts *out* of MCP exposure via
130    /// `#[api_doc(mcp = false)]`. Honored even under the whole-API hatch
131    /// (`AppBuilder::expose_all_as_mcp`). Not an intra-doc link: this field is
132    /// always compiled, but the builder method is gated behind the `mcp`
133    /// feature, so a hard link would break docs built without it.
134    pub mcp_exclude: bool,
135    /// True when the endpoint opts in to *streaming* MCP exposure via
136    /// `#[api_doc(mcp, stream)]`. A streaming tool returns an Autumn `Sse`
137    /// stream that the MCP endpoint projects onto the Streamable-HTTP SSE
138    /// channel as `notifications/progress` messages terminated by the final
139    /// `tools/call` result. Because an `Sse` handler has no JSON response
140    /// schema, this flag also exempts the tool from the JSON-out eligibility
141    /// gate that otherwise excludes schema-less routes.
142    pub mcp_stream: bool,
143}
144
145/// Reference to a schema definition, produced by the route macros.
146#[derive(Copy, Clone, Debug, PartialEq, Eq)]
147pub struct SchemaEntry {
148    /// Short human-readable type name (used as `#/components/schemas/Name`).
149    pub name: &'static str,
150    /// Whether this is a primitive JSON type (string/number/bool/array) as
151    /// opposed to a named object ref.
152    pub kind: SchemaKind,
153}
154
155/// Classifier for how a type should appear in the spec.
156#[derive(Copy, Clone, Debug, PartialEq, Eq)]
157pub enum SchemaKind {
158    /// Refers to a named component schema.
159    Ref,
160    /// A primitive JSON type inlined at the reference site.
161    Primitive(&'static str),
162    /// A JSON array whose items follow the referenced sub-schema. Used
163    /// for handlers that return `Json<Vec<T>>` (or accept one as a
164    /// request body) — emitting `Ref` for those would produce an
165    /// object schema instead of the array the endpoint actually
166    /// serializes.
167    Array(&'static SchemaEntry),
168    /// A nullable schema — used when the handler wraps the payload in
169    /// `Option<T>`. The referenced sub-entry describes `T`.
170    Nullable(&'static SchemaEntry),
171}
172
173// ──────────────────────────────────────────────────────────────────
174// Configuration — users opt into OpenAPI generation explicitly.
175// ──────────────────────────────────────────────────────────────────
176
177/// User-facing configuration for OpenAPI generation.
178///
179/// Passed to [`AppBuilder::openapi`](crate::app::AppBuilder::openapi)
180/// to enable spec generation and mount the documentation endpoints.
181#[cfg(feature = "openapi")]
182#[derive(Clone)]
183pub struct OpenApiConfig {
184    /// API title that appears in the Swagger UI header.
185    pub title: String,
186    /// API version (e.g. `"1.0.0"`).
187    pub version: String,
188    /// Optional free-form API description (Markdown permitted in UI).
189    pub description: Option<String>,
190    /// Path serving the raw `openapi.json`. Defaults to `/v3/api-docs`.
191    pub openapi_json_path: String,
192    /// Path serving the Swagger UI HTML. Defaults to `/swagger-ui`. Set
193    /// to `None` to disable the UI while still exposing the JSON.
194    pub swagger_ui_path: Option<String>,
195    /// Session cookie name used by secured route security docs.
196    ///
197    /// Runtime OpenAPI mounting replaces this with `session.cookie_name`
198    /// from the loaded app config.
199    pub session_cookie_name: String,
200    /// User-registered component schemas keyed by schema name.
201    pub additional_schemas: BTreeMap<String, serde_json::Value>,
202    /// API versions registry.
203    pub api_versions: Vec<crate::app::ApiVersion>,
204}
205
206#[cfg(feature = "openapi")]
207impl OpenApiConfig {
208    /// Create a new config with the required `title` and `version`.
209    #[must_use]
210    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
211        Self {
212            title: title.into(),
213            version: version.into(),
214            description: None,
215            openapi_json_path: "/openapi.json".to_owned(),
216            swagger_ui_path: Some("/swagger-ui".to_owned()),
217            session_cookie_name: "autumn.sid".to_owned(),
218            additional_schemas: BTreeMap::new(),
219            api_versions: Vec::new(),
220        }
221    }
222
223    /// Set a free-form API description.
224    #[must_use]
225    pub fn description(mut self, description: impl Into<String>) -> Self {
226        self.description = Some(description.into());
227        self
228    }
229
230    /// Override the path serving `openapi.json`.
231    #[must_use]
232    pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
233        self.openapi_json_path = path.into();
234        self
235    }
236
237    /// Override the Swagger UI path (or `None` to disable it).
238    #[must_use]
239    pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
240        self.swagger_ui_path = path;
241        self
242    }
243
244    /// Override the session cookie name documented for secured routes.
245    #[must_use]
246    pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
247        self.session_cookie_name = name.into();
248        self
249    }
250
251    /// Register a custom component schema. Useful when a handler's
252    /// payload type does not implement `OpenApiSchema`.
253    #[must_use]
254    pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
255        self.additional_schemas.insert(name.into(), schema);
256        self
257    }
258}
259
260// ──────────────────────────────────────────────────────────────────
261// Schema trait + primitive impls (feature-gated)
262// ──────────────────────────────────────────────────────────────────
263
264/// Describes a type's JSON schema for OpenAPI generation.
265///
266/// Provide a manual implementation for complex types to expose rich
267/// schemas in the generated spec. A blanket default is not provided —
268/// routes whose types do not implement this trait simply emit a generic
269/// `object` placeholder referring to the type name.
270///
271/// This trait is always available (no feature gate) so that `#[model]`-generated
272/// types can implement it unconditionally. The spec generation machinery that
273/// consumes implementations is still gated behind the `openapi` feature.
274pub trait OpenApiSchema {
275    /// Component schema name (appears under `#/components/schemas/`).
276    fn schema_name() -> &'static str;
277
278    /// Produce the JSON schema for this type.
279    fn schema() -> serde_json::Value;
280}
281
282macro_rules! impl_primitive_schema {
283    ($ty:ty, $name:literal, $json:literal) => {
284        impl OpenApiSchema for $ty {
285            fn schema_name() -> &'static str {
286                $name
287            }
288            fn schema() -> serde_json::Value {
289                serde_json::json!({ "type": $json })
290            }
291        }
292    };
293}
294
295impl_primitive_schema!(bool, "boolean", "boolean");
296impl_primitive_schema!(String, "string", "string");
297impl_primitive_schema!(&'static str, "string", "string");
298impl_primitive_schema!(i8, "integer", "integer");
299impl_primitive_schema!(i16, "integer", "integer");
300impl_primitive_schema!(i32, "integer", "integer");
301impl_primitive_schema!(i64, "integer", "integer");
302impl_primitive_schema!(u8, "integer", "integer");
303impl_primitive_schema!(u16, "integer", "integer");
304impl_primitive_schema!(u32, "integer", "integer");
305impl_primitive_schema!(u64, "integer", "integer");
306impl_primitive_schema!(f32, "number", "number");
307impl_primitive_schema!(f64, "number", "number");
308impl_primitive_schema!(serde_json::Value, "object", "object");
309
310// ──────────────────────────────────────────────────────────────────
311// Runtime registry of component schemas populated while building the spec.
312// ──────────────────────────────────────────────────────────────────
313
314/// Accumulates component schemas while a spec is being built.
315#[derive(Default)]
316pub struct SchemaRegistry {
317    schemas: BTreeMap<String, serde_json::Value>,
318}
319
320impl SchemaRegistry {
321    /// Register a type via its `OpenApiSchema` implementation. A
322    /// duplicate insertion is a no-op (the existing entry wins).
323    pub fn register<T: OpenApiSchema>(&mut self) {
324        let name = T::schema_name().to_owned();
325        self.schemas.entry(name).or_insert_with(T::schema);
326    }
327
328    /// Insert a raw pre-built schema by name.
329    pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
330        self.schemas.insert(name.into(), schema);
331    }
332
333    /// Drain the collected schemas, consuming the registry.
334    #[must_use]
335    pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
336        self.schemas
337    }
338
339    /// Peek at the collected schemas without consuming the registry.
340    #[must_use]
341    pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
342        &self.schemas
343    }
344}
345
346// ──────────────────────────────────────────────────────────────────
347// Serializable OpenAPI 3.0 document types.
348//
349// Only the fields Autumn actually populates are modelled — unused
350// OpenAPI keys (callbacks, links, discriminators…) are intentionally
351// omitted so the generated JSON stays clean. Gated behind the
352// `openapi` feature so the runtime spec builder doesn't add code
353// size / dependency pressure to apps that never serve a JSON spec.
354// ──────────────────────────────────────────────────────────────────
355
356#[cfg(feature = "openapi")]
357/// Represents a root OpenAPI 3.0 specification document.
358#[derive(Debug, Serialize, Deserialize)]
359pub struct OpenApiSpec {
360    /// The OpenAPI version string (e.g., `3.0.3`).
361    pub openapi: String,
362    /// General information about the API.
363    pub info: Info,
364    /// The available paths and operations for the API.
365    pub paths: BTreeMap<String, PathItem>,
366    /// Reusable schemas, parameters, and other components.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub components: Option<Components>,
369}
370
371#[cfg(feature = "openapi")]
372/// Provides metadata about the API.
373#[derive(Debug, Serialize, Deserialize)]
374pub struct Info {
375    /// The title of the API.
376    pub title: String,
377    /// The version of the OpenAPI document.
378    pub version: String,
379    /// A description of the API.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub description: Option<String>,
382}
383
384#[cfg(feature = "openapi")]
385/// Describes the operations available on a single path.
386#[derive(Default, Debug, Serialize, Deserialize)]
387pub struct PathItem {
388    /// A definition of a GET operation on this path.
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub get: Option<Operation>,
391    /// A definition of a POST operation on this path.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub post: Option<Operation>,
394    /// A definition of a PUT operation on this path.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub put: Option<Operation>,
397    /// A definition of a DELETE operation on this path.
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub delete: Option<Operation>,
400    /// A definition of a PATCH operation on this path.
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub patch: Option<Operation>,
403}
404
405#[cfg(feature = "openapi")]
406/// Describes a single API operation on a path.
407#[derive(Debug, Serialize, Deserialize)]
408pub struct Operation {
409    /// Unique string used to identify the operation.
410    #[serde(rename = "operationId")]
411    pub operation_id: String,
412    /// A short summary of what the operation does.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub summary: Option<String>,
415    /// A verbose explanation of the operation behavior.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub description: Option<String>,
418    /// A list of tags for API documentation control.
419    #[serde(skip_serializing_if = "Vec::is_empty")]
420    pub tags: Vec<String>,
421    /// A list of parameters that are applicable for this operation.
422    #[serde(skip_serializing_if = "Vec::is_empty")]
423    pub parameters: Vec<Parameter>,
424    /// The request body applicable for this operation.
425    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
426    pub request_body: Option<RequestBody>,
427    /// The list of possible responses as they are returned from executing this operation.
428    pub responses: BTreeMap<String, Response>,
429    /// Security requirements for this operation. Non-empty when the route uses `#[secured]`.
430    #[serde(skip_serializing_if = "Vec::is_empty")]
431    pub security: Vec<BTreeMap<String, Vec<String>>>,
432    /// Declares this operation to be deprecated.
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub deprecated: Option<bool>,
435}
436
437#[cfg(feature = "openapi")]
438/// Describes a single operation parameter.
439#[derive(Debug, Serialize, Deserialize)]
440pub struct Parameter {
441    /// The name of the parameter.
442    pub name: String,
443    /// The location of the parameter. Possible values are "query", "header", "path" or "cookie".
444    #[serde(rename = "in")]
445    pub location: String,
446    /// Determines whether this parameter is mandatory.
447    pub required: bool,
448    /// The schema defining the type used for the parameter.
449    pub schema: serde_json::Value,
450    /// Serialization style. `"form"` with `explode: true` makes each object
451    /// property a separate query key — the correct mapping for `Query<T>`.
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub style: Option<String>,
454    /// When `true` with `style: "form"`, each schema property becomes an
455    /// independent query parameter (e.g. `?q=foo&page=2`).
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub explode: Option<bool>,
458}
459
460#[cfg(feature = "openapi")]
461/// Describes a single request body.
462#[derive(Debug, Serialize, Deserialize)]
463pub struct RequestBody {
464    /// Determines if the request body is required in the request.
465    pub required: bool,
466    /// The content of the request body, keyed by media type.
467    pub content: BTreeMap<String, MediaType>,
468}
469
470#[cfg(feature = "openapi")]
471/// Describes a single response from an API Operation.
472#[derive(Debug, Serialize, Deserialize)]
473pub struct Response {
474    /// A short description of the response.
475    pub description: String,
476    /// A map containing descriptions of potential response payloads, keyed by media type.
477    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
478    pub content: BTreeMap<String, MediaType>,
479}
480
481#[cfg(feature = "openapi")]
482/// Provides schema and examples for the media type identified by its key.
483#[derive(Debug, Serialize, Deserialize)]
484pub struct MediaType {
485    /// The schema defining the content of the request, response, or parameter.
486    pub schema: serde_json::Value,
487}
488
489#[cfg(feature = "openapi")]
490/// Holds a set of reusable objects for different aspects of the OAS.
491#[derive(Debug, Serialize, Deserialize)]
492pub struct Components {
493    /// Reusable Schema Objects.
494    pub schemas: BTreeMap<String, serde_json::Value>,
495    /// Security scheme definitions (e.g. SessionAuth).
496    #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
497    pub security_schemes: BTreeMap<String, serde_json::Value>,
498}
499
500// ──────────────────────────────────────────────────────────────────
501// Spec generator
502// ──────────────────────────────────────────────────────────────────
503
504/// Write the generated OpenAPI spec to `dist/openapi.json` and
505/// `dist/openapi.yaml` inside `dist_dir`.
506///
507/// Called during `autumn build` (when `AUTUMN_BUILD_STATIC=1`) to emit
508/// a machine-readable API contract alongside the pre-rendered HTML pages.
509///
510/// # Errors
511///
512/// Returns an [`std::io::Error`] if the directory cannot be created or
513/// either file cannot be written.
514#[cfg(feature = "openapi")]
515pub fn write_openapi_spec_to_dist(
516    spec: &OpenApiSpec,
517    dist_dir: &std::path::Path,
518) -> std::io::Result<()> {
519    std::fs::create_dir_all(dist_dir)?;
520
521    let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
522    std::fs::write(dist_dir.join("openapi.json"), &json)?;
523
524    let yaml = serde_yaml::to_string(spec).map_err(std::io::Error::other)?;
525    std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
526
527    Ok(())
528}
529
530/// Build an [`OpenApiSpec`] from a collection of routes and user config.
531///
532/// This is the core of the auto-generation: every route's [`ApiDoc`] is
533/// translated into an [`Operation`] under the matching [`PathItem`].
534#[cfg(feature = "openapi")]
535#[must_use]
536pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
537    generate_spec_at(config, routes, chrono::Utc::now())
538}
539
540#[cfg(feature = "openapi")]
541#[must_use]
542pub fn generate_spec_at(
543    config: &OpenApiConfig,
544    routes: &[&ApiDoc],
545    now: chrono::DateTime<chrono::Utc>,
546) -> OpenApiSpec {
547    let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
548    let mut registry = SchemaRegistry::default();
549
550    for (name, schema) in &config.additional_schemas {
551        registry.insert(name.clone(), schema.clone());
552    }
553    registry.insert("ProblemDetails", problem_details_schema());
554
555    // Collect every named schema reference produced by any operation so
556    // we can back-fill component entries for types the user didn't
557    // explicitly register. Without this, auto-inferred `Json<MyDto>`
558    // payloads would emit `$ref`s pointing at nonexistent component
559    // schemas — an invalid OpenAPI document.
560    let mut referenced_names: std::collections::BTreeSet<&'static str> =
561        std::collections::BTreeSet::new();
562
563    let mut any_secured = false;
564
565    for api_doc in routes {
566        if api_doc.hidden {
567            continue;
568        }
569        if api_doc.secured {
570            any_secured = true;
571        }
572        if let Some(register) = api_doc.register_schemas {
573            (register)(&mut registry);
574        }
575
576        if let Some(entry) = &api_doc.request_body {
577            collect_ref_names(entry, &mut referenced_names);
578        }
579        if let Some(entry) = &api_doc.response {
580            collect_ref_names(entry, &mut referenced_names);
581        }
582        if let Some(entry) = &api_doc.query_schema {
583            collect_ref_names(entry, &mut referenced_names);
584        }
585
586        let operation = operation_for(api_doc, &config.api_versions, now);
587        let entry = paths.entry(api_doc.path.to_owned()).or_default();
588        match api_doc.method {
589            "GET" => entry.get = Some(operation),
590            "POST" => entry.post = Some(operation),
591            "PUT" => entry.put = Some(operation),
592            "DELETE" => entry.delete = Some(operation),
593            "PATCH" => entry.patch = Some(operation),
594            // Unknown methods are silently skipped; Autumn's route macros
595            // only emit the five verbs above today.
596            _ => {}
597        }
598    }
599
600    // Back-fill a minimal `{"type": "object", "title": "X"}` schema for
601    // every referenced name the user didn't already register. Types that
602    // implement OpenApiSchema can be registered explicitly via
603    // OpenApiConfig::register_schema to replace the placeholder.
604    for name in referenced_names {
605        if !registry.schemas().contains_key(name) {
606            registry.insert(
607                name,
608                serde_json::json!({
609                    "type": "object",
610                    "title": name,
611                }),
612            );
613        }
614    }
615
616    // Register the same cookie-backed session auth that `#[secured]` uses at runtime.
617    let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
618    if any_secured {
619        security_schemes.insert(
620            "SessionAuth".to_owned(),
621            serde_json::json!({
622                "type": "apiKey",
623                "in": "cookie",
624                "name": config.session_cookie_name.clone(),
625                "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
626            }),
627        );
628    }
629
630    let components_map = registry.into_map();
631    let components = if !components_map.is_empty() || !security_schemes.is_empty() {
632        Some(Components {
633            schemas: components_map,
634            security_schemes,
635        })
636    } else {
637        None
638    };
639
640    OpenApiSpec {
641        openapi: "3.1.0".to_owned(),
642        info: Info {
643            title: config.title.clone(),
644            version: config.version.clone(),
645            description: config.description.clone(),
646        },
647        paths,
648        components,
649    }
650}
651
652#[cfg(feature = "openapi")]
653#[allow(clippy::too_many_lines)]
654fn operation_for(
655    api_doc: &ApiDoc,
656    api_versions: &[crate::app::ApiVersion],
657    now: chrono::DateTime<chrono::Utc>,
658) -> Operation {
659    let mut tags = if api_doc.tags.is_empty() {
660        default_tag(api_doc.path)
661            .map(|t| vec![t.to_owned()])
662            .unwrap_or_default()
663    } else {
664        api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
665    };
666
667    if let Some(version) = api_doc.api_version {
668        tags.push(version.to_string());
669    }
670
671    let is_deprecated = api_doc.api_version.is_some_and(|version| {
672        api_versions
673            .iter()
674            .find(|av| av.version == version)
675            .is_some_and(|av| {
676                let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
677                let is_sun = av.sunset_at.is_some_and(|s| now >= s);
678                is_dep || is_sun
679            })
680    });
681    let deprecated = if is_deprecated { Some(true) } else { None };
682
683    // Path parameters — always required.
684    let mut parameters: Vec<Parameter> = api_doc
685        .path_params
686        .iter()
687        .map(|name| Parameter {
688            name: (*name).to_owned(),
689            location: "path".to_owned(),
690            required: true,
691            schema: serde_json::json!({ "type": "string" }),
692            style: None,
693            explode: None,
694        })
695        .collect();
696
697    // Query parameters from `Query<T>` extractor.
698    // Use `style: form, explode: true` so each field of the query struct
699    // is serialized as an independent query key (e.g. `?q=foo&page=2`),
700    // which matches what the server's `Query<T>` deserialization expects.
701    if let Some(query_entry) = &api_doc.query_schema {
702        parameters.push(Parameter {
703            name: query_entry.name.to_owned(),
704            location: "query".to_owned(),
705            required: false,
706            schema: schema_value_for(query_entry),
707            style: Some("form".to_owned()),
708            explode: Some(true),
709        });
710    }
711
712    let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
713        required: true,
714        content: std::iter::once((
715            "application/json".to_owned(),
716            MediaType {
717                schema: schema_value_for(entry),
718            },
719        ))
720        .collect(),
721    });
722
723    let mut responses: BTreeMap<String, Response> = BTreeMap::new();
724    let status = if api_doc.success_status == 0 {
725        200
726    } else {
727        api_doc.success_status
728    };
729    let response_content = api_doc
730        .response
731        .as_ref()
732        .map(|entry| {
733            let mut content = BTreeMap::new();
734            content.insert(
735                "application/json".to_owned(),
736                MediaType {
737                    schema: schema_value_for(entry),
738                },
739            );
740            content
741        })
742        .unwrap_or_default();
743    responses.insert(
744        status.to_string(),
745        Response {
746            description: status_description(status).to_owned(),
747            content: response_content,
748        },
749    );
750    insert_problem_responses(&mut responses);
751
752    // If this route version has a sunset schedule and is not opted out, document 410 Gone
753    let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
754        api_versions
755            .iter()
756            .find(|av| av.version == version)
757            .is_some_and(|av| av.sunset_at.is_some())
758            && !api_doc.sunset_opt_out
759    });
760
761    if is_subject_to_sunset {
762        responses.entry("410".to_owned()).or_insert_with(|| {
763            let mut content = BTreeMap::new();
764            content.insert(
765                "application/problem+json".to_owned(),
766                MediaType {
767                    schema: serde_json::json!({
768                        "$ref": "#/components/schemas/ProblemDetails",
769                    }),
770                },
771            );
772            Response {
773                description: status_description(410).to_owned(),
774                content,
775            }
776        });
777    }
778
779    // Security requirement: `#[secured]` is backed by the Autumn session cookie.
780    let security = if api_doc.secured {
781        let mut req = BTreeMap::new();
782        req.insert("SessionAuth".to_owned(), Vec::new());
783        vec![req]
784    } else {
785        Vec::new()
786    };
787
788    Operation {
789        operation_id: api_doc.operation_id.to_owned(),
790        summary: api_doc.summary.map(str::to_owned),
791        description: api_doc.description.map(str::to_owned),
792        tags,
793        parameters,
794        request_body,
795        responses,
796        security,
797        deprecated,
798    }
799}
800
801/// Render a [`SchemaEntry`] into its JSON Schema value.
802///
803/// Produces the same shape the OpenAPI generator emits. Exposed so the MCP
804/// projection can derive a tool's `inputSchema` from the exact same typed
805/// contract — guaranteeing the tool schema cannot drift from the handler.
806#[cfg(feature = "openapi")]
807#[must_use]
808pub fn schema_entry_to_value(entry: &SchemaEntry) -> serde_json::Value {
809    schema_value_for(entry)
810}
811
812#[cfg(feature = "openapi")]
813fn schema_value_for(entry: &SchemaEntry) -> serde_json::Value {
814    match entry.kind {
815        SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
816        SchemaKind::Ref => {
817            serde_json::json!({ "$ref": format!("#/components/schemas/{}", entry.name) })
818        }
819        SchemaKind::Array(items) => serde_json::json!({
820            "type": "array",
821            "items": schema_value_for(items),
822        }),
823        SchemaKind::Nullable(inner) => {
824            // OpenAPI 3.1 aligns with JSON Schema 2020-12, which supports
825            // `type: "null"` natively:
826            //   * For a `$ref`, use `oneOf: [{$ref: ...}, {type: "null"}]`
827            //     so the ref can stand alone without `allOf` workarounds.
828            //   * For primitives, use the compact type-array form: `type: ["T", "null"]`.
829            //   * For all other schemas (arrays, nested nullable, etc.), use `oneOf`
830            //     so the full inner schema (e.g. `items`) is preserved.
831            match inner.kind {
832                SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
833                    serde_json::json!({
834                        "oneOf": [
835                            schema_value_for(inner),
836                            { "type": "null" },
837                        ],
838                    })
839                }
840                SchemaKind::Primitive(base_type) => {
841                    serde_json::json!({ "type": [base_type, "null"] })
842                }
843            }
844        }
845    }
846}
847
848/// Walk into a `SchemaEntry` and yield every named ref reached through
849/// `Array` / `Nullable` wrappers. Back-fill logic uses this so a
850/// `Json<Vec<User>>` response registers a `User` component schema.
851#[cfg(feature = "openapi")]
852fn collect_ref_names(entry: &SchemaEntry, out: &mut std::collections::BTreeSet<&'static str>) {
853    match entry.kind {
854        SchemaKind::Ref => {
855            out.insert(entry.name);
856        }
857        SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => collect_ref_names(inner, out),
858        SchemaKind::Primitive(_) => {}
859    }
860}
861
862#[cfg(feature = "openapi")]
863fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
864    for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
865        responses.entry(status.to_string()).or_insert_with(|| {
866            let mut content = BTreeMap::new();
867            content.insert(
868                "application/problem+json".to_owned(),
869                MediaType {
870                    schema: serde_json::json!({
871                        "$ref": "#/components/schemas/ProblemDetails",
872                    }),
873                },
874            );
875            Response {
876                description: status_description(status).to_owned(),
877                content,
878            }
879        });
880    }
881}
882
883#[cfg(feature = "openapi")]
884fn problem_details_schema() -> serde_json::Value {
885    serde_json::json!({
886        "type": "object",
887        "additionalProperties": false,
888        "required": [
889            "type",
890            "title",
891            "status",
892            "detail",
893            "instance",
894            "code",
895            "request_id",
896            "errors",
897        ],
898        "properties": {
899            "type": {
900                "type": "string",
901                "format": "uri-reference",
902            },
903            "title": {
904                "type": "string",
905            },
906            "status": {
907                "type": "integer",
908                "minimum": 400,
909                "maximum": 599,
910            },
911            "detail": {
912                "type": "string",
913            },
914            "instance": {
915                "type": ["string", "null"],
916            },
917            "code": {
918                "type": "string",
919                "pattern": "^autumn\\.[a-z0-9_]+$",
920            },
921            "request_id": {
922                "type": ["string", "null"],
923            },
924            "errors": {
925                "type": "array",
926                "items": {
927                    "type": "object",
928                    "additionalProperties": false,
929                    "required": ["field", "messages"],
930                    "properties": {
931                        "field": {
932                            "type": "string",
933                        },
934                        "messages": {
935                            "type": "array",
936                            "items": {
937                                "type": "string",
938                            },
939                        },
940                    },
941                },
942            },
943        },
944    })
945}
946
947#[cfg(feature = "openapi")]
948fn default_tag(path: &str) -> Option<&str> {
949    path.trim_start_matches('/')
950        .split('/')
951        .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
952}
953
954#[cfg(feature = "openapi")]
955const fn status_description(status: u16) -> &'static str {
956    match status {
957        200 => "OK",
958        201 => "Created",
959        202 => "Accepted",
960        204 => "No Content",
961        301 => "Moved Permanently",
962        302 => "Found",
963        400 => "Bad Request",
964        401 => "Unauthorized",
965        403 => "Forbidden",
966        404 => "Not Found",
967        409 => "Conflict",
968        413 => "Payload Too Large",
969        415 => "Unsupported Media Type",
970        422 => "Unprocessable Entity",
971        500 => "Internal Server Error",
972        503 => "Service Unavailable",
973        _ => "Response",
974    }
975}
976
977// ──────────────────────────────────────────────────────────────────
978// Swagger UI HTML
979// ──────────────────────────────────────────────────────────────────
980
981#[cfg(feature = "openapi")]
982pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
983#[cfg(feature = "openapi")]
984pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
985#[cfg(feature = "openapi")]
986pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
987    include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
988#[cfg(feature = "openapi")]
989const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
990#[cfg(feature = "openapi")]
991const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
992#[cfg(feature = "openapi")]
993const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
994
995/// Compute the same-origin asset URLs mounted beneath the Swagger UI HTML path.
996#[cfg(feature = "openapi")]
997#[must_use]
998pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
999    [
1000        swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
1001        swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
1002        swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
1003    ]
1004}
1005
1006#[cfg(feature = "openapi")]
1007#[must_use]
1008fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
1009    let base = swagger_path.trim_end_matches('/');
1010    if base.is_empty() || base == "/" {
1011        format!("/{asset_file}")
1012    } else {
1013        format!("{base}/{asset_file}")
1014    }
1015}
1016
1017/// Minimal Swagger UI bootstrap HTML that loads same-origin vendored assets.
1018#[cfg(feature = "openapi")]
1019#[must_use]
1020pub fn swagger_ui_html(
1021    title: &str,
1022    css_url: &str,
1023    bundle_url: &str,
1024    initializer_url: &str,
1025) -> String {
1026    let title = html_escape(title);
1027    let css_url = html_escape(css_url);
1028    let bundle_url = html_escape(bundle_url);
1029    let initializer_url = html_escape(initializer_url);
1030    let mut out = String::with_capacity(1024);
1031    out.push_str("<!DOCTYPE html>\n");
1032    out.push_str("<html lang=\"en\">\n");
1033    out.push_str("  <head>\n");
1034    out.push_str("    <meta charset=\"utf-8\" />\n");
1035    out.push_str("    <title>");
1036    out.push_str(&title);
1037    out.push_str("</title>\n");
1038    out.push_str("    <link rel=\"stylesheet\" href=\"");
1039    out.push_str(&css_url);
1040    out.push_str("\" />\n");
1041    out.push_str("  </head>\n");
1042    out.push_str("  <body>\n");
1043    out.push_str("    <div id=\"swagger-ui\"></div>\n");
1044    out.push_str("    <script src=\"");
1045    out.push_str(&bundle_url);
1046    out.push_str("\" charset=\"UTF-8\"></script>\n");
1047    out.push_str("    <script src=\"");
1048    out.push_str(&initializer_url);
1049    out.push_str("\" charset=\"UTF-8\"></script>\n");
1050    out.push_str("  </body>\n");
1051    out.push_str("</html>\n");
1052    out
1053}
1054
1055/// External Swagger UI initializer script so the default `script-src 'self'`
1056/// CSP can boot the docs UI without permitting inline JavaScript.
1057#[cfg(feature = "openapi")]
1058#[must_use]
1059pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
1060    let spec_url = serde_json::to_string(spec_url)
1061        .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
1062    let mut out = String::with_capacity(256);
1063    out.push_str("window.onload = function() {\n");
1064    out.push_str("  window.ui = SwaggerUIBundle({\n");
1065    out.push_str("    url: ");
1066    out.push_str(&spec_url);
1067    out.push_str(",\n");
1068    out.push_str("    dom_id: \"#swagger-ui\",\n");
1069    out.push_str("    deepLinking: true\n");
1070    out.push_str("  });\n");
1071    out.push_str("};\n");
1072    out
1073}
1074
1075#[cfg(feature = "openapi")]
1076fn html_escape(s: &str) -> String {
1077    s.replace('&', "&amp;")
1078        .replace('<', "&lt;")
1079        .replace('>', "&gt;")
1080        .replace('"', "&quot;")
1081}
1082
1083// ──────────────────────────────────────────────────────────────────
1084// Tests
1085// ──────────────────────────────────────────────────────────────────
1086
1087#[cfg(all(test, feature = "openapi"))]
1088mod tests {
1089    use super::*;
1090
1091    fn make_doc() -> ApiDoc {
1092        ApiDoc {
1093            method: "GET",
1094            path: "/users/{id}",
1095            operation_id: "get_user",
1096            summary: Some("Fetch a user"),
1097            description: None,
1098            tags: &[],
1099            path_params: &["id"],
1100            request_body: None,
1101            response: None,
1102            success_status: 200,
1103            hidden: false,
1104            query_schema: None,
1105            secured: false,
1106            required_roles: &[],
1107            register_schemas: None,
1108            api_version: None,
1109            ..Default::default()
1110        }
1111    }
1112
1113    #[test]
1114    fn config_builder_methods_work() {
1115        let config = OpenApiConfig::new("Demo", "1.0.0")
1116            .description("A cool API")
1117            .openapi_json_path("/api.json")
1118            .swagger_ui_path(None)
1119            .session_cookie_name("demo.sid");
1120
1121        assert_eq!(config.title, "Demo");
1122        assert_eq!(config.version, "1.0.0");
1123        assert_eq!(config.description.unwrap(), "A cool API");
1124        assert_eq!(config.openapi_json_path, "/api.json");
1125        assert_eq!(config.swagger_ui_path, None);
1126        assert_eq!(config.session_cookie_name, "demo.sid");
1127    }
1128
1129    #[test]
1130    fn secured_spec_uses_configured_session_cookie_name() {
1131        let mut doc = make_doc();
1132        doc.path = "/protected";
1133        doc.operation_id = "protected";
1134        doc.path_params = &[];
1135        doc.secured = true;
1136
1137        let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
1138        let spec = generate_spec(&config, &[&doc]);
1139        let scheme = &spec
1140            .components
1141            .as_ref()
1142            .expect("secured routes emit security components")
1143            .security_schemes["SessionAuth"];
1144
1145        assert_eq!(scheme["type"], "apiKey");
1146        assert_eq!(scheme["in"], "cookie");
1147        assert_eq!(scheme["name"], "demo.sid");
1148    }
1149
1150    #[test]
1151    fn generate_spec_builds_path_with_parameters() {
1152        let doc = make_doc();
1153        let config = OpenApiConfig::new("Demo", "1.0.0");
1154        let spec = generate_spec(&config, &[&doc]);
1155
1156        assert_eq!(spec.openapi, "3.1.0");
1157        assert_eq!(spec.info.title, "Demo");
1158        assert!(spec.paths.contains_key("/users/{id}"));
1159
1160        let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1161        assert_eq!(op.operation_id, "get_user");
1162        assert_eq!(op.parameters.len(), 1);
1163        assert_eq!(op.parameters[0].name, "id");
1164        assert_eq!(op.parameters[0].location, "path");
1165        assert_eq!(op.tags, vec!["users".to_owned()]);
1166    }
1167
1168    #[test]
1169    fn generate_spec_skips_hidden_routes() {
1170        let mut doc = make_doc();
1171        doc.hidden = true;
1172        let config = OpenApiConfig::new("Demo", "1.0.0");
1173        let spec = generate_spec(&config, &[&doc]);
1174        assert!(spec.paths.is_empty());
1175    }
1176
1177    #[test]
1178    fn generate_spec_writes_request_body_ref() {
1179        let mut doc = make_doc();
1180        doc.method = "POST";
1181        doc.path = "/users";
1182        doc.operation_id = "create_user";
1183        doc.path_params = &[];
1184        doc.request_body = Some(SchemaEntry {
1185            name: "CreateUser",
1186            kind: SchemaKind::Ref,
1187        });
1188        doc.success_status = 201;
1189
1190        let config = OpenApiConfig::new("Demo", "1.0.0");
1191        let spec = generate_spec(&config, &[&doc]);
1192        let op = spec.paths["/users"].post.as_ref().unwrap();
1193        let body = op.request_body.as_ref().unwrap();
1194        assert!(body.required);
1195        let media = body.content.get("application/json").unwrap();
1196        assert_eq!(
1197            media.schema,
1198            serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
1199        );
1200        assert!(op.responses.contains_key("201"));
1201    }
1202
1203    #[test]
1204    fn generate_spec_inlines_primitive_response() {
1205        let mut doc = make_doc();
1206        doc.response = Some(SchemaEntry {
1207            name: "string",
1208            kind: SchemaKind::Primitive("string"),
1209        });
1210        let config = OpenApiConfig::new("Demo", "1.0.0");
1211        let spec = generate_spec(&config, &[&doc]);
1212        let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1213        let media = op.responses["200"].content.get("application/json").unwrap();
1214        assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
1215    }
1216
1217    #[test]
1218    fn swagger_ui_html_uses_same_origin_assets() {
1219        let html = swagger_ui_html(
1220            "Demo",
1221            "/swagger-ui/swagger-ui.css",
1222            "/swagger-ui/swagger-ui-bundle.js",
1223            "/swagger-ui/swagger-initializer.js",
1224        );
1225        assert!(html.contains("/swagger-ui/swagger-ui.css"));
1226        assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
1227        assert!(html.contains("/swagger-ui/swagger-initializer.js"));
1228        assert!(!html.contains("unpkg.com"));
1229        assert!(!html.contains("window.onload = function()"));
1230    }
1231
1232    #[test]
1233    fn swagger_ui_initializer_js_references_spec_url() {
1234        let js = swagger_ui_initializer_js("/openapi.json");
1235        assert!(js.contains("SwaggerUIBundle"));
1236        assert!(js.contains(r#""/openapi.json""#));
1237    }
1238
1239    #[test]
1240    fn generate_spec_includes_additional_schemas() {
1241        let doc = make_doc();
1242        let config = OpenApiConfig::new("Demo", "1.0.0")
1243            .register_schema("Foo", serde_json::json!({ "type": "object" }));
1244        let spec = generate_spec(&config, &[&doc]);
1245        let components = spec.components.unwrap();
1246        assert!(components.schemas.contains_key("Foo"));
1247    }
1248
1249    #[test]
1250    fn generate_spec_back_fills_unregistered_ref_schemas() {
1251        // A Json<CreateUser> handler emits a `$ref` with no component
1252        // schema registered. The generator must back-fill a placeholder
1253        // schema so the resulting OpenAPI document is valid.
1254        let mut doc = make_doc();
1255        doc.method = "POST";
1256        doc.path = "/users";
1257        doc.path_params = &[];
1258        doc.request_body = Some(SchemaEntry {
1259            name: "CreateUser",
1260            kind: SchemaKind::Ref,
1261        });
1262        doc.response = Some(SchemaEntry {
1263            name: "User",
1264            kind: SchemaKind::Ref,
1265        });
1266
1267        let config = OpenApiConfig::new("Demo", "1.0.0");
1268        let spec = generate_spec(&config, &[&doc]);
1269        let components = spec.components.expect("components must be emitted");
1270        let create = components
1271            .schemas
1272            .get("CreateUser")
1273            .expect("CreateUser should be back-filled");
1274        let user = components
1275            .schemas
1276            .get("User")
1277            .expect("User should be back-filled");
1278        assert_eq!(create["type"], "object");
1279        assert_eq!(create["title"], "CreateUser");
1280        assert_eq!(user["type"], "object");
1281        assert_eq!(user["title"], "User");
1282    }
1283
1284    #[test]
1285    fn generate_spec_preserves_user_registered_schemas_over_backfill() {
1286        let mut doc = make_doc();
1287        doc.response = Some(SchemaEntry {
1288            name: "User",
1289            kind: SchemaKind::Ref,
1290        });
1291
1292        let user_schema = serde_json::json!({
1293            "type": "object",
1294            "properties": {"id": {"type": "integer"}},
1295        });
1296        let config =
1297            OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
1298        let spec = generate_spec(&config, &[&doc]);
1299        let components = spec.components.unwrap();
1300        let stored = components.schemas.get("User").unwrap();
1301        assert_eq!(stored, &user_schema, "user schema must not be overwritten");
1302    }
1303
1304    #[test]
1305    fn status_description_returns_correct_strings() {
1306        assert_eq!(status_description(200), "OK");
1307        assert_eq!(status_description(201), "Created");
1308        assert_eq!(status_description(202), "Accepted");
1309        assert_eq!(status_description(204), "No Content");
1310        assert_eq!(status_description(301), "Moved Permanently");
1311        assert_eq!(status_description(302), "Found");
1312        assert_eq!(status_description(400), "Bad Request");
1313        assert_eq!(status_description(401), "Unauthorized");
1314        assert_eq!(status_description(403), "Forbidden");
1315        assert_eq!(status_description(404), "Not Found");
1316        assert_eq!(status_description(409), "Conflict");
1317        assert_eq!(status_description(413), "Payload Too Large");
1318        assert_eq!(status_description(415), "Unsupported Media Type");
1319        assert_eq!(status_description(422), "Unprocessable Entity");
1320        assert_eq!(status_description(500), "Internal Server Error");
1321        assert_eq!(status_description(503), "Service Unavailable");
1322        assert_eq!(status_description(418), "Response");
1323    }
1324
1325    #[test]
1326    fn default_tag_picks_first_static_segment() {
1327        assert_eq!(default_tag("/users/{id}"), Some("users"));
1328        assert_eq!(default_tag("/api/v1/users"), Some("api"));
1329        assert_eq!(default_tag("/"), None);
1330        assert_eq!(default_tag("/{id}"), None);
1331    }
1332
1333    // ── OpenAPI 3.1 compliance tests (RED phase) ───────────────────────────
1334
1335    #[test]
1336    fn spec_version_is_3_1_0() {
1337        let config = OpenApiConfig::new("Demo", "1.0.0");
1338        let spec = generate_spec(&config, &[]);
1339        assert_eq!(
1340            spec.openapi, "3.1.0",
1341            "Autumn must emit OpenAPI 3.1.0, not {}",
1342            spec.openapi
1343        );
1344    }
1345
1346    #[test]
1347    fn nullable_ref_uses_openapi_3_1_one_of() {
1348        // OpenAPI 3.1 aligns with JSON Schema 2020-12: nullable refs use
1349        // `oneOf: [{$ref: ...}, {type: "null"}]` instead of 3.0's
1350        // `nullable: true` + `allOf` workaround.
1351        static INNER: SchemaEntry = SchemaEntry {
1352            name: "User",
1353            kind: SchemaKind::Ref,
1354        };
1355        let entry = SchemaEntry {
1356            name: "nullable",
1357            kind: SchemaKind::Nullable(&INNER),
1358        };
1359        let value = schema_value_for(&entry);
1360        assert!(
1361            value.get("nullable").is_none(),
1362            "3.1 must not emit `nullable: true` (that is 3.0 only)"
1363        );
1364        assert!(
1365            value.get("allOf").is_none(),
1366            "3.1 must not use allOf for nullable refs"
1367        );
1368        let one_of = value["oneOf"]
1369            .as_array()
1370            .expect("3.1 nullable ref must use oneOf");
1371        assert_eq!(one_of.len(), 2);
1372        assert_eq!(
1373            one_of[0]["$ref"], "#/components/schemas/User",
1374            "first oneOf branch must be the $ref"
1375        );
1376        assert_eq!(
1377            one_of[1]["type"], "null",
1378            "second oneOf branch must be {{type: null}}"
1379        );
1380    }
1381
1382    #[test]
1383    fn nullable_primitive_uses_type_array() {
1384        // OpenAPI 3.1 uses `type: ["integer", "null"]` for nullable
1385        // primitives instead of the 3.0 `nullable: true` flag.
1386        static INNER: SchemaEntry = SchemaEntry {
1387            name: "integer",
1388            kind: SchemaKind::Primitive("integer"),
1389        };
1390        let entry = SchemaEntry {
1391            name: "nullable",
1392            kind: SchemaKind::Nullable(&INNER),
1393        };
1394        let value = schema_value_for(&entry);
1395        assert!(
1396            value.get("nullable").is_none(),
1397            "3.1 must not emit `nullable: true`"
1398        );
1399        let types = value["type"]
1400            .as_array()
1401            .expect("3.1 nullable primitive must use a type array");
1402        assert!(
1403            types.contains(&serde_json::Value::String("integer".to_owned())),
1404            "type array must include the base type"
1405        );
1406        assert!(
1407            types.contains(&serde_json::Value::String("null".to_owned())),
1408            "type array must include null"
1409        );
1410    }
1411
1412    #[test]
1413    fn write_openapi_spec_to_dist_creates_json_file() {
1414        let tmp = tempfile::TempDir::new().unwrap();
1415        let dist = tmp.path().join("dist");
1416        std::fs::create_dir_all(&dist).unwrap();
1417
1418        let config = OpenApiConfig::new("TestAPI", "2.0.0");
1419        let spec = generate_spec(&config, &[]);
1420
1421        write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1422
1423        let json_path = dist.join("openapi.json");
1424        assert!(json_path.exists(), "dist/openapi.json must be written");
1425
1426        let content = std::fs::read_to_string(&json_path).unwrap();
1427        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1428        assert_eq!(parsed["openapi"], "3.1.0");
1429        assert_eq!(parsed["info"]["title"], "TestAPI");
1430    }
1431
1432    #[test]
1433    fn write_openapi_spec_to_dist_creates_yaml_file() {
1434        let tmp = tempfile::TempDir::new().unwrap();
1435        let dist = tmp.path().join("dist");
1436        std::fs::create_dir_all(&dist).unwrap();
1437
1438        let config = OpenApiConfig::new("TestAPI", "2.0.0");
1439        let spec = generate_spec(&config, &[]);
1440
1441        write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1442
1443        let yaml_path = dist.join("openapi.yaml");
1444        assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
1445
1446        let content = std::fs::read_to_string(&yaml_path).unwrap();
1447        assert!(
1448            content.contains("openapi:"),
1449            "YAML must include the openapi field"
1450        );
1451        assert!(content.contains("3.1.0"), "YAML must include the version");
1452        assert!(content.contains("TestAPI"), "YAML must include the title");
1453    }
1454
1455    #[test]
1456    fn schema_registry_into_map_returns_all_schemas() {
1457        let mut registry = SchemaRegistry::default();
1458        registry.insert("Foo", serde_json::json!({ "type": "string" }));
1459        registry.insert("Bar", serde_json::json!({ "type": "integer" }));
1460
1461        let map = registry.into_map();
1462        assert_eq!(map.len(), 2);
1463        assert_eq!(
1464            map.get("Foo").unwrap(),
1465            &serde_json::json!({ "type": "string" })
1466        );
1467        assert_eq!(
1468            map.get("Bar").unwrap(),
1469            &serde_json::json!({ "type": "integer" })
1470        );
1471    }
1472
1473    #[test]
1474    fn schema_registry_deduplicates() {
1475        struct Foo;
1476        impl OpenApiSchema for Foo {
1477            fn schema_name() -> &'static str {
1478                "Foo"
1479            }
1480            fn schema() -> serde_json::Value {
1481                serde_json::json!({ "type": "object", "title": "Foo" })
1482            }
1483        }
1484
1485        let mut registry = SchemaRegistry::default();
1486        registry.register::<Foo>();
1487        registry.register::<Foo>();
1488        assert_eq!(registry.schemas().len(), 1);
1489    }
1490
1491    #[test]
1492    fn primitive_impls_cover_common_types() {
1493        assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
1494        assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
1495        assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
1496        assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
1497    }
1498
1499    #[test]
1500    fn swagger_ui_html_embeds_spec_url() {
1501        let html = swagger_ui_html(
1502            "My API",
1503            "/swagger-ui/swagger-ui.css",
1504            "/swagger-ui/swagger-ui-bundle.js",
1505            "/swagger-ui/swagger-initializer.js",
1506        );
1507        assert!(html.contains("/swagger-ui/swagger-ui.css"));
1508        assert!(html.contains("My API"));
1509    }
1510
1511    #[test]
1512    fn swagger_ui_html_escapes_attributes() {
1513        let html = swagger_ui_html(
1514            "A \"cool\" & fun API",
1515            "/swagger-ui/swagger-ui.css?x=<y>",
1516            "/swagger-ui/swagger-ui-bundle.js",
1517            "/swagger-ui/swagger-initializer.js",
1518        );
1519        assert!(html.contains("/swagger-ui/swagger-ui.css?x=&lt;y&gt;"));
1520        assert!(html.contains("A &quot;cool&quot; &amp; fun API"));
1521    }
1522}