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