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