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