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            if !ext.is_empty() {
181                op = op.extensions(Some(ext));
182            }
183
184            // Parameters
185            for p in &spec.params {
186                let in_ = match p.location {
187                    operation_builder::ParamLocation::Path => ParameterIn::Path,
188                    operation_builder::ParamLocation::Query => ParameterIn::Query,
189                    operation_builder::ParamLocation::Header => ParameterIn::Header,
190                    operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
191                };
192                let required =
193                    if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
194                        Required::True
195                    } else {
196                        Required::False
197                    };
198
199                let schema_type = match p.param_type.as_str() {
200                    "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
201                    "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
202                    "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
203                    _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
204                };
205                let schema = Schema::Object(ObjectBuilder::new().schema_type(schema_type).build());
206
207                let param = ParameterBuilder::new()
208                    .name(&p.name)
209                    .parameter_in(in_)
210                    .required(required)
211                    .description(p.description.clone())
212                    .schema(Some(schema))
213                    .build();
214
215                op = op.parameter(param);
216            }
217
218            // Request body
219            if let Some(rb) = &spec.request_body {
220                let content = build_request_body_content(&rb.schema);
221                let mut rbld = RequestBodyBuilder::new()
222                    .description(rb.description.clone())
223                    .content(rb.content_type.to_owned(), content);
224                if rb.required {
225                    rbld = rbld.required(Some(Required::True));
226                }
227                op = op.request_body(Some(rbld.build()));
228            }
229
230            // Responses
231            let mut responses = ResponsesBuilder::new();
232            for r in &spec.responses {
233                // Body-less response (e.g. 204 No Content) is signalled by an
234                // empty `content_type`. Emit just `description` — attaching a
235                // `content` block would make code-generators expect a body.
236                if r.content_type.is_empty() {
237                    let resp = ResponseBuilder::new().description(&r.description).build();
238                    responses = responses.response(r.status.to_string(), resp);
239                    continue;
240                }
241                let is_json_like = r.content_type == "application/json"
242                    || r.content_type == problem::APPLICATION_PROBLEM_JSON
243                    || r.content_type == "text/event-stream";
244                let resp = if is_json_like {
245                    // Manually build content to preserve the correct content type
246                    let content = ContentBuilder::new()
247                        .schema(Some(build_response_schema(r.schema.as_ref())))
248                        .build();
249                    ResponseBuilder::new()
250                        .description(&r.description)
251                        .content(r.content_type, content)
252                        .build()
253                } else {
254                    let schema = Schema::Object(
255                        ObjectBuilder::new()
256                            .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
257                            .format(Some(SchemaFormat::Custom(r.content_type.into())))
258                            .build(),
259                    );
260                    let content = ContentBuilder::new().schema(Some(schema)).build();
261                    ResponseBuilder::new()
262                        .description(&r.description)
263                        .content(r.content_type, content)
264                        .build()
265                };
266                responses = responses.response(r.status.to_string(), resp);
267            }
268            op = op.responses(responses.build());
269
270            // Add security requirement if operation requires authentication
271            if spec.authenticated {
272                let sec_req = utoipa::openapi::security::SecurityRequirement::new(
273                    "bearerAuth",
274                    Vec::<String>::new(),
275                );
276                op = op.security(sec_req);
277            }
278
279            let method = match spec.method {
280                Method::POST => HttpMethod::Post,
281                Method::PUT => HttpMethod::Put,
282                Method::DELETE => HttpMethod::Delete,
283                Method::PATCH => HttpMethod::Patch,
284                // GET and any other method default to Get
285                _ => HttpMethod::Get,
286            };
287
288            let item = PathItemBuilder::new().operation(method, op.build()).build();
289            // Convert Axum-style path to OpenAPI-style path
290            let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
291            paths = paths.path(openapi_path, item);
292        }
293
294        // 2) Components (from our registry)
295        let reg = self.components_registry.load();
296        let mut components = ComponentsBuilder::new();
297        for (name, schema) in reg.iter() {
298            components = components.schema(name.clone(), schema.clone());
299        }
300
301        // Add bearer auth security scheme
302        components = components.security_scheme(
303            "bearerAuth",
304            SecurityScheme::Http(
305                HttpBuilder::new()
306                    .scheme(HttpAuthScheme::Bearer)
307                    .bearer_format("JWT")
308                    .build(),
309            ),
310        );
311
312        // 3) Info & final OpenAPI doc
313        let openapi_info = InfoBuilder::new()
314            .title(&info.title)
315            .version(&info.version)
316            .description(info.description.clone())
317            .build();
318
319        let servers = (!info.servers.is_empty()).then(|| {
320            info.servers
321                .iter()
322                .cloned()
323                .map(Server::new)
324                .collect::<Vec<_>>()
325        });
326
327        let openapi = OpenApiBuilder::new()
328            .info(openapi_info)
329            .servers(servers)
330            .paths(paths.build())
331            .components(Some(components.build()))
332            .build();
333
334        warn_dangling_refs_in_openapi(&openapi);
335
336        Ok(openapi)
337    }
338}
339
340impl Default for OpenApiRegistryImpl {
341    fn default() -> Self {
342        Self::new()
343    }
344}
345
346impl OpenApiRegistry for OpenApiRegistryImpl {
347    fn register_operation(&self, spec: &operation_builder::OperationSpec) {
348        let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
349        self.operation_specs
350            .insert(operation_key.clone(), spec.clone());
351
352        tracing::debug!(
353            handler_id = %spec.handler_id,
354            method = %spec.method.as_str(),
355            path = %spec.path,
356            summary = %spec.summary.as_deref().unwrap_or("No summary"),
357            operation_key = %operation_key,
358            "Registered API operation in registry"
359        );
360    }
361
362    fn ensure_schema_raw(&self, root_name: &str, schemas: SchemaCollection) -> String {
363        // Snapshot & copy-on-write
364        let current = self.components_registry.load();
365        let mut reg = (**current).clone();
366
367        for (name, schema) in schemas {
368            // Conflict policy: identical → no-op; different → HARD ERROR. Two
369            // distinct types resolving to the same schema name (utoipa uses the
370            // bare type ident by default) would otherwise silently clobber each
371            // other in `components.schemas`, producing a spec where one type
372            // masquerades under another's name — a hard-to-diagnose wire
373            // mismatch. Fail fast at registration instead.
374            if let Some(existing) = reg.get(&name) {
375                let a = serde_json::to_value(existing).ok();
376                let b = serde_json::to_value(&schema).ok();
377                if a == b {
378                    continue; // Skip identical schemas
379                }
380                panic!(
381                    "OpenAPI schema name collision: `{name}` is registered with two different \
382                     definitions. Two distinct types share the same schema name — rename one, or \
383                     give it a distinct `#[schema(as = \"...\")]` alias. For a `Vec<T>` response \
384                     use `OperationBuilder::json_array_response_with_schema::<T>()`, which emits \
385                     an inline array instead of registering a component named `Vec`. \
386                     existing={}, new={}",
387                    a.map(|v| truncate_json(&v)).unwrap_or_default(),
388                    b.map(|v| truncate_json(&v)).unwrap_or_default(),
389                );
390            }
391            reg.insert(name, schema);
392        }
393
394        self.components_registry.store(Arc::new(reg));
395        root_name.to_owned()
396    }
397
398    fn as_any(&self) -> &dyn std::any::Any {
399        self
400    }
401}
402
403/// Render a JSON value to a compact, length-bounded string for diagnostics.
404/// Bounded by character count (char-boundary safe) rather than bytes.
405fn truncate_json(v: &serde_json::Value) -> String {
406    const MAX: usize = 200;
407    let s = v.to_string();
408    if s.chars().count() > MAX {
409        let mut out: String = s.chars().take(MAX).collect();
410        out.push('\u{2026}');
411        out
412    } else {
413        s
414    }
415}
416
417/// Build the `OpenAPI` content object for a request body schema variant.
418fn build_request_body_content(
419    schema: &operation_builder::RequestBodySchema,
420) -> utoipa::openapi::content::Content {
421    match schema {
422        operation_builder::RequestBodySchema::Ref { schema_name } => ContentBuilder::new()
423            .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
424            .build(),
425        operation_builder::RequestBodySchema::MultipartFile { field_name } => {
426            // Build multipart/form-data schema with a single binary file field
427            // type: object
428            // properties:
429            //   {field_name}: { type: string, format: binary }
430            // required: [ field_name ]
431            let file_schema = Schema::Object(
432                ObjectBuilder::new()
433                    .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
434                    .format(Some(SchemaFormat::Custom("binary".into())))
435                    .build(),
436            );
437            let obj = ObjectBuilder::new()
438                .property(field_name.clone(), file_schema)
439                .required(field_name.clone());
440            ContentBuilder::new()
441                .schema(Some(Schema::Object(obj.build())))
442                .build()
443        }
444        operation_builder::RequestBodySchema::Binary => {
445            // Represent raw binary body as type string, format binary.
446            // This is used for application/octet-stream and similar raw binary content.
447            let schema = Schema::Object(
448                ObjectBuilder::new()
449                    .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
450                    .format(Some(SchemaFormat::Custom("binary".into())))
451                    .build(),
452            );
453            ContentBuilder::new().schema(Some(schema)).build()
454        }
455        operation_builder::RequestBodySchema::InlineObject => {
456            // Preserve previous behavior for inline object bodies
457            ContentBuilder::new()
458                .schema(Some(Schema::Object(ObjectBuilder::new().build())))
459                .build()
460        }
461    }
462}
463
464/// Build the response body schema for a [`operation_builder::ResponseSchema`].
465///
466/// `None` — a JSON response with no declared schema — yields a free-form
467/// object, preserving the previous behaviour.
468fn build_response_schema(schema: Option<&operation_builder::ResponseSchema>) -> RefOr<Schema> {
469    match schema {
470        Some(operation_builder::ResponseSchema::Ref { schema_name }) => {
471            RefOr::Ref(Ref::from_schema_name(schema_name.clone()))
472        }
473        // Top-level arrays are emitted INLINE, with only the item type
474        // registered as a named component. Naming the array itself would use
475        // utoipa's `Vec` (generics are stripped from `ToSchema::name()`), so
476        // every list endpoint in the process would fight over one component.
477        Some(operation_builder::ResponseSchema::Array { items_schema_name }) => {
478            RefOr::T(Schema::Array(
479                ArrayBuilder::new()
480                    .items(RefOr::Ref(Ref::from_schema_name(items_schema_name.clone())))
481                    .build(),
482            ))
483        }
484        None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
485    }
486}
487
488/// Walk the finalized `OpenAPI` document and warn about dangling `$ref` targets.
489///
490/// Scans the entire document (operations, request bodies, responses, and schemas)
491/// so that `$ref`s emitted outside `components.schemas` are also caught.
492fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
493    for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
494        tracing::warn!(
495            schema = %ref_name,
496            "Dangling $ref: schema '{}' is referenced but not registered. \
497             Add an explicit `ensure_schema::<T>(registry)` call.",
498            ref_name,
499        );
500    }
501}
502
503/// Serialize the full `OpenAPI` document to JSON, collect every
504/// `#/components/schemas/{name}` reference, and return those not defined
505/// in `components.schemas`.
506fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
507    let value = match serde_json::to_value(openapi) {
508        Ok(v) => v,
509        Err(err) => {
510            tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
511            return Vec::new();
512        }
513    };
514
515    let mut all_refs = HashSet::new();
516    collect_refs_from_json(&value, &mut all_refs);
517
518    // Defined schema names live under components.schemas keys
519    let defined: HashSet<&str> = value
520        .pointer("/components/schemas")
521        .and_then(|v| v.as_object())
522        .map(|obj| obj.keys().map(String::as_str).collect())
523        .unwrap_or_default();
524
525    all_refs
526        .into_iter()
527        .filter(|name| !defined.contains(name.as_str()))
528        .collect()
529}
530
531/// Recursively extract `#/components/schemas/{name}` targets from a JSON value.
532fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
533    match value {
534        serde_json::Value::Object(map) => {
535            if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
536                && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
537            {
538                refs.insert(name.to_owned());
539            }
540            for v in map.values() {
541                collect_refs_from_json(v, refs);
542            }
543        }
544        serde_json::Value::Array(arr) => {
545            for v in arr {
546                collect_refs_from_json(v, refs);
547            }
548        }
549        _ => {}
550    }
551}
552
553#[cfg(test)]
554#[cfg_attr(coverage_nightly, coverage(off))]
555mod tests {
556    use super::*;
557    use crate::api::operation_builder::{
558        OperationSpec, ParamLocation, ParamSpec, ResponseSchema, ResponseSpec, VendorExtensions,
559    };
560    use http::Method;
561
562    /// Minimal `OperationSpec` carrying a single 200 response with `schema`.
563    fn spec_with_response(
564        path: &str,
565        handler: &str,
566        schema: Option<ResponseSchema>,
567    ) -> OperationSpec {
568        OperationSpec {
569            method: Method::GET,
570            path: path.to_owned(),
571            operation_id: Some(handler.to_owned()),
572            summary: None,
573            description: None,
574            tags: vec![],
575            params: vec![],
576            request_body: None,
577            responses: vec![ResponseSpec {
578                status: 200,
579                content_type: "application/json",
580                description: "OK".to_owned(),
581                schema,
582            }],
583            handler_id: handler.to_owned(),
584            authenticated: false,
585            is_public: false,
586            rate_limit: None,
587            allowed_request_content_types: None,
588            vendor_extensions: VendorExtensions::default(),
589            license_requirement: None,
590        }
591    }
592
593    /// The 200 response schema for `path`, as JSON.
594    fn response_schema_json(doc: &serde_json::Value, path: &str) -> serde_json::Value {
595        doc["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
596            .clone()
597    }
598
599    fn test_info() -> OpenApiInfo {
600        OpenApiInfo {
601            title: "T".to_owned(),
602            version: "1".to_owned(),
603            description: None,
604            servers: Vec::new(),
605        }
606    }
607
608    #[test]
609    fn test_registry_creation() {
610        let registry = OpenApiRegistryImpl::new();
611        assert_eq!(registry.operation_specs.len(), 0);
612        assert_eq!(registry.components_registry.load().len(), 0);
613    }
614
615    #[test]
616    fn test_register_operation() {
617        let registry = OpenApiRegistryImpl::new();
618        let spec = OperationSpec {
619            method: Method::GET,
620            path: "/test".to_owned(),
621            operation_id: Some("test_op".to_owned()),
622            summary: Some("Test operation".to_owned()),
623            description: None,
624            tags: vec![],
625            params: vec![],
626            request_body: None,
627            responses: vec![ResponseSpec {
628                status: 200,
629                content_type: "application/json",
630                description: "Success".to_owned(),
631                schema: None,
632            }],
633            handler_id: "get_test".to_owned(),
634            authenticated: false,
635            is_public: false,
636            rate_limit: None,
637            allowed_request_content_types: None,
638            vendor_extensions: VendorExtensions::default(),
639            license_requirement: None,
640        };
641
642        registry.register_operation(&spec);
643        assert_eq!(registry.operation_specs.len(), 1);
644    }
645
646    #[test]
647    fn test_build_empty_openapi() {
648        let registry = OpenApiRegistryImpl::new();
649        let info = OpenApiInfo {
650            title: "Test API".to_owned(),
651            version: "1.0.0".to_owned(),
652            description: Some("Test API Description".to_owned()),
653            servers: Vec::new(),
654        };
655        let doc = registry.build_openapi(&info).unwrap();
656        let json = serde_json::to_value(&doc).unwrap();
657
658        // Verify it's valid OpenAPI document structure
659        assert!(json.get("openapi").is_some());
660        assert!(json.get("info").is_some());
661        assert!(json.get("paths").is_some());
662
663        // Verify info section
664        let openapi_info = json.get("info").unwrap();
665        assert_eq!(openapi_info.get("title").unwrap(), "Test API");
666        assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
667        assert_eq!(
668            openapi_info.get("description").unwrap(),
669            "Test API Description"
670        );
671    }
672
673    #[test]
674    fn test_build_openapi_with_operation() {
675        let registry = OpenApiRegistryImpl::new();
676        let spec = OperationSpec {
677            method: Method::GET,
678            path: "/users/{id}".to_owned(),
679            operation_id: Some("get_user".to_owned()),
680            summary: Some("Get user by ID".to_owned()),
681            description: Some("Retrieves a user by their ID".to_owned()),
682            tags: vec!["users".to_owned()],
683            params: vec![ParamSpec {
684                name: "id".to_owned(),
685                location: ParamLocation::Path,
686                required: true,
687                description: Some("User ID".to_owned()),
688                param_type: "string".to_owned(),
689            }],
690            request_body: None,
691            responses: vec![ResponseSpec {
692                status: 200,
693                content_type: "application/json",
694                description: "User found".to_owned(),
695                schema: None,
696            }],
697            handler_id: "get_users_id".to_owned(),
698            authenticated: false,
699            is_public: false,
700            rate_limit: None,
701            allowed_request_content_types: None,
702            vendor_extensions: VendorExtensions::default(),
703            license_requirement: None,
704        };
705
706        registry.register_operation(&spec);
707        let info = OpenApiInfo::default();
708        let doc = registry.build_openapi(&info).unwrap();
709        let json = serde_json::to_value(&doc).unwrap();
710
711        // Verify path exists
712        let paths = json.get("paths").unwrap();
713        assert!(paths.get("/users/{id}").is_some());
714
715        // Verify operation details
716        let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
717        assert_eq!(get_op.get("operationId").unwrap(), "get_user");
718        assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
719    }
720
721    #[test]
722    fn test_ensure_schema_raw() {
723        let registry = OpenApiRegistryImpl::new();
724        let schema = Schema::Object(ObjectBuilder::new().build());
725        let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
726
727        let name = registry.ensure_schema_raw("TestSchema", schemas);
728        assert_eq!(name, "TestSchema");
729        assert_eq!(registry.components_registry.load().len(), 1);
730    }
731
732    #[test]
733    fn test_build_openapi_with_binary_request() {
734        use crate::api::operation_builder::RequestBodySchema;
735
736        let registry = OpenApiRegistryImpl::new();
737        let spec = OperationSpec {
738            method: Method::POST,
739            path: "/files/v1/upload".to_owned(),
740            operation_id: Some("upload_file".to_owned()),
741            summary: Some("Upload a file".to_owned()),
742            description: Some("Upload raw binary file".to_owned()),
743            tags: vec!["upload".to_owned()],
744            params: vec![],
745            request_body: Some(crate::api::operation_builder::RequestBodySpec {
746                content_type: "application/octet-stream",
747                description: Some("Raw file bytes".to_owned()),
748                schema: RequestBodySchema::Binary,
749                required: true,
750            }),
751            responses: vec![ResponseSpec {
752                status: 200,
753                content_type: "application/json",
754                description: "Upload successful".to_owned(),
755                schema: None,
756            }],
757            handler_id: "post_upload".to_owned(),
758            authenticated: false,
759            is_public: false,
760            rate_limit: None,
761            allowed_request_content_types: Some(vec!["application/octet-stream"]),
762            vendor_extensions: VendorExtensions::default(),
763            license_requirement: None,
764        };
765
766        registry.register_operation(&spec);
767        let info = OpenApiInfo::default();
768        let doc = registry.build_openapi(&info).unwrap();
769        let json = serde_json::to_value(&doc).unwrap();
770
771        // Verify path exists
772        let paths = json.get("paths").unwrap();
773        assert!(paths.get("/files/v1/upload").is_some());
774
775        // Verify request body has application/octet-stream with binary schema
776        let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
777        let request_body = post_op.get("requestBody").unwrap();
778        let content = request_body.get("content").unwrap();
779        let octet_stream = content
780            .get("application/octet-stream")
781            .expect("application/octet-stream content type should exist");
782
783        // Verify schema is type: string, format: binary
784        let schema = octet_stream.get("schema").unwrap();
785        assert_eq!(schema.get("type").unwrap(), "string");
786        assert_eq!(schema.get("format").unwrap(), "binary");
787
788        // Verify required flag
789        assert_eq!(request_body.get("required").unwrap(), true);
790    }
791
792    #[test]
793    fn test_build_openapi_with_pagination() {
794        let registry = OpenApiRegistryImpl::new();
795
796        let mut filter: operation_builder::ODataPagination<
797            std::collections::BTreeMap<String, Vec<String>>,
798        > = operation_builder::ODataPagination::default();
799        filter.allowed_fields.insert(
800            "name".to_owned(),
801            vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
802                .into_iter()
803                .map(String::from)
804                .collect(),
805        );
806        filter.allowed_fields.insert(
807            "age".to_owned(),
808            vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
809                .into_iter()
810                .map(String::from)
811                .collect(),
812        );
813
814        let mut order_by: operation_builder::ODataPagination<Vec<String>> =
815            operation_builder::ODataPagination::default();
816        order_by.allowed_fields.push("name asc".to_owned());
817        order_by.allowed_fields.push("name desc".to_owned());
818        order_by.allowed_fields.push("age asc".to_owned());
819        order_by.allowed_fields.push("age desc".to_owned());
820
821        let mut spec = OperationSpec {
822            method: Method::GET,
823            path: "/test".to_owned(),
824            operation_id: Some("test_op".to_owned()),
825            summary: Some("Test".to_owned()),
826            description: None,
827            tags: vec![],
828            params: vec![],
829            request_body: None,
830            responses: vec![ResponseSpec {
831                status: 200,
832                content_type: "application/json",
833                description: "OK".to_owned(),
834                schema: None,
835            }],
836            handler_id: "get_test".to_owned(),
837            authenticated: false,
838            is_public: false,
839            rate_limit: None,
840            allowed_request_content_types: None,
841            vendor_extensions: VendorExtensions::default(),
842            license_requirement: None,
843        };
844        spec.vendor_extensions.x_odata_filter = Some(filter);
845        spec.vendor_extensions.x_odata_orderby = Some(order_by);
846
847        registry.register_operation(&spec);
848        let info = OpenApiInfo::default();
849        let doc = registry.build_openapi(&info).unwrap();
850        let json = serde_json::to_value(&doc).unwrap();
851
852        let paths = json.get("paths").unwrap();
853        let op = paths.get("/test").unwrap().get("get").unwrap();
854
855        let filter_ext = op
856            .get("x-odata-filter")
857            .expect("x-odata-filter should be present");
858
859        let allowed_fields = filter_ext.get("allowedFields").unwrap();
860        assert!(allowed_fields.get("name").is_some());
861        assert!(allowed_fields.get("age").is_some());
862
863        let order_ext = op
864            .get("x-odata-orderby")
865            .expect("x-odata-orderby should be present");
866
867        let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
868        assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
869        assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
870    }
871
872    /// Helper: build a minimal `OpenAPI` doc with the given component schemas.
873    fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
874        let mut components = ComponentsBuilder::new();
875        for (name, schema) in schemas {
876            components = components.schema(name, schema);
877        }
878        OpenApiBuilder::new()
879            .components(Some(components.build()))
880            .build()
881    }
882
883    #[test]
884    fn test_dangling_refs_detects_missing_in_components() {
885        let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
886        // Register "Foo" with a $ref to "Bar" which is NOT registered
887        let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
888            "type": "object",
889            "properties": {
890                "bar": { "$ref": "#/components/schemas/Bar" }
891            }
892        }))
893        .unwrap();
894        schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
895
896        let openapi = build_test_openapi(schemas);
897        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
898        assert_eq!(dangling, vec!["Bar".to_owned()]);
899    }
900
901    #[test]
902    fn test_dangling_refs_no_false_positives() {
903        let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
904        // Register "Bar"
905        let bar_schema = Schema::Object(ObjectBuilder::new().build());
906        schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
907
908        // Register "Foo" referencing "Bar"
909        let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
910            "type": "object",
911            "properties": {
912                "bar": { "$ref": "#/components/schemas/Bar" }
913            }
914        }))
915        .unwrap();
916        schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
917
918        let openapi = build_test_openapi(schemas);
919        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
920        assert!(
921            dangling.is_empty(),
922            "Expected no dangling refs but got: {dangling:?}"
923        );
924    }
925
926    #[test]
927    fn test_dangling_refs_detects_missing_in_operations() {
928        // Build an OpenAPI doc with a response $ref to "MissingDto" but no
929        // matching component schema — simulates the scenario CodeRabbit flagged.
930        let openapi_json = serde_json::json!({
931            "openapi": "3.1.0",
932            "info": { "title": "test", "version": "0.1.0" },
933            "paths": {
934                "/items": {
935                    "get": {
936                        "responses": {
937                            "200": {
938                                "description": "OK",
939                                "content": {
940                                    "application/json": {
941                                        "schema": { "$ref": "#/components/schemas/MissingDto" }
942                                    }
943                                }
944                            }
945                        }
946                    }
947                }
948            },
949            "components": {
950                "schemas": {}
951            }
952        });
953        let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
954        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
955        assert_eq!(dangling, vec!["MissingDto".to_owned()]);
956    }
957
958    // --- array responses -------------------------------------------------
959    //
960    // A top-level array must be emitted inline, referencing the item type,
961    // and must NOT create a component of its own. utoipa names every `Vec<T>`
962    // `Vec`, so a named array component makes all list endpoints collide.
963
964    #[test]
965    fn array_response_emits_inline_array_referencing_item() {
966        let registry = OpenApiRegistryImpl::new();
967        registry.register_operation(&spec_with_response(
968            "/gears",
969            "list_gears",
970            Some(ResponseSchema::Array {
971                items_schema_name: "GearDto".to_owned(),
972            }),
973        ));
974
975        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
976        let schema = response_schema_json(&doc, "/gears");
977
978        assert_eq!(schema["type"], "array");
979        assert_eq!(schema["items"]["$ref"], "#/components/schemas/GearDto");
980        // The array itself is not a component.
981        assert!(schema.get("$ref").is_none());
982        assert!(doc["components"]["schemas"].get("Vec").is_none());
983    }
984
985    #[test]
986    fn ref_response_still_emits_plain_ref() {
987        let registry = OpenApiRegistryImpl::new();
988        registry.register_operation(&spec_with_response(
989            "/gear",
990            "get_gear",
991            Some(ResponseSchema::Ref {
992                schema_name: "GearDto".to_owned(),
993            }),
994        ));
995
996        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
997        let schema = response_schema_json(&doc, "/gear");
998
999        assert_eq!(schema["$ref"], "#/components/schemas/GearDto");
1000        assert!(schema.get("type").is_none());
1001    }
1002
1003    #[test]
1004    fn schemaless_json_response_still_emits_free_form_object() {
1005        let registry = OpenApiRegistryImpl::new();
1006        registry.register_operation(&spec_with_response("/any", "any_op", None));
1007
1008        let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1009        let schema = response_schema_json(&doc, "/any");
1010
1011        assert!(schema.get("$ref").is_none());
1012        assert_ne!(schema["type"], "array");
1013    }
1014
1015    /// The regression this whole change exists for: two list endpoints
1016    /// returning different item types used to both register a component named
1017    /// `Vec`, silently clobbering each other (and, after M-14, panicking).
1018    #[test]
1019    fn two_distinct_array_responses_do_not_collide() {
1020        #[derive(utoipa::ToSchema)]
1021        #[allow(dead_code)]
1022        struct AlphaDto {
1023            alpha: String,
1024        }
1025        #[derive(utoipa::ToSchema)]
1026        #[allow(dead_code)]
1027        struct BetaDto {
1028            beta: i32,
1029        }
1030
1031        let registry = OpenApiRegistryImpl::new();
1032        // Registering the ITEM types is what the array builder method does.
1033        let a = ensure_schema::<AlphaDto>(&registry);
1034        let b = ensure_schema::<BetaDto>(&registry);
1035        assert_eq!((a.as_str(), b.as_str()), ("AlphaDto", "BetaDto"));
1036
1037        registry.register_operation(&spec_with_response(
1038            "/alphas",
1039            "list_alphas",
1040            Some(ResponseSchema::Array {
1041                items_schema_name: a,
1042            }),
1043        ));
1044        registry.register_operation(&spec_with_response(
1045            "/betas",
1046            "list_betas",
1047            Some(ResponseSchema::Array {
1048                items_schema_name: b,
1049            }),
1050        ));
1051
1052        let openapi = registry.build_openapi(&test_info()).unwrap();
1053        assert!(
1054            collect_all_dangling_refs_in_openapi(&openapi).is_empty(),
1055            "array item refs must point at registered components"
1056        );
1057
1058        let doc = serde_json::to_value(&openapi).unwrap();
1059        let schemas = &doc["components"]["schemas"];
1060        assert!(schemas.get("AlphaDto").is_some());
1061        assert!(schemas.get("BetaDto").is_some());
1062        assert!(schemas.get("Vec").is_none());
1063        assert_eq!(
1064            response_schema_json(&doc, "/alphas")["items"]["$ref"],
1065            "#/components/schemas/AlphaDto"
1066        );
1067        assert_eq!(
1068            response_schema_json(&doc, "/betas")["items"]["$ref"],
1069            "#/components/schemas/BetaDto"
1070        );
1071    }
1072
1073    #[test]
1074    #[should_panic(expected = "would register the component name `Vec`")]
1075    fn ensure_schema_rejects_vec_directly() {
1076        #[derive(utoipa::ToSchema)]
1077        #[allow(dead_code)]
1078        struct ItemDto {
1079            x: u8,
1080        }
1081        let registry = OpenApiRegistryImpl::new();
1082        let _ = ensure_schema::<Vec<ItemDto>>(&registry);
1083    }
1084
1085    #[test]
1086    #[should_panic(expected = "OpenAPI schema name collision")]
1087    fn ensure_schema_raw_panics_on_conflicting_definition() {
1088        let registry = OpenApiRegistryImpl::new();
1089        registry.ensure_schema_raw(
1090            "Dup",
1091            vec![("Dup".to_owned(), RefOr::Ref(Ref::from_schema_name("First")))],
1092        );
1093        registry.ensure_schema_raw(
1094            "Dup",
1095            vec![(
1096                "Dup".to_owned(),
1097                RefOr::Ref(Ref::from_schema_name("Second")),
1098            )],
1099        );
1100    }
1101
1102    #[test]
1103    fn ensure_schema_raw_allows_identical_reregistration() {
1104        let registry = OpenApiRegistryImpl::new();
1105        let entry = || {
1106            vec![(
1107                "Same".to_owned(),
1108                RefOr::Ref(Ref::from_schema_name("Target")),
1109            )]
1110        };
1111        registry.ensure_schema_raw("Same", entry());
1112        registry.ensure_schema_raw("Same", entry());
1113        assert_eq!(registry.components_registry.load().len(), 1);
1114    }
1115}