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