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