Skip to main content

modkit/api/
openapi_registry.rs

1// Updated: 2026-04-28 by Constructor Tech
2//! `OpenAPI` registry for schema and operation management
3//!
4//! This module 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::{ComponentsBuilder, ObjectBuilder, Schema, SchemaFormat, SchemaType},
23    security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
24};
25
26use crate::api::{operation_builder, problem};
27
28/// Type alias for schema collections used in API operations.
29type SchemaCollection = Vec<(String, RefOr<Schema>)>;
30
31/// `OpenAPI` document metadata (title, version, description)
32#[derive(Debug, Clone)]
33pub struct OpenApiInfo {
34    pub title: String,
35    pub version: String,
36    pub description: Option<String>,
37}
38
39impl Default for OpenApiInfo {
40    fn default() -> Self {
41        Self {
42            title: "API Documentation".to_owned(),
43            version: "0.1.0".to_owned(),
44            description: None,
45        }
46    }
47}
48
49/// `OpenAPI` registry trait for operation and schema registration
50pub trait OpenApiRegistry: Send + Sync {
51    /// Register an API operation specification
52    fn register_operation(&self, spec: &operation_builder::OperationSpec);
53
54    /// Ensure schema for a type (including transitive dependencies) is registered
55    /// under components and return the canonical component name for `$ref`.
56    /// This is a type-erased version for dyn compatibility.
57    fn ensure_schema_raw(&self, name: &str, schemas: SchemaCollection) -> String;
58
59    /// Downcast support for accessing the concrete implementation if needed.
60    fn as_any(&self) -> &dyn std::any::Any;
61}
62
63/// Helper function to call `ensure_schema` with proper type information
64pub fn ensure_schema<T: utoipa::ToSchema + utoipa::PartialSchema + 'static>(
65    registry: &dyn OpenApiRegistry,
66) -> String {
67    use utoipa::PartialSchema;
68
69    // 1) Canonical component name for T as seen by utoipa
70    let root_name = T::name().to_string();
71
72    // 2) Always insert T's own schema first (actual object, not a ref)
73    //    This avoids self-referential components.
74    let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
75
76    // 3) Collect and append all referenced schemas (dependencies) of T
77    T::schemas(&mut collected);
78
79    // 4) Pass to registry for insertion
80    registry.ensure_schema_raw(&root_name, collected)
81}
82
83/// Implementation of `OpenAPI` registry with lock-free data structures
84pub struct OpenApiRegistryImpl {
85    /// Store operation specs keyed by "METHOD:path"
86    pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
87    /// Store schema components using arc-swap for lock-free reads
88    /// `BTreeMap` ensures deterministic ordering of schemas in the `OpenAPI` document
89    pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
90}
91
92impl OpenApiRegistryImpl {
93    /// Create a new empty registry
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            operation_specs: DashMap::new(),
98            components_registry: ArcSwap::from_pointee(BTreeMap::new()),
99        }
100    }
101
102    /// Build `OpenAPI` specification from registered operations and components.
103    ///
104    /// # Arguments
105    /// * `info` - `OpenAPI` document metadata (title, version, description)
106    ///
107    /// # Errors
108    /// Returns an error if the `OpenAPI` specification cannot be built.
109    #[allow(unknown_lints, de0205_operation_builder)]
110    pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
111        use http::Method;
112
113        // Log operation count for visibility
114        let op_count = self.operation_specs.len();
115        tracing::info!("Building OpenAPI: found {op_count} registered operations");
116
117        // 1) Paths
118        let mut paths = PathsBuilder::new();
119
120        for spec in self.operation_specs.iter().map(|e| e.value().clone()) {
121            let mut op = UOperationBuilder::new()
122                .operation_id(spec.operation_id.clone().or(Some(spec.handler_id.clone())))
123                .summary(spec.summary.clone())
124                .description(spec.description.clone());
125
126            for tag in &spec.tags {
127                op = op.tag(tag.clone());
128            }
129
130            // Vendor extensions
131            let mut ext = utoipa::openapi::extensions::Extensions::default();
132
133            // Rate limit
134            if let Some(rl) = spec.rate_limit.as_ref() {
135                ext.insert("x-rate-limit-rps".to_owned(), serde_json::json!(rl.rps));
136                ext.insert("x-rate-limit-burst".to_owned(), serde_json::json!(rl.burst));
137                ext.insert(
138                    "x-in-flight-limit".to_owned(),
139                    serde_json::json!(rl.in_flight),
140                );
141            }
142
143            // Pagination
144            if let Some(pagination) = spec.vendor_extensions.x_odata_filter.as_ref()
145                && let Ok(value) = serde_json::to_value(pagination)
146            {
147                ext.insert("x-odata-filter".to_owned(), value);
148            }
149            if let Some(pagination) = spec.vendor_extensions.x_odata_orderby.as_ref()
150                && let Ok(value) = serde_json::to_value(pagination)
151            {
152                ext.insert("x-odata-orderby".to_owned(), value);
153            }
154
155            if !ext.is_empty() {
156                op = op.extensions(Some(ext));
157            }
158
159            // Parameters
160            for p in &spec.params {
161                let in_ = match p.location {
162                    operation_builder::ParamLocation::Path => ParameterIn::Path,
163                    operation_builder::ParamLocation::Query => ParameterIn::Query,
164                    operation_builder::ParamLocation::Header => ParameterIn::Header,
165                    operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
166                };
167                let required =
168                    if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
169                        Required::True
170                    } else {
171                        Required::False
172                    };
173
174                let schema_type = match p.param_type.as_str() {
175                    "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
176                    "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
177                    "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
178                    _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
179                };
180                let schema = Schema::Object(ObjectBuilder::new().schema_type(schema_type).build());
181
182                let param = ParameterBuilder::new()
183                    .name(&p.name)
184                    .parameter_in(in_)
185                    .required(required)
186                    .description(p.description.clone())
187                    .schema(Some(schema))
188                    .build();
189
190                op = op.parameter(param);
191            }
192
193            // Request body
194            if let Some(rb) = &spec.request_body {
195                let content = match &rb.schema {
196                    operation_builder::RequestBodySchema::Ref { schema_name } => {
197                        ContentBuilder::new()
198                            .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
199                            .build()
200                    }
201                    operation_builder::RequestBodySchema::MultipartFile { field_name } => {
202                        // Build multipart/form-data schema with a single binary file field
203                        // type: object
204                        // properties:
205                        //   {field_name}: { type: string, format: binary }
206                        // required: [ field_name ]
207                        let file_schema = Schema::Object(
208                            ObjectBuilder::new()
209                                .schema_type(SchemaType::Type(
210                                    utoipa::openapi::schema::Type::String,
211                                ))
212                                .format(Some(SchemaFormat::Custom("binary".into())))
213                                .build(),
214                        );
215                        let obj = ObjectBuilder::new()
216                            .property(field_name.clone(), file_schema)
217                            .required(field_name.clone());
218                        let schema = Schema::Object(obj.build());
219                        ContentBuilder::new().schema(Some(schema)).build()
220                    }
221                    operation_builder::RequestBodySchema::Binary => {
222                        // Represent raw binary body as type string, format binary.
223                        // This is used for application/octet-stream and similar raw binary content.
224                        let schema = Schema::Object(
225                            ObjectBuilder::new()
226                                .schema_type(SchemaType::Type(
227                                    utoipa::openapi::schema::Type::String,
228                                ))
229                                .format(Some(SchemaFormat::Custom("binary".into())))
230                                .build(),
231                        );
232
233                        ContentBuilder::new().schema(Some(schema)).build()
234                    }
235                    operation_builder::RequestBodySchema::InlineObject => {
236                        // Preserve previous behavior for inline object bodies
237                        ContentBuilder::new()
238                            .schema(Some(Schema::Object(ObjectBuilder::new().build())))
239                            .build()
240                    }
241                };
242                let mut rbld = RequestBodyBuilder::new()
243                    .description(rb.description.clone())
244                    .content(rb.content_type.to_owned(), content);
245                if rb.required {
246                    rbld = rbld.required(Some(Required::True));
247                }
248                op = op.request_body(Some(rbld.build()));
249            }
250
251            // Responses
252            let mut responses = ResponsesBuilder::new();
253            for r in &spec.responses {
254                // Body-less response (e.g. 204 No Content) is signalled by an
255                // empty `content_type`. Emit just `description` — attaching a
256                // `content` block would make code-generators expect a body.
257                if r.content_type.is_empty() {
258                    let resp = ResponseBuilder::new().description(&r.description).build();
259                    responses = responses.response(r.status.to_string(), resp);
260                    continue;
261                }
262                let is_json_like = r.content_type == "application/json"
263                    || r.content_type == problem::APPLICATION_PROBLEM_JSON
264                    || r.content_type == "text/event-stream";
265                let resp = if is_json_like {
266                    if let Some(name) = &r.schema_name {
267                        // Manually build content to preserve the correct content type
268                        let content = ContentBuilder::new()
269                            .schema(Some(RefOr::Ref(Ref::new(format!(
270                                "#/components/schemas/{name}"
271                            )))))
272                            .build();
273                        ResponseBuilder::new()
274                            .description(&r.description)
275                            .content(r.content_type, content)
276                            .build()
277                    } else {
278                        let content = ContentBuilder::new()
279                            .schema(Some(Schema::Object(ObjectBuilder::new().build())))
280                            .build();
281                        ResponseBuilder::new()
282                            .description(&r.description)
283                            .content(r.content_type, content)
284                            .build()
285                    }
286                } else {
287                    let schema = Schema::Object(
288                        ObjectBuilder::new()
289                            .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
290                            .format(Some(SchemaFormat::Custom(r.content_type.into())))
291                            .build(),
292                    );
293                    let content = ContentBuilder::new().schema(Some(schema)).build();
294                    ResponseBuilder::new()
295                        .description(&r.description)
296                        .content(r.content_type, content)
297                        .build()
298                };
299                responses = responses.response(r.status.to_string(), resp);
300            }
301            op = op.responses(responses.build());
302
303            // Add security requirement if operation requires authentication
304            if spec.authenticated {
305                let sec_req = utoipa::openapi::security::SecurityRequirement::new(
306                    "bearerAuth",
307                    Vec::<String>::new(),
308                );
309                op = op.security(sec_req);
310            }
311
312            let method = match spec.method {
313                Method::POST => HttpMethod::Post,
314                Method::PUT => HttpMethod::Put,
315                Method::DELETE => HttpMethod::Delete,
316                Method::PATCH => HttpMethod::Patch,
317                // GET and any other method default to Get
318                _ => HttpMethod::Get,
319            };
320
321            let item = PathItemBuilder::new().operation(method, op.build()).build();
322            // Convert Axum-style path to OpenAPI-style path
323            let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
324            paths = paths.path(openapi_path, item);
325        }
326
327        // 2) Components (from our registry)
328        let reg = self.components_registry.load();
329        let mut components = ComponentsBuilder::new();
330        for (name, schema) in reg.iter() {
331            components = components.schema(name.clone(), schema.clone());
332        }
333
334        // Add bearer auth security scheme
335        components = components.security_scheme(
336            "bearerAuth",
337            SecurityScheme::Http(
338                HttpBuilder::new()
339                    .scheme(HttpAuthScheme::Bearer)
340                    .bearer_format("JWT")
341                    .build(),
342            ),
343        );
344
345        // 3) Info & final OpenAPI doc
346        let openapi_info = InfoBuilder::new()
347            .title(&info.title)
348            .version(&info.version)
349            .description(info.description.clone())
350            .build();
351
352        let openapi = OpenApiBuilder::new()
353            .info(openapi_info)
354            .paths(paths.build())
355            .components(Some(components.build()))
356            .build();
357
358        warn_dangling_refs_in_openapi(&openapi);
359
360        Ok(openapi)
361    }
362}
363
364impl Default for OpenApiRegistryImpl {
365    fn default() -> Self {
366        Self::new()
367    }
368}
369
370impl OpenApiRegistry for OpenApiRegistryImpl {
371    fn register_operation(&self, spec: &operation_builder::OperationSpec) {
372        let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
373        self.operation_specs
374            .insert(operation_key.clone(), spec.clone());
375
376        tracing::debug!(
377            handler_id = %spec.handler_id,
378            method = %spec.method.as_str(),
379            path = %spec.path,
380            summary = %spec.summary.as_deref().unwrap_or("No summary"),
381            operation_key = %operation_key,
382            "Registered API operation in registry"
383        );
384    }
385
386    fn ensure_schema_raw(&self, root_name: &str, schemas: SchemaCollection) -> String {
387        // Snapshot & copy-on-write
388        let current = self.components_registry.load();
389        let mut reg = (**current).clone();
390
391        for (name, schema) in schemas {
392            // Conflict policy: identical → no-op; different → warn & override
393            if let Some(existing) = reg.get(&name) {
394                let a = serde_json::to_value(existing).ok();
395                let b = serde_json::to_value(&schema).ok();
396                if a == b {
397                    continue; // Skip identical schemas
398                }
399                tracing::warn!(%name, "Schema content conflict; overriding with latest");
400            }
401            reg.insert(name, schema);
402        }
403
404        self.components_registry.store(Arc::new(reg));
405        root_name.to_owned()
406    }
407
408    fn as_any(&self) -> &dyn std::any::Any {
409        self
410    }
411}
412
413/// Walk the finalized `OpenAPI` document and warn about dangling `$ref` targets.
414///
415/// Scans the entire document (operations, request bodies, responses, and schemas)
416/// so that `$ref`s emitted outside `components.schemas` are also caught.
417fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
418    for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
419        tracing::warn!(
420            schema = %ref_name,
421            "Dangling $ref: schema '{}' is referenced but not registered. \
422             Add an explicit `ensure_schema::<T>(registry)` call.",
423            ref_name,
424        );
425    }
426}
427
428/// Serialize the full `OpenAPI` document to JSON, collect every
429/// `#/components/schemas/{name}` reference, and return those not defined
430/// in `components.schemas`.
431fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
432    let value = match serde_json::to_value(openapi) {
433        Ok(v) => v,
434        Err(err) => {
435            tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
436            return Vec::new();
437        }
438    };
439
440    let mut all_refs = HashSet::new();
441    collect_refs_from_json(&value, &mut all_refs);
442
443    // Defined schema names live under components.schemas keys
444    let defined: HashSet<&str> = value
445        .pointer("/components/schemas")
446        .and_then(|v| v.as_object())
447        .map(|obj| obj.keys().map(String::as_str).collect())
448        .unwrap_or_default();
449
450    all_refs
451        .into_iter()
452        .filter(|name| !defined.contains(name.as_str()))
453        .collect()
454}
455
456/// Recursively extract `#/components/schemas/{name}` targets from a JSON value.
457fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
458    match value {
459        serde_json::Value::Object(map) => {
460            if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
461                && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
462            {
463                refs.insert(name.to_owned());
464            }
465            for v in map.values() {
466                collect_refs_from_json(v, refs);
467            }
468        }
469        serde_json::Value::Array(arr) => {
470            for v in arr {
471                collect_refs_from_json(v, refs);
472            }
473        }
474        _ => {}
475    }
476}
477
478#[cfg(test)]
479#[cfg_attr(coverage_nightly, coverage(off))]
480mod tests {
481    use super::*;
482    use crate::api::operation_builder::{
483        OperationSpec, ParamLocation, ParamSpec, ResponseSpec, VendorExtensions,
484    };
485    use http::Method;
486
487    #[test]
488    fn test_registry_creation() {
489        let registry = OpenApiRegistryImpl::new();
490        assert_eq!(registry.operation_specs.len(), 0);
491        assert_eq!(registry.components_registry.load().len(), 0);
492    }
493
494    #[test]
495    fn test_register_operation() {
496        let registry = OpenApiRegistryImpl::new();
497        let spec = OperationSpec {
498            method: Method::GET,
499            path: "/test".to_owned(),
500            operation_id: Some("test_op".to_owned()),
501            summary: Some("Test operation".to_owned()),
502            description: None,
503            tags: vec![],
504            params: vec![],
505            request_body: None,
506            responses: vec![ResponseSpec {
507                status: 200,
508                content_type: "application/json",
509                description: "Success".to_owned(),
510                schema_name: None,
511            }],
512            handler_id: "get_test".to_owned(),
513            authenticated: false,
514            is_public: false,
515            rate_limit: None,
516            allowed_request_content_types: None,
517            vendor_extensions: VendorExtensions::default(),
518            license_requirement: None,
519        };
520
521        registry.register_operation(&spec);
522        assert_eq!(registry.operation_specs.len(), 1);
523    }
524
525    #[test]
526    fn test_build_empty_openapi() {
527        let registry = OpenApiRegistryImpl::new();
528        let info = OpenApiInfo {
529            title: "Test API".to_owned(),
530            version: "1.0.0".to_owned(),
531            description: Some("Test API Description".to_owned()),
532        };
533        let doc = registry.build_openapi(&info).unwrap();
534        let json = serde_json::to_value(&doc).unwrap();
535
536        // Verify it's valid OpenAPI document structure
537        assert!(json.get("openapi").is_some());
538        assert!(json.get("info").is_some());
539        assert!(json.get("paths").is_some());
540
541        // Verify info section
542        let openapi_info = json.get("info").unwrap();
543        assert_eq!(openapi_info.get("title").unwrap(), "Test API");
544        assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
545        assert_eq!(
546            openapi_info.get("description").unwrap(),
547            "Test API Description"
548        );
549    }
550
551    #[test]
552    fn test_build_openapi_with_operation() {
553        let registry = OpenApiRegistryImpl::new();
554        let spec = OperationSpec {
555            method: Method::GET,
556            path: "/users/{id}".to_owned(),
557            operation_id: Some("get_user".to_owned()),
558            summary: Some("Get user by ID".to_owned()),
559            description: Some("Retrieves a user by their ID".to_owned()),
560            tags: vec!["users".to_owned()],
561            params: vec![ParamSpec {
562                name: "id".to_owned(),
563                location: ParamLocation::Path,
564                required: true,
565                description: Some("User ID".to_owned()),
566                param_type: "string".to_owned(),
567            }],
568            request_body: None,
569            responses: vec![ResponseSpec {
570                status: 200,
571                content_type: "application/json",
572                description: "User found".to_owned(),
573                schema_name: None,
574            }],
575            handler_id: "get_users_id".to_owned(),
576            authenticated: false,
577            is_public: false,
578            rate_limit: None,
579            allowed_request_content_types: None,
580            vendor_extensions: VendorExtensions::default(),
581            license_requirement: None,
582        };
583
584        registry.register_operation(&spec);
585        let info = OpenApiInfo::default();
586        let doc = registry.build_openapi(&info).unwrap();
587        let json = serde_json::to_value(&doc).unwrap();
588
589        // Verify path exists
590        let paths = json.get("paths").unwrap();
591        assert!(paths.get("/users/{id}").is_some());
592
593        // Verify operation details
594        let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
595        assert_eq!(get_op.get("operationId").unwrap(), "get_user");
596        assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
597    }
598
599    #[test]
600    fn test_ensure_schema_raw() {
601        let registry = OpenApiRegistryImpl::new();
602        let schema = Schema::Object(ObjectBuilder::new().build());
603        let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
604
605        let name = registry.ensure_schema_raw("TestSchema", schemas);
606        assert_eq!(name, "TestSchema");
607        assert_eq!(registry.components_registry.load().len(), 1);
608    }
609
610    #[test]
611    fn test_build_openapi_with_binary_request() {
612        use crate::api::operation_builder::RequestBodySchema;
613
614        let registry = OpenApiRegistryImpl::new();
615        let spec = OperationSpec {
616            method: Method::POST,
617            path: "/files/v1/upload".to_owned(),
618            operation_id: Some("upload_file".to_owned()),
619            summary: Some("Upload a file".to_owned()),
620            description: Some("Upload raw binary file".to_owned()),
621            tags: vec!["upload".to_owned()],
622            params: vec![],
623            request_body: Some(crate::api::operation_builder::RequestBodySpec {
624                content_type: "application/octet-stream",
625                description: Some("Raw file bytes".to_owned()),
626                schema: RequestBodySchema::Binary,
627                required: true,
628            }),
629            responses: vec![ResponseSpec {
630                status: 200,
631                content_type: "application/json",
632                description: "Upload successful".to_owned(),
633                schema_name: None,
634            }],
635            handler_id: "post_upload".to_owned(),
636            authenticated: false,
637            is_public: false,
638            rate_limit: None,
639            allowed_request_content_types: Some(vec!["application/octet-stream"]),
640            vendor_extensions: VendorExtensions::default(),
641            license_requirement: None,
642        };
643
644        registry.register_operation(&spec);
645        let info = OpenApiInfo::default();
646        let doc = registry.build_openapi(&info).unwrap();
647        let json = serde_json::to_value(&doc).unwrap();
648
649        // Verify path exists
650        let paths = json.get("paths").unwrap();
651        assert!(paths.get("/files/v1/upload").is_some());
652
653        // Verify request body has application/octet-stream with binary schema
654        let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
655        let request_body = post_op.get("requestBody").unwrap();
656        let content = request_body.get("content").unwrap();
657        let octet_stream = content
658            .get("application/octet-stream")
659            .expect("application/octet-stream content type should exist");
660
661        // Verify schema is type: string, format: binary
662        let schema = octet_stream.get("schema").unwrap();
663        assert_eq!(schema.get("type").unwrap(), "string");
664        assert_eq!(schema.get("format").unwrap(), "binary");
665
666        // Verify required flag
667        assert_eq!(request_body.get("required").unwrap(), true);
668    }
669
670    #[test]
671    fn test_build_openapi_with_pagination() {
672        let registry = OpenApiRegistryImpl::new();
673
674        let mut filter: operation_builder::ODataPagination<
675            std::collections::BTreeMap<String, Vec<String>>,
676        > = operation_builder::ODataPagination::default();
677        filter.allowed_fields.insert(
678            "name".to_owned(),
679            vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
680                .into_iter()
681                .map(String::from)
682                .collect(),
683        );
684        filter.allowed_fields.insert(
685            "age".to_owned(),
686            vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
687                .into_iter()
688                .map(String::from)
689                .collect(),
690        );
691
692        let mut order_by: operation_builder::ODataPagination<Vec<String>> =
693            operation_builder::ODataPagination::default();
694        order_by.allowed_fields.push("name asc".to_owned());
695        order_by.allowed_fields.push("name desc".to_owned());
696        order_by.allowed_fields.push("age asc".to_owned());
697        order_by.allowed_fields.push("age desc".to_owned());
698
699        let mut spec = OperationSpec {
700            method: Method::GET,
701            path: "/test".to_owned(),
702            operation_id: Some("test_op".to_owned()),
703            summary: Some("Test".to_owned()),
704            description: None,
705            tags: vec![],
706            params: vec![],
707            request_body: None,
708            responses: vec![ResponseSpec {
709                status: 200,
710                content_type: "application/json",
711                description: "OK".to_owned(),
712                schema_name: None,
713            }],
714            handler_id: "get_test".to_owned(),
715            authenticated: false,
716            is_public: false,
717            rate_limit: None,
718            allowed_request_content_types: None,
719            vendor_extensions: VendorExtensions::default(),
720            license_requirement: None,
721        };
722        spec.vendor_extensions.x_odata_filter = Some(filter);
723        spec.vendor_extensions.x_odata_orderby = Some(order_by);
724
725        registry.register_operation(&spec);
726        let info = OpenApiInfo::default();
727        let doc = registry.build_openapi(&info).unwrap();
728        let json = serde_json::to_value(&doc).unwrap();
729
730        let paths = json.get("paths").unwrap();
731        let op = paths.get("/test").unwrap().get("get").unwrap();
732
733        let filter_ext = op
734            .get("x-odata-filter")
735            .expect("x-odata-filter should be present");
736
737        let allowed_fields = filter_ext.get("allowedFields").unwrap();
738        assert!(allowed_fields.get("name").is_some());
739        assert!(allowed_fields.get("age").is_some());
740
741        let order_ext = op
742            .get("x-odata-orderby")
743            .expect("x-odata-orderby should be present");
744
745        let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
746        assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
747        assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
748    }
749
750    /// Helper: build a minimal `OpenAPI` doc with the given component schemas.
751    fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
752        let mut components = ComponentsBuilder::new();
753        for (name, schema) in schemas {
754            components = components.schema(name, schema);
755        }
756        OpenApiBuilder::new()
757            .components(Some(components.build()))
758            .build()
759    }
760
761    #[test]
762    fn test_dangling_refs_detects_missing_in_components() {
763        let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
764        // Register "Foo" with a $ref to "Bar" which is NOT registered
765        let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
766            "type": "object",
767            "properties": {
768                "bar": { "$ref": "#/components/schemas/Bar" }
769            }
770        }))
771        .unwrap();
772        schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
773
774        let openapi = build_test_openapi(schemas);
775        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
776        assert_eq!(dangling, vec!["Bar".to_owned()]);
777    }
778
779    #[test]
780    fn test_dangling_refs_no_false_positives() {
781        let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
782        // Register "Bar"
783        let bar_schema = Schema::Object(ObjectBuilder::new().build());
784        schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
785
786        // Register "Foo" referencing "Bar"
787        let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
788            "type": "object",
789            "properties": {
790                "bar": { "$ref": "#/components/schemas/Bar" }
791            }
792        }))
793        .unwrap();
794        schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
795
796        let openapi = build_test_openapi(schemas);
797        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
798        assert!(
799            dangling.is_empty(),
800            "Expected no dangling refs but got: {dangling:?}"
801        );
802    }
803
804    #[test]
805    fn test_dangling_refs_detects_missing_in_operations() {
806        // Build an OpenAPI doc with a response $ref to "MissingDto" but no
807        // matching component schema — simulates the scenario CodeRabbit flagged.
808        let openapi_json = serde_json::json!({
809            "openapi": "3.1.0",
810            "info": { "title": "test", "version": "0.1.0" },
811            "paths": {
812                "/items": {
813                    "get": {
814                        "responses": {
815                            "200": {
816                                "description": "OK",
817                                "content": {
818                                    "application/json": {
819                                        "schema": { "$ref": "#/components/schemas/MissingDto" }
820                                    }
821                                }
822                            }
823                        }
824                    }
825                }
826            },
827            "components": {
828                "schemas": {}
829            }
830        });
831        let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
832        let dangling = collect_all_dangling_refs_in_openapi(&openapi);
833        assert_eq!(dangling, vec!["MissingDto".to_owned()]);
834    }
835}