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