Skip to main content

toolkit/api/
openapi_registry.rs

1// Updated: 2026-04-28 by Constructor Tech
2//! `OpenAPI` registry for schema and operation management
3//!
4//! This gear provides a standalone `OpenAPI` registry that collects operation specs
5//! and schemas, and builds a complete `OpenAPI` document from them.
6
7use anyhow::Result;
8use arc_swap::ArcSwap;
9use dashmap::DashMap;
10use std::collections::{BTreeMap, HashSet};
11use std::sync::Arc;
12use utoipa::openapi::{
13    OpenApi, OpenApiBuilder, Ref, RefOr, Required,
14    content::ContentBuilder,
15    header::HeaderBuilder,
16    info::InfoBuilder,
17    path::{
18        HttpMethod, OperationBuilder as UOperationBuilder, ParameterBuilder, ParameterIn,
19        PathItemBuilder, PathsBuilder,
20    },
21    request_body::RequestBodyBuilder,
22    response::{Response, ResponsesBuilder},
23    schema::{ArrayBuilder, ComponentsBuilder, ObjectBuilder, Schema, SchemaFormat, SchemaType},
24    security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
25    server::Server,
26};
27
28use crate::api::operation_builder;
29use toolkit_canonical_errors::problem;
30use toolkit_contract::StreamFraming;
31
32/// Type alias for schema collections used in API operations.
33type SchemaCollection = Vec<(String, RefOr<Schema>)>;
34
35/// `OpenAPI` document metadata (title, version, description)
36#[derive(Debug, Clone)]
37pub struct OpenApiInfo {
38    pub title: String,
39    pub version: String,
40    pub description: Option<String>,
41    pub servers: Vec<String>,
42}
43
44impl Default for OpenApiInfo {
45    fn default() -> Self {
46        Self {
47            title: "API Documentation".to_owned(),
48            version: "0.1.0".to_owned(),
49            description: None,
50            servers: Vec::new(),
51        }
52    }
53}
54
55/// `OpenAPI` registry trait for operation and schema registration
56pub trait OpenApiRegistry: Send + Sync {
57    /// Register an API operation specification
58    fn register_operation(&self, spec: &operation_builder::OperationSpec);
59
60    /// Ensure schema for a type (including transitive dependencies) is registered
61    /// under components and return the canonical component name for `$ref`.
62    /// This is a type-erased version for dyn compatibility.
63    fn ensure_schema_raw(&self, name: &str, schemas: SchemaCollection) -> String;
64
65    /// Downcast support for accessing the concrete implementation if needed.
66    fn as_any(&self) -> &dyn std::any::Any;
67}
68
69/// Helper function to call `ensure_schema` with proper type information
70///
71/// # Panics
72/// Panics if `T` is a `Vec<_>`. utoipa names every `Vec<_>` `Vec`, so
73/// registering one as a component would collide with every other list
74/// response; use
75/// [`OperationBuilder::json_array_response_with_schema`](crate::api::operation_builder::OperationBuilder::json_array_response_with_schema)
76/// instead. Also panics if `T`'s name is already registered with a different
77/// definition (see `ensure_schema_raw`).
78pub fn ensure_schema<T: utoipa::ToSchema + utoipa::PartialSchema + 'static>(
79    registry: &dyn OpenApiRegistry,
80) -> String {
81    use utoipa::PartialSchema;
82
83    // 1) Canonical component name for T as seen by utoipa
84    let root_name = T::name().to_string();
85
86    // utoipa's default `ToSchema::name()` drops generic arguments, so every
87    // `Vec<_>` collapses to the single name `Vec` and distinct list responses
88    // clobber each other (or, since M-14, panic on collision). `Vec` is a std
89    // type, so `#[schema(as = "...")]` cannot rescue it — the caller has to use
90    // the array-aware builder instead. Guard only this one name: a legitimate
91    // user DTO could plausibly be called `Option`, `Page`, or `Map`.
92    assert!(
93        root_name != "Vec",
94        "ensure_schema::<Vec<_>>() would register the component name `Vec`, which every other \
95         Vec<T> also resolves to. Use `OperationBuilder::json_array_response_with_schema::<Item>()` \
96         to emit an inline array with only the item type named."
97    );
98
99    // 2) Always insert T's own schema first (actual object, not a ref)
100    //    This avoids self-referential components.
101    let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
102
103    // 3) Collect and append all referenced schemas (dependencies) of T
104    T::schemas(&mut collected);
105
106    // 4) Pass to registry for insertion
107    registry.ensure_schema_raw(&root_name, collected)
108}
109
110/// Build the `x-*` vendor extensions for one operation.
111fn operation_vendor_extensions(
112    spec: &operation_builder::OperationSpec,
113) -> utoipa::openapi::extensions::Extensions {
114    let mut ext = utoipa::openapi::extensions::Extensions::default();
115
116    // Pagination
117    if let Some(pagination) = spec.vendor_extensions.x_odata_filter.as_ref()
118        && let Ok(value) = serde_json::to_value(pagination)
119    {
120        ext.insert("x-odata-filter".to_owned(), value);
121    }
122    if let Some(pagination) = spec.vendor_extensions.x_odata_orderby.as_ref()
123        && let Ok(value) = serde_json::to_value(pagination)
124    {
125        ext.insert("x-odata-orderby".to_owned(), value);
126    }
127
128    // Visibility axis (`OperationSpec.exposed`): mark routes that are
129    // registered in the gateway for external access. The `GatewayProvider`
130    // reads this vendor extension to select which routes to reverse-proxy.
131    // The key is mirrored as a constant in `cf-gears-toolkit-gateway`.
132    if spec.exposed {
133        ext.insert(
134            "x-toolkit-visibility".to_owned(),
135            serde_json::Value::String("exposed".to_owned()),
136        );
137    }
138
139    // Throttling zone bindings. The contract layer only knows zone
140    // *names*; the API gateway enriches these operations with the
141    // zones' numeric limits (`x-rate-limit-rps` / `x-rate-limit-burst`)
142    // when it builds the final document, using the zone name emitted
143    // here as the join key.
144    if let Some(throttling) = spec.throttling.as_ref() {
145        if let Some(zone) = throttling.rate_limit_zone.as_ref() {
146            ext.insert(
147                "x-throttling-rate-limit-zone".to_owned(),
148                serde_json::Value::String(zone.clone()),
149            );
150        }
151        if let Some(zone) = throttling.in_flight_limit_zone.as_ref() {
152            ext.insert(
153                "x-throttling-in-flight-limit-zone".to_owned(),
154                serde_json::Value::String(zone.clone()),
155            );
156        }
157    }
158
159    ext
160}
161
162/// Build a scalar parameter schema, preserving its format and minimum.
163///
164/// `format` is the token as the document carries it. `SchemaFormat::Custom`
165/// serializes it verbatim, which is what a `KnownFormat` serializes to as well
166/// — `"int64"` here and `SchemaFormat::KnownFormat(KnownFormat::Int64)` build
167/// the same document — so a declaration site states the token and takes no
168/// `utoipa` dependency for it.
169fn param_schema_object(
170    schema_type: SchemaType,
171    format: Option<&str>,
172    minimum: Option<f64>,
173) -> utoipa::openapi::schema::Object {
174    ObjectBuilder::new()
175        .schema_type(schema_type)
176        .format(format.map(|format| SchemaFormat::Custom(format.to_owned())))
177        .minimum(minimum)
178        .build()
179}
180
181/// Implementation of `OpenAPI` registry with lock-free data structures
182pub struct OpenApiRegistryImpl {
183    /// Store operation specs keyed by "METHOD:path"
184    pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
185    /// Store schema components using arc-swap for lock-free reads
186    /// `BTreeMap` ensures deterministic ordering of schemas in the `OpenAPI` document
187    pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
188}
189
190impl OpenApiRegistryImpl {
191    /// Create a new empty registry
192    #[must_use]
193    pub fn new() -> Self {
194        Self {
195            operation_specs: DashMap::new(),
196            components_registry: ArcSwap::from_pointee(BTreeMap::new()),
197        }
198    }
199
200    /// Build `OpenAPI` specification from registered operations and components.
201    ///
202    /// # Arguments
203    /// * `info` - `OpenAPI` document metadata (title, version, description)
204    ///
205    /// # Errors
206    /// Returns an error if the `OpenAPI` specification cannot be built.
207    #[allow(unknown_lints, de0205_operation_builder)]
208    pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
209        use http::Method;
210
211        // Log operation count for visibility
212        let op_count = self.operation_specs.len();
213        tracing::info!("Building OpenAPI: found {op_count} registered operations");
214
215        // 1) Paths
216        let mut paths = PathsBuilder::new();
217
218        for spec in self.operation_specs.iter().map(|e| e.value().clone()) {
219            let mut op = UOperationBuilder::new()
220                .operation_id(spec.operation_id.clone().or(Some(spec.handler_id.clone())))
221                .summary(spec.summary.clone())
222                .description(spec.description.clone());
223
224            for tag in &spec.tags {
225                op = op.tag(tag.clone());
226            }
227
228            let ext = operation_vendor_extensions(&spec);
229            if !ext.is_empty() {
230                op = op.extensions(Some(ext));
231            }
232
233            // Parameters
234            for p in &spec.params {
235                let in_ = match p.location {
236                    operation_builder::ParamLocation::Path => ParameterIn::Path,
237                    operation_builder::ParamLocation::Query => ParameterIn::Query,
238                    operation_builder::ParamLocation::Header => ParameterIn::Header,
239                    operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
240                };
241                let required =
242                    if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
243                        Required::True
244                    } else {
245                        Required::False
246                    };
247
248                let schema_type = match p.param_type.as_str() {
249                    "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
250                    "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
251                    "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
252                    _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
253                };
254                let item_object = param_schema_object(schema_type, p.format.as_deref(), p.minimum);
255
256                let mut builder = ParameterBuilder::new()
257                    .name(&p.name)
258                    .parameter_in(in_)
259                    .required(required)
260                    .description(p.description.clone());
261
262                if p.array {
263                    // `style: form, explode: true` is the repeated-key encoding
264                    // (`?tag=a&tag=b`). Spelling it out matters: the OpenAPI
265                    // default for a query array is `form` with `explode: true`,
266                    // but generators differ on whether they assume it, and the
267                    // wire format has to be unambiguous for a client written
268                    // against this spec to interoperate.
269                    builder = builder
270                        .style(Some(utoipa::openapi::path::ParameterStyle::Form))
271                        .explode(Some(true))
272                        .schema(Some(Schema::Array(
273                            utoipa::openapi::schema::ArrayBuilder::new()
274                                .items(item_object)
275                                .build(),
276                        )));
277                } else {
278                    builder = builder.schema(Some(Schema::Object(item_object)));
279                }
280
281                op = op.parameter(builder.build());
282            }
283
284            // Request body
285            if let Some(rb) = &spec.request_body {
286                let content = build_request_body_content(&rb.schema);
287                let mut rbld = RequestBodyBuilder::new()
288                    .description(rb.description.clone())
289                    .content(rb.content_type.to_owned(), content);
290                if rb.required {
291                    rbld = rbld.required(Some(Required::True));
292                }
293                op = op.request_body(Some(rbld.build()));
294            }
295
296            // Responses
297            let mut responses_by_status = BTreeMap::<u16, Response>::new();
298            for r in &spec.responses {
299                let response = responses_by_status
300                    .entry(r.status)
301                    .or_insert_with(|| Response::new(&r.description));
302                // Preserve the historical last-declaration-wins behavior for
303                // the status-level description while merging media types.
304                response.description.clone_from(&r.description);
305
306                // Body-less response (e.g. 204 No Content) is signalled by an
307                // empty `content_type`. Emit just `description` — attaching a
308                // `content` block would make code-generators expect a body.
309                if !r.content_type.is_empty() {
310                    // Streaming media types are json-like here too: their
311                    // declared schema is the *item* type, so it must render as
312                    // a `$ref` rather than as an opaque string blob. Derived
313                    // from `StreamFraming` rather than hard-coded, so a new
314                    // framing variant is covered automatically instead of
315                    // silently falling through to the opaque-string branch.
316                    let is_json_like = r.content_type == "application/json"
317                        || r.content_type == problem::APPLICATION_PROBLEM_JSON
318                        || StreamFraming::is_stream_media_type(r.content_type);
319                    let content = if is_json_like {
320                        // Manually build content to preserve the correct content type.
321                        ContentBuilder::new()
322                            .schema(Some(build_response_schema(r.schema.as_ref())))
323                            .build()
324                    } else {
325                        let schema = Schema::Object(
326                            ObjectBuilder::new()
327                                .schema_type(SchemaType::Type(
328                                    utoipa::openapi::schema::Type::String,
329                                ))
330                                .format(Some(SchemaFormat::Custom(r.content_type.into())))
331                                .build(),
332                        );
333                        ContentBuilder::new().schema(Some(schema)).build()
334                    };
335                    response.content.insert(r.content_type.to_owned(), content);
336                }
337
338                for header in &r.headers {
339                    let schema_type = match header.header_type {
340                        operation_builder::ResponseHeaderType::String => {
341                            SchemaType::Type(utoipa::openapi::schema::Type::String)
342                        }
343                        operation_builder::ResponseHeaderType::Integer => {
344                            SchemaType::Type(utoipa::openapi::schema::Type::Integer)
345                        }
346                        operation_builder::ResponseHeaderType::Boolean => {
347                            SchemaType::Type(utoipa::openapi::schema::Type::Boolean)
348                        }
349                    };
350                    let declared = HeaderBuilder::new()
351                        .description(header.description.clone())
352                        .schema(ObjectBuilder::new().schema_type(schema_type).build())
353                        .build();
354                    response.headers.insert(header.name.clone(), declared);
355                }
356            }
357            let responses = ResponsesBuilder::new().responses_from_iter(
358                responses_by_status
359                    .into_iter()
360                    .map(|(status, response)| (status.to_string(), response)),
361            );
362            op = op.responses(responses.build());
363
364            // Add security requirement if operation requires authentication
365            if spec.authenticated {
366                let sec_req = utoipa::openapi::security::SecurityRequirement::new(
367                    "bearerAuth",
368                    Vec::<String>::new(),
369                );
370                op = op.security(sec_req);
371            }
372
373            let method = match spec.method {
374                Method::POST => HttpMethod::Post,
375                Method::PUT => HttpMethod::Put,
376                Method::DELETE => HttpMethod::Delete,
377                Method::PATCH => HttpMethod::Patch,
378                // GET and any other method default to Get
379                _ => HttpMethod::Get,
380            };
381
382            let item = PathItemBuilder::new().operation(method, op.build()).build();
383            // Convert Axum-style path to OpenAPI-style path
384            let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
385            paths = paths.path(openapi_path, item);
386        }
387
388        // 2) Components (from our registry)
389        let reg = self.components_registry.load();
390        let mut components = ComponentsBuilder::new();
391        for (name, schema) in reg.iter() {
392            components = components.schema(name.clone(), schema.clone());
393        }
394
395        // Add bearer auth security scheme
396        components = components.security_scheme(
397            "bearerAuth",
398            SecurityScheme::Http(
399                HttpBuilder::new()
400                    .scheme(HttpAuthScheme::Bearer)
401                    .bearer_format("JWT")
402                    .build(),
403            ),
404        );
405
406        // 3) Info & final OpenAPI doc
407        let openapi_info = InfoBuilder::new()
408            .title(&info.title)
409            .version(&info.version)
410            .description(info.description.clone())
411            .build();
412
413        let servers = (!info.servers.is_empty()).then(|| {
414            info.servers
415                .iter()
416                .cloned()
417                .map(Server::new)
418                .collect::<Vec<_>>()
419        });
420
421        let mut openapi = OpenApiBuilder::new()
422            .info(openapi_info)
423            .servers(servers)
424            .paths(paths.build())
425            .components(Some(components.build()))
426            .build();
427
428        // Document-level vendor extension: this spec is generated from Rust
429        // contract traits + `schemars`/`utoipa` schemas, which cover a deliberate
430        // subset of REST (ADR-0002). It is the MINIMUM conformance contract —
431        // remote services may expose strictly more, never less. Downstream
432        // directory validators key their superset semantics off this marker.
433        let mut ext = utoipa::openapi::extensions::Extensions::default();
434        ext.insert(
435            "x-toolkit-spec-scope".to_owned(),
436            serde_json::json!("minimum-conformance"),
437        );
438        openapi.extensions = Some(ext);
439
440        warn_dangling_refs_in_openapi(&openapi);
441
442        Ok(openapi)
443    }
444}
445
446impl Default for OpenApiRegistryImpl {
447    fn default() -> Self {
448        Self::new()
449    }
450}
451
452impl OpenApiRegistry for OpenApiRegistryImpl {
453    fn register_operation(&self, spec: &operation_builder::OperationSpec) {
454        let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
455        // Surface duplicate (method, path) registrations — e.g. a generated
456        // `register_<trait>_routes()` colliding with a hand-written route, or two
457        // SDKs registering the same path (M-13). Silently overwriting the earlier
458        // operation's OpenAPI spec is a hard-to-diagnose drift; the axum router
459        // itself will also panic on the duplicate route at bind time.
460        if let Some(prev) = self
461            .operation_specs
462            .insert(operation_key.clone(), spec.clone())
463            && prev.handler_id != spec.handler_id
464        {
465            tracing::warn!(
466                operation_key = %operation_key,
467                previous_handler = %prev.handler_id,
468                new_handler = %spec.handler_id,
469                "duplicate OpenAPI operation registration; the earlier operation spec was \
470                 overwritten - generated and manual routes must not share a (method, path)"
471            );
472        }
473
474        tracing::debug!(
475            handler_id = %spec.handler_id,
476            method = %spec.method.as_str(),
477            path = %spec.path,
478            summary = %spec.summary.as_deref().unwrap_or("No summary"),
479            operation_key = %operation_key,
480            "Registered API operation in registry"
481        );
482    }
483
484    fn ensure_schema_raw(&self, root_name: &str, schemas: SchemaCollection) -> String {
485        // Snapshot & copy-on-write
486        let current = self.components_registry.load();
487        let mut reg = (**current).clone();
488
489        for (name, schema) in schemas {
490            // Conflict policy: identical → no-op; different → HARD ERROR. Two
491            // distinct types resolving to the same schema name (utoipa uses the
492            // bare type ident by default) would otherwise silently clobber each
493            // other in `components.schemas`, producing a spec where one type
494            // masquerades under another's name — a hard-to-diagnose wire
495            // mismatch. Fail fast at registration instead.
496            if let Some(existing) = reg.get(&name) {
497                let a = serde_json::to_value(existing).ok();
498                let b = serde_json::to_value(&schema).ok();
499                if a == b {
500                    continue; // Skip identical schemas
501                }
502                panic!(
503                    "OpenAPI schema name collision: `{name}` is registered with two different \
504                     definitions. Two distinct types share the same schema name — rename one, or \
505                     give it a distinct `#[schema(as = \"...\")]` alias. For a `Vec<T>` response \
506                     use `OperationBuilder::json_array_response_with_schema::<T>()`, which emits \
507                     an inline array instead of registering a component named `Vec`. \
508                     existing={}, new={}",
509                    a.map(|v| truncate_json(&v)).unwrap_or_default(),
510                    b.map(|v| truncate_json(&v)).unwrap_or_default(),
511                );
512            }
513            reg.insert(name, schema);
514        }
515
516        self.components_registry.store(Arc::new(reg));
517        root_name.to_owned()
518    }
519
520    fn as_any(&self) -> &dyn std::any::Any {
521        self
522    }
523}
524
525/// Render a JSON value to a compact, length-bounded string for diagnostics.
526/// Bounded by character count (char-boundary safe) rather than bytes.
527fn truncate_json(v: &serde_json::Value) -> String {
528    const MAX: usize = 200;
529    let s = v.to_string();
530    if s.chars().count() > MAX {
531        let mut out: String = s.chars().take(MAX).collect();
532        out.push('\u{2026}');
533        out
534    } else {
535        s
536    }
537}
538
539/// Build the `OpenAPI` content object for a request body schema variant.
540fn build_request_body_content(
541    schema: &operation_builder::RequestBodySchema,
542) -> utoipa::openapi::content::Content {
543    match schema {
544        operation_builder::RequestBodySchema::Ref { schema_name } => ContentBuilder::new()
545            .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
546            .build(),
547        operation_builder::RequestBodySchema::MultipartFile { field_name } => {
548            // Build multipart/form-data schema with a single binary file field
549            // type: object
550            // properties:
551            //   {field_name}: { type: string, format: binary }
552            // required: [ field_name ]
553            let file_schema = Schema::Object(
554                ObjectBuilder::new()
555                    .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
556                    .format(Some(SchemaFormat::Custom("binary".into())))
557                    .build(),
558            );
559            let obj = ObjectBuilder::new()
560                .property(field_name.clone(), file_schema)
561                .required(field_name.clone());
562            ContentBuilder::new()
563                .schema(Some(Schema::Object(obj.build())))
564                .build()
565        }
566        operation_builder::RequestBodySchema::Binary => {
567            // Represent raw binary body as type string, format binary.
568            // This is used for application/octet-stream and similar raw binary content.
569            let schema = Schema::Object(
570                ObjectBuilder::new()
571                    .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
572                    .format(Some(SchemaFormat::Custom("binary".into())))
573                    .build(),
574            );
575            ContentBuilder::new().schema(Some(schema)).build()
576        }
577        operation_builder::RequestBodySchema::InlineObject => {
578            // Preserve previous behavior for inline object bodies
579            ContentBuilder::new()
580                .schema(Some(Schema::Object(ObjectBuilder::new().build())))
581                .build()
582        }
583    }
584}
585
586/// Build the response body schema for a [`operation_builder::ResponseSchema`].
587///
588/// `None` — a JSON response with no declared schema — yields a free-form
589/// object, preserving the previous behaviour.
590fn build_response_schema(schema: Option<&operation_builder::ResponseSchema>) -> RefOr<Schema> {
591    match schema {
592        Some(operation_builder::ResponseSchema::Ref { schema_name }) => {
593            RefOr::Ref(Ref::from_schema_name(schema_name.clone()))
594        }
595        // Top-level arrays are emitted INLINE, with only the item type
596        // registered as a named component. Naming the array itself would use
597        // utoipa's `Vec` (generics are stripped from `ToSchema::name()`), so
598        // every list endpoint in the process would fight over one component.
599        Some(operation_builder::ResponseSchema::Array { items_schema_name }) => {
600            RefOr::T(Schema::Array(
601                ArrayBuilder::new()
602                    .items(RefOr::Ref(Ref::from_schema_name(items_schema_name.clone())))
603                    .build(),
604            ))
605        }
606        None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
607    }
608}
609
610/// Walk the finalized `OpenAPI` document and warn about dangling `$ref` targets.
611///
612/// Scans the entire document (operations, request bodies, responses, and schemas)
613/// so that `$ref`s emitted outside `components.schemas` are also caught.
614fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
615    for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
616        tracing::warn!(
617            schema = %ref_name,
618            "Dangling $ref: schema '{}' is referenced but not registered. \
619             Add an explicit `ensure_schema::<T>(registry)` call.",
620            ref_name,
621        );
622    }
623}
624
625/// Serialize the full `OpenAPI` document to JSON, collect every
626/// `#/components/schemas/{name}` reference, and return those not defined
627/// in `components.schemas`.
628fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
629    let value = match serde_json::to_value(openapi) {
630        Ok(v) => v,
631        Err(err) => {
632            tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
633            return Vec::new();
634        }
635    };
636
637    let mut all_refs = HashSet::new();
638    collect_refs_from_json(&value, &mut all_refs);
639
640    // Defined schema names live under components.schemas keys
641    let defined: HashSet<&str> = value
642        .pointer("/components/schemas")
643        .and_then(|v| v.as_object())
644        .map(|obj| obj.keys().map(String::as_str).collect())
645        .unwrap_or_default();
646
647    all_refs
648        .into_iter()
649        .filter(|name| !defined.contains(name.as_str()))
650        .collect()
651}
652
653/// Recursively extract `#/components/schemas/{name}` targets from a JSON value.
654fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
655    match value {
656        serde_json::Value::Object(map) => {
657            if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
658                && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
659            {
660                refs.insert(name.to_owned());
661            }
662            for v in map.values() {
663                collect_refs_from_json(v, refs);
664            }
665        }
666        serde_json::Value::Array(arr) => {
667            for v in arr {
668                collect_refs_from_json(v, refs);
669            }
670        }
671        _ => {}
672    }
673}
674
675#[cfg(test)]
676#[cfg_attr(coverage_nightly, coverage(off))]
677mod tests {
678    use super::*;
679    use crate::api::operation_builder::{
680        OperationSpec, ParamSpec, ResponseHeaderSpec, ResponseHeaderType, ResponseSchema,
681        ResponseSpec, VendorExtensions,
682    };
683    use http::Method;
684
685    /// Minimal `OperationSpec` carrying a single 200 response with `schema`.
686    fn spec_with_response(
687        path: &str,
688        handler: &str,
689        schema: Option<ResponseSchema>,
690    ) -> OperationSpec {
691        OperationSpec {
692            method: Method::GET,
693            path: path.to_owned(),
694            operation_id: Some(handler.to_owned()),
695            summary: None,
696            description: None,
697            tags: vec![],
698            params: vec![],
699            request_body: None,
700            responses: vec![ResponseSpec {
701                status: 200,
702                content_type: "application/json",
703                description: "OK".to_owned(),
704                schema,
705                headers: vec![],
706            }],
707            handler_id: handler.to_owned(),
708            authenticated: false,
709            exposed: false,
710            throttling: None,
711            allowed_request_content_types: None,
712            vendor_extensions: VendorExtensions::default(),
713            license_requirement: None,
714        }
715    }
716
717    /// The 200 response schema for `path`, as JSON.
718    fn response_schema_json(doc: &serde_json::Value, path: &str) -> serde_json::Value {
719        doc["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
720            .clone()
721    }
722
723    fn test_info() -> OpenApiInfo {
724        OpenApiInfo {
725            title: "T".to_owned(),
726            version: "1".to_owned(),
727            description: None,
728            servers: Vec::new(),
729        }
730    }
731
732    #[test]
733    fn throttling_zone_names_are_emitted_as_vendor_extensions() {
734        use crate::api::operation_builder::ThrottlingSpec;
735
736        let registry = OpenApiRegistryImpl::new();
737        let mut spec = spec_with_response("/throttled", "throttled_op", None);
738        spec.throttling = Some(ThrottlingSpec {
739            rate_limit_zone: Some("rl_zone".to_owned()),
740            in_flight_limit_zone: Some("ifl_zone".to_owned()),
741            require_security_context: false,
742            dry_run: false,
743        });
744        registry.register_operation(&spec);
745        // An operation without throttling must not carry the extensions.
746        registry.register_operation(&spec_with_response("/plain", "plain_op", None));
747
748        let doc = registry.build_openapi(&test_info()).unwrap();
749        let json = serde_json::to_value(&doc).unwrap();
750
751        let throttled = &json["paths"]["/throttled"]["get"];
752        assert_eq!(
753            throttled["x-throttling-rate-limit-zone"],
754            serde_json::json!("rl_zone")
755        );
756        assert_eq!(
757            throttled["x-throttling-in-flight-limit-zone"],
758            serde_json::json!("ifl_zone")
759        );
760
761        let plain = &json["paths"]["/plain"]["get"];
762        assert!(plain.get("x-throttling-rate-limit-zone").is_none());
763        assert!(plain.get("x-throttling-in-flight-limit-zone").is_none());
764    }
765
766    #[test]
767    fn test_registry_creation() {
768        let registry = OpenApiRegistryImpl::new();
769        assert_eq!(registry.operation_specs.len(), 0);
770        assert_eq!(registry.components_registry.load().len(), 0);
771    }
772
773    #[test]
774    fn parameter_formats_are_preserved_in_scalar_and_array_schemas() {
775        use serde_json::json;
776
777        // `int64` is a format `utoipa` knows and `resource-version` is not:
778        // both reach the document as the token the declaration spelled, which
779        // is what lets `ParamSpec::format` be a plain string.
780        for (format, param_type, expected) in [
781            (
782                Some("int64"),
783                "integer",
784                json!({"type": "integer", "format": "int64", "minimum": 1}),
785            ),
786            (
787                Some("resource-version"),
788                "integer",
789                json!({"type": "integer", "format": "resource-version", "minimum": 1}),
790            ),
791            (None, "integer", json!({"type": "integer", "minimum": 1})),
792        ] {
793            for array in [false, true] {
794                let registry = OpenApiRegistryImpl::new();
795                let mut spec = spec_with_response("/test", "get_test", None);
796                let mut param = ParamSpec::query("version")
797                    .required(true)
798                    .param_type(param_type)
799                    .array(array)
800                    .minimum(1.0);
801                if let Some(format) = format {
802                    param = param.format(format);
803                }
804                spec.params.push(param);
805                registry.register_operation(&spec);
806                let doc = registry.build_openapi(&test_info()).expect("build OpenAPI");
807                let json = serde_json::to_value(doc).expect("serialize OpenAPI");
808                let schema = &json["paths"]["/test"]["get"]["parameters"][0]["schema"];
809                let expected_schema = if array {
810                    json!({"type": "array", "items": expected})
811                } else {
812                    expected.clone()
813                };
814                assert_eq!(schema, &expected_schema, "format={format:?}, array={array}");
815            }
816        }
817    }
818
819    #[test]
820    fn test_register_operation() {
821        let registry = OpenApiRegistryImpl::new();
822        let spec = OperationSpec {
823            method: Method::GET,
824            path: "/test".to_owned(),
825            operation_id: Some("test_op".to_owned()),
826            summary: Some("Test operation".to_owned()),
827            description: None,
828            tags: vec![],
829            params: vec![],
830            request_body: None,
831            responses: vec![ResponseSpec {
832                status: 200,
833                content_type: "application/json",
834                description: "Success".to_owned(),
835                schema: None,
836                headers: vec![],
837            }],
838            handler_id: "get_test".to_owned(),
839            authenticated: false,
840            exposed: false,
841            throttling: None,
842            allowed_request_content_types: None,
843            vendor_extensions: VendorExtensions::default(),
844            license_requirement: None,
845        };
846
847        registry.register_operation(&spec);
848        assert_eq!(registry.operation_specs.len(), 1);
849    }
850
851    #[test]
852    fn response_headers_are_emitted_with_their_declared_types() {
853        let registry = OpenApiRegistryImpl::new();
854        let mut spec = spec_with_response("/submit", "submit", None);
855        spec.responses[0].headers = vec![
856            ResponseHeaderSpec::new(
857                "Location",
858                "Operation resource URI",
859                ResponseHeaderType::String,
860            ),
861            ResponseHeaderSpec::new(
862                "Retry-After",
863                "Retry delay in seconds",
864                ResponseHeaderType::Integer,
865            ),
866            ResponseHeaderSpec::new(
867                "Idempotency-Replayed",
868                "Whether this is a replay",
869                ResponseHeaderType::Boolean,
870            ),
871        ];
872        registry.register_operation(&spec);
873
874        let doc = registry.build_openapi(&test_info()).unwrap();
875        let json = serde_json::to_value(doc).unwrap();
876        let headers = &json["paths"]["/submit"]["get"]["responses"]["200"]["headers"];
877        assert_eq!(headers["Location"]["schema"]["type"], "string");
878        assert_eq!(headers["Location"]["description"], "Operation resource URI");
879        assert_eq!(headers["Retry-After"]["schema"]["type"], "integer");
880        assert_eq!(headers["Idempotency-Replayed"]["schema"]["type"], "boolean");
881    }
882
883    #[test]
884    fn response_content_types_with_the_same_status_are_combined() {
885        let registry = OpenApiRegistryImpl::new();
886        let mut spec = spec_with_response("/document", "document", None);
887        spec.responses[0].content_type = "text/plain";
888        spec.responses[0].description = "Plain document".to_owned();
889        spec.responses[0].headers = vec![ResponseHeaderSpec::new(
890            "X-Plain-Document",
891            "Whether plain text is available",
892            ResponseHeaderType::Boolean,
893        )];
894        spec.responses.push(
895            ResponseSpec::new(
896                http::StatusCode::OK.as_u16(),
897                "text/html",
898                "HTML document",
899                None,
900            )
901            .with_headers([ResponseHeaderSpec::new(
902                "X-HTML-Document",
903                "Whether HTML is available",
904                ResponseHeaderType::Boolean,
905            )]),
906        );
907        registry.register_operation(&spec);
908
909        let doc = registry.build_openapi(&test_info()).unwrap();
910        let json = serde_json::to_value(doc).unwrap();
911        let response = &json["paths"]["/document"]["get"]["responses"]["200"];
912        assert_eq!(response["description"], "HTML document");
913        assert_eq!(
914            response["content"]["text/plain"]["schema"]["format"],
915            "text/plain"
916        );
917        assert_eq!(
918            response["content"]["text/html"]["schema"]["format"],
919            "text/html"
920        );
921        assert_eq!(
922            response["headers"]["X-Plain-Document"]["schema"]["type"],
923            "boolean"
924        );
925        assert_eq!(
926            response["headers"]["X-HTML-Document"]["schema"]["type"],
927            "boolean"
928        );
929    }
930
931    #[test]
932    fn bodyless_response_can_declare_headers_without_content() {
933        let registry = OpenApiRegistryImpl::new();
934        let mut spec = spec_with_response("/jobs", "jobs", None);
935        spec.responses[0].content_type = "";
936        spec.responses[0].schema = None;
937        spec.responses[0].headers = vec![ResponseHeaderSpec::without_description(
938            "Retry-After",
939            ResponseHeaderType::Integer,
940        )];
941        registry.register_operation(&spec);
942
943        let doc = registry.build_openapi(&test_info()).unwrap();
944        let json = serde_json::to_value(doc).unwrap();
945        let response = &json["paths"]["/jobs"]["get"]["responses"]["200"];
946        assert!(response.get("content").is_none());
947        assert_eq!(
948            response["headers"]["Retry-After"]["schema"]["type"],
949            "integer"
950        );
951        assert!(
952            response["headers"]["Retry-After"]
953                .get("description")
954                .is_none()
955        );
956    }
957
958    #[test]
959    fn test_build_empty_openapi() {
960        let registry = OpenApiRegistryImpl::new();
961        let info = OpenApiInfo {
962            title: "Test API".to_owned(),
963            version: "1.0.0".to_owned(),
964            description: Some("Test API Description".to_owned()),
965            servers: Vec::new(),
966        };
967        let doc = registry.build_openapi(&info).unwrap();
968        let json = serde_json::to_value(&doc).unwrap();
969
970        // Verify it's valid OpenAPI document structure
971        assert!(json.get("openapi").is_some());
972        assert!(json.get("info").is_some());
973        assert!(json.get("paths").is_some());
974
975        // Verify info section
976        let openapi_info = json.get("info").unwrap();
977        assert_eq!(openapi_info.get("title").unwrap(), "Test API");
978        assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
979        assert_eq!(
980            openapi_info.get("description").unwrap(),
981            "Test API Description"
982        );
983    }
984
985    #[test]
986    fn test_build_openapi_with_operation() {
987        let registry = OpenApiRegistryImpl::new();
988        let spec = OperationSpec {
989            method: Method::GET,
990            path: "/users/{id}".to_owned(),
991            operation_id: Some("get_user".to_owned()),
992            summary: Some("Get user by ID".to_owned()),
993            description: Some("Retrieves a user by their ID".to_owned()),
994            tags: vec!["users".to_owned()],
995            params: vec![ParamSpec::path("id").description("User ID")],
996            request_body: None,
997            responses: vec![ResponseSpec {
998                status: 200,
999                content_type: "application/json",
1000                description: "User found".to_owned(),
1001                schema: None,
1002                headers: vec![],
1003            }],
1004            handler_id: "get_users_id".to_owned(),
1005            authenticated: false,
1006            exposed: false,
1007            throttling: None,
1008            allowed_request_content_types: None,
1009            vendor_extensions: VendorExtensions::default(),
1010            license_requirement: None,
1011        };
1012
1013        registry.register_operation(&spec);
1014        let info = OpenApiInfo::default();
1015        let doc = registry.build_openapi(&info).unwrap();
1016        let json = serde_json::to_value(&doc).unwrap();
1017
1018        // Verify path exists
1019        let paths = json.get("paths").unwrap();
1020        assert!(paths.get("/users/{id}").is_some());
1021
1022        // Verify operation details
1023        let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
1024        assert_eq!(get_op.get("operationId").unwrap(), "get_user");
1025        assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
1026    }
1027
1028    #[test]
1029    fn test_ensure_schema_raw() {
1030        let registry = OpenApiRegistryImpl::new();
1031        let schema = Schema::Object(ObjectBuilder::new().build());
1032        let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
1033
1034        let name = registry.ensure_schema_raw("TestSchema", schemas);
1035        assert_eq!(name, "TestSchema");
1036        assert_eq!(registry.components_registry.load().len(), 1);
1037    }
1038
1039    #[test]
1040    fn test_build_openapi_with_binary_request() {
1041        use crate::api::operation_builder::RequestBodySchema;
1042
1043        let registry = OpenApiRegistryImpl::new();
1044        let spec = OperationSpec {
1045            method: Method::POST,
1046            path: "/files/v1/upload".to_owned(),
1047            operation_id: Some("upload_file".to_owned()),
1048            summary: Some("Upload a file".to_owned()),
1049            description: Some("Upload raw binary file".to_owned()),
1050            tags: vec!["upload".to_owned()],
1051            params: vec![],
1052            request_body: Some(crate::api::operation_builder::RequestBodySpec {
1053                content_type: "application/octet-stream",
1054                description: Some("Raw file bytes".to_owned()),
1055                schema: RequestBodySchema::Binary,
1056                required: true,
1057            }),
1058            responses: vec![ResponseSpec {
1059                status: 200,
1060                content_type: "application/json",
1061                description: "Upload successful".to_owned(),
1062                schema: None,
1063                headers: vec![],
1064            }],
1065            handler_id: "post_upload".to_owned(),
1066            authenticated: false,
1067            exposed: false,
1068            throttling: None,
1069            allowed_request_content_types: Some(vec!["application/octet-stream"]),
1070            vendor_extensions: VendorExtensions::default(),
1071            license_requirement: None,
1072        };
1073
1074        registry.register_operation(&spec);
1075        let info = OpenApiInfo::default();
1076        let doc = registry.build_openapi(&info).unwrap();
1077        let json = serde_json::to_value(&doc).unwrap();
1078
1079        // Verify path exists
1080        let paths = json.get("paths").unwrap();
1081        assert!(paths.get("/files/v1/upload").is_some());
1082
1083        // Verify request body has application/octet-stream with binary schema
1084        let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
1085        let request_body = post_op.get("requestBody").unwrap();
1086        let content = request_body.get("content").unwrap();
1087        let octet_stream = content
1088            .get("application/octet-stream")
1089            .expect("application/octet-stream content type should exist");
1090
1091        // Verify schema is type: string, format: binary
1092        let schema = octet_stream.get("schema").unwrap();
1093        assert_eq!(schema.get("type").unwrap(), "string");
1094        assert_eq!(schema.get("format").unwrap(), "binary");
1095
1096        // Verify required flag
1097        assert_eq!(request_body.get("required").unwrap(), true);
1098    }
1099
1100    #[test]
1101    fn test_build_openapi_with_pagination() {
1102        let registry = OpenApiRegistryImpl::new();
1103
1104        let mut filter: operation_builder::ODataPagination<
1105            std::collections::BTreeMap<String, Vec<String>>,
1106        > = operation_builder::ODataPagination::default();
1107        filter.allowed_fields.insert(
1108            "name".to_owned(),
1109            vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
1110                .into_iter()
1111                .map(String::from)
1112                .collect(),
1113        );
1114        filter.allowed_fields.insert(
1115            "age".to_owned(),
1116            vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
1117                .into_iter()
1118                .map(String::from)
1119                .collect(),
1120        );
1121
1122        let mut order_by: operation_builder::ODataPagination<Vec<String>> =
1123            operation_builder::ODataPagination::default();
1124        order_by.allowed_fields.push("name asc".to_owned());
1125        order_by.allowed_fields.push("name desc".to_owned());
1126        order_by.allowed_fields.push("age asc".to_owned());
1127        order_by.allowed_fields.push("age desc".to_owned());
1128
1129        let mut spec = OperationSpec {
1130            method: Method::GET,
1131            path: "/test".to_owned(),
1132            operation_id: Some("test_op".to_owned()),
1133            summary: Some("Test".to_owned()),
1134            description: None,
1135            tags: vec![],
1136            params: vec![],
1137            request_body: None,
1138            responses: vec![ResponseSpec {
1139                status: 200,
1140                content_type: "application/json",
1141                description: "OK".to_owned(),
1142                schema: None,
1143                headers: vec![],
1144            }],
1145            handler_id: "get_test".to_owned(),
1146            authenticated: false,
1147            exposed: false,
1148            throttling: None,
1149            allowed_request_content_types: None,
1150            vendor_extensions: VendorExtensions::default(),
1151            license_requirement: None,
1152        };
1153        spec.vendor_extensions.x_odata_filter = Some(filter);
1154        spec.vendor_extensions.x_odata_orderby = Some(order_by);
1155
1156        registry.register_operation(&spec);
1157        let info = OpenApiInfo::default();
1158        let doc = registry.build_openapi(&info).unwrap();
1159        let json = serde_json::to_value(&doc).unwrap();
1160
1161        let paths = json.get("paths").unwrap();
1162        let op = paths.get("/test").unwrap().get("get").unwrap();
1163
1164        let filter_ext = op
1165            .get("x-odata-filter")
1166            .expect("x-odata-filter should be present");
1167
1168        let allowed_fields = filter_ext.get("allowedFields").unwrap();
1169        assert!(allowed_fields.get("name").is_some());
1170        assert!(allowed_fields.get("age").is_some());
1171
1172        let order_ext = op
1173            .get("x-odata-orderby")
1174            .expect("x-odata-orderby should be present");
1175
1176        let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
1177        assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
1178        assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
1179    }
1180
1181    #[test]
1182    fn test_public_operation_emits_visibility_extension() {
1183        let registry = OpenApiRegistryImpl::new();
1184        let public = OperationSpec {
1185            method: Method::GET,
1186            path: "/calc/v1/ping".to_owned(),
1187            operation_id: Some("ping".to_owned()),
1188            summary: Some("Ping".to_owned()),
1189            description: None,
1190            tags: vec![],
1191            params: vec![],
1192            request_body: None,
1193            responses: vec![ResponseSpec {
1194                status: 200,
1195                content_type: "application/json",
1196                description: "OK".to_owned(),
1197                schema: None,
1198                headers: vec![],
1199            }],
1200            handler_id: "get_ping".to_owned(),
1201            authenticated: false,
1202            exposed: true,
1203            throttling: None,
1204            allowed_request_content_types: None,
1205            vendor_extensions: VendorExtensions::default(),
1206            license_requirement: None,
1207        };
1208        // A second, internal operation must NOT carry the extension.
1209        let mut internal = public.clone();
1210        internal.path = "/calc/v1/internal".to_owned();
1211        internal.handler_id = "get_internal".to_owned();
1212        internal.operation_id = Some("internal".to_owned());
1213        internal.exposed = false;
1214
1215        registry.register_operation(&public);
1216        registry.register_operation(&internal);
1217        let doc = registry.build_openapi(&OpenApiInfo::default()).unwrap();
1218        let json = serde_json::to_value(&doc).unwrap();
1219        let paths = json.get("paths").unwrap();
1220
1221        let public_op = paths.get("/calc/v1/ping").unwrap().get("get").unwrap();
1222        assert_eq!(
1223            public_op
1224                .get("x-toolkit-visibility")
1225                .and_then(|v| v.as_str()),
1226            Some("exposed"),
1227            "public operation must advertise the gateway visibility extension"
1228        );
1229
1230        let internal_op = paths.get("/calc/v1/internal").unwrap().get("get").unwrap();
1231        assert!(
1232            internal_op.get("x-toolkit-visibility").is_none(),
1233            "non-public operation must not carry the visibility extension"
1234        );
1235    }
1236
1237    /// Helper: build a minimal `OpenAPI` doc with the given component schemas.
1238    fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
1239        let mut components = ComponentsBuilder::new();
1240        for (name, schema) in schemas {
1241            components = components.schema(name, schema);
1242        }
1243        OpenApiBuilder::new()
1244            .components(Some(components.build()))
1245            .build()
1246    }
1247
1248    #[test]
1249    fn test_dangling_refs_detects_missing_in_components() {
1250        let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1251        // Register "Foo" with a $ref to "Bar" which is NOT registered
1252        let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1253            "type": "object",
1254            "properties": {
1255                "bar": { "$ref": "#/components/schemas/Bar" }
1256            }
1257        }))
1258        .unwrap();
1259        schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1260
1261        let openapi = build_test_openapi(schemas);
1262        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1263        assert_eq!(dangling, vec!["Bar".to_owned()]);
1264    }
1265
1266    #[test]
1267    fn test_dangling_refs_no_false_positives() {
1268        let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1269        // Register "Bar"
1270        let bar_schema = Schema::Object(ObjectBuilder::new().build());
1271        schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
1272
1273        // Register "Foo" referencing "Bar"
1274        let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1275            "type": "object",
1276            "properties": {
1277                "bar": { "$ref": "#/components/schemas/Bar" }
1278            }
1279        }))
1280        .unwrap();
1281        schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1282
1283        let openapi = build_test_openapi(schemas);
1284        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1285        assert!(
1286            dangling.is_empty(),
1287            "Expected no dangling refs but got: {dangling:?}"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_dangling_refs_detects_missing_in_operations() {
1293        // Build an OpenAPI doc with a response $ref to "MissingDto" but no
1294        // matching component schema — simulates the scenario CodeRabbit flagged.
1295        let openapi_json = serde_json::json!({
1296            "openapi": "3.1.0",
1297            "info": { "title": "test", "version": "0.1.0" },
1298            "paths": {
1299                "/items": {
1300                    "get": {
1301                        "responses": {
1302                            "200": {
1303                                "description": "OK",
1304                                "content": {
1305                                    "application/json": {
1306                                        "schema": { "$ref": "#/components/schemas/MissingDto" }
1307                                    }
1308                                }
1309                            }
1310                        }
1311                    }
1312                }
1313            },
1314            "components": {
1315                "schemas": {}
1316            }
1317        });
1318        let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
1319        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1320        assert_eq!(dangling, vec!["MissingDto".to_owned()]);
1321    }
1322
1323    // --- array responses -------------------------------------------------
1324    //
1325    // A top-level array must be emitted inline, referencing the item type,
1326    // and must NOT create a component of its own. utoipa names every `Vec<T>`
1327    // `Vec`, so a named array component makes all list endpoints collide.
1328
1329    #[test]
1330    fn array_response_emits_inline_array_referencing_item() {
1331        let registry = OpenApiRegistryImpl::new();
1332        registry.register_operation(&spec_with_response(
1333            "/gears",
1334            "list_gears",
1335            Some(ResponseSchema::Array {
1336                items_schema_name: "GearDto".to_owned(),
1337            }),
1338        ));
1339
1340        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1341        let schema = response_schema_json(&doc, "/gears");
1342
1343        assert_eq!(schema["type"], "array");
1344        assert_eq!(schema["items"]["$ref"], "#/components/schemas/GearDto");
1345        // The array itself is not a component.
1346        assert!(schema.get("$ref").is_none());
1347        assert!(doc["components"]["schemas"].get("Vec").is_none());
1348    }
1349
1350    #[test]
1351    fn ref_response_still_emits_plain_ref() {
1352        let registry = OpenApiRegistryImpl::new();
1353        registry.register_operation(&spec_with_response(
1354            "/gear",
1355            "get_gear",
1356            Some(ResponseSchema::Ref {
1357                schema_name: "GearDto".to_owned(),
1358            }),
1359        ));
1360
1361        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1362        let schema = response_schema_json(&doc, "/gear");
1363
1364        assert_eq!(schema["$ref"], "#/components/schemas/GearDto");
1365        assert!(schema.get("type").is_none());
1366    }
1367
1368    /// `OperationBuilder::multipart_json` declares the *item* schema under the
1369    /// bare `multipart/mixed` media-type key: no `boundary=` parameter (that is
1370    /// generated per response at runtime, so it is not a property of the
1371    /// operation), and a `$ref` rather than the opaque string blob a
1372    /// non-json-like media type would render as.
1373    #[test]
1374    fn multipart_mixed_response_emits_the_item_ref_under_a_bare_media_type() {
1375        let registry = OpenApiRegistryImpl::new();
1376        let mut spec = spec_with_response(
1377            "/events",
1378            "stream_events",
1379            Some(ResponseSchema::Ref {
1380                schema_name: "FrameDto".to_owned(),
1381            }),
1382        );
1383        spec.responses[0].content_type = "multipart/mixed";
1384        registry.register_operation(&spec);
1385
1386        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1387        let content = &doc["paths"]["/events"]["get"]["responses"]["200"]["content"];
1388
1389        assert_eq!(
1390            content["multipart/mixed"]["schema"]["$ref"],
1391            "#/components/schemas/FrameDto"
1392        );
1393        // The runtime boundary must not leak into the spec's media-type key.
1394        assert_eq!(
1395            content.as_object().map(|o| o.keys().collect::<Vec<_>>()),
1396            Some(vec![&"multipart/mixed".to_owned()])
1397        );
1398        // Not rendered as a string with a custom format — that is what a
1399        // non-json-like media type would produce, and it would lose the item
1400        // schema entirely.
1401        assert!(content["multipart/mixed"]["schema"].get("format").is_none());
1402    }
1403
1404    #[test]
1405    fn schemaless_json_response_still_emits_free_form_object() {
1406        let registry = OpenApiRegistryImpl::new();
1407        registry.register_operation(&spec_with_response("/any", "any_op", None));
1408
1409        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1410        let schema = response_schema_json(&doc, "/any");
1411
1412        assert!(schema.get("$ref").is_none());
1413        assert_ne!(schema["type"], "array");
1414    }
1415
1416    /// The regression this whole change exists for: two list endpoints
1417    /// returning different item types used to both register a component named
1418    /// `Vec`, silently clobbering each other (and, after M-14, panicking).
1419    #[test]
1420    fn two_distinct_array_responses_do_not_collide() {
1421        #[derive(utoipa::ToSchema)]
1422        #[allow(dead_code)]
1423        struct AlphaDto {
1424            alpha: String,
1425        }
1426        #[derive(utoipa::ToSchema)]
1427        #[allow(dead_code)]
1428        struct BetaDto {
1429            beta: i32,
1430        }
1431
1432        let registry = OpenApiRegistryImpl::new();
1433        // Registering the ITEM types is what the array builder method does.
1434        let a = ensure_schema::<AlphaDto>(&registry);
1435        let b = ensure_schema::<BetaDto>(&registry);
1436        assert_eq!((a.as_str(), b.as_str()), ("AlphaDto", "BetaDto"));
1437
1438        registry.register_operation(&spec_with_response(
1439            "/alphas",
1440            "list_alphas",
1441            Some(ResponseSchema::Array {
1442                items_schema_name: a,
1443            }),
1444        ));
1445        registry.register_operation(&spec_with_response(
1446            "/betas",
1447            "list_betas",
1448            Some(ResponseSchema::Array {
1449                items_schema_name: b,
1450            }),
1451        ));
1452
1453        let openapi = registry.build_openapi(&test_info()).unwrap();
1454        assert!(
1455            collect_all_dangling_refs_in_openapi(&openapi).is_empty(),
1456            "array item refs must point at registered components"
1457        );
1458
1459        let doc = serde_json::to_value(&openapi).unwrap();
1460        let schemas = &doc["components"]["schemas"];
1461        assert!(schemas.get("AlphaDto").is_some());
1462        assert!(schemas.get("BetaDto").is_some());
1463        assert!(schemas.get("Vec").is_none());
1464        assert_eq!(
1465            response_schema_json(&doc, "/alphas")["items"]["$ref"],
1466            "#/components/schemas/AlphaDto"
1467        );
1468        assert_eq!(
1469            response_schema_json(&doc, "/betas")["items"]["$ref"],
1470            "#/components/schemas/BetaDto"
1471        );
1472    }
1473
1474    #[test]
1475    #[should_panic(expected = "would register the component name `Vec`")]
1476    fn ensure_schema_rejects_vec_directly() {
1477        #[derive(utoipa::ToSchema)]
1478        #[allow(dead_code)]
1479        struct ItemDto {
1480            x: u8,
1481        }
1482        let registry = OpenApiRegistryImpl::new();
1483        let _ = ensure_schema::<Vec<ItemDto>>(&registry);
1484    }
1485
1486    #[test]
1487    #[should_panic(expected = "OpenAPI schema name collision")]
1488    fn ensure_schema_raw_panics_on_conflicting_definition() {
1489        let registry = OpenApiRegistryImpl::new();
1490        registry.ensure_schema_raw(
1491            "Dup",
1492            vec![("Dup".to_owned(), RefOr::Ref(Ref::from_schema_name("First")))],
1493        );
1494        registry.ensure_schema_raw(
1495            "Dup",
1496            vec![(
1497                "Dup".to_owned(),
1498                RefOr::Ref(Ref::from_schema_name("Second")),
1499            )],
1500        );
1501    }
1502
1503    #[test]
1504    fn ensure_schema_raw_allows_identical_reregistration() {
1505        let registry = OpenApiRegistryImpl::new();
1506        let entry = || {
1507            vec![(
1508                "Same".to_owned(),
1509                RefOr::Ref(Ref::from_schema_name("Target")),
1510            )]
1511        };
1512        registry.ensure_schema_raw("Same", entry());
1513        registry.ensure_schema_raw("Same", entry());
1514        assert_eq!(registry.components_registry.load().len(), 1);
1515    }
1516}