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