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::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme},
25 server::Server,
26};
27
28use crate::api::operation_builder;
29use toolkit_canonical_errors::problem;
30use toolkit_contract::StreamFraming;
31
32pub const INTERNAL_TOKEN_SECURITY_SCHEME: &str = "internalToken";
35
36type SchemaCollection = Vec<(String, RefOr<Schema>)>;
38
39#[derive(Debug, Clone)]
41pub struct OpenApiInfo {
42 pub title: String,
43 pub version: String,
44 pub description: Option<String>,
45 pub servers: Vec<String>,
46}
47
48impl Default for OpenApiInfo {
49 fn default() -> Self {
50 Self {
51 title: "API Documentation".to_owned(),
52 version: "0.1.0".to_owned(),
53 description: None,
54 servers: Vec::new(),
55 }
56 }
57}
58
59pub trait OpenApiRegistry: Send + Sync {
61 fn register_operation(&self, spec: &operation_builder::OperationSpec);
63
64 fn ensure_schema_raw(&self, name: &str, schemas: SchemaCollection) -> String;
68
69 fn as_any(&self) -> &dyn std::any::Any;
71}
72
73pub fn ensure_schema<T: utoipa::ToSchema + utoipa::PartialSchema + 'static>(
83 registry: &dyn OpenApiRegistry,
84) -> String {
85 use utoipa::PartialSchema;
86
87 let root_name = T::name().to_string();
89
90 assert!(
97 root_name != "Vec",
98 "ensure_schema::<Vec<_>>() would register the component name `Vec`, which every other \
99 Vec<T> also resolves to. Use `OperationBuilder::json_array_response_with_schema::<Item>()` \
100 to emit an inline array with only the item type named."
101 );
102
103 let mut collected: SchemaCollection = vec![(root_name.clone(), <T as PartialSchema>::schema())];
106
107 T::schemas(&mut collected);
109
110 registry.ensure_schema_raw(&root_name, collected)
112}
113
114fn operation_vendor_extensions(
116 spec: &operation_builder::OperationSpec,
117) -> utoipa::openapi::extensions::Extensions {
118 let mut ext = utoipa::openapi::extensions::Extensions::default();
119
120 if let Some(pagination) = spec.vendor_extensions.x_odata_filter.as_ref()
122 && let Ok(value) = serde_json::to_value(pagination)
123 {
124 ext.insert("x-odata-filter".to_owned(), value);
125 }
126 if let Some(pagination) = spec.vendor_extensions.x_odata_orderby.as_ref()
127 && let Ok(value) = serde_json::to_value(pagination)
128 {
129 ext.insert("x-odata-orderby".to_owned(), value);
130 }
131
132 if spec.exposed && spec.auth != operation_builder::RouteAuth::Platform {
135 ext.insert(
136 "x-toolkit-visibility".to_owned(),
137 serde_json::Value::String("exposed".to_owned()),
138 );
139 }
140
141 if let Some(throttling) = spec.throttling.as_ref() {
147 if let Some(zone) = throttling.rate_limit_zone.as_ref() {
148 ext.insert(
149 "x-throttling-rate-limit-zone".to_owned(),
150 serde_json::Value::String(zone.clone()),
151 );
152 }
153 if let Some(zone) = throttling.in_flight_limit_zone.as_ref() {
154 ext.insert(
155 "x-throttling-in-flight-limit-zone".to_owned(),
156 serde_json::Value::String(zone.clone()),
157 );
158 }
159 }
160
161 ext
162}
163
164fn param_schema_object(
172 schema_type: SchemaType,
173 format: Option<&str>,
174 minimum: Option<f64>,
175) -> utoipa::openapi::schema::Object {
176 ObjectBuilder::new()
177 .schema_type(schema_type)
178 .format(format.map(|format| SchemaFormat::Custom(format.to_owned())))
179 .minimum(minimum)
180 .build()
181}
182
183pub struct OpenApiRegistryImpl {
185 pub operation_specs: DashMap<String, operation_builder::OperationSpec>,
187 pub components_registry: ArcSwap<BTreeMap<String, RefOr<Schema>>>,
190}
191
192impl OpenApiRegistryImpl {
193 #[must_use]
195 pub fn new() -> Self {
196 Self {
197 operation_specs: DashMap::new(),
198 components_registry: ArcSwap::from_pointee(BTreeMap::new()),
199 }
200 }
201
202 #[allow(unknown_lints, de0205_operation_builder)]
210 pub fn build_openapi(&self, info: &OpenApiInfo) -> Result<OpenApi> {
211 use http::Method;
212
213 let op_count = self.operation_specs.len();
215 tracing::info!("Building OpenAPI: found {op_count} registered operations");
216
217 let mut paths = PathsBuilder::new();
219
220 for spec in self.operation_specs.iter().map(|e| e.value().clone()) {
221 let mut op = UOperationBuilder::new()
222 .operation_id(spec.operation_id.clone().or(Some(spec.handler_id.clone())))
223 .summary(spec.summary.clone())
224 .description(spec.description.clone());
225
226 for tag in &spec.tags {
227 op = op.tag(tag.clone());
228 }
229
230 let ext = operation_vendor_extensions(&spec);
231 if !ext.is_empty() {
232 op = op.extensions(Some(ext));
233 }
234
235 for p in &spec.params {
237 op = op.parameter(build_parameter(p));
238 }
239
240 if let Some(rb) = &spec.request_body {
242 let content = build_request_body_content(&rb.schema);
243 let mut rbld = RequestBodyBuilder::new()
244 .description(rb.description.clone())
245 .content(rb.content_type.to_owned(), content);
246 if rb.required {
247 rbld = rbld.required(Some(Required::True));
248 }
249 op = op.request_body(Some(rbld.build()));
250 }
251
252 let mut responses_by_status = BTreeMap::<u16, Response>::new();
254 for r in &spec.responses {
255 let response = responses_by_status
256 .entry(r.status)
257 .or_insert_with(|| Response::new(&r.description));
258 response.description.clone_from(&r.description);
261
262 if !r.content_type.is_empty() {
266 let is_json_like = r.content_type == "application/json"
273 || r.content_type == problem::APPLICATION_PROBLEM_JSON
274 || StreamFraming::is_stream_media_type(r.content_type);
275 let content = if is_json_like {
276 ContentBuilder::new()
278 .schema(Some(build_response_schema(r.schema.as_ref())))
279 .build()
280 } else {
281 let schema = Schema::Object(
282 ObjectBuilder::new()
283 .schema_type(SchemaType::Type(
284 utoipa::openapi::schema::Type::String,
285 ))
286 .format(Some(SchemaFormat::Custom(r.content_type.into())))
287 .build(),
288 );
289 ContentBuilder::new().schema(Some(schema)).build()
290 };
291 response.content.insert(r.content_type.to_owned(), content);
292 }
293
294 for header in &r.headers {
295 let schema_type = match header.header_type {
296 operation_builder::ResponseHeaderType::String => {
297 SchemaType::Type(utoipa::openapi::schema::Type::String)
298 }
299 operation_builder::ResponseHeaderType::Integer => {
300 SchemaType::Type(utoipa::openapi::schema::Type::Integer)
301 }
302 operation_builder::ResponseHeaderType::Boolean => {
303 SchemaType::Type(utoipa::openapi::schema::Type::Boolean)
304 }
305 };
306 let declared = HeaderBuilder::new()
307 .description(header.description.clone())
308 .schema(ObjectBuilder::new().schema_type(schema_type).build())
309 .build();
310 response.headers.insert(header.name.clone(), declared);
311 }
312 }
313 let responses = ResponsesBuilder::new().responses_from_iter(
314 responses_by_status
315 .into_iter()
316 .map(|(status, response)| (status.to_string(), response)),
317 );
318 op = op.responses(responses.build());
319
320 let scheme = match spec.auth {
322 operation_builder::RouteAuth::Platform => Some(INTERNAL_TOKEN_SECURITY_SCHEME),
323 operation_builder::RouteAuth::Authenticated => Some("bearerAuth"),
324 operation_builder::RouteAuth::Anonymous => None,
325 };
326 if let Some(scheme) = scheme {
327 op = op.security(utoipa::openapi::security::SecurityRequirement::new(
328 scheme,
329 Vec::<String>::new(),
330 ));
331 }
332
333 let method = match spec.method {
334 Method::POST => HttpMethod::Post,
335 Method::PUT => HttpMethod::Put,
336 Method::DELETE => HttpMethod::Delete,
337 Method::PATCH => HttpMethod::Patch,
338 _ => HttpMethod::Get,
340 };
341
342 let item = PathItemBuilder::new().operation(method, op.build()).build();
343 let openapi_path = operation_builder::axum_to_openapi_path(&spec.path);
345 paths = paths.path(openapi_path, item);
346 }
347
348 let reg = self.components_registry.load();
350 let mut components = ComponentsBuilder::new();
351 for (name, schema) in reg.iter() {
352 components = components.schema(name.clone(), schema.clone());
353 }
354
355 components = components.security_scheme(
357 "bearerAuth",
358 SecurityScheme::Http(
359 HttpBuilder::new()
360 .scheme(HttpAuthScheme::Bearer)
361 .bearer_format("JWT")
362 .build(),
363 ),
364 );
365
366 components = components.security_scheme(
368 INTERNAL_TOKEN_SECURITY_SCHEME,
369 SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::with_description(
370 toolkit_security::constants::INTERNAL_TOKEN_HEADER,
371 "Platform-plane workload credential, validated by the receiving listener",
372 ))),
373 );
374
375 let openapi_info = InfoBuilder::new()
377 .title(&info.title)
378 .version(&info.version)
379 .description(info.description.clone())
380 .build();
381
382 let servers = (!info.servers.is_empty()).then(|| {
383 info.servers
384 .iter()
385 .cloned()
386 .map(Server::new)
387 .collect::<Vec<_>>()
388 });
389
390 let mut openapi = OpenApiBuilder::new()
391 .info(openapi_info)
392 .servers(servers)
393 .paths(paths.build())
394 .components(Some(components.build()))
395 .build();
396
397 let mut ext = utoipa::openapi::extensions::Extensions::default();
403 ext.insert(
404 "x-toolkit-spec-scope".to_owned(),
405 serde_json::json!("minimum-conformance"),
406 );
407 openapi.extensions = Some(ext);
408
409 warn_dangling_refs_in_openapi(&openapi);
410
411 Ok(openapi)
412 }
413}
414
415impl Default for OpenApiRegistryImpl {
416 fn default() -> Self {
417 Self::new()
418 }
419}
420
421impl OpenApiRegistry for OpenApiRegistryImpl {
422 fn register_operation(&self, spec: &operation_builder::OperationSpec) {
423 let operation_key = format!("{}:{}", spec.method.as_str(), spec.path);
424 let mut spec = std::borrow::Cow::Borrowed(spec);
427 if spec.exposed && spec.auth == operation_builder::RouteAuth::Platform {
428 tracing::warn!(
429 method = %spec.method,
430 path = %spec.path,
431 "a platform-authenticated operation is declared exposed; it is not exposed"
432 );
433 spec.to_mut().exposed = false;
434 }
435 let spec = spec.as_ref();
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_parameter(p: &operation_builder::ParamSpec) -> utoipa::openapi::path::Parameter {
522 let in_ = match p.location {
523 operation_builder::ParamLocation::Path => ParameterIn::Path,
524 operation_builder::ParamLocation::Query => ParameterIn::Query,
525 operation_builder::ParamLocation::Header => ParameterIn::Header,
526 operation_builder::ParamLocation::Cookie => ParameterIn::Cookie,
527 };
528 let required = if matches!(p.location, operation_builder::ParamLocation::Path) || p.required {
529 Required::True
530 } else {
531 Required::False
532 };
533
534 let schema_type = match p.param_type.as_str() {
535 "integer" => SchemaType::Type(utoipa::openapi::schema::Type::Integer),
536 "number" => SchemaType::Type(utoipa::openapi::schema::Type::Number),
537 "boolean" => SchemaType::Type(utoipa::openapi::schema::Type::Boolean),
538 _ => SchemaType::Type(utoipa::openapi::schema::Type::String),
539 };
540 let mut item_object = param_schema_object(schema_type, p.format.as_deref(), p.minimum);
541 if !p.enum_values.is_empty() {
544 item_object = ObjectBuilder::from(item_object)
545 .enum_values(Some(p.enum_values.clone()))
546 .build();
547 }
548
549 let mut builder = ParameterBuilder::new()
550 .name(&p.name)
551 .parameter_in(in_)
552 .required(required)
553 .description(p.description.clone());
554
555 if p.array {
556 builder = builder
562 .style(Some(utoipa::openapi::path::ParameterStyle::Form))
563 .explode(Some(true))
564 .schema(Some(Schema::Array(
565 utoipa::openapi::schema::ArrayBuilder::new()
566 .items(item_object)
567 .build(),
568 )));
569 } else {
570 builder = builder.schema(Some(Schema::Object(item_object)));
571 }
572
573 builder.build()
574}
575
576fn build_request_body_content(
578 schema: &operation_builder::RequestBodySchema,
579) -> utoipa::openapi::content::Content {
580 match schema {
581 operation_builder::RequestBodySchema::Ref { schema_name } => ContentBuilder::new()
582 .schema(Some(RefOr::Ref(Ref::from_schema_name(schema_name.clone()))))
583 .build(),
584 operation_builder::RequestBodySchema::MultipartFile { field_name } => {
585 let file_schema = Schema::Object(
591 ObjectBuilder::new()
592 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
593 .format(Some(SchemaFormat::Custom("binary".into())))
594 .build(),
595 );
596 let obj = ObjectBuilder::new()
597 .property(field_name.clone(), file_schema)
598 .required(field_name.clone());
599 ContentBuilder::new()
600 .schema(Some(Schema::Object(obj.build())))
601 .build()
602 }
603 operation_builder::RequestBodySchema::Binary => {
604 let schema = Schema::Object(
607 ObjectBuilder::new()
608 .schema_type(SchemaType::Type(utoipa::openapi::schema::Type::String))
609 .format(Some(SchemaFormat::Custom("binary".into())))
610 .build(),
611 );
612 ContentBuilder::new().schema(Some(schema)).build()
613 }
614 operation_builder::RequestBodySchema::InlineObject => {
615 ContentBuilder::new()
617 .schema(Some(Schema::Object(ObjectBuilder::new().build())))
618 .build()
619 }
620 }
621}
622
623fn build_response_schema(schema: Option<&operation_builder::ResponseSchema>) -> RefOr<Schema> {
628 match schema {
629 Some(operation_builder::ResponseSchema::Ref { schema_name }) => {
630 RefOr::Ref(Ref::from_schema_name(schema_name.clone()))
631 }
632 Some(operation_builder::ResponseSchema::Array { items_schema_name }) => {
637 RefOr::T(Schema::Array(
638 ArrayBuilder::new()
639 .items(RefOr::Ref(Ref::from_schema_name(items_schema_name.clone())))
640 .build(),
641 ))
642 }
643 None => RefOr::T(Schema::Object(ObjectBuilder::new().build())),
644 }
645}
646
647fn warn_dangling_refs_in_openapi(openapi: &OpenApi) {
652 for ref_name in &collect_all_dangling_refs_in_openapi(openapi) {
653 tracing::warn!(
654 schema = %ref_name,
655 "Dangling $ref: schema '{}' is referenced but not registered. \
656 Add an explicit `ensure_schema::<T>(registry)` call.",
657 ref_name,
658 );
659 }
660}
661
662fn collect_all_dangling_refs_in_openapi(openapi: &OpenApi) -> Vec<String> {
666 let value = match serde_json::to_value(openapi) {
667 Ok(v) => v,
668 Err(err) => {
669 tracing::debug!(error = %err, "Failed to serialize OpenAPI doc for dangling $ref check");
670 return Vec::new();
671 }
672 };
673
674 let mut all_refs = HashSet::new();
675 collect_refs_from_json(&value, &mut all_refs);
676
677 let defined: HashSet<&str> = value
679 .pointer("/components/schemas")
680 .and_then(|v| v.as_object())
681 .map(|obj| obj.keys().map(String::as_str).collect())
682 .unwrap_or_default();
683
684 all_refs
685 .into_iter()
686 .filter(|name| !defined.contains(name.as_str()))
687 .collect()
688}
689
690fn collect_refs_from_json(value: &serde_json::Value, refs: &mut HashSet<String>) {
692 match value {
693 serde_json::Value::Object(map) => {
694 if let Some(serde_json::Value::String(ref_str)) = map.get("$ref")
695 && let Some(name) = ref_str.strip_prefix("#/components/schemas/")
696 {
697 refs.insert(name.to_owned());
698 }
699 for v in map.values() {
700 collect_refs_from_json(v, refs);
701 }
702 }
703 serde_json::Value::Array(arr) => {
704 for v in arr {
705 collect_refs_from_json(v, refs);
706 }
707 }
708 _ => {}
709 }
710}
711
712#[cfg(test)]
713#[cfg_attr(coverage_nightly, coverage(off))]
714mod tests {
715 use super::*;
716 use crate::api::operation_builder::{
717 OperationSpec, ParamSpec, ResponseHeaderSpec, ResponseHeaderType, ResponseSchema,
718 ResponseSpec, VendorExtensions,
719 };
720 use http::Method;
721
722 fn spec_with_response(
724 path: &str,
725 handler: &str,
726 schema: Option<ResponseSchema>,
727 ) -> OperationSpec {
728 OperationSpec {
729 method: Method::GET,
730 path: path.to_owned(),
731 operation_id: Some(handler.to_owned()),
732 summary: None,
733 description: None,
734 tags: vec![],
735 params: vec![],
736 request_body: None,
737 responses: vec![ResponseSpec {
738 status: 200,
739 content_type: "application/json",
740 description: "OK".to_owned(),
741 schema,
742 headers: vec![],
743 }],
744 handler_id: handler.to_owned(),
745 auth: operation_builder::RouteAuth::Anonymous,
746 exposed: false,
747 throttling: None,
748 allowed_request_content_types: None,
749 vendor_extensions: VendorExtensions::default(),
750 license_requirement: None,
751 }
752 }
753
754 fn response_schema_json(doc: &serde_json::Value, path: &str) -> serde_json::Value {
756 doc["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
757 .clone()
758 }
759
760 fn test_info() -> OpenApiInfo {
761 OpenApiInfo {
762 title: "T".to_owned(),
763 version: "1".to_owned(),
764 description: None,
765 servers: Vec::new(),
766 }
767 }
768
769 #[test]
770 fn throttling_zone_names_are_emitted_as_vendor_extensions() {
771 use crate::api::operation_builder::ThrottlingSpec;
772
773 let registry = OpenApiRegistryImpl::new();
774 let mut spec = spec_with_response("/throttled", "throttled_op", None);
775 spec.throttling = Some(ThrottlingSpec {
776 rate_limit_zone: Some("rl_zone".to_owned()),
777 in_flight_limit_zone: Some("ifl_zone".to_owned()),
778 require_security_context: false,
779 dry_run: false,
780 });
781 registry.register_operation(&spec);
782 registry.register_operation(&spec_with_response("/plain", "plain_op", None));
784
785 let doc = registry.build_openapi(&test_info()).unwrap();
786 let json = serde_json::to_value(&doc).unwrap();
787
788 let throttled = &json["paths"]["/throttled"]["get"];
789 assert_eq!(
790 throttled["x-throttling-rate-limit-zone"],
791 serde_json::json!("rl_zone")
792 );
793 assert_eq!(
794 throttled["x-throttling-in-flight-limit-zone"],
795 serde_json::json!("ifl_zone")
796 );
797
798 let plain = &json["paths"]["/plain"]["get"];
799 assert!(plain.get("x-throttling-rate-limit-zone").is_none());
800 assert!(plain.get("x-throttling-in-flight-limit-zone").is_none());
801 }
802
803 #[test]
804 fn test_registry_creation() {
805 let registry = OpenApiRegistryImpl::new();
806 assert_eq!(registry.operation_specs.len(), 0);
807 assert_eq!(registry.components_registry.load().len(), 0);
808 }
809
810 #[test]
811 fn parameter_formats_are_preserved_in_scalar_and_array_schemas() {
812 use serde_json::json;
813
814 for (format, param_type, expected) in [
818 (
819 Some("int64"),
820 "integer",
821 json!({"type": "integer", "format": "int64", "minimum": 1}),
822 ),
823 (
824 Some("resource-version"),
825 "integer",
826 json!({"type": "integer", "format": "resource-version", "minimum": 1}),
827 ),
828 (None, "integer", json!({"type": "integer", "minimum": 1})),
829 ] {
830 for array in [false, true] {
831 let registry = OpenApiRegistryImpl::new();
832 let mut spec = spec_with_response("/test", "get_test", None);
833 let mut param = ParamSpec::query("version")
834 .required(true)
835 .param_type(param_type)
836 .array(array)
837 .minimum(1.0);
838 if let Some(format) = format {
839 param = param.format(format);
840 }
841 spec.params.push(param);
842 registry.register_operation(&spec);
843 let doc = registry.build_openapi(&test_info()).expect("build OpenAPI");
844 let json = serde_json::to_value(doc).expect("serialize OpenAPI");
845 let schema = &json["paths"]["/test"]["get"]["parameters"][0]["schema"];
846 let expected_schema = if array {
847 json!({"type": "array", "items": expected})
848 } else {
849 expected.clone()
850 };
851 assert_eq!(schema, &expected_schema, "format={format:?}, array={array}");
852 }
853 }
854 }
855
856 #[test]
857 fn test_register_operation() {
858 let registry = OpenApiRegistryImpl::new();
859 let spec = OperationSpec {
860 method: Method::GET,
861 path: "/test".to_owned(),
862 operation_id: Some("test_op".to_owned()),
863 summary: Some("Test operation".to_owned()),
864 description: None,
865 tags: vec![],
866 params: vec![],
867 request_body: None,
868 responses: vec![ResponseSpec {
869 status: 200,
870 content_type: "application/json",
871 description: "Success".to_owned(),
872 schema: None,
873 headers: vec![],
874 }],
875 handler_id: "get_test".to_owned(),
876 auth: operation_builder::RouteAuth::Anonymous,
877 exposed: false,
878 throttling: None,
879 allowed_request_content_types: None,
880 vendor_extensions: VendorExtensions::default(),
881 license_requirement: None,
882 };
883
884 registry.register_operation(&spec);
885 assert_eq!(registry.operation_specs.len(), 1);
886 }
887
888 #[test]
889 fn response_headers_are_emitted_with_their_declared_types() {
890 let registry = OpenApiRegistryImpl::new();
891 let mut spec = spec_with_response("/submit", "submit", None);
892 spec.responses[0].headers = vec![
893 ResponseHeaderSpec::new(
894 "Location",
895 "Operation resource URI",
896 ResponseHeaderType::String,
897 ),
898 ResponseHeaderSpec::new(
899 "Retry-After",
900 "Retry delay in seconds",
901 ResponseHeaderType::Integer,
902 ),
903 ResponseHeaderSpec::new(
904 "Idempotency-Replayed",
905 "Whether this is a replay",
906 ResponseHeaderType::Boolean,
907 ),
908 ];
909 registry.register_operation(&spec);
910
911 let doc = registry.build_openapi(&test_info()).unwrap();
912 let json = serde_json::to_value(doc).unwrap();
913 let headers = &json["paths"]["/submit"]["get"]["responses"]["200"]["headers"];
914 assert_eq!(headers["Location"]["schema"]["type"], "string");
915 assert_eq!(headers["Location"]["description"], "Operation resource URI");
916 assert_eq!(headers["Retry-After"]["schema"]["type"], "integer");
917 assert_eq!(headers["Idempotency-Replayed"]["schema"]["type"], "boolean");
918 }
919
920 #[test]
921 fn response_content_types_with_the_same_status_are_combined() {
922 let registry = OpenApiRegistryImpl::new();
923 let mut spec = spec_with_response("/document", "document", None);
924 spec.responses[0].content_type = "text/plain";
925 spec.responses[0].description = "Plain document".to_owned();
926 spec.responses[0].headers = vec![ResponseHeaderSpec::new(
927 "X-Plain-Document",
928 "Whether plain text is available",
929 ResponseHeaderType::Boolean,
930 )];
931 spec.responses.push(
932 ResponseSpec::new(
933 http::StatusCode::OK.as_u16(),
934 "text/html",
935 "HTML document",
936 None,
937 )
938 .with_headers([ResponseHeaderSpec::new(
939 "X-HTML-Document",
940 "Whether HTML is available",
941 ResponseHeaderType::Boolean,
942 )]),
943 );
944 registry.register_operation(&spec);
945
946 let doc = registry.build_openapi(&test_info()).unwrap();
947 let json = serde_json::to_value(doc).unwrap();
948 let response = &json["paths"]["/document"]["get"]["responses"]["200"];
949 assert_eq!(response["description"], "HTML document");
950 assert_eq!(
951 response["content"]["text/plain"]["schema"]["format"],
952 "text/plain"
953 );
954 assert_eq!(
955 response["content"]["text/html"]["schema"]["format"],
956 "text/html"
957 );
958 assert_eq!(
959 response["headers"]["X-Plain-Document"]["schema"]["type"],
960 "boolean"
961 );
962 assert_eq!(
963 response["headers"]["X-HTML-Document"]["schema"]["type"],
964 "boolean"
965 );
966 }
967
968 #[test]
969 fn bodyless_response_can_declare_headers_without_content() {
970 let registry = OpenApiRegistryImpl::new();
971 let mut spec = spec_with_response("/jobs", "jobs", None);
972 spec.responses[0].content_type = "";
973 spec.responses[0].schema = None;
974 spec.responses[0].headers = vec![ResponseHeaderSpec::without_description(
975 "Retry-After",
976 ResponseHeaderType::Integer,
977 )];
978 registry.register_operation(&spec);
979
980 let doc = registry.build_openapi(&test_info()).unwrap();
981 let json = serde_json::to_value(doc).unwrap();
982 let response = &json["paths"]["/jobs"]["get"]["responses"]["200"];
983 assert!(response.get("content").is_none());
984 assert_eq!(
985 response["headers"]["Retry-After"]["schema"]["type"],
986 "integer"
987 );
988 assert!(
989 response["headers"]["Retry-After"]
990 .get("description")
991 .is_none()
992 );
993 }
994
995 #[test]
996 fn test_build_empty_openapi() {
997 let registry = OpenApiRegistryImpl::new();
998 let info = OpenApiInfo {
999 title: "Test API".to_owned(),
1000 version: "1.0.0".to_owned(),
1001 description: Some("Test API Description".to_owned()),
1002 servers: Vec::new(),
1003 };
1004 let doc = registry.build_openapi(&info).unwrap();
1005 let json = serde_json::to_value(&doc).unwrap();
1006
1007 assert!(json.get("openapi").is_some());
1009 assert!(json.get("info").is_some());
1010 assert!(json.get("paths").is_some());
1011
1012 let openapi_info = json.get("info").unwrap();
1014 assert_eq!(openapi_info.get("title").unwrap(), "Test API");
1015 assert_eq!(openapi_info.get("version").unwrap(), "1.0.0");
1016 assert_eq!(
1017 openapi_info.get("description").unwrap(),
1018 "Test API Description"
1019 );
1020 }
1021
1022 #[test]
1023 fn test_build_openapi_with_operation() {
1024 let registry = OpenApiRegistryImpl::new();
1025 let spec = OperationSpec {
1026 method: Method::GET,
1027 path: "/users/{id}".to_owned(),
1028 operation_id: Some("get_user".to_owned()),
1029 summary: Some("Get user by ID".to_owned()),
1030 description: Some("Retrieves a user by their ID".to_owned()),
1031 tags: vec!["users".to_owned()],
1032 params: vec![ParamSpec::path("id").description("User ID")],
1033 request_body: None,
1034 responses: vec![ResponseSpec {
1035 status: 200,
1036 content_type: "application/json",
1037 description: "User found".to_owned(),
1038 schema: None,
1039 headers: vec![],
1040 }],
1041 handler_id: "get_users_id".to_owned(),
1042 auth: operation_builder::RouteAuth::Anonymous,
1043 exposed: false,
1044 throttling: None,
1045 allowed_request_content_types: None,
1046 vendor_extensions: VendorExtensions::default(),
1047 license_requirement: None,
1048 };
1049
1050 registry.register_operation(&spec);
1051 let info = OpenApiInfo::default();
1052 let doc = registry.build_openapi(&info).unwrap();
1053 let json = serde_json::to_value(&doc).unwrap();
1054
1055 let paths = json.get("paths").unwrap();
1057 assert!(paths.get("/users/{id}").is_some());
1058
1059 let get_op = paths.get("/users/{id}").unwrap().get("get").unwrap();
1061 assert_eq!(get_op.get("operationId").unwrap(), "get_user");
1062 assert_eq!(get_op.get("summary").unwrap(), "Get user by ID");
1063 }
1064
1065 #[test]
1066 fn test_ensure_schema_raw() {
1067 let registry = OpenApiRegistryImpl::new();
1068 let schema = Schema::Object(ObjectBuilder::new().build());
1069 let schemas = vec![("TestSchema".to_owned(), RefOr::T(schema))];
1070
1071 let name = registry.ensure_schema_raw("TestSchema", schemas);
1072 assert_eq!(name, "TestSchema");
1073 assert_eq!(registry.components_registry.load().len(), 1);
1074 }
1075
1076 #[test]
1077 fn test_build_openapi_with_binary_request() {
1078 use crate::api::operation_builder::RequestBodySchema;
1079
1080 let registry = OpenApiRegistryImpl::new();
1081 let spec = OperationSpec {
1082 method: Method::POST,
1083 path: "/files/v1/upload".to_owned(),
1084 operation_id: Some("upload_file".to_owned()),
1085 summary: Some("Upload a file".to_owned()),
1086 description: Some("Upload raw binary file".to_owned()),
1087 tags: vec!["upload".to_owned()],
1088 params: vec![],
1089 request_body: Some(crate::api::operation_builder::RequestBodySpec {
1090 content_type: "application/octet-stream",
1091 description: Some("Raw file bytes".to_owned()),
1092 schema: RequestBodySchema::Binary,
1093 required: true,
1094 }),
1095 responses: vec![ResponseSpec {
1096 status: 200,
1097 content_type: "application/json",
1098 description: "Upload successful".to_owned(),
1099 schema: None,
1100 headers: vec![],
1101 }],
1102 handler_id: "post_upload".to_owned(),
1103 auth: operation_builder::RouteAuth::Anonymous,
1104 exposed: false,
1105 throttling: None,
1106 allowed_request_content_types: Some(vec!["application/octet-stream"]),
1107 vendor_extensions: VendorExtensions::default(),
1108 license_requirement: None,
1109 };
1110
1111 registry.register_operation(&spec);
1112 let info = OpenApiInfo::default();
1113 let doc = registry.build_openapi(&info).unwrap();
1114 let json = serde_json::to_value(&doc).unwrap();
1115
1116 let paths = json.get("paths").unwrap();
1118 assert!(paths.get("/files/v1/upload").is_some());
1119
1120 let post_op = paths.get("/files/v1/upload").unwrap().get("post").unwrap();
1122 let request_body = post_op.get("requestBody").unwrap();
1123 let content = request_body.get("content").unwrap();
1124 let octet_stream = content
1125 .get("application/octet-stream")
1126 .expect("application/octet-stream content type should exist");
1127
1128 let schema = octet_stream.get("schema").unwrap();
1130 assert_eq!(schema.get("type").unwrap(), "string");
1131 assert_eq!(schema.get("format").unwrap(), "binary");
1132
1133 assert_eq!(request_body.get("required").unwrap(), true);
1135 }
1136
1137 #[test]
1138 fn test_build_openapi_with_pagination() {
1139 let registry = OpenApiRegistryImpl::new();
1140
1141 let mut filter: operation_builder::ODataPagination<
1142 std::collections::BTreeMap<String, Vec<String>>,
1143 > = operation_builder::ODataPagination::default();
1144 filter.allowed_fields.insert(
1145 "name".to_owned(),
1146 vec!["eq", "ne", "contains", "startswith", "endswith", "in"]
1147 .into_iter()
1148 .map(String::from)
1149 .collect(),
1150 );
1151 filter.allowed_fields.insert(
1152 "age".to_owned(),
1153 vec!["eq", "ne", "gt", "ge", "lt", "le", "in"]
1154 .into_iter()
1155 .map(String::from)
1156 .collect(),
1157 );
1158
1159 let mut order_by: operation_builder::ODataPagination<Vec<String>> =
1160 operation_builder::ODataPagination::default();
1161 order_by.allowed_fields.push("name asc".to_owned());
1162 order_by.allowed_fields.push("name desc".to_owned());
1163 order_by.allowed_fields.push("age asc".to_owned());
1164 order_by.allowed_fields.push("age desc".to_owned());
1165
1166 let mut spec = OperationSpec {
1167 method: Method::GET,
1168 path: "/test".to_owned(),
1169 operation_id: Some("test_op".to_owned()),
1170 summary: Some("Test".to_owned()),
1171 description: None,
1172 tags: vec![],
1173 params: vec![],
1174 request_body: None,
1175 responses: vec![ResponseSpec {
1176 status: 200,
1177 content_type: "application/json",
1178 description: "OK".to_owned(),
1179 schema: None,
1180 headers: vec![],
1181 }],
1182 handler_id: "get_test".to_owned(),
1183 auth: operation_builder::RouteAuth::Anonymous,
1184 exposed: false,
1185 throttling: None,
1186 allowed_request_content_types: None,
1187 vendor_extensions: VendorExtensions::default(),
1188 license_requirement: None,
1189 };
1190 spec.vendor_extensions.x_odata_filter = Some(filter);
1191 spec.vendor_extensions.x_odata_orderby = Some(order_by);
1192
1193 registry.register_operation(&spec);
1194 let info = OpenApiInfo::default();
1195 let doc = registry.build_openapi(&info).unwrap();
1196 let json = serde_json::to_value(&doc).unwrap();
1197
1198 let paths = json.get("paths").unwrap();
1199 let op = paths.get("/test").unwrap().get("get").unwrap();
1200
1201 let filter_ext = op
1202 .get("x-odata-filter")
1203 .expect("x-odata-filter should be present");
1204
1205 let allowed_fields = filter_ext.get("allowedFields").unwrap();
1206 assert!(allowed_fields.get("name").is_some());
1207 assert!(allowed_fields.get("age").is_some());
1208
1209 let order_ext = op
1210 .get("x-odata-orderby")
1211 .expect("x-odata-orderby should be present");
1212
1213 let allowed_order = order_ext.get("allowedFields").unwrap().as_array().unwrap();
1214 assert!(allowed_order.iter().any(|v| v.as_str() == Some("name asc")));
1215 assert!(allowed_order.iter().any(|v| v.as_str() == Some("age desc")));
1216 }
1217
1218 #[test]
1219 fn platform_operations_require_the_internal_token_scheme() {
1220 let registry = OpenApiRegistryImpl::new();
1221 let mut spec = OperationSpec {
1222 method: Method::POST,
1223 path: "/tr/v1/entities".to_owned(),
1224 operation_id: Some("register".to_owned()),
1225 summary: None,
1226 description: None,
1227 tags: vec![],
1228 params: vec![],
1229 request_body: None,
1230 responses: vec![ResponseSpec {
1231 status: 202,
1232 content_type: "application/json",
1233 description: "Accepted".to_owned(),
1234 schema: None,
1235 headers: vec![],
1236 }],
1237 handler_id: "register".to_owned(),
1238 auth: operation_builder::RouteAuth::Platform,
1239 exposed: false,
1240 throttling: None,
1241 allowed_request_content_types: None,
1242 vendor_extensions: VendorExtensions::default(),
1243 license_requirement: None,
1244 };
1245 registry.register_operation(&spec);
1246 spec.method = Method::GET;
1247 spec.handler_id = "read".to_owned();
1248 spec.operation_id = Some("read".to_owned());
1249 spec.auth = operation_builder::RouteAuth::Authenticated;
1250 registry.register_operation(&spec);
1251
1252 let json =
1253 serde_json::to_value(registry.build_openapi(&OpenApiInfo::default()).unwrap()).unwrap();
1254 let item = &json["paths"]["/tr/v1/entities"];
1255 assert_eq!(
1256 item["post"]["security"],
1257 serde_json::json!([{ "internalToken": [] }]),
1258 "platform"
1259 );
1260 assert_eq!(
1261 item["get"]["security"],
1262 serde_json::json!([{ "bearerAuth": [] }]),
1263 "tenant-authenticated"
1264 );
1265 let scheme = &json["components"]["securitySchemes"]["internalToken"];
1266 assert_eq!(scheme["type"], "apiKey");
1267 assert_eq!(scheme["in"], "header");
1268 assert_eq!(scheme["name"], "x-toolkit-internal-token");
1269 }
1270
1271 #[test]
1272 fn test_public_operation_emits_visibility_extension() {
1273 let registry = OpenApiRegistryImpl::new();
1274 let public = OperationSpec {
1275 method: Method::GET,
1276 path: "/calc/v1/ping".to_owned(),
1277 operation_id: Some("ping".to_owned()),
1278 summary: Some("Ping".to_owned()),
1279 description: None,
1280 tags: vec![],
1281 params: vec![],
1282 request_body: None,
1283 responses: vec![ResponseSpec {
1284 status: 200,
1285 content_type: "application/json",
1286 description: "OK".to_owned(),
1287 schema: None,
1288 headers: vec![],
1289 }],
1290 handler_id: "get_ping".to_owned(),
1291 auth: operation_builder::RouteAuth::Anonymous,
1292 exposed: true,
1293 throttling: None,
1294 allowed_request_content_types: None,
1295 vendor_extensions: VendorExtensions::default(),
1296 license_requirement: None,
1297 };
1298 let mut internal = public.clone();
1300 internal.path = "/calc/v1/internal".to_owned();
1301 internal.handler_id = "get_internal".to_owned();
1302 internal.operation_id = Some("internal".to_owned());
1303 internal.exposed = false;
1304
1305 registry.register_operation(&public);
1306 registry.register_operation(&internal);
1307 let doc = registry.build_openapi(&OpenApiInfo::default()).unwrap();
1308 let json = serde_json::to_value(&doc).unwrap();
1309 let paths = json.get("paths").unwrap();
1310
1311 let public_op = paths.get("/calc/v1/ping").unwrap().get("get").unwrap();
1312 assert_eq!(
1313 public_op
1314 .get("x-toolkit-visibility")
1315 .and_then(|v| v.as_str()),
1316 Some("exposed"),
1317 "public operation must advertise the gateway visibility extension"
1318 );
1319
1320 let internal_op = paths.get("/calc/v1/internal").unwrap().get("get").unwrap();
1321 assert!(
1322 internal_op.get("x-toolkit-visibility").is_none(),
1323 "non-public operation must not carry the visibility extension"
1324 );
1325 }
1326
1327 #[test]
1328 fn a_platform_operation_declared_exposed_is_not_exposed() {
1329 let registry = OpenApiRegistryImpl::new();
1330 let platform = OperationSpec {
1331 method: Method::POST,
1332 path: "/calc/v1/platform".to_owned(),
1333 operation_id: Some("platform".to_owned()),
1334 summary: None,
1335 description: None,
1336 tags: vec![],
1337 params: vec![],
1338 request_body: None,
1339 responses: vec![ResponseSpec {
1340 status: 200,
1341 content_type: "application/json",
1342 description: "OK".to_owned(),
1343 schema: None,
1344 headers: vec![],
1345 }],
1346 handler_id: "post_platform".to_owned(),
1347 auth: operation_builder::RouteAuth::Platform,
1348 exposed: true,
1349 throttling: None,
1350 allowed_request_content_types: None,
1351 vendor_extensions: VendorExtensions::default(),
1352 license_requirement: None,
1353 };
1354
1355 registry.register_operation(&platform);
1356 let doc = registry.build_openapi(&OpenApiInfo::default()).unwrap();
1357 let json = serde_json::to_value(&doc).unwrap();
1358 let op = &json["paths"]["/calc/v1/platform"]["post"];
1359
1360 assert!(
1361 op.get("x-toolkit-visibility").is_none(),
1362 "no host may publish a platform operation"
1363 );
1364 assert_eq!(op["security"], serde_json::json!([{ "internalToken": [] }]));
1365 let stored = registry
1366 .operation_specs
1367 .get("POST:/calc/v1/platform")
1368 .expect("registered");
1369 assert!(
1370 !stored.exposed,
1371 "stored unexposed, so every host reads it so"
1372 );
1373 }
1374
1375 fn build_test_openapi(schemas: BTreeMap<String, RefOr<Schema>>) -> OpenApi {
1377 let mut components = ComponentsBuilder::new();
1378 for (name, schema) in schemas {
1379 components = components.schema(name, schema);
1380 }
1381 OpenApiBuilder::new()
1382 .components(Some(components.build()))
1383 .build()
1384 }
1385
1386 #[test]
1387 fn test_dangling_refs_detects_missing_in_components() {
1388 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1389 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1391 "type": "object",
1392 "properties": {
1393 "bar": { "$ref": "#/components/schemas/Bar" }
1394 }
1395 }))
1396 .unwrap();
1397 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1398
1399 let openapi = build_test_openapi(schemas);
1400 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1401 assert_eq!(dangling, vec!["Bar".to_owned()]);
1402 }
1403
1404 #[test]
1405 fn test_dangling_refs_no_false_positives() {
1406 let mut schemas: BTreeMap<String, RefOr<Schema>> = BTreeMap::new();
1407 let bar_schema = Schema::Object(ObjectBuilder::new().build());
1409 schemas.insert("Bar".to_owned(), RefOr::T(bar_schema));
1410
1411 let foo_schema = serde_json::from_value::<Schema>(serde_json::json!({
1413 "type": "object",
1414 "properties": {
1415 "bar": { "$ref": "#/components/schemas/Bar" }
1416 }
1417 }))
1418 .unwrap();
1419 schemas.insert("Foo".to_owned(), RefOr::T(foo_schema));
1420
1421 let openapi = build_test_openapi(schemas);
1422 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1423 assert!(
1424 dangling.is_empty(),
1425 "Expected no dangling refs but got: {dangling:?}"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_dangling_refs_detects_missing_in_operations() {
1431 let openapi_json = serde_json::json!({
1434 "openapi": "3.1.0",
1435 "info": { "title": "test", "version": "0.1.0" },
1436 "paths": {
1437 "/items": {
1438 "get": {
1439 "responses": {
1440 "200": {
1441 "description": "OK",
1442 "content": {
1443 "application/json": {
1444 "schema": { "$ref": "#/components/schemas/MissingDto" }
1445 }
1446 }
1447 }
1448 }
1449 }
1450 }
1451 },
1452 "components": {
1453 "schemas": {}
1454 }
1455 });
1456 let openapi: OpenApi = serde_json::from_value(openapi_json).unwrap();
1457 let dangling = collect_all_dangling_refs_in_openapi(&openapi);
1458 assert_eq!(dangling, vec!["MissingDto".to_owned()]);
1459 }
1460
1461 #[test]
1468 fn array_response_emits_inline_array_referencing_item() {
1469 let registry = OpenApiRegistryImpl::new();
1470 registry.register_operation(&spec_with_response(
1471 "/gears",
1472 "list_gears",
1473 Some(ResponseSchema::Array {
1474 items_schema_name: "GearDto".to_owned(),
1475 }),
1476 ));
1477
1478 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1479 let schema = response_schema_json(&doc, "/gears");
1480
1481 assert_eq!(schema["type"], "array");
1482 assert_eq!(schema["items"]["$ref"], "#/components/schemas/GearDto");
1483 assert!(schema.get("$ref").is_none());
1485 assert!(doc["components"]["schemas"].get("Vec").is_none());
1486 }
1487
1488 #[test]
1489 fn ref_response_still_emits_plain_ref() {
1490 let registry = OpenApiRegistryImpl::new();
1491 registry.register_operation(&spec_with_response(
1492 "/gear",
1493 "get_gear",
1494 Some(ResponseSchema::Ref {
1495 schema_name: "GearDto".to_owned(),
1496 }),
1497 ));
1498
1499 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1500 let schema = response_schema_json(&doc, "/gear");
1501
1502 assert_eq!(schema["$ref"], "#/components/schemas/GearDto");
1503 assert!(schema.get("type").is_none());
1504 }
1505
1506 #[test]
1512 fn multipart_mixed_response_emits_the_item_ref_under_a_bare_media_type() {
1513 let registry = OpenApiRegistryImpl::new();
1514 let mut spec = spec_with_response(
1515 "/events",
1516 "stream_events",
1517 Some(ResponseSchema::Ref {
1518 schema_name: "FrameDto".to_owned(),
1519 }),
1520 );
1521 spec.responses[0].content_type = "multipart/mixed";
1522 registry.register_operation(&spec);
1523
1524 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1525 let content = &doc["paths"]["/events"]["get"]["responses"]["200"]["content"];
1526
1527 assert_eq!(
1528 content["multipart/mixed"]["schema"]["$ref"],
1529 "#/components/schemas/FrameDto"
1530 );
1531 assert_eq!(
1533 content.as_object().map(|o| o.keys().collect::<Vec<_>>()),
1534 Some(vec![&"multipart/mixed".to_owned()])
1535 );
1536 assert!(content["multipart/mixed"]["schema"].get("format").is_none());
1540 }
1541
1542 #[test]
1543 fn schemaless_json_response_still_emits_free_form_object() {
1544 let registry = OpenApiRegistryImpl::new();
1545 registry.register_operation(&spec_with_response("/any", "any_op", None));
1546
1547 let doc = serde_json::to_value(registry.build_openapi(&test_info()).unwrap()).unwrap();
1548 let schema = response_schema_json(&doc, "/any");
1549
1550 assert!(schema.get("$ref").is_none());
1551 assert_ne!(schema["type"], "array");
1552 }
1553
1554 #[test]
1558 fn two_distinct_array_responses_do_not_collide() {
1559 #[derive(utoipa::ToSchema)]
1560 #[allow(dead_code)]
1561 struct AlphaDto {
1562 alpha: String,
1563 }
1564 #[derive(utoipa::ToSchema)]
1565 #[allow(dead_code)]
1566 struct BetaDto {
1567 beta: i32,
1568 }
1569
1570 let registry = OpenApiRegistryImpl::new();
1571 let a = ensure_schema::<AlphaDto>(®istry);
1573 let b = ensure_schema::<BetaDto>(®istry);
1574 assert_eq!((a.as_str(), b.as_str()), ("AlphaDto", "BetaDto"));
1575
1576 registry.register_operation(&spec_with_response(
1577 "/alphas",
1578 "list_alphas",
1579 Some(ResponseSchema::Array {
1580 items_schema_name: a,
1581 }),
1582 ));
1583 registry.register_operation(&spec_with_response(
1584 "/betas",
1585 "list_betas",
1586 Some(ResponseSchema::Array {
1587 items_schema_name: b,
1588 }),
1589 ));
1590
1591 let openapi = registry.build_openapi(&test_info()).unwrap();
1592 assert!(
1593 collect_all_dangling_refs_in_openapi(&openapi).is_empty(),
1594 "array item refs must point at registered components"
1595 );
1596
1597 let doc = serde_json::to_value(&openapi).unwrap();
1598 let schemas = &doc["components"]["schemas"];
1599 assert!(schemas.get("AlphaDto").is_some());
1600 assert!(schemas.get("BetaDto").is_some());
1601 assert!(schemas.get("Vec").is_none());
1602 assert_eq!(
1603 response_schema_json(&doc, "/alphas")["items"]["$ref"],
1604 "#/components/schemas/AlphaDto"
1605 );
1606 assert_eq!(
1607 response_schema_json(&doc, "/betas")["items"]["$ref"],
1608 "#/components/schemas/BetaDto"
1609 );
1610 }
1611
1612 #[test]
1613 #[should_panic(expected = "would register the component name `Vec`")]
1614 fn ensure_schema_rejects_vec_directly() {
1615 #[derive(utoipa::ToSchema)]
1616 #[allow(dead_code)]
1617 struct ItemDto {
1618 x: u8,
1619 }
1620 let registry = OpenApiRegistryImpl::new();
1621 let _ = ensure_schema::<Vec<ItemDto>>(®istry);
1622 }
1623
1624 #[test]
1625 #[should_panic(expected = "OpenAPI schema name collision")]
1626 fn ensure_schema_raw_panics_on_conflicting_definition() {
1627 let registry = OpenApiRegistryImpl::new();
1628 registry.ensure_schema_raw(
1629 "Dup",
1630 vec![("Dup".to_owned(), RefOr::Ref(Ref::from_schema_name("First")))],
1631 );
1632 registry.ensure_schema_raw(
1633 "Dup",
1634 vec![(
1635 "Dup".to_owned(),
1636 RefOr::Ref(Ref::from_schema_name("Second")),
1637 )],
1638 );
1639 }
1640
1641 #[test]
1642 fn ensure_schema_raw_allows_identical_reregistration() {
1643 let registry = OpenApiRegistryImpl::new();
1644 let entry = || {
1645 vec![(
1646 "Same".to_owned(),
1647 RefOr::Ref(Ref::from_schema_name("Target")),
1648 )]
1649 };
1650 registry.ensure_schema_raw("Same", entry());
1651 registry.ensure_schema_raw("Same", entry());
1652 assert_eq!(registry.components_registry.load().len(), 1);
1653 }
1654}