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