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 header::HeaderBuilder,
16 info::InfoBuilder,
17 path::{
18 HttpMethod, OperationBuilder as UOperationBuilder, ParameterBuilder, ParameterIn,
19 PathItemBuilder, PathsBuilder,
20 },
21 request_body::RequestBodyBuilder,
22 response::{Response, ResponsesBuilder},
23 schema::{ArrayBuilder, ComponentsBuilder, ObjectBuilder, Schema, SchemaFormat, SchemaType},
24 security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
25 server::Server,
26};
27
28use crate::api::operation_builder;
29use toolkit_canonical_errors::problem;
30use toolkit_contract::StreamFraming;
31
32type SchemaCollection = Vec<(String, RefOr<Schema>)>;
34
35#[derive(Debug, Clone)]
37pub struct OpenApiInfo {
38 pub title: String,
39 pub version: String,
40 pub description: Option<String>,
41 pub servers: Vec<String>,
42}
43
44impl Default for OpenApiInfo {
45 fn default() -> Self {
46 Self {
47 title: "API Documentation".to_owned(),
48 version: "0.1.0".to_owned(),
49 description: None,
50 servers: Vec::new(),
51 }
52 }
53}
54
55pub trait OpenApiRegistry: Send + Sync {
57 fn register_operation(&self, spec: &operation_builder::OperationSpec);
59
60 fn ensure_schema_raw(&self, name: &str, schemas: SchemaCollection) -> String;
64
65 fn as_any(&self) -> &dyn std::any::Any;
67}
68
69pub fn ensure_schema<T: utoipa::ToSchema + utoipa::PartialSchema + 'static>(
79 registry: &dyn OpenApiRegistry,
80) -> String {
81 use utoipa::PartialSchema;
82
83 let root_name = T::name().to_string();
85
86 assert!(
93 root_name != "Vec",
94 "ensure_schema::<Vec<_>>() would register the component name `Vec`, which every other \
95 Vec<T> also resolves to. Use `OperationBuilder::json_array_response_with_schema::<Item>()` \
96 to emit an inline array with only the item type named."
97 );
98
99 let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
102
103 T::schemas(&mut collected);
105
106 registry.ensure_schema_raw(&root_name, collected)
108}
109
110fn operation_vendor_extensions(
112 spec: &operation_builder::OperationSpec,
113) -> utoipa::openapi::extensions::Extensions {
114 let mut ext = utoipa::openapi::extensions::Extensions::default();
115
116 if let Some(pagination) = spec.vendor_extensions.x_odata_filter.as_ref()
118 && let Ok(value) = serde_json::to_value(pagination)
119 {
120 ext.insert("x-odata-filter".to_owned(), value);
121 }
122 if let Some(pagination) = spec.vendor_extensions.x_odata_orderby.as_ref()
123 && let Ok(value) = serde_json::to_value(pagination)
124 {
125 ext.insert("x-odata-orderby".to_owned(), value);
126 }
127
128 if spec.exposed {
133 ext.insert(
134 "x-toolkit-visibility".to_owned(),
135 serde_json::Value::String("exposed".to_owned()),
136 );
137 }
138
139 if let Some(throttling) = spec.throttling.as_ref() {
145 if let Some(zone) = throttling.rate_limit_zone.as_ref() {
146 ext.insert(
147 "x-throttling-rate-limit-zone".to_owned(),
148 serde_json::Value::String(zone.clone()),
149 );
150 }
151 if let Some(zone) = throttling.in_flight_limit_zone.as_ref() {
152 ext.insert(
153 "x-throttling-in-flight-limit-zone".to_owned(),
154 serde_json::Value::String(zone.clone()),
155 );
156 }
157 }
158
159 ext
160}
161
162fn param_schema_object(
170 schema_type: SchemaType,
171 format: Option<&str>,
172 minimum: Option<f64>,
173) -> utoipa::openapi::schema::Object {
174 ObjectBuilder::new()
175 .schema_type(schema_type)
176 .format(format.map(|format| SchemaFormat::Custom(format.to_owned())))
177 .minimum(minimum)
178 .build()
179}
180
181pub struct OpenApiRegistryImpl {
183 pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
185 pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
188}
189
190impl OpenApiRegistryImpl {
191 #[must_use]
193 pub fn new() -> Self {
194 Self {
195 operation_specs: DashMap::new(),
196 components_registry: ArcSwap::from_pointee(BTreeMap::new()),
197 }
198 }
199
200 #[allow(unknown_lints, de0205_operation_builder)]
208 pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
209 use http::Method;
210
211 let op_count = self.operation_specs.len();
213 tracing::info!("Building OpenAPI: found {op_count} registered operations");
214
215 let mut paths = PathsBuilder::new();
217
218 for spec in self.operation_specs.iter().map(|e| e.value().clone()) {
219 let mut op = UOperationBuilder::new()
220 .operation_id(spec.operation_id.clone().or(Some(spec.handler_id.clone())))
221 .summary(spec.summary.clone())
222 .description(spec.description.clone());
223
224 for tag in &spec.tags {
225 op = op.tag(tag.clone());
226 }
227
228 let ext = operation_vendor_extensions(&spec);
229 if !ext.is_empty() {
230 op = op.extensions(Some(ext));
231 }
232
233 for p in &spec.params {
235 let in_ = match p.location {
236 operation_builder::ParamLocation::Path => ParameterIn::Path,
237 operation_builder::ParamLocation::Query => ParameterIn::Query,
238 operation_builder::ParamLocation::Header => ParameterIn::Header,
239 operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
240 };
241 let required =
242 if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
243 Required::True
244 } else {
245 Required::False
246 };
247
248 let schema_type = match p.param_type.as_str() {
249 "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
250 "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
251 "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
252 _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
253 };
254 let item_object = param_schema_object(schema_type, p.format.as_deref(), p.minimum);
255
256 let mut builder = ParameterBuilder::new()
257 .name(&p.name)
258 .parameter_in(in_)
259 .required(required)
260 .description(p.description.clone());
261
262 if p.array {
263 builder = builder
270 .style(Some(utoipa::openapi::path::ParameterStyle::Form))
271 .explode(Some(true))
272 .schema(Some(Schema::Array(
273 utoipa::openapi::schema::ArrayBuilder::new()
274 .items(item_object)
275 .build(),
276 )));
277 } else {
278 builder = builder.schema(Some(Schema::Object(item_object)));
279 }
280
281 op = op.parameter(builder.build());
282 }
283
284 if let Some(rb) = &spec.request_body {
286 let content = build_request_body_content(&rb.schema);
287 let mut rbld = RequestBodyBuilder::new()
288 .description(rb.description.clone())
289 .content(rb.content_type.to_owned(), content);
290 if rb.required {
291 rbld = rbld.required(Some(Required::True));
292 }
293 op = op.request_body(Some(rbld.build()));
294 }
295
296 let mut responses_by_status = BTreeMap::<u16, Response>::new();
298 for r in &spec.responses {
299 let response = responses_by_status
300 .entry(r.status)
301 .or_insert_with(|| Response::new(&r.description));
302 response.description.clone_from(&r.description);
305
306 if !r.content_type.is_empty() {
310 let is_json_like = r.content_type == "application/json"
317 || r.content_type == problem::APPLICATION_PROBLEM_JSON
318 || StreamFraming::is_stream_media_type(r.content_type);
319 let content = if is_json_like {
320 ContentBuilder::new()
322 .schema(Some(build_response_schema(r.schema.as_ref())))
323 .build()
324 } else {
325 let schema = Schema::Object(
326 ObjectBuilder::new()
327 .schema_type(SchemaType::Type(
328 utoipa::openapi::schema::Type::String,
329 ))
330 .format(Some(SchemaFormat::Custom(r.content_type.into())))
331 .build(),
332 );
333 ContentBuilder::new().schema(Some(schema)).build()
334 };
335 response.content.insert(r.content_type.to_owned(), content);
336 }
337
338 for header in &r.headers {
339 let schema_type = match header.header_type {
340 operation_builder::ResponseHeaderType::String => {
341 SchemaType::Type(utoipa::openapi::schema::Type::String)
342 }
343 operation_builder::ResponseHeaderType::Integer => {
344 SchemaType::Type(utoipa::openapi::schema::Type::Integer)
345 }
346 operation_builder::ResponseHeaderType::Boolean => {
347 SchemaType::Type(utoipa::openapi::schema::Type::Boolean)
348 }
349 };
350 let declared = HeaderBuilder::new()
351 .description(header.description.clone())
352 .schema(ObjectBuilder::new().schema_type(schema_type).build())
353 .build();
354 response.headers.insert(header.name.clone(), declared);
355 }
356 }
357 let responses = ResponsesBuilder::new().responses_from_iter(
358 responses_by_status
359 .into_iter()
360 .map(|(status, response)| (status.to_string(), response)),
361 );
362 op = op.responses(responses.build());
363
364 if spec.authenticated {
366 let sec_req = utoipa::openapi::security::SecurityRequirement::new(
367 "bearerAuth",
368 Vec::<String>::new(),
369 );
370 op = op.security(sec_req);
371 }
372
373 let method = match spec.method {
374 Method::POST => HttpMethod::Post,
375 Method::PUT => HttpMethod::Put,
376 Method::DELETE => HttpMethod::Delete,
377 Method::PATCH => HttpMethod::Patch,
378 _ => HttpMethod::Get,
380 };
381
382 let item = PathItemBuilder::new().operation(method, op.build()).build();
383 let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
385 paths = paths.path(openapi_path, item);
386 }
387
388 let reg = self.components_registry.load();
390 let mut components = ComponentsBuilder::new();
391 for (name, schema) in reg.iter() {
392 components = components.schema(name.clone(), schema.clone());
393 }
394
395 components = components.security_scheme(
397 "bearerAuth",
398 SecurityScheme::Http(
399 HttpBuilder::new()
400 .scheme(HttpAuthScheme::Bearer)
401 .bearer_format("JWT")
402 .build(),
403 ),
404 );
405
406 let openapi_info = InfoBuilder::new()
408 .title(&info.title)
409 .version(&info.version)
410 .description(info.description.clone())
411 .build();
412
413 let servers = (!info.servers.is_empty()).then(|| {
414 info.servers
415 .iter()
416 .cloned()
417 .map(Server::new)
418 .collect::<Vec<_>>()
419 });
420
421 let mut openapi = OpenApiBuilder::new()
422 .info(openapi_info)
423 .servers(servers)
424 .paths(paths.build())
425 .components(Some(components.build()))
426 .build();
427
428 let mut ext = utoipa::openapi::extensions::Extensions::default();
434 ext.insert(
435 "x-toolkit-spec-scope".to_owned(),
436 serde_json::json!("minimum-conformance"),
437 );
438 openapi.extensions = Some(ext);
439
440 warn_dangling_refs_in_openapi(&openapi);
441
442 Ok(openapi)
443 }
444}
445
446impl Default for OpenApiRegistryImpl {
447 fn default() -> Self {
448 Self::new()
449 }
450}
451
452impl OpenApiRegistry for OpenApiRegistryImpl {
453 fn register_operation(&self, spec: &operation_builder::OperationSpec) {
454 let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
455 if let Some(prev) = self
461 .operation_specs
462 .insert(operation_key.clone(), spec.clone())
463 && prev.handler_id != spec.handler_id
464 {
465 tracing::warn!(
466 operation_key = %operation_key,
467 previous_handler = %prev.handler_id,
468 new_handler = %spec.handler_id,
469 "duplicate OpenAPI operation registration; the earlier operation spec was \
470 overwritten - generated and manual routes must not share a (method, path)"
471 );
472 }
473
474 tracing::debug!(
475 handler_id = %spec.handler_id,
476 method = %spec.method.as_str(),
477 path = %spec.path,
478 summary = %spec.summary.as_deref().unwrap_or("No summary"),
479 operation_key = %operation_key,
480 "Registered API operation in registry"
481 );
482 }
483
484 fn ensure_schema_raw(&self, root_name: &str, schemas: SchemaCollection) -> String {
485 let current = self.components_registry.load();
487 let mut reg = (**current).clone();
488
489 for (name, schema) in schemas {
490 if let Some(existing) = reg.get(&name) {
497 let a = serde_json::to_value(existing).ok();
498 let b = serde_json::to_value(&schema).ok();
499 if a == b {
500 continue; }
502 panic!(
503 "OpenAPI schema name collision: `{name}` is registered with two different \
504 definitions. Two distinct types share the same schema name — rename one, or \
505 give it a distinct `#[schema(as = \"...\")]` alias. For a `Vec<T>` response \
506 use `OperationBuilder::json_array_response_with_schema::<T>()`, which emits \
507 an inline array instead of registering a component named `Vec`. \
508 existing={}, new={}",
509 a.map(|v| truncate_json(&v)).unwrap_or_default(),
510 b.map(|v| truncate_json(&v)).unwrap_or_default(),
511 );
512 }
513 reg.insert(name, schema);
514 }
515
516 self.components_registry.store(Arc::new(reg));
517 root_name.to_owned()
518 }
519
520 fn as_any(&self) -> &dyn std::any::Any {
521 self
522 }
523}
524
525fn truncate_json(v: &serde_json::Value) -> String {
528 const MAX: usize = 200;
529 let s = v.to_string();
530 if s.chars().count() > MAX {
531 let mut out: String = s.chars().take(MAX).collect();
532 out.push('\u{2026}');
533 out
534 } else {
535 s
536 }
537}
538
539fn build_request_body_content(
541 schema: &operation_builder::RequestBodySchema,
542) -> utoipa::openapi::content::Content {
543 match schema {
544 operation_builder::RequestBodySchema::Ref { schema_name } => ContentBuilder::new()
545 .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
546 .build(),
547 operation_builder::RequestBodySchema::MultipartFile { field_name } => {
548 let file_schema = Schema::Object(
554 ObjectBuilder::new()
555 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
556 .format(Some(SchemaFormat::Custom("binary".into())))
557 .build(),
558 );
559 let obj = ObjectBuilder::new()
560 .property(field_name.clone(), file_schema)
561 .required(field_name.clone());
562 ContentBuilder::new()
563 .schema(Some(Schema::Object(obj.build())))
564 .build()
565 }
566 operation_builder::RequestBodySchema::Binary => {
567 let schema = Schema::Object(
570 ObjectBuilder::new()
571 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
572 .format(Some(SchemaFormat::Custom("binary".into())))
573 .build(),
574 );
575 ContentBuilder::new().schema(Some(schema)).build()
576 }
577 operation_builder::RequestBodySchema::InlineObject => {
578 ContentBuilder::new()
580 .schema(Some(Schema::Object(ObjectBuilder::new().build())))
581 .build()
582 }
583 }
584}
585
586fn build_response_schema(schema: Option<&operation_builder::ResponseSchema>) -> RefOr<Schema> {
591 match schema {
592 Some(operation_builder::ResponseSchema::Ref { schema_name }) => {
593 RefOr::Ref(Ref::from_schema_name(schema_name.clone()))
594 }
595 Some(operation_builder::ResponseSchema::Array { items_schema_name }) => {
600 RefOr::T(Schema::Array(
601 ArrayBuilder::new()
602 .items(RefOr::Ref(Ref::from_schema_name(items_schema_name.clone())))
603 .build(),
604 ))
605 }
606 None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
607 }
608}
609
610fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
615 for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
616 tracing::warn!(
617 schema = %ref_name,
618 "Dangling $ref: schema '{}' is referenced but not registered. \
619 Add an explicit `ensure_schema::<T>(registry)` call.",
620 ref_name,
621 );
622 }
623}
624
625fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
629 let value = match serde_json::to_value(openapi) {
630 Ok(v) => v,
631 Err(err) => {
632 tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
633 return Vec::new();
634 }
635 };
636
637 let mut all_refs = HashSet::new();
638 collect_refs_from_json(&value, &mut all_refs);
639
640 let defined: HashSet<&str> = value
642 .pointer("/components/schemas")
643 .and_then(|v| v.as_object())
644 .map(|obj| obj.keys().map(String::as_str).collect())
645 .unwrap_or_default();
646
647 all_refs
648 .into_iter()
649 .filter(|name| !defined.contains(name.as_str()))
650 .collect()
651}
652
653fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
655 match value {
656 serde_json::Value::Object(map) => {
657 if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
658 && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
659 {
660 refs.insert(name.to_owned());
661 }
662 for v in map.values() {
663 collect_refs_from_json(v, refs);
664 }
665 }
666 serde_json::Value::Array(arr) => {
667 for v in arr {
668 collect_refs_from_json(v, refs);
669 }
670 }
671 _ => {}
672 }
673}
674
675#[cfg(test)]
676#[cfg_attr(coverage_nightly, coverage(off))]
677mod tests {
678 use super::*;
679 use crate::api::operation_builder::{
680 OperationSpec, ParamSpec, ResponseHeaderSpec, ResponseHeaderType, ResponseSchema,
681 ResponseSpec, VendorExtensions,
682 };
683 use http::Method;
684
685 fn spec_with_response(
687 path: &str,
688 handler: &str,
689 schema: Option<ResponseSchema>,
690 ) -> OperationSpec {
691 OperationSpec {
692 method: Method::GET,
693 path: path.to_owned(),
694 operation_id: Some(handler.to_owned()),
695 summary: None,
696 description: None,
697 tags: vec![],
698 params: vec![],
699 request_body: None,
700 responses: vec![ResponseSpec {
701 status: 200,
702 content_type: "application/json",
703 description: "OK".to_owned(),
704 schema,
705 headers: vec![],
706 }],
707 handler_id: handler.to_owned(),
708 authenticated: false,
709 exposed: false,
710 throttling: None,
711 allowed_request_content_types: None,
712 vendor_extensions: VendorExtensions::default(),
713 license_requirement: None,
714 }
715 }
716
717 fn response_schema_json(doc: &serde_json::Value, path: &str) -> serde_json::Value {
719 doc["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
720 .clone()
721 }
722
723 fn test_info() -> OpenApiInfo {
724 OpenApiInfo {
725 title: "T".to_owned(),
726 version: "1".to_owned(),
727 description: None,
728 servers: Vec::new(),
729 }
730 }
731
732 #[test]
733 fn throttling_zone_names_are_emitted_as_vendor_extensions() {
734 use crate::api::operation_builder::ThrottlingSpec;
735
736 let registry = OpenApiRegistryImpl::new();
737 let mut spec = spec_with_response("/throttled", "throttled_op", None);
738 spec.throttling = Some(ThrottlingSpec {
739 rate_limit_zone: Some("rl_zone".to_owned()),
740 in_flight_limit_zone: Some("ifl_zone".to_owned()),
741 require_security_context: false,
742 dry_run: false,
743 });
744 registry.register_operation(&spec);
745 registry.register_operation(&spec_with_response("/plain", "plain_op", None));
747
748 let doc = registry.build_openapi(&test_info()).unwrap();
749 let json = serde_json::to_value(&doc).unwrap();
750
751 let throttled = &json["paths"]["/throttled"]["get"];
752 assert_eq!(
753 throttled["x-throttling-rate-limit-zone"],
754 serde_json::json!("rl_zone")
755 );
756 assert_eq!(
757 throttled["x-throttling-in-flight-limit-zone"],
758 serde_json::json!("ifl_zone")
759 );
760
761 let plain = &json["paths"]["/plain"]["get"];
762 assert!(plain.get("x-throttling-rate-limit-zone").is_none());
763 assert!(plain.get("x-throttling-in-flight-limit-zone").is_none());
764 }
765
766 #[test]
767 fn test_registry_creation() {
768 let registry = OpenApiRegistryImpl::new();
769 assert_eq!(registry.operation_specs.len(), 0);
770 assert_eq!(registry.components_registry.load().len(), 0);
771 }
772
773 #[test]
774 fn parameter_formats_are_preserved_in_scalar_and_array_schemas() {
775 use serde_json::json;
776
777 for (format, param_type, expected) in [
781 (
782 Some("int64"),
783 "integer",
784 json!({"type": "integer", "format": "int64", "minimum": 1}),
785 ),
786 (
787 Some("resource-version"),
788 "integer",
789 json!({"type": "integer", "format": "resource-version", "minimum": 1}),
790 ),
791 (None, "integer", json!({"type": "integer", "minimum": 1})),
792 ] {
793 for array in [false, true] {
794 let registry = OpenApiRegistryImpl::new();
795 let mut spec = spec_with_response("/test", "get_test", None);
796 let mut param = ParamSpec::query("version")
797 .required(true)
798 .param_type(param_type)
799 .array(array)
800 .minimum(1.0);
801 if let Some(format) = format {
802 param = param.format(format);
803 }
804 spec.params.push(param);
805 registry.register_operation(&spec);
806 let doc = registry.build_openapi(&test_info()).expect("build OpenAPI");
807 let json = serde_json::to_value(doc).expect("serialize OpenAPI");
808 let schema = &json["paths"]["/test"]["get"]["parameters"][0]["schema"];
809 let expected_schema = if array {
810 json!({"type": "array", "items": expected})
811 } else {
812 expected.clone()
813 };
814 assert_eq!(schema, &expected_schema, "format={format:?}, array={array}");
815 }
816 }
817 }
818
819 #[test]
820 fn test_register_operation() {
821 let registry = OpenApiRegistryImpl::new();
822 let spec = OperationSpec {
823 method: Method::GET,
824 path: "/test".to_owned(),
825 operation_id: Some("test_op".to_owned()),
826 summary: Some("Test operation".to_owned()),
827 description: None,
828 tags: vec![],
829 params: vec![],
830 request_body: None,
831 responses: vec![ResponseSpec {
832 status: 200,
833 content_type: "application/json",
834 description: "Success".to_owned(),
835 schema: None,
836 headers: vec![],
837 }],
838 handler_id: "get_test".to_owned(),
839 authenticated: false,
840 exposed: false,
841 throttling: None,
842 allowed_request_content_types: None,
843 vendor_extensions: VendorExtensions::default(),
844 license_requirement: None,
845 };
846
847 registry.register_operation(&spec);
848 assert_eq!(registry.operation_specs.len(), 1);
849 }
850
851 #[test]
852 fn response_headers_are_emitted_with_their_declared_types() {
853 let registry = OpenApiRegistryImpl::new();
854 let mut spec = spec_with_response("/submit", "submit", None);
855 spec.responses[0].headers = vec![
856 ResponseHeaderSpec::new(
857 "Location",
858 "Operation resource URI",
859 ResponseHeaderType::String,
860 ),
861 ResponseHeaderSpec::new(
862 "Retry-After",
863 "Retry delay in seconds",
864 ResponseHeaderType::Integer,
865 ),
866 ResponseHeaderSpec::new(
867 "Idempotency-Replayed",
868 "Whether this is a replay",
869 ResponseHeaderType::Boolean,
870 ),
871 ];
872 registry.register_operation(&spec);
873
874 let doc = registry.build_openapi(&test_info()).unwrap();
875 let json = serde_json::to_value(doc).unwrap();
876 let headers = &json["paths"]["/submit"]["get"]["responses"]["200"]["headers"];
877 assert_eq!(headers["Location"]["schema"]["type"], "string");
878 assert_eq!(headers["Location"]["description"], "Operation resource URI");
879 assert_eq!(headers["Retry-After"]["schema"]["type"], "integer");
880 assert_eq!(headers["Idempotency-Replayed"]["schema"]["type"], "boolean");
881 }
882
883 #[test]
884 fn response_content_types_with_the_same_status_are_combined() {
885 let registry = OpenApiRegistryImpl::new();
886 let mut spec = spec_with_response("/document", "document", None);
887 spec.responses[0].content_type = "text/plain";
888 spec.responses[0].description = "Plain document".to_owned();
889 spec.responses[0].headers = vec![ResponseHeaderSpec::new(
890 "X-Plain-Document",
891 "Whether plain text is available",
892 ResponseHeaderType::Boolean,
893 )];
894 spec.responses.push(
895 ResponseSpec::new(
896 http::StatusCode::OK.as_u16(),
897 "text/html",
898 "HTML document",
899 None,
900 )
901 .with_headers([ResponseHeaderSpec::new(
902 "X-HTML-Document",
903 "Whether HTML is available",
904 ResponseHeaderType::Boolean,
905 )]),
906 );
907 registry.register_operation(&spec);
908
909 let doc = registry.build_openapi(&test_info()).unwrap();
910 let json = serde_json::to_value(doc).unwrap();
911 let response = &json["paths"]["/document"]["get"]["responses"]["200"];
912 assert_eq!(response["description"], "HTML document");
913 assert_eq!(
914 response["content"]["text/plain"]["schema"]["format"],
915 "text/plain"
916 );
917 assert_eq!(
918 response["content"]["text/html"]["schema"]["format"],
919 "text/html"
920 );
921 assert_eq!(
922 response["headers"]["X-Plain-Document"]["schema"]["type"],
923 "boolean"
924 );
925 assert_eq!(
926 response["headers"]["X-HTML-Document"]["schema"]["type"],
927 "boolean"
928 );
929 }
930
931 #[test]
932 fn bodyless_response_can_declare_headers_without_content() {
933 let registry = OpenApiRegistryImpl::new();
934 let mut spec = spec_with_response("/jobs", "jobs", None);
935 spec.responses[0].content_type = "";
936 spec.responses[0].schema = None;
937 spec.responses[0].headers = vec![ResponseHeaderSpec::without_description(
938 "Retry-After",
939 ResponseHeaderType::Integer,
940 )];
941 registry.register_operation(&spec);
942
943 let doc = registry.build_openapi(&test_info()).unwrap();
944 let json = serde_json::to_value(doc).unwrap();
945 let response = &json["paths"]["/jobs"]["get"]["responses"]["200"];
946 assert!(response.get("content").is_none());
947 assert_eq!(
948 response["headers"]["Retry-After"]["schema"]["type"],
949 "integer"
950 );
951 assert!(
952 response["headers"]["Retry-After"]
953 .get("description")
954 .is_none()
955 );
956 }
957
958 #[test]
959 fn test_build_empty_openapi() {
960 let registry = OpenApiRegistryImpl::new();
961 let info = OpenApiInfo {
962 title: "Test API".to_owned(),
963 version: "1.0.0".to_owned(),
964 description: Some("Test API Description".to_owned()),
965 servers: Vec::new(),
966 };
967 let doc = registry.build_openapi(&info).unwrap();
968 let json = serde_json::to_value(&doc).unwrap();
969
970 assert!(json.get("openapi").is_some());
972 assert!(json.get("info").is_some());
973 assert!(json.get("paths").is_some());
974
975 let openapi_info = json.get("info").unwrap();
977 assert_eq!(openapi_info.get("title").unwrap(), "Test API");
978 assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
979 assert_eq!(
980 openapi_info.get("description").unwrap(),
981 "Test API Description"
982 );
983 }
984
985 #[test]
986 fn test_build_openapi_with_operation() {
987 let registry = OpenApiRegistryImpl::new();
988 let spec = OperationSpec {
989 method: Method::GET,
990 path: "/users/{id}".to_owned(),
991 operation_id: Some("get_user".to_owned()),
992 summary: Some("Get user by ID".to_owned()),
993 description: Some("Retrieves a user by their ID".to_owned()),
994 tags: vec!["users".to_owned()],
995 params: vec![ParamSpec::path("id").description("User ID")],
996 request_body: None,
997 responses: vec![ResponseSpec {
998 status: 200,
999 content_type: "application/json",
1000 description: "User found".to_owned(),
1001 schema: None,
1002 headers: vec![],
1003 }],
1004 handler_id: "get_users_id".to_owned(),
1005 authenticated: false,
1006 exposed: false,
1007 throttling: None,
1008 allowed_request_content_types: None,
1009 vendor_extensions: VendorExtensions::default(),
1010 license_requirement: None,
1011 };
1012
1013 registry.register_operation(&spec);
1014 let info = OpenApiInfo::default();
1015 let doc = registry.build_openapi(&info).unwrap();
1016 let json = serde_json::to_value(&doc).unwrap();
1017
1018 let paths = json.get("paths").unwrap();
1020 assert!(paths.get("/users/{id}").is_some());
1021
1022 let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
1024 assert_eq!(get_op.get("operationId").unwrap(), "get_user");
1025 assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
1026 }
1027
1028 #[test]
1029 fn test_ensure_schema_raw() {
1030 let registry = OpenApiRegistryImpl::new();
1031 let schema = Schema::Object(ObjectBuilder::new().build());
1032 let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
1033
1034 let name = registry.ensure_schema_raw("TestSchema", schemas);
1035 assert_eq!(name, "TestSchema");
1036 assert_eq!(registry.components_registry.load().len(), 1);
1037 }
1038
1039 #[test]
1040 fn test_build_openapi_with_binary_request() {
1041 use crate::api::operation_builder::RequestBodySchema;
1042
1043 let registry = OpenApiRegistryImpl::new();
1044 let spec = OperationSpec {
1045 method: Method::POST,
1046 path: "/files/v1/upload".to_owned(),
1047 operation_id: Some("upload_file".to_owned()),
1048 summary: Some("Upload a file".to_owned()),
1049 description: Some("Upload raw binary file".to_owned()),
1050 tags: vec!["upload".to_owned()],
1051 params: vec![],
1052 request_body: Some(crate::api::operation_builder::RequestBodySpec {
1053 content_type: "application/octet-stream",
1054 description: Some("Raw file bytes".to_owned()),
1055 schema: RequestBodySchema::Binary,
1056 required: true,
1057 }),
1058 responses: vec![ResponseSpec {
1059 status: 200,
1060 content_type: "application/json",
1061 description: "Upload successful".to_owned(),
1062 schema: None,
1063 headers: vec![],
1064 }],
1065 handler_id: "post_upload".to_owned(),
1066 authenticated: false,
1067 exposed: false,
1068 throttling: None,
1069 allowed_request_content_types: Some(vec!["application/octet-stream"]),
1070 vendor_extensions: VendorExtensions::default(),
1071 license_requirement: None,
1072 };
1073
1074 registry.register_operation(&spec);
1075 let info = OpenApiInfo::default();
1076 let doc = registry.build_openapi(&info).unwrap();
1077 let json = serde_json::to_value(&doc).unwrap();
1078
1079 let paths = json.get("paths").unwrap();
1081 assert!(paths.get("/files/v1/upload").is_some());
1082
1083 let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
1085 let request_body = post_op.get("requestBody").unwrap();
1086 let content = request_body.get("content").unwrap();
1087 let octet_stream = content
1088 .get("application/octet-stream")
1089 .expect("application/octet-stream content type should exist");
1090
1091 let schema = octet_stream.get("schema").unwrap();
1093 assert_eq!(schema.get("type").unwrap(), "string");
1094 assert_eq!(schema.get("format").unwrap(), "binary");
1095
1096 assert_eq!(request_body.get("required").unwrap(), true);
1098 }
1099
1100 #[test]
1101 fn test_build_openapi_with_pagination() {
1102 let registry = OpenApiRegistryImpl::new();
1103
1104 let mut filter: operation_builder::ODataPagination<
1105 std::collections::BTreeMap<String, Vec<String>>,
1106 > = operation_builder::ODataPagination::default();
1107 filter.allowed_fields.insert(
1108 "name".to_owned(),
1109 vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
1110 .into_iter()
1111 .map(String::from)
1112 .collect(),
1113 );
1114 filter.allowed_fields.insert(
1115 "age".to_owned(),
1116 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
1117 .into_iter()
1118 .map(String::from)
1119 .collect(),
1120 );
1121
1122 let mut order_by: operation_builder::ODataPagination<Vec<String>> =
1123 operation_builder::ODataPagination::default();
1124 order_by.allowed_fields.push("name asc".to_owned());
1125 order_by.allowed_fields.push("name desc".to_owned());
1126 order_by.allowed_fields.push("age asc".to_owned());
1127 order_by.allowed_fields.push("age desc".to_owned());
1128
1129 let mut spec = OperationSpec {
1130 method: Method::GET,
1131 path: "/test".to_owned(),
1132 operation_id: Some("test_op".to_owned()),
1133 summary: Some("Test".to_owned()),
1134 description: None,
1135 tags: vec![],
1136 params: vec![],
1137 request_body: None,
1138 responses: vec![ResponseSpec {
1139 status: 200,
1140 content_type: "application/json",
1141 description: "OK".to_owned(),
1142 schema: None,
1143 headers: vec![],
1144 }],
1145 handler_id: "get_test".to_owned(),
1146 authenticated: false,
1147 exposed: false,
1148 throttling: None,
1149 allowed_request_content_types: None,
1150 vendor_extensions: VendorExtensions::default(),
1151 license_requirement: None,
1152 };
1153 spec.vendor_extensions.x_odata_filter = Some(filter);
1154 spec.vendor_extensions.x_odata_orderby = Some(order_by);
1155
1156 registry.register_operation(&spec);
1157 let info = OpenApiInfo::default();
1158 let doc = registry.build_openapi(&info).unwrap();
1159 let json = serde_json::to_value(&doc).unwrap();
1160
1161 let paths = json.get("paths").unwrap();
1162 let op = paths.get("/test").unwrap().get("get").unwrap();
1163
1164 let filter_ext = op
1165 .get("x-odata-filter")
1166 .expect("x-odata-filter should be present");
1167
1168 let allowed_fields = filter_ext.get("allowedFields").unwrap();
1169 assert!(allowed_fields.get("name").is_some());
1170 assert!(allowed_fields.get("age").is_some());
1171
1172 let order_ext = op
1173 .get("x-odata-orderby")
1174 .expect("x-odata-orderby should be present");
1175
1176 let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
1177 assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
1178 assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
1179 }
1180
1181 #[test]
1182 fn test_public_operation_emits_visibility_extension() {
1183 let registry = OpenApiRegistryImpl::new();
1184 let public = OperationSpec {
1185 method: Method::GET,
1186 path: "/calc/v1/ping".to_owned(),
1187 operation_id: Some("ping".to_owned()),
1188 summary: Some("Ping".to_owned()),
1189 description: None,
1190 tags: vec![],
1191 params: vec![],
1192 request_body: None,
1193 responses: vec![ResponseSpec {
1194 status: 200,
1195 content_type: "application/json",
1196 description: "OK".to_owned(),
1197 schema: None,
1198 headers: vec![],
1199 }],
1200 handler_id: "get_ping".to_owned(),
1201 authenticated: false,
1202 exposed: true,
1203 throttling: None,
1204 allowed_request_content_types: None,
1205 vendor_extensions: VendorExtensions::default(),
1206 license_requirement: None,
1207 };
1208 let mut internal = public.clone();
1210 internal.path = "/calc/v1/internal".to_owned();
1211 internal.handler_id = "get_internal".to_owned();
1212 internal.operation_id = Some("internal".to_owned());
1213 internal.exposed = false;
1214
1215 registry.register_operation(&public);
1216 registry.register_operation(&internal);
1217 let doc = registry.build_openapi(&OpenApiInfo::default()).unwrap();
1218 let json = serde_json::to_value(&doc).unwrap();
1219 let paths = json.get("paths").unwrap();
1220
1221 let public_op = paths.get("/calc/v1/ping").unwrap().get("get").unwrap();
1222 assert_eq!(
1223 public_op
1224 .get("x-toolkit-visibility")
1225 .and_then(|v| v.as_str()),
1226 Some("exposed"),
1227 "public operation must advertise the gateway visibility extension"
1228 );
1229
1230 let internal_op = paths.get("/calc/v1/internal").unwrap().get("get").unwrap();
1231 assert!(
1232 internal_op.get("x-toolkit-visibility").is_none(),
1233 "non-public operation must not carry the visibility extension"
1234 );
1235 }
1236
1237 fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
1239 let mut components = ComponentsBuilder::new();
1240 for (name, schema) in schemas {
1241 components = components.schema(name, schema);
1242 }
1243 OpenApiBuilder::new()
1244 .components(Some(components.build()))
1245 .build()
1246 }
1247
1248 #[test]
1249 fn test_dangling_refs_detects_missing_in_components() {
1250 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1251 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1253 "type": "object",
1254 "properties": {
1255 "bar": { "$ref": "#/components/schemas/Bar" }
1256 }
1257 }))
1258 .unwrap();
1259 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1260
1261 let openapi = build_test_openapi(schemas);
1262 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1263 assert_eq!(dangling, vec!["Bar".to_owned()]);
1264 }
1265
1266 #[test]
1267 fn test_dangling_refs_no_false_positives() {
1268 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1269 let bar_schema = Schema::Object(ObjectBuilder::new().build());
1271 schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
1272
1273 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1275 "type": "object",
1276 "properties": {
1277 "bar": { "$ref": "#/components/schemas/Bar" }
1278 }
1279 }))
1280 .unwrap();
1281 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1282
1283 let openapi = build_test_openapi(schemas);
1284 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1285 assert!(
1286 dangling.is_empty(),
1287 "Expected no dangling refs but got: {dangling:?}"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_dangling_refs_detects_missing_in_operations() {
1293 let openapi_json = serde_json::json!({
1296 "openapi": "3.1.0",
1297 "info": { "title": "test", "version": "0.1.0" },
1298 "paths": {
1299 "/items": {
1300 "get": {
1301 "responses": {
1302 "200": {
1303 "description": "OK",
1304 "content": {
1305 "application/json": {
1306 "schema": { "$ref": "#/components/schemas/MissingDto" }
1307 }
1308 }
1309 }
1310 }
1311 }
1312 }
1313 },
1314 "components": {
1315 "schemas": {}
1316 }
1317 });
1318 let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
1319 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1320 assert_eq!(dangling, vec!["MissingDto".to_owned()]);
1321 }
1322
1323 #[test]
1330 fn array_response_emits_inline_array_referencing_item() {
1331 let registry = OpenApiRegistryImpl::new();
1332 registry.register_operation(&spec_with_response(
1333 "/gears",
1334 "list_gears",
1335 Some(ResponseSchema::Array {
1336 items_schema_name: "GearDto".to_owned(),
1337 }),
1338 ));
1339
1340 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1341 let schema = response_schema_json(&doc, "/gears");
1342
1343 assert_eq!(schema["type"], "array");
1344 assert_eq!(schema["items"]["$ref"], "#/components/schemas/GearDto");
1345 assert!(schema.get("$ref").is_none());
1347 assert!(doc["components"]["schemas"].get("Vec").is_none());
1348 }
1349
1350 #[test]
1351 fn ref_response_still_emits_plain_ref() {
1352 let registry = OpenApiRegistryImpl::new();
1353 registry.register_operation(&spec_with_response(
1354 "/gear",
1355 "get_gear",
1356 Some(ResponseSchema::Ref {
1357 schema_name: "GearDto".to_owned(),
1358 }),
1359 ));
1360
1361 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1362 let schema = response_schema_json(&doc, "/gear");
1363
1364 assert_eq!(schema["$ref"], "#/components/schemas/GearDto");
1365 assert!(schema.get("type").is_none());
1366 }
1367
1368 #[test]
1374 fn multipart_mixed_response_emits_the_item_ref_under_a_bare_media_type() {
1375 let registry = OpenApiRegistryImpl::new();
1376 let mut spec = spec_with_response(
1377 "/events",
1378 "stream_events",
1379 Some(ResponseSchema::Ref {
1380 schema_name: "FrameDto".to_owned(),
1381 }),
1382 );
1383 spec.responses[0].content_type = "multipart/mixed";
1384 registry.register_operation(&spec);
1385
1386 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1387 let content = &doc["paths"]["/events"]["get"]["responses"]["200"]["content"];
1388
1389 assert_eq!(
1390 content["multipart/mixed"]["schema"]["$ref"],
1391 "#/components/schemas/FrameDto"
1392 );
1393 assert_eq!(
1395 content.as_object().map(|o| o.keys().collect::<Vec<_>>()),
1396 Some(vec![&"multipart/mixed".to_owned()])
1397 );
1398 assert!(content["multipart/mixed"]["schema"].get("format").is_none());
1402 }
1403
1404 #[test]
1405 fn schemaless_json_response_still_emits_free_form_object() {
1406 let registry = OpenApiRegistryImpl::new();
1407 registry.register_operation(&spec_with_response("/any", "any_op", None));
1408
1409 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1410 let schema = response_schema_json(&doc, "/any");
1411
1412 assert!(schema.get("$ref").is_none());
1413 assert_ne!(schema["type"], "array");
1414 }
1415
1416 #[test]
1420 fn two_distinct_array_responses_do_not_collide() {
1421 #[derive(utoipa::ToSchema)]
1422 #[allow(dead_code)]
1423 struct AlphaDto {
1424 alpha: String,
1425 }
1426 #[derive(utoipa::ToSchema)]
1427 #[allow(dead_code)]
1428 struct BetaDto {
1429 beta: i32,
1430 }
1431
1432 let registry = OpenApiRegistryImpl::new();
1433 let a = ensure_schema::<AlphaDto>(®istry);
1435 let b = ensure_schema::<BetaDto>(®istry);
1436 assert_eq!((a.as_str(), b.as_str()), ("AlphaDto", "BetaDto"));
1437
1438 registry.register_operation(&spec_with_response(
1439 "/alphas",
1440 "list_alphas",
1441 Some(ResponseSchema::Array {
1442 items_schema_name: a,
1443 }),
1444 ));
1445 registry.register_operation(&spec_with_response(
1446 "/betas",
1447 "list_betas",
1448 Some(ResponseSchema::Array {
1449 items_schema_name: b,
1450 }),
1451 ));
1452
1453 let openapi = registry.build_openapi(&test_info()).unwrap();
1454 assert!(
1455 collect_all_dangling_refs_in_openapi(&openapi).is_empty(),
1456 "array item refs must point at registered components"
1457 );
1458
1459 let doc = serde_json::to_value(&openapi).unwrap();
1460 let schemas = &doc["components"]["schemas"];
1461 assert!(schemas.get("AlphaDto").is_some());
1462 assert!(schemas.get("BetaDto").is_some());
1463 assert!(schemas.get("Vec").is_none());
1464 assert_eq!(
1465 response_schema_json(&doc, "/alphas")["items"]["$ref"],
1466 "#/components/schemas/AlphaDto"
1467 );
1468 assert_eq!(
1469 response_schema_json(&doc, "/betas")["items"]["$ref"],
1470 "#/components/schemas/BetaDto"
1471 );
1472 }
1473
1474 #[test]
1475 #[should_panic(expected = "would register the component name `Vec`")]
1476 fn ensure_schema_rejects_vec_directly() {
1477 #[derive(utoipa::ToSchema)]
1478 #[allow(dead_code)]
1479 struct ItemDto {
1480 x: u8,
1481 }
1482 let registry = OpenApiRegistryImpl::new();
1483 let _ = ensure_schema::<Vec<ItemDto>>(®istry);
1484 }
1485
1486 #[test]
1487 #[should_panic(expected = "OpenAPI schema name collision")]
1488 fn ensure_schema_raw_panics_on_conflicting_definition() {
1489 let registry = OpenApiRegistryImpl::new();
1490 registry.ensure_schema_raw(
1491 "Dup",
1492 vec![("Dup".to_owned(), RefOr::Ref(Ref::from_schema_name("First")))],
1493 );
1494 registry.ensure_schema_raw(
1495 "Dup",
1496 vec![(
1497 "Dup".to_owned(),
1498 RefOr::Ref(Ref::from_schema_name("Second")),
1499 )],
1500 );
1501 }
1502
1503 #[test]
1504 fn ensure_schema_raw_allows_identical_reregistration() {
1505 let registry = OpenApiRegistryImpl::new();
1506 let entry = || {
1507 vec![(
1508 "Same".to_owned(),
1509 RefOr::Ref(Ref::from_schema_name("Target")),
1510 )]
1511 };
1512 registry.ensure_schema_raw("Same", entry());
1513 registry.ensure_schema_raw("Same", entry());
1514 assert_eq!(registry.components_registry.load().len(), 1);
1515 }
1516}