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