Skip to main content

toolkit/api/
openapi_registry.rs

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