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