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 server::Server,
25};
26
27use crate::api::operation_builder;
28use toolkit_canonical_errors::problem;
29
30type SchemaCollection = Vec<(String, RefOr<Schema>)>;
32
33#[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
53pub trait OpenApiRegistry: Send + Sync {
55 fn register_operation(&self, spec: &operation_builder::OperationSpec);
57
58 fn ensure_schema_raw(&self, name: &str, schemas: SchemaCollection) -> String;
62
63 fn as_any(&self) -> &dyn std::any::Any;
65}
66
67pub fn ensure_schema<T: utoipa::ToSchema + utoipa::PartialSchema + 'static>(
69 registry: &dyn OpenApiRegistry,
70) -> String {
71 use utoipa::PartialSchema;
72
73 let root_name = T::name().to_string();
75
76 let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
79
80 T::schemas(&mut collected);
82
83 registry.ensure_schema_raw(&root_name, collected)
85}
86
87pub struct OpenApiRegistryImpl {
89 pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
91 pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
94}
95
96impl OpenApiRegistryImpl {
97 #[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 #[allow(unknown_lints, de0205_operation_builder)]
114 pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
115 use http::Method;
116
117 let op_count = self.operation_specs.len();
119 tracing::info!("Building OpenAPI: found {op_count} registered operations");
120
121 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 let mut ext = utoipa::openapi::extensions::Extensions::default();
136
137 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 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 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 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 let mut responses = ResponsesBuilder::new();
211 for r in &spec.responses {
212 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 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 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 _ => HttpMethod::Get,
277 };
278
279 let item = PathItemBuilder::new().operation(method, op.build()).build();
280 let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
282 paths = paths.path(openapi_path, item);
283 }
284
285 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 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 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 let current = self.components_registry.load();
356 let mut reg = (**current).clone();
357
358 for (name, schema) in schemas {
359 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; }
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
380fn 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 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 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 ContentBuilder::new()
421 .schema(Some(Schema::Object(ObjectBuilder::new().build())))
422 .build()
423 }
424 }
425}
426
427fn 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
442fn 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 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
470fn 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 assert!(json.get("openapi").is_some());
553 assert!(json.get("info").is_some());
554 assert!(json.get("paths").is_some());
555
556 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 let paths = json.get("paths").unwrap();
606 assert!(paths.get("/users/{id}").is_some());
607
608 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 let paths = json.get("paths").unwrap();
666 assert!(paths.get("/files/v1/upload").is_some());
667
668 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 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 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 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 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 let bar_schema = Schema::Object(ObjectBuilder::new().build());
799 schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
800
801 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 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}