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 spec.exposed {
185 ext.insert(
186 "x-toolkit-visibility".to_owned(),
187 serde_json::Value::String("exposed".to_owned()),
188 );
189 }
190
191 if !ext.is_empty() {
192 op = op.extensions(Some(ext));
193 }
194
195 for p in &spec.params {
197 let in_ = match p.location {
198 operation_builder::ParamLocation::Path => ParameterIn::Path,
199 operation_builder::ParamLocation::Query => ParameterIn::Query,
200 operation_builder::ParamLocation::Header => ParameterIn::Header,
201 operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
202 };
203 let required =
204 if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
205 Required::True
206 } else {
207 Required::False
208 };
209
210 let schema_type = match p.param_type.as_str() {
211 "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
212 "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
213 "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
214 _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
215 };
216 let item_object = ObjectBuilder::new().schema_type(schema_type).build();
217
218 let mut builder = ParameterBuilder::new()
219 .name(&p.name)
220 .parameter_in(in_)
221 .required(required)
222 .description(p.description.clone());
223
224 if p.array {
225 builder = builder
232 .style(Some(utoipa::openapi::path::ParameterStyle::Form))
233 .explode(Some(true))
234 .schema(Some(Schema::Array(
235 utoipa::openapi::schema::ArrayBuilder::new()
236 .items(item_object)
237 .build(),
238 )));
239 } else {
240 builder = builder.schema(Some(Schema::Object(item_object)));
241 }
242
243 op = op.parameter(builder.build());
244 }
245
246 if let Some(rb) = &spec.request_body {
248 let content = build_request_body_content(&rb.schema);
249 let mut rbld = RequestBodyBuilder::new()
250 .description(rb.description.clone())
251 .content(rb.content_type.to_owned(), content);
252 if rb.required {
253 rbld = rbld.required(Some(Required::True));
254 }
255 op = op.request_body(Some(rbld.build()));
256 }
257
258 let mut responses = ResponsesBuilder::new();
260 for r in &spec.responses {
261 if r.content_type.is_empty() {
265 let resp = ResponseBuilder::new().description(&r.description).build();
266 responses = responses.response(r.status.to_string(), resp);
267 continue;
268 }
269 let is_json_like = r.content_type == "application/json"
270 || r.content_type == problem::APPLICATION_PROBLEM_JSON
271 || r.content_type == "text/event-stream";
272 let resp = if is_json_like {
273 let content = ContentBuilder::new()
275 .schema(Some(build_response_schema(r.schema.as_ref())))
276 .build();
277 ResponseBuilder::new()
278 .description(&r.description)
279 .content(r.content_type, content)
280 .build()
281 } else {
282 let schema = Schema::Object(
283 ObjectBuilder::new()
284 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
285 .format(Some(SchemaFormat::Custom(r.content_type.into())))
286 .build(),
287 );
288 let content = ContentBuilder::new().schema(Some(schema)).build();
289 ResponseBuilder::new()
290 .description(&r.description)
291 .content(r.content_type, content)
292 .build()
293 };
294 responses = responses.response(r.status.to_string(), resp);
295 }
296 op = op.responses(responses.build());
297
298 if spec.authenticated {
300 let sec_req = utoipa::openapi::security::SecurityRequirement::new(
301 "bearerAuth",
302 Vec::<String>::new(),
303 );
304 op = op.security(sec_req);
305 }
306
307 let method = match spec.method {
308 Method::POST => HttpMethod::Post,
309 Method::PUT => HttpMethod::Put,
310 Method::DELETE => HttpMethod::Delete,
311 Method::PATCH => HttpMethod::Patch,
312 _ => HttpMethod::Get,
314 };
315
316 let item = PathItemBuilder::new().operation(method, op.build()).build();
317 let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
319 paths = paths.path(openapi_path, item);
320 }
321
322 let reg = self.components_registry.load();
324 let mut components = ComponentsBuilder::new();
325 for (name, schema) in reg.iter() {
326 components = components.schema(name.clone(), schema.clone());
327 }
328
329 components = components.security_scheme(
331 "bearerAuth",
332 SecurityScheme::Http(
333 HttpBuilder::new()
334 .scheme(HttpAuthScheme::Bearer)
335 .bearer_format("JWT")
336 .build(),
337 ),
338 );
339
340 let openapi_info = InfoBuilder::new()
342 .title(&info.title)
343 .version(&info.version)
344 .description(info.description.clone())
345 .build();
346
347 let servers = (!info.servers.is_empty()).then(|| {
348 info.servers
349 .iter()
350 .cloned()
351 .map(Server::new)
352 .collect::<Vec<_>>()
353 });
354
355 let mut openapi = OpenApiBuilder::new()
356 .info(openapi_info)
357 .servers(servers)
358 .paths(paths.build())
359 .components(Some(components.build()))
360 .build();
361
362 let mut ext = utoipa::openapi::extensions::Extensions::default();
368 ext.insert(
369 "x-toolkit-spec-scope".to_owned(),
370 serde_json::json!("minimum-conformance"),
371 );
372 openapi.extensions = Some(ext);
373
374 warn_dangling_refs_in_openapi(&openapi);
375
376 Ok(openapi)
377 }
378}
379
380impl Default for OpenApiRegistryImpl {
381 fn default() -> Self {
382 Self::new()
383 }
384}
385
386impl OpenApiRegistry for OpenApiRegistryImpl {
387 fn register_operation(&self, spec: &operation_builder::OperationSpec) {
388 let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
389 if let Some(prev) = self
395 .operation_specs
396 .insert(operation_key.clone(), spec.clone())
397 && prev.handler_id != spec.handler_id
398 {
399 tracing::warn!(
400 operation_key = %operation_key,
401 previous_handler = %prev.handler_id,
402 new_handler = %spec.handler_id,
403 "duplicate OpenAPI operation registration; the earlier operation spec was \
404 overwritten - generated and manual routes must not share a (method, path)"
405 );
406 }
407
408 tracing::debug!(
409 handler_id = %spec.handler_id,
410 method = %spec.method.as_str(),
411 path = %spec.path,
412 summary = %spec.summary.as_deref().unwrap_or("No summary"),
413 operation_key = %operation_key,
414 "Registered API operation in registry"
415 );
416 }
417
418 fn ensure_schema_raw(&self, root_name: &str, schemas: SchemaCollection) -> String {
419 let current = self.components_registry.load();
421 let mut reg = (**current).clone();
422
423 for (name, schema) in schemas {
424 if let Some(existing) = reg.get(&name) {
431 let a = serde_json::to_value(existing).ok();
432 let b = serde_json::to_value(&schema).ok();
433 if a == b {
434 continue; }
436 panic!(
437 "OpenAPI schema name collision: `{name}` is registered with two different \
438 definitions. Two distinct types share the same schema name — rename one, or \
439 give it a distinct `#[schema(as = \"...\")]` alias. For a `Vec<T>` response \
440 use `OperationBuilder::json_array_response_with_schema::<T>()`, which emits \
441 an inline array instead of registering a component named `Vec`. \
442 existing={}, new={}",
443 a.map(|v| truncate_json(&v)).unwrap_or_default(),
444 b.map(|v| truncate_json(&v)).unwrap_or_default(),
445 );
446 }
447 reg.insert(name, schema);
448 }
449
450 self.components_registry.store(Arc::new(reg));
451 root_name.to_owned()
452 }
453
454 fn as_any(&self) -> &dyn std::any::Any {
455 self
456 }
457}
458
459fn truncate_json(v: &serde_json::Value) -> String {
462 const MAX: usize = 200;
463 let s = v.to_string();
464 if s.chars().count() > MAX {
465 let mut out: String = s.chars().take(MAX).collect();
466 out.push('\u{2026}');
467 out
468 } else {
469 s
470 }
471}
472
473fn build_request_body_content(
475 schema: &operation_builder::RequestBodySchema,
476) -> utoipa::openapi::content::Content {
477 match schema {
478 operation_builder::RequestBodySchema::Ref { schema_name } => ContentBuilder::new()
479 .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
480 .build(),
481 operation_builder::RequestBodySchema::MultipartFile { field_name } => {
482 let file_schema = Schema::Object(
488 ObjectBuilder::new()
489 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
490 .format(Some(SchemaFormat::Custom("binary".into())))
491 .build(),
492 );
493 let obj = ObjectBuilder::new()
494 .property(field_name.clone(), file_schema)
495 .required(field_name.clone());
496 ContentBuilder::new()
497 .schema(Some(Schema::Object(obj.build())))
498 .build()
499 }
500 operation_builder::RequestBodySchema::Binary => {
501 let schema = Schema::Object(
504 ObjectBuilder::new()
505 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
506 .format(Some(SchemaFormat::Custom("binary".into())))
507 .build(),
508 );
509 ContentBuilder::new().schema(Some(schema)).build()
510 }
511 operation_builder::RequestBodySchema::InlineObject => {
512 ContentBuilder::new()
514 .schema(Some(Schema::Object(ObjectBuilder::new().build())))
515 .build()
516 }
517 }
518}
519
520fn build_response_schema(schema: Option<&operation_builder::ResponseSchema>) -> RefOr<Schema> {
525 match schema {
526 Some(operation_builder::ResponseSchema::Ref { schema_name }) => {
527 RefOr::Ref(Ref::from_schema_name(schema_name.clone()))
528 }
529 Some(operation_builder::ResponseSchema::Array { items_schema_name }) => {
534 RefOr::T(Schema::Array(
535 ArrayBuilder::new()
536 .items(RefOr::Ref(Ref::from_schema_name(items_schema_name.clone())))
537 .build(),
538 ))
539 }
540 None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
541 }
542}
543
544fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
549 for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
550 tracing::warn!(
551 schema = %ref_name,
552 "Dangling $ref: schema '{}' is referenced but not registered. \
553 Add an explicit `ensure_schema::<T>(registry)` call.",
554 ref_name,
555 );
556 }
557}
558
559fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
563 let value = match serde_json::to_value(openapi) {
564 Ok(v) => v,
565 Err(err) => {
566 tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
567 return Vec::new();
568 }
569 };
570
571 let mut all_refs = HashSet::new();
572 collect_refs_from_json(&value, &mut all_refs);
573
574 let defined: HashSet<&str> = value
576 .pointer("/components/schemas")
577 .and_then(|v| v.as_object())
578 .map(|obj| obj.keys().map(String::as_str).collect())
579 .unwrap_or_default();
580
581 all_refs
582 .into_iter()
583 .filter(|name| !defined.contains(name.as_str()))
584 .collect()
585}
586
587fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
589 match value {
590 serde_json::Value::Object(map) => {
591 if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
592 && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
593 {
594 refs.insert(name.to_owned());
595 }
596 for v in map.values() {
597 collect_refs_from_json(v, refs);
598 }
599 }
600 serde_json::Value::Array(arr) => {
601 for v in arr {
602 collect_refs_from_json(v, refs);
603 }
604 }
605 _ => {}
606 }
607}
608
609#[cfg(test)]
610#[cfg_attr(coverage_nightly, coverage(off))]
611mod tests {
612 use super::*;
613 use crate::api::operation_builder::{
614 OperationSpec, ParamLocation, ParamSpec, ResponseSchema, ResponseSpec, VendorExtensions,
615 };
616 use http::Method;
617
618 fn spec_with_response(
620 path: &str,
621 handler: &str,
622 schema: Option<ResponseSchema>,
623 ) -> OperationSpec {
624 OperationSpec {
625 method: Method::GET,
626 path: path.to_owned(),
627 operation_id: Some(handler.to_owned()),
628 summary: None,
629 description: None,
630 tags: vec![],
631 params: vec![],
632 request_body: None,
633 responses: vec![ResponseSpec {
634 status: 200,
635 content_type: "application/json",
636 description: "OK".to_owned(),
637 schema,
638 }],
639 handler_id: handler.to_owned(),
640 authenticated: false,
641 exposed: false,
642 rate_limit: None,
643 allowed_request_content_types: None,
644 vendor_extensions: VendorExtensions::default(),
645 license_requirement: None,
646 }
647 }
648
649 fn response_schema_json(doc: &serde_json::Value, path: &str) -> serde_json::Value {
651 doc["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
652 .clone()
653 }
654
655 fn test_info() -> OpenApiInfo {
656 OpenApiInfo {
657 title: "T".to_owned(),
658 version: "1".to_owned(),
659 description: None,
660 servers: Vec::new(),
661 }
662 }
663
664 #[test]
665 fn test_registry_creation() {
666 let registry = OpenApiRegistryImpl::new();
667 assert_eq!(registry.operation_specs.len(), 0);
668 assert_eq!(registry.components_registry.load().len(), 0);
669 }
670
671 #[test]
672 fn test_register_operation() {
673 let registry = OpenApiRegistryImpl::new();
674 let spec = OperationSpec {
675 method: Method::GET,
676 path: "/test".to_owned(),
677 operation_id: Some("test_op".to_owned()),
678 summary: Some("Test operation".to_owned()),
679 description: None,
680 tags: vec![],
681 params: vec![],
682 request_body: None,
683 responses: vec![ResponseSpec {
684 status: 200,
685 content_type: "application/json",
686 description: "Success".to_owned(),
687 schema: None,
688 }],
689 handler_id: "get_test".to_owned(),
690 authenticated: false,
691 exposed: false,
692 rate_limit: None,
693 allowed_request_content_types: None,
694 vendor_extensions: VendorExtensions::default(),
695 license_requirement: None,
696 };
697
698 registry.register_operation(&spec);
699 assert_eq!(registry.operation_specs.len(), 1);
700 }
701
702 #[test]
703 fn test_build_empty_openapi() {
704 let registry = OpenApiRegistryImpl::new();
705 let info = OpenApiInfo {
706 title: "Test API".to_owned(),
707 version: "1.0.0".to_owned(),
708 description: Some("Test API Description".to_owned()),
709 servers: Vec::new(),
710 };
711 let doc = registry.build_openapi(&info).unwrap();
712 let json = serde_json::to_value(&doc).unwrap();
713
714 assert!(json.get("openapi").is_some());
716 assert!(json.get("info").is_some());
717 assert!(json.get("paths").is_some());
718
719 let openapi_info = json.get("info").unwrap();
721 assert_eq!(openapi_info.get("title").unwrap(), "Test API");
722 assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
723 assert_eq!(
724 openapi_info.get("description").unwrap(),
725 "Test API Description"
726 );
727 }
728
729 #[test]
730 fn test_build_openapi_with_operation() {
731 let registry = OpenApiRegistryImpl::new();
732 let spec = OperationSpec {
733 method: Method::GET,
734 path: "/users/{id}".to_owned(),
735 operation_id: Some("get_user".to_owned()),
736 summary: Some("Get user by ID".to_owned()),
737 description: Some("Retrieves a user by their ID".to_owned()),
738 tags: vec!["users".to_owned()],
739 params: vec![ParamSpec {
740 name: "id".to_owned(),
741 location: ParamLocation::Path,
742 required: true,
743 description: Some("User ID".to_owned()),
744 param_type: "string".to_owned(),
745 array: false,
746 }],
747 request_body: None,
748 responses: vec![ResponseSpec {
749 status: 200,
750 content_type: "application/json",
751 description: "User found".to_owned(),
752 schema: None,
753 }],
754 handler_id: "get_users_id".to_owned(),
755 authenticated: false,
756 exposed: false,
757 rate_limit: None,
758 allowed_request_content_types: None,
759 vendor_extensions: VendorExtensions::default(),
760 license_requirement: None,
761 };
762
763 registry.register_operation(&spec);
764 let info = OpenApiInfo::default();
765 let doc = registry.build_openapi(&info).unwrap();
766 let json = serde_json::to_value(&doc).unwrap();
767
768 let paths = json.get("paths").unwrap();
770 assert!(paths.get("/users/{id}").is_some());
771
772 let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
774 assert_eq!(get_op.get("operationId").unwrap(), "get_user");
775 assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
776 }
777
778 #[test]
779 fn test_ensure_schema_raw() {
780 let registry = OpenApiRegistryImpl::new();
781 let schema = Schema::Object(ObjectBuilder::new().build());
782 let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
783
784 let name = registry.ensure_schema_raw("TestSchema", schemas);
785 assert_eq!(name, "TestSchema");
786 assert_eq!(registry.components_registry.load().len(), 1);
787 }
788
789 #[test]
790 fn test_build_openapi_with_binary_request() {
791 use crate::api::operation_builder::RequestBodySchema;
792
793 let registry = OpenApiRegistryImpl::new();
794 let spec = OperationSpec {
795 method: Method::POST,
796 path: "/files/v1/upload".to_owned(),
797 operation_id: Some("upload_file".to_owned()),
798 summary: Some("Upload a file".to_owned()),
799 description: Some("Upload raw binary file".to_owned()),
800 tags: vec!["upload".to_owned()],
801 params: vec![],
802 request_body: Some(crate::api::operation_builder::RequestBodySpec {
803 content_type: "application/octet-stream",
804 description: Some("Raw file bytes".to_owned()),
805 schema: RequestBodySchema::Binary,
806 required: true,
807 }),
808 responses: vec![ResponseSpec {
809 status: 200,
810 content_type: "application/json",
811 description: "Upload successful".to_owned(),
812 schema: None,
813 }],
814 handler_id: "post_upload".to_owned(),
815 authenticated: false,
816 exposed: false,
817 rate_limit: None,
818 allowed_request_content_types: Some(vec!["application/octet-stream"]),
819 vendor_extensions: VendorExtensions::default(),
820 license_requirement: None,
821 };
822
823 registry.register_operation(&spec);
824 let info = OpenApiInfo::default();
825 let doc = registry.build_openapi(&info).unwrap();
826 let json = serde_json::to_value(&doc).unwrap();
827
828 let paths = json.get("paths").unwrap();
830 assert!(paths.get("/files/v1/upload").is_some());
831
832 let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
834 let request_body = post_op.get("requestBody").unwrap();
835 let content = request_body.get("content").unwrap();
836 let octet_stream = content
837 .get("application/octet-stream")
838 .expect("application/octet-stream content type should exist");
839
840 let schema = octet_stream.get("schema").unwrap();
842 assert_eq!(schema.get("type").unwrap(), "string");
843 assert_eq!(schema.get("format").unwrap(), "binary");
844
845 assert_eq!(request_body.get("required").unwrap(), true);
847 }
848
849 #[test]
850 fn test_build_openapi_with_pagination() {
851 let registry = OpenApiRegistryImpl::new();
852
853 let mut filter: operation_builder::ODataPagination<
854 std::collections::BTreeMap<String, Vec<String>>,
855 > = operation_builder::ODataPagination::default();
856 filter.allowed_fields.insert(
857 "name".to_owned(),
858 vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
859 .into_iter()
860 .map(String::from)
861 .collect(),
862 );
863 filter.allowed_fields.insert(
864 "age".to_owned(),
865 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
866 .into_iter()
867 .map(String::from)
868 .collect(),
869 );
870
871 let mut order_by: operation_builder::ODataPagination<Vec<String>> =
872 operation_builder::ODataPagination::default();
873 order_by.allowed_fields.push("name asc".to_owned());
874 order_by.allowed_fields.push("name desc".to_owned());
875 order_by.allowed_fields.push("age asc".to_owned());
876 order_by.allowed_fields.push("age desc".to_owned());
877
878 let mut spec = OperationSpec {
879 method: Method::GET,
880 path: "/test".to_owned(),
881 operation_id: Some("test_op".to_owned()),
882 summary: Some("Test".to_owned()),
883 description: None,
884 tags: vec![],
885 params: vec![],
886 request_body: None,
887 responses: vec![ResponseSpec {
888 status: 200,
889 content_type: "application/json",
890 description: "OK".to_owned(),
891 schema: None,
892 }],
893 handler_id: "get_test".to_owned(),
894 authenticated: false,
895 exposed: false,
896 rate_limit: None,
897 allowed_request_content_types: None,
898 vendor_extensions: VendorExtensions::default(),
899 license_requirement: None,
900 };
901 spec.vendor_extensions.x_odata_filter = Some(filter);
902 spec.vendor_extensions.x_odata_orderby = Some(order_by);
903
904 registry.register_operation(&spec);
905 let info = OpenApiInfo::default();
906 let doc = registry.build_openapi(&info).unwrap();
907 let json = serde_json::to_value(&doc).unwrap();
908
909 let paths = json.get("paths").unwrap();
910 let op = paths.get("/test").unwrap().get("get").unwrap();
911
912 let filter_ext = op
913 .get("x-odata-filter")
914 .expect("x-odata-filter should be present");
915
916 let allowed_fields = filter_ext.get("allowedFields").unwrap();
917 assert!(allowed_fields.get("name").is_some());
918 assert!(allowed_fields.get("age").is_some());
919
920 let order_ext = op
921 .get("x-odata-orderby")
922 .expect("x-odata-orderby should be present");
923
924 let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
925 assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
926 assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
927 }
928
929 #[test]
930 fn test_public_operation_emits_visibility_extension() {
931 let registry = OpenApiRegistryImpl::new();
932 let public = OperationSpec {
933 method: Method::GET,
934 path: "/calc/v1/ping".to_owned(),
935 operation_id: Some("ping".to_owned()),
936 summary: Some("Ping".to_owned()),
937 description: None,
938 tags: vec![],
939 params: vec![],
940 request_body: None,
941 responses: vec![ResponseSpec {
942 status: 200,
943 content_type: "application/json",
944 description: "OK".to_owned(),
945 schema: None,
946 }],
947 handler_id: "get_ping".to_owned(),
948 authenticated: false,
949 exposed: true,
950 rate_limit: None,
951 allowed_request_content_types: None,
952 vendor_extensions: VendorExtensions::default(),
953 license_requirement: None,
954 };
955 let mut internal = public.clone();
957 internal.path = "/calc/v1/internal".to_owned();
958 internal.handler_id = "get_internal".to_owned();
959 internal.operation_id = Some("internal".to_owned());
960 internal.exposed = false;
961
962 registry.register_operation(&public);
963 registry.register_operation(&internal);
964 let doc = registry.build_openapi(&OpenApiInfo::default()).unwrap();
965 let json = serde_json::to_value(&doc).unwrap();
966 let paths = json.get("paths").unwrap();
967
968 let public_op = paths.get("/calc/v1/ping").unwrap().get("get").unwrap();
969 assert_eq!(
970 public_op
971 .get("x-toolkit-visibility")
972 .and_then(|v| v.as_str()),
973 Some("exposed"),
974 "public operation must advertise the gateway visibility extension"
975 );
976
977 let internal_op = paths.get("/calc/v1/internal").unwrap().get("get").unwrap();
978 assert!(
979 internal_op.get("x-toolkit-visibility").is_none(),
980 "non-public operation must not carry the visibility extension"
981 );
982 }
983
984 fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
986 let mut components = ComponentsBuilder::new();
987 for (name, schema) in schemas {
988 components = components.schema(name, schema);
989 }
990 OpenApiBuilder::new()
991 .components(Some(components.build()))
992 .build()
993 }
994
995 #[test]
996 fn test_dangling_refs_detects_missing_in_components() {
997 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
998 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1000 "type": "object",
1001 "properties": {
1002 "bar": { "$ref": "#/components/schemas/Bar" }
1003 }
1004 }))
1005 .unwrap();
1006 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1007
1008 let openapi = build_test_openapi(schemas);
1009 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1010 assert_eq!(dangling, vec!["Bar".to_owned()]);
1011 }
1012
1013 #[test]
1014 fn test_dangling_refs_no_false_positives() {
1015 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1016 let bar_schema = Schema::Object(ObjectBuilder::new().build());
1018 schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
1019
1020 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1022 "type": "object",
1023 "properties": {
1024 "bar": { "$ref": "#/components/schemas/Bar" }
1025 }
1026 }))
1027 .unwrap();
1028 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1029
1030 let openapi = build_test_openapi(schemas);
1031 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1032 assert!(
1033 dangling.is_empty(),
1034 "Expected no dangling refs but got: {dangling:?}"
1035 );
1036 }
1037
1038 #[test]
1039 fn test_dangling_refs_detects_missing_in_operations() {
1040 let openapi_json = serde_json::json!({
1043 "openapi": "3.1.0",
1044 "info": { "title": "test", "version": "0.1.0" },
1045 "paths": {
1046 "/items": {
1047 "get": {
1048 "responses": {
1049 "200": {
1050 "description": "OK",
1051 "content": {
1052 "application/json": {
1053 "schema": { "$ref": "#/components/schemas/MissingDto" }
1054 }
1055 }
1056 }
1057 }
1058 }
1059 }
1060 },
1061 "components": {
1062 "schemas": {}
1063 }
1064 });
1065 let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
1066 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1067 assert_eq!(dangling, vec!["MissingDto".to_owned()]);
1068 }
1069
1070 #[test]
1077 fn array_response_emits_inline_array_referencing_item() {
1078 let registry = OpenApiRegistryImpl::new();
1079 registry.register_operation(&spec_with_response(
1080 "/gears",
1081 "list_gears",
1082 Some(ResponseSchema::Array {
1083 items_schema_name: "GearDto".to_owned(),
1084 }),
1085 ));
1086
1087 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1088 let schema = response_schema_json(&doc, "/gears");
1089
1090 assert_eq!(schema["type"], "array");
1091 assert_eq!(schema["items"]["$ref"], "#/components/schemas/GearDto");
1092 assert!(schema.get("$ref").is_none());
1094 assert!(doc["components"]["schemas"].get("Vec").is_none());
1095 }
1096
1097 #[test]
1098 fn ref_response_still_emits_plain_ref() {
1099 let registry = OpenApiRegistryImpl::new();
1100 registry.register_operation(&spec_with_response(
1101 "/gear",
1102 "get_gear",
1103 Some(ResponseSchema::Ref {
1104 schema_name: "GearDto".to_owned(),
1105 }),
1106 ));
1107
1108 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1109 let schema = response_schema_json(&doc, "/gear");
1110
1111 assert_eq!(schema["$ref"], "#/components/schemas/GearDto");
1112 assert!(schema.get("type").is_none());
1113 }
1114
1115 #[test]
1116 fn schemaless_json_response_still_emits_free_form_object() {
1117 let registry = OpenApiRegistryImpl::new();
1118 registry.register_operation(&spec_with_response("/any", "any_op", None));
1119
1120 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1121 let schema = response_schema_json(&doc, "/any");
1122
1123 assert!(schema.get("$ref").is_none());
1124 assert_ne!(schema["type"], "array");
1125 }
1126
1127 #[test]
1131 fn two_distinct_array_responses_do_not_collide() {
1132 #[derive(utoipa::ToSchema)]
1133 #[allow(dead_code)]
1134 struct AlphaDto {
1135 alpha: String,
1136 }
1137 #[derive(utoipa::ToSchema)]
1138 #[allow(dead_code)]
1139 struct BetaDto {
1140 beta: i32,
1141 }
1142
1143 let registry = OpenApiRegistryImpl::new();
1144 let a = ensure_schema::<AlphaDto>(®istry);
1146 let b = ensure_schema::<BetaDto>(®istry);
1147 assert_eq!((a.as_str(), b.as_str()), ("AlphaDto", "BetaDto"));
1148
1149 registry.register_operation(&spec_with_response(
1150 "/alphas",
1151 "list_alphas",
1152 Some(ResponseSchema::Array {
1153 items_schema_name: a,
1154 }),
1155 ));
1156 registry.register_operation(&spec_with_response(
1157 "/betas",
1158 "list_betas",
1159 Some(ResponseSchema::Array {
1160 items_schema_name: b,
1161 }),
1162 ));
1163
1164 let openapi = registry.build_openapi(&test_info()).unwrap();
1165 assert!(
1166 collect_all_dangling_refs_in_openapi(&openapi).is_empty(),
1167 "array item refs must point at registered components"
1168 );
1169
1170 let doc = serde_json::to_value(&openapi).unwrap();
1171 let schemas = &doc["components"]["schemas"];
1172 assert!(schemas.get("AlphaDto").is_some());
1173 assert!(schemas.get("BetaDto").is_some());
1174 assert!(schemas.get("Vec").is_none());
1175 assert_eq!(
1176 response_schema_json(&doc, "/alphas")["items"]["$ref"],
1177 "#/components/schemas/AlphaDto"
1178 );
1179 assert_eq!(
1180 response_schema_json(&doc, "/betas")["items"]["$ref"],
1181 "#/components/schemas/BetaDto"
1182 );
1183 }
1184
1185 #[test]
1186 #[should_panic(expected = "would register the component name `Vec`")]
1187 fn ensure_schema_rejects_vec_directly() {
1188 #[derive(utoipa::ToSchema)]
1189 #[allow(dead_code)]
1190 struct ItemDto {
1191 x: u8,
1192 }
1193 let registry = OpenApiRegistryImpl::new();
1194 let _ = ensure_schema::<Vec<ItemDto>>(®istry);
1195 }
1196
1197 #[test]
1198 #[should_panic(expected = "OpenAPI schema name collision")]
1199 fn ensure_schema_raw_panics_on_conflicting_definition() {
1200 let registry = OpenApiRegistryImpl::new();
1201 registry.ensure_schema_raw(
1202 "Dup",
1203 vec![("Dup".to_owned(), RefOr::Ref(Ref::from_schema_name("First")))],
1204 );
1205 registry.ensure_schema_raw(
1206 "Dup",
1207 vec![(
1208 "Dup".to_owned(),
1209 RefOr::Ref(Ref::from_schema_name("Second")),
1210 )],
1211 );
1212 }
1213
1214 #[test]
1215 fn ensure_schema_raw_allows_identical_reregistration() {
1216 let registry = OpenApiRegistryImpl::new();
1217 let entry = || {
1218 vec![(
1219 "Same".to_owned(),
1220 RefOr::Ref(Ref::from_schema_name("Target")),
1221 )]
1222 };
1223 registry.ensure_schema_raw("Same", entry());
1224 registry.ensure_schema_raw("Same", entry());
1225 assert_eq!(registry.components_registry.load().len(), 1);
1226 }
1227}