1use 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
27type SchemaCollection = Vec<(String, RefOr<Schema>)>;
29
30#[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
48pub trait OpenApiRegistry: Send + Sync {
50 fn register_operation(&self, spec: &operation_builder::OperationSpec);
52
53 fn ensure_schema_raw(&self, name: &str, schemas: SchemaCollection) -> String;
57
58 fn as_any(&self) -> &dyn std::any::Any;
60}
61
62pub fn ensure_schema<T: utoipa::ToSchema + utoipa::PartialSchema + 'static>(
64 registry: &dyn OpenApiRegistry,
65) -> String {
66 use utoipa::PartialSchema;
67
68 let root_name = T::name().to_string();
70
71 let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
74
75 T::schemas(&mut collected);
77
78 registry.ensure_schema_raw(&root_name, collected)
80}
81
82pub struct OpenApiRegistryImpl {
84 pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
86 pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
89}
90
91impl OpenApiRegistryImpl {
92 #[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 #[allow(unknown_lints, de0205_operation_builder)]
109 pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
110 use http::Method;
111
112 let op_count = self.operation_specs.len();
114 tracing::info!("Building OpenAPI: found {op_count} registered operations");
115
116 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 let mut ext = utoipa::openapi::extensions::Extensions::default();
131
132 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 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 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 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 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 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 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 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 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 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 _ => HttpMethod::Get,
310 };
311
312 let item = PathItemBuilder::new().operation(method, op.build()).build();
313 let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
315 paths = paths.path(openapi_path, item);
316 }
317
318 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 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 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 let current = self.components_registry.load();
380 let mut reg = (**current).clone();
381
382 for (name, schema) in schemas {
383 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; }
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
404fn 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
419fn 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 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
447fn 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 assert!(json.get("openapi").is_some());
529 assert!(json.get("info").is_some());
530 assert!(json.get("paths").is_some());
531
532 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 let paths = json.get("paths").unwrap();
582 assert!(paths.get("/users/{id}").is_some());
583
584 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 let paths = json.get("paths").unwrap();
642 assert!(paths.get("/files/v1/upload").is_some());
643
644 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 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 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 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 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 let bar_schema = Schema::Object(ObjectBuilder::new().build());
775 schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
776
777 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 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}