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