Skip to main content

modkit/api/
openapi_registry.rs

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