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