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::{ArrayBuilder, 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>(
77 registry: &dyn OpenApiRegistry,
78) -> String {
79 use utoipa::PartialSchema;
80
81 let root_name = T::name().to_string();
83
84 assert!(
91 root_name != "Vec",
92 "ensure_schema::<Vec<_>>() would register the component name `Vec`, which every other \
93 Vec<T> also resolves to. Use `OperationBuilder::json_array_response_with_schema::<Item>()` \
94 to emit an inline array with only the item type named."
95 );
96
97 let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
100
101 T::schemas(&mut collected);
103
104 registry.ensure_schema_raw(&root_name, collected)
106}
107
108pub struct OpenApiRegistryImpl {
110 pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
112 pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
115}
116
117impl OpenApiRegistryImpl {
118 #[must_use]
120 pub fn new() -> Self {
121 Self {
122 operation_specs: DashMap::new(),
123 components_registry: ArcSwap::from_pointee(BTreeMap::new()),
124 }
125 }
126
127 #[allow(unknown_lints, de0205_operation_builder)]
135 pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
136 use http::Method;
137
138 let op_count = self.operation_specs.len();
140 tracing::info!("Building OpenAPI: found {op_count} registered operations");
141
142 let mut paths = PathsBuilder::new();
144
145 for spec in self.operation_specs.iter().map(|e| e.value().clone()) {
146 let mut op = UOperationBuilder::new()
147 .operation_id(spec.operation_id.clone().or(Some(spec.handler_id.clone())))
148 .summary(spec.summary.clone())
149 .description(spec.description.clone());
150
151 for tag in &spec.tags {
152 op = op.tag(tag.clone());
153 }
154
155 let mut ext = utoipa::openapi::extensions::Extensions::default();
157
158 if let Some(rl) = spec.rate_limit.as_ref() {
160 ext.insert("x-rate-limit-rps".to_owned(), serde_json::json!(rl.rps));
161 ext.insert("x-rate-limit-burst".to_owned(), serde_json::json!(rl.burst));
162 ext.insert(
163 "x-in-flight-limit".to_owned(),
164 serde_json::json!(rl.in_flight),
165 );
166 }
167
168 if let Some(pagination) = spec.vendor_extensions.x_odata_filter.as_ref()
170 && let Ok(value) = serde_json::to_value(pagination)
171 {
172 ext.insert("x-odata-filter".to_owned(), value);
173 }
174 if let Some(pagination) = spec.vendor_extensions.x_odata_orderby.as_ref()
175 && let Ok(value) = serde_json::to_value(pagination)
176 {
177 ext.insert("x-odata-orderby".to_owned(), value);
178 }
179
180 if !ext.is_empty() {
181 op = op.extensions(Some(ext));
182 }
183
184 for p in &spec.params {
186 let in_ = match p.location {
187 operation_builder::ParamLocation::Path => ParameterIn::Path,
188 operation_builder::ParamLocation::Query => ParameterIn::Query,
189 operation_builder::ParamLocation::Header => ParameterIn::Header,
190 operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
191 };
192 let required =
193 if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
194 Required::True
195 } else {
196 Required::False
197 };
198
199 let schema_type = match p.param_type.as_str() {
200 "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
201 "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
202 "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
203 _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
204 };
205 let schema = Schema::Object(ObjectBuilder::new().schema_type(schema_type).build());
206
207 let param = ParameterBuilder::new()
208 .name(&p.name)
209 .parameter_in(in_)
210 .required(required)
211 .description(p.description.clone())
212 .schema(Some(schema))
213 .build();
214
215 op = op.parameter(param);
216 }
217
218 if let Some(rb) = &spec.request_body {
220 let content = build_request_body_content(&rb.schema);
221 let mut rbld = RequestBodyBuilder::new()
222 .description(rb.description.clone())
223 .content(rb.content_type.to_owned(), content);
224 if rb.required {
225 rbld = rbld.required(Some(Required::True));
226 }
227 op = op.request_body(Some(rbld.build()));
228 }
229
230 let mut responses = ResponsesBuilder::new();
232 for r in &spec.responses {
233 if r.content_type.is_empty() {
237 let resp = ResponseBuilder::new().description(&r.description).build();
238 responses = responses.response(r.status.to_string(), resp);
239 continue;
240 }
241 let is_json_like = r.content_type == "application/json"
242 || r.content_type == problem::APPLICATION_PROBLEM_JSON
243 || r.content_type == "text/event-stream";
244 let resp = if is_json_like {
245 let content = ContentBuilder::new()
247 .schema(Some(build_response_schema(r.schema.as_ref())))
248 .build();
249 ResponseBuilder::new()
250 .description(&r.description)
251 .content(r.content_type, content)
252 .build()
253 } else {
254 let schema = Schema::Object(
255 ObjectBuilder::new()
256 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
257 .format(Some(SchemaFormat::Custom(r.content_type.into())))
258 .build(),
259 );
260 let content = ContentBuilder::new().schema(Some(schema)).build();
261 ResponseBuilder::new()
262 .description(&r.description)
263 .content(r.content_type, content)
264 .build()
265 };
266 responses = responses.response(r.status.to_string(), resp);
267 }
268 op = op.responses(responses.build());
269
270 if spec.authenticated {
272 let sec_req = utoipa::openapi::security::SecurityRequirement::new(
273 "bearerAuth",
274 Vec::<String>::new(),
275 );
276 op = op.security(sec_req);
277 }
278
279 let method = match spec.method {
280 Method::POST => HttpMethod::Post,
281 Method::PUT => HttpMethod::Put,
282 Method::DELETE => HttpMethod::Delete,
283 Method::PATCH => HttpMethod::Patch,
284 _ => HttpMethod::Get,
286 };
287
288 let item = PathItemBuilder::new().operation(method, op.build()).build();
289 let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
291 paths = paths.path(openapi_path, item);
292 }
293
294 let reg = self.components_registry.load();
296 let mut components = ComponentsBuilder::new();
297 for (name, schema) in reg.iter() {
298 components = components.schema(name.clone(), schema.clone());
299 }
300
301 components = components.security_scheme(
303 "bearerAuth",
304 SecurityScheme::Http(
305 HttpBuilder::new()
306 .scheme(HttpAuthScheme::Bearer)
307 .bearer_format("JWT")
308 .build(),
309 ),
310 );
311
312 let openapi_info = InfoBuilder::new()
314 .title(&info.title)
315 .version(&info.version)
316 .description(info.description.clone())
317 .build();
318
319 let servers = (!info.servers.is_empty()).then(|| {
320 info.servers
321 .iter()
322 .cloned()
323 .map(Server::new)
324 .collect::<Vec<_>>()
325 });
326
327 let openapi = OpenApiBuilder::new()
328 .info(openapi_info)
329 .servers(servers)
330 .paths(paths.build())
331 .components(Some(components.build()))
332 .build();
333
334 warn_dangling_refs_in_openapi(&openapi);
335
336 Ok(openapi)
337 }
338}
339
340impl Default for OpenApiRegistryImpl {
341 fn default() -> Self {
342 Self::new()
343 }
344}
345
346impl OpenApiRegistry for OpenApiRegistryImpl {
347 fn register_operation(&self, spec: &operation_builder::OperationSpec) {
348 let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
349 self.operation_specs
350 .insert(operation_key.clone(), spec.clone());
351
352 tracing::debug!(
353 handler_id = %spec.handler_id,
354 method = %spec.method.as_str(),
355 path = %spec.path,
356 summary = %spec.summary.as_deref().unwrap_or("No summary"),
357 operation_key = %operation_key,
358 "Registered API operation in registry"
359 );
360 }
361
362 fn ensure_schema_raw(&self, root_name: &str, schemas: SchemaCollection) -> String {
363 let current = self.components_registry.load();
365 let mut reg = (**current).clone();
366
367 for (name, schema) in schemas {
368 if let Some(existing) = reg.get(&name) {
375 let a = serde_json::to_value(existing).ok();
376 let b = serde_json::to_value(&schema).ok();
377 if a == b {
378 continue; }
380 panic!(
381 "OpenAPI schema name collision: `{name}` is registered with two different \
382 definitions. Two distinct types share the same schema name — rename one, or \
383 give it a distinct `#[schema(as = \"...\")]` alias. For a `Vec<T>` response \
384 use `OperationBuilder::json_array_response_with_schema::<T>()`, which emits \
385 an inline array instead of registering a component named `Vec`. \
386 existing={}, new={}",
387 a.map(|v| truncate_json(&v)).unwrap_or_default(),
388 b.map(|v| truncate_json(&v)).unwrap_or_default(),
389 );
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 truncate_json(v: &serde_json::Value) -> String {
406 const MAX: usize = 200;
407 let s = v.to_string();
408 if s.chars().count() > MAX {
409 let mut out: String = s.chars().take(MAX).collect();
410 out.push('\u{2026}');
411 out
412 } else {
413 s
414 }
415}
416
417fn build_request_body_content(
419 schema: &operation_builder::RequestBodySchema,
420) -> utoipa::openapi::content::Content {
421 match schema {
422 operation_builder::RequestBodySchema::Ref { schema_name } => ContentBuilder::new()
423 .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
424 .build(),
425 operation_builder::RequestBodySchema::MultipartFile { field_name } => {
426 let file_schema = Schema::Object(
432 ObjectBuilder::new()
433 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
434 .format(Some(SchemaFormat::Custom("binary".into())))
435 .build(),
436 );
437 let obj = ObjectBuilder::new()
438 .property(field_name.clone(), file_schema)
439 .required(field_name.clone());
440 ContentBuilder::new()
441 .schema(Some(Schema::Object(obj.build())))
442 .build()
443 }
444 operation_builder::RequestBodySchema::Binary => {
445 let schema = Schema::Object(
448 ObjectBuilder::new()
449 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
450 .format(Some(SchemaFormat::Custom("binary".into())))
451 .build(),
452 );
453 ContentBuilder::new().schema(Some(schema)).build()
454 }
455 operation_builder::RequestBodySchema::InlineObject => {
456 ContentBuilder::new()
458 .schema(Some(Schema::Object(ObjectBuilder::new().build())))
459 .build()
460 }
461 }
462}
463
464fn build_response_schema(schema: Option<&operation_builder::ResponseSchema>) -> RefOr<Schema> {
469 match schema {
470 Some(operation_builder::ResponseSchema::Ref { schema_name }) => {
471 RefOr::Ref(Ref::from_schema_name(schema_name.clone()))
472 }
473 Some(operation_builder::ResponseSchema::Array { items_schema_name }) => {
478 RefOr::T(Schema::Array(
479 ArrayBuilder::new()
480 .items(RefOr::Ref(Ref::from_schema_name(items_schema_name.clone())))
481 .build(),
482 ))
483 }
484 None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
485 }
486}
487
488fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
493 for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
494 tracing::warn!(
495 schema = %ref_name,
496 "Dangling $ref: schema '{}' is referenced but not registered. \
497 Add an explicit `ensure_schema::<T>(registry)` call.",
498 ref_name,
499 );
500 }
501}
502
503fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
507 let value = match serde_json::to_value(openapi) {
508 Ok(v) => v,
509 Err(err) => {
510 tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
511 return Vec::new();
512 }
513 };
514
515 let mut all_refs = HashSet::new();
516 collect_refs_from_json(&value, &mut all_refs);
517
518 let defined: HashSet<&str> = value
520 .pointer("/components/schemas")
521 .and_then(|v| v.as_object())
522 .map(|obj| obj.keys().map(String::as_str).collect())
523 .unwrap_or_default();
524
525 all_refs
526 .into_iter()
527 .filter(|name| !defined.contains(name.as_str()))
528 .collect()
529}
530
531fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
533 match value {
534 serde_json::Value::Object(map) => {
535 if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
536 && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
537 {
538 refs.insert(name.to_owned());
539 }
540 for v in map.values() {
541 collect_refs_from_json(v, refs);
542 }
543 }
544 serde_json::Value::Array(arr) => {
545 for v in arr {
546 collect_refs_from_json(v, refs);
547 }
548 }
549 _ => {}
550 }
551}
552
553#[cfg(test)]
554#[cfg_attr(coverage_nightly, coverage(off))]
555mod tests {
556 use super::*;
557 use crate::api::operation_builder::{
558 OperationSpec, ParamLocation, ParamSpec, ResponseSchema, ResponseSpec, VendorExtensions,
559 };
560 use http::Method;
561
562 fn spec_with_response(
564 path: &str,
565 handler: &str,
566 schema: Option<ResponseSchema>,
567 ) -> OperationSpec {
568 OperationSpec {
569 method: Method::GET,
570 path: path.to_owned(),
571 operation_id: Some(handler.to_owned()),
572 summary: None,
573 description: None,
574 tags: vec![],
575 params: vec![],
576 request_body: None,
577 responses: vec![ResponseSpec {
578 status: 200,
579 content_type: "application/json",
580 description: "OK".to_owned(),
581 schema,
582 }],
583 handler_id: handler.to_owned(),
584 authenticated: false,
585 is_public: false,
586 rate_limit: None,
587 allowed_request_content_types: None,
588 vendor_extensions: VendorExtensions::default(),
589 license_requirement: None,
590 }
591 }
592
593 fn response_schema_json(doc: &serde_json::Value, path: &str) -> serde_json::Value {
595 doc["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
596 .clone()
597 }
598
599 fn test_info() -> OpenApiInfo {
600 OpenApiInfo {
601 title: "T".to_owned(),
602 version: "1".to_owned(),
603 description: None,
604 servers: Vec::new(),
605 }
606 }
607
608 #[test]
609 fn test_registry_creation() {
610 let registry = OpenApiRegistryImpl::new();
611 assert_eq!(registry.operation_specs.len(), 0);
612 assert_eq!(registry.components_registry.load().len(), 0);
613 }
614
615 #[test]
616 fn test_register_operation() {
617 let registry = OpenApiRegistryImpl::new();
618 let spec = OperationSpec {
619 method: Method::GET,
620 path: "/test".to_owned(),
621 operation_id: Some("test_op".to_owned()),
622 summary: Some("Test operation".to_owned()),
623 description: None,
624 tags: vec![],
625 params: vec![],
626 request_body: None,
627 responses: vec![ResponseSpec {
628 status: 200,
629 content_type: "application/json",
630 description: "Success".to_owned(),
631 schema: None,
632 }],
633 handler_id: "get_test".to_owned(),
634 authenticated: false,
635 is_public: false,
636 rate_limit: None,
637 allowed_request_content_types: None,
638 vendor_extensions: VendorExtensions::default(),
639 license_requirement: None,
640 };
641
642 registry.register_operation(&spec);
643 assert_eq!(registry.operation_specs.len(), 1);
644 }
645
646 #[test]
647 fn test_build_empty_openapi() {
648 let registry = OpenApiRegistryImpl::new();
649 let info = OpenApiInfo {
650 title: "Test API".to_owned(),
651 version: "1.0.0".to_owned(),
652 description: Some("Test API Description".to_owned()),
653 servers: Vec::new(),
654 };
655 let doc = registry.build_openapi(&info).unwrap();
656 let json = serde_json::to_value(&doc).unwrap();
657
658 assert!(json.get("openapi").is_some());
660 assert!(json.get("info").is_some());
661 assert!(json.get("paths").is_some());
662
663 let openapi_info = json.get("info").unwrap();
665 assert_eq!(openapi_info.get("title").unwrap(), "Test API");
666 assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
667 assert_eq!(
668 openapi_info.get("description").unwrap(),
669 "Test API Description"
670 );
671 }
672
673 #[test]
674 fn test_build_openapi_with_operation() {
675 let registry = OpenApiRegistryImpl::new();
676 let spec = OperationSpec {
677 method: Method::GET,
678 path: "/users/{id}".to_owned(),
679 operation_id: Some("get_user".to_owned()),
680 summary: Some("Get user by ID".to_owned()),
681 description: Some("Retrieves a user by their ID".to_owned()),
682 tags: vec!["users".to_owned()],
683 params: vec![ParamSpec {
684 name: "id".to_owned(),
685 location: ParamLocation::Path,
686 required: true,
687 description: Some("User ID".to_owned()),
688 param_type: "string".to_owned(),
689 }],
690 request_body: None,
691 responses: vec![ResponseSpec {
692 status: 200,
693 content_type: "application/json",
694 description: "User found".to_owned(),
695 schema: None,
696 }],
697 handler_id: "get_users_id".to_owned(),
698 authenticated: false,
699 is_public: false,
700 rate_limit: None,
701 allowed_request_content_types: None,
702 vendor_extensions: VendorExtensions::default(),
703 license_requirement: None,
704 };
705
706 registry.register_operation(&spec);
707 let info = OpenApiInfo::default();
708 let doc = registry.build_openapi(&info).unwrap();
709 let json = serde_json::to_value(&doc).unwrap();
710
711 let paths = json.get("paths").unwrap();
713 assert!(paths.get("/users/{id}").is_some());
714
715 let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
717 assert_eq!(get_op.get("operationId").unwrap(), "get_user");
718 assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
719 }
720
721 #[test]
722 fn test_ensure_schema_raw() {
723 let registry = OpenApiRegistryImpl::new();
724 let schema = Schema::Object(ObjectBuilder::new().build());
725 let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
726
727 let name = registry.ensure_schema_raw("TestSchema", schemas);
728 assert_eq!(name, "TestSchema");
729 assert_eq!(registry.components_registry.load().len(), 1);
730 }
731
732 #[test]
733 fn test_build_openapi_with_binary_request() {
734 use crate::api::operation_builder::RequestBodySchema;
735
736 let registry = OpenApiRegistryImpl::new();
737 let spec = OperationSpec {
738 method: Method::POST,
739 path: "/files/v1/upload".to_owned(),
740 operation_id: Some("upload_file".to_owned()),
741 summary: Some("Upload a file".to_owned()),
742 description: Some("Upload raw binary file".to_owned()),
743 tags: vec!["upload".to_owned()],
744 params: vec![],
745 request_body: Some(crate::api::operation_builder::RequestBodySpec {
746 content_type: "application/octet-stream",
747 description: Some("Raw file bytes".to_owned()),
748 schema: RequestBodySchema::Binary,
749 required: true,
750 }),
751 responses: vec![ResponseSpec {
752 status: 200,
753 content_type: "application/json",
754 description: "Upload successful".to_owned(),
755 schema: None,
756 }],
757 handler_id: "post_upload".to_owned(),
758 authenticated: false,
759 is_public: false,
760 rate_limit: None,
761 allowed_request_content_types: Some(vec!["application/octet-stream"]),
762 vendor_extensions: VendorExtensions::default(),
763 license_requirement: None,
764 };
765
766 registry.register_operation(&spec);
767 let info = OpenApiInfo::default();
768 let doc = registry.build_openapi(&info).unwrap();
769 let json = serde_json::to_value(&doc).unwrap();
770
771 let paths = json.get("paths").unwrap();
773 assert!(paths.get("/files/v1/upload").is_some());
774
775 let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
777 let request_body = post_op.get("requestBody").unwrap();
778 let content = request_body.get("content").unwrap();
779 let octet_stream = content
780 .get("application/octet-stream")
781 .expect("application/octet-stream content type should exist");
782
783 let schema = octet_stream.get("schema").unwrap();
785 assert_eq!(schema.get("type").unwrap(), "string");
786 assert_eq!(schema.get("format").unwrap(), "binary");
787
788 assert_eq!(request_body.get("required").unwrap(), true);
790 }
791
792 #[test]
793 fn test_build_openapi_with_pagination() {
794 let registry = OpenApiRegistryImpl::new();
795
796 let mut filter: operation_builder::ODataPagination<
797 std::collections::BTreeMap<String, Vec<String>>,
798 > = operation_builder::ODataPagination::default();
799 filter.allowed_fields.insert(
800 "name".to_owned(),
801 vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
802 .into_iter()
803 .map(String::from)
804 .collect(),
805 );
806 filter.allowed_fields.insert(
807 "age".to_owned(),
808 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
809 .into_iter()
810 .map(String::from)
811 .collect(),
812 );
813
814 let mut order_by: operation_builder::ODataPagination<Vec<String>> =
815 operation_builder::ODataPagination::default();
816 order_by.allowed_fields.push("name asc".to_owned());
817 order_by.allowed_fields.push("name desc".to_owned());
818 order_by.allowed_fields.push("age asc".to_owned());
819 order_by.allowed_fields.push("age desc".to_owned());
820
821 let mut spec = OperationSpec {
822 method: Method::GET,
823 path: "/test".to_owned(),
824 operation_id: Some("test_op".to_owned()),
825 summary: Some("Test".to_owned()),
826 description: None,
827 tags: vec![],
828 params: vec![],
829 request_body: None,
830 responses: vec![ResponseSpec {
831 status: 200,
832 content_type: "application/json",
833 description: "OK".to_owned(),
834 schema: None,
835 }],
836 handler_id: "get_test".to_owned(),
837 authenticated: false,
838 is_public: false,
839 rate_limit: None,
840 allowed_request_content_types: None,
841 vendor_extensions: VendorExtensions::default(),
842 license_requirement: None,
843 };
844 spec.vendor_extensions.x_odata_filter = Some(filter);
845 spec.vendor_extensions.x_odata_orderby = Some(order_by);
846
847 registry.register_operation(&spec);
848 let info = OpenApiInfo::default();
849 let doc = registry.build_openapi(&info).unwrap();
850 let json = serde_json::to_value(&doc).unwrap();
851
852 let paths = json.get("paths").unwrap();
853 let op = paths.get("/test").unwrap().get("get").unwrap();
854
855 let filter_ext = op
856 .get("x-odata-filter")
857 .expect("x-odata-filter should be present");
858
859 let allowed_fields = filter_ext.get("allowedFields").unwrap();
860 assert!(allowed_fields.get("name").is_some());
861 assert!(allowed_fields.get("age").is_some());
862
863 let order_ext = op
864 .get("x-odata-orderby")
865 .expect("x-odata-orderby should be present");
866
867 let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
868 assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
869 assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
870 }
871
872 fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
874 let mut components = ComponentsBuilder::new();
875 for (name, schema) in schemas {
876 components = components.schema(name, schema);
877 }
878 OpenApiBuilder::new()
879 .components(Some(components.build()))
880 .build()
881 }
882
883 #[test]
884 fn test_dangling_refs_detects_missing_in_components() {
885 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
886 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
888 "type": "object",
889 "properties": {
890 "bar": { "$ref": "#/components/schemas/Bar" }
891 }
892 }))
893 .unwrap();
894 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
895
896 let openapi = build_test_openapi(schemas);
897 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
898 assert_eq!(dangling, vec!["Bar".to_owned()]);
899 }
900
901 #[test]
902 fn test_dangling_refs_no_false_positives() {
903 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
904 let bar_schema = Schema::Object(ObjectBuilder::new().build());
906 schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
907
908 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
910 "type": "object",
911 "properties": {
912 "bar": { "$ref": "#/components/schemas/Bar" }
913 }
914 }))
915 .unwrap();
916 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
917
918 let openapi = build_test_openapi(schemas);
919 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
920 assert!(
921 dangling.is_empty(),
922 "Expected no dangling refs but got: {dangling:?}"
923 );
924 }
925
926 #[test]
927 fn test_dangling_refs_detects_missing_in_operations() {
928 let openapi_json = serde_json::json!({
931 "openapi": "3.1.0",
932 "info": { "title": "test", "version": "0.1.0" },
933 "paths": {
934 "/items": {
935 "get": {
936 "responses": {
937 "200": {
938 "description": "OK",
939 "content": {
940 "application/json": {
941 "schema": { "$ref": "#/components/schemas/MissingDto" }
942 }
943 }
944 }
945 }
946 }
947 }
948 },
949 "components": {
950 "schemas": {}
951 }
952 });
953 let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
954 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
955 assert_eq!(dangling, vec!["MissingDto".to_owned()]);
956 }
957
958 #[test]
965 fn array_response_emits_inline_array_referencing_item() {
966 let registry = OpenApiRegistryImpl::new();
967 registry.register_operation(&spec_with_response(
968 "/gears",
969 "list_gears",
970 Some(ResponseSchema::Array {
971 items_schema_name: "GearDto".to_owned(),
972 }),
973 ));
974
975 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
976 let schema = response_schema_json(&doc, "/gears");
977
978 assert_eq!(schema["type"], "array");
979 assert_eq!(schema["items"]["$ref"], "#/components/schemas/GearDto");
980 assert!(schema.get("$ref").is_none());
982 assert!(doc["components"]["schemas"].get("Vec").is_none());
983 }
984
985 #[test]
986 fn ref_response_still_emits_plain_ref() {
987 let registry = OpenApiRegistryImpl::new();
988 registry.register_operation(&spec_with_response(
989 "/gear",
990 "get_gear",
991 Some(ResponseSchema::Ref {
992 schema_name: "GearDto".to_owned(),
993 }),
994 ));
995
996 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
997 let schema = response_schema_json(&doc, "/gear");
998
999 assert_eq!(schema["$ref"], "#/components/schemas/GearDto");
1000 assert!(schema.get("type").is_none());
1001 }
1002
1003 #[test]
1004 fn schemaless_json_response_still_emits_free_form_object() {
1005 let registry = OpenApiRegistryImpl::new();
1006 registry.register_operation(&spec_with_response("/any", "any_op", None));
1007
1008 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1009 let schema = response_schema_json(&doc, "/any");
1010
1011 assert!(schema.get("$ref").is_none());
1012 assert_ne!(schema["type"], "array");
1013 }
1014
1015 #[test]
1019 fn two_distinct_array_responses_do_not_collide() {
1020 #[derive(utoipa::ToSchema)]
1021 #[allow(dead_code)]
1022 struct AlphaDto {
1023 alpha: String,
1024 }
1025 #[derive(utoipa::ToSchema)]
1026 #[allow(dead_code)]
1027 struct BetaDto {
1028 beta: i32,
1029 }
1030
1031 let registry = OpenApiRegistryImpl::new();
1032 let a = ensure_schema::<AlphaDto>(®istry);
1034 let b = ensure_schema::<BetaDto>(®istry);
1035 assert_eq!((a.as_str(), b.as_str()), ("AlphaDto", "BetaDto"));
1036
1037 registry.register_operation(&spec_with_response(
1038 "/alphas",
1039 "list_alphas",
1040 Some(ResponseSchema::Array {
1041 items_schema_name: a,
1042 }),
1043 ));
1044 registry.register_operation(&spec_with_response(
1045 "/betas",
1046 "list_betas",
1047 Some(ResponseSchema::Array {
1048 items_schema_name: b,
1049 }),
1050 ));
1051
1052 let openapi = registry.build_openapi(&test_info()).unwrap();
1053 assert!(
1054 collect_all_dangling_refs_in_openapi(&openapi).is_empty(),
1055 "array item refs must point at registered components"
1056 );
1057
1058 let doc = serde_json::to_value(&openapi).unwrap();
1059 let schemas = &doc["components"]["schemas"];
1060 assert!(schemas.get("AlphaDto").is_some());
1061 assert!(schemas.get("BetaDto").is_some());
1062 assert!(schemas.get("Vec").is_none());
1063 assert_eq!(
1064 response_schema_json(&doc, "/alphas")["items"]["$ref"],
1065 "#/components/schemas/AlphaDto"
1066 );
1067 assert_eq!(
1068 response_schema_json(&doc, "/betas")["items"]["$ref"],
1069 "#/components/schemas/BetaDto"
1070 );
1071 }
1072
1073 #[test]
1074 #[should_panic(expected = "would register the component name `Vec`")]
1075 fn ensure_schema_rejects_vec_directly() {
1076 #[derive(utoipa::ToSchema)]
1077 #[allow(dead_code)]
1078 struct ItemDto {
1079 x: u8,
1080 }
1081 let registry = OpenApiRegistryImpl::new();
1082 let _ = ensure_schema::<Vec<ItemDto>>(®istry);
1083 }
1084
1085 #[test]
1086 #[should_panic(expected = "OpenAPI schema name collision")]
1087 fn ensure_schema_raw_panics_on_conflicting_definition() {
1088 let registry = OpenApiRegistryImpl::new();
1089 registry.ensure_schema_raw(
1090 "Dup",
1091 vec![("Dup".to_owned(), RefOr::Ref(Ref::from_schema_name("First")))],
1092 );
1093 registry.ensure_schema_raw(
1094 "Dup",
1095 vec![(
1096 "Dup".to_owned(),
1097 RefOr::Ref(Ref::from_schema_name("Second")),
1098 )],
1099 );
1100 }
1101
1102 #[test]
1103 fn ensure_schema_raw_allows_identical_reregistration() {
1104 let registry = OpenApiRegistryImpl::new();
1105 let entry = || {
1106 vec![(
1107 "Same".to_owned(),
1108 RefOr::Ref(Ref::from_schema_name("Target")),
1109 )]
1110 };
1111 registry.ensure_schema_raw("Same", entry());
1112 registry.ensure_schema_raw("Same", entry());
1113 assert_eq!(registry.components_registry.load().len(), 1);
1114 }
1115}