1use std::collections::{BTreeSet, HashMap, HashSet};
13use std::fmt;
14use std::sync::Arc;
15
16use bytes::Bytes;
17use utoipa::openapi::security::{Flow, HttpAuthScheme, HttpBuilder, OAuth2, SecurityScheme};
18use utoipa::openapi::{
19 ContactBuilder, InfoBuilder, License, OpenApi, OpenApiBuilder, ServerBuilder,
20};
21
22#[derive(Clone)]
31pub struct ApiDoc {
32 pub openapi: Arc<OpenApi>,
34 pub spec_json: Bytes,
37}
38
39impl fmt::Debug for ApiDoc {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 f.debug_struct("ApiDoc")
42 .field("title", &self.openapi.info.title)
43 .field("version", &self.openapi.info.version)
44 .field("paths", &self.openapi.paths.paths.len())
45 .field("spec_json_bytes", &self.spec_json.len())
46 .finish()
47 }
48}
49
50#[derive(Debug)]
52pub enum BuildError {
53 Serialize(serde_json::Error),
57}
58
59impl fmt::Display for BuildError {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 Self::Serialize(e) => write!(f, "failed to serialize OpenAPI document: {e}"),
63 }
64 }
65}
66
67impl std::error::Error for BuildError {
68 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
69 match self {
70 Self::Serialize(e) => Some(e),
71 }
72 }
73}
74
75#[derive(Default)]
96pub struct ApiDocBuilder {
97 title: Option<String>,
98 version: Option<String>,
99 description: Option<String>,
100 contact_name: Option<String>,
101 contact_email: Option<String>,
102 contact_url: Option<String>,
103 license_name: Option<String>,
104 license_url: Option<String>,
105 servers: Vec<(String, Option<String>)>,
106 security_schemes: Vec<(String, SecurityScheme)>,
107 tags: Vec<(String, String)>,
110 tag_groups: Vec<(String, Vec<String>)>,
112 default_tag_group: Option<String>,
115 tag_group_delimiter: Option<String>,
118 schema_tags: Vec<(String, String)>,
119 base: Option<OpenApi>,
120 sse_spec_version: SseSpecVersion,
123}
124
125#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
144pub enum SseSpecVersion {
145 V3_1,
149 #[default]
152 V3_2,
153}
154
155impl ApiDocBuilder {
156 pub fn new() -> Self {
159 Self::default()
160 }
161
162 pub fn title(mut self, title: impl Into<String>) -> Self {
164 self.title = Some(title.into());
165 self
166 }
167
168 pub fn version(mut self, version: impl Into<String>) -> Self {
171 self.version = Some(version.into());
172 self
173 }
174
175 pub fn description(mut self, description: impl Into<String>) -> Self {
178 self.description = Some(description.into());
179 self
180 }
181
182 pub fn contact_name(mut self, name: impl Into<String>) -> Self {
184 self.contact_name = Some(name.into());
185 self
186 }
187
188 pub fn contact_email(mut self, email: impl Into<String>) -> Self {
190 self.contact_email = Some(email.into());
191 self
192 }
193
194 pub fn contact_url(mut self, url: impl Into<String>) -> Self {
196 self.contact_url = Some(url.into());
197 self
198 }
199
200 pub fn license(mut self, name: impl Into<String>) -> Self {
202 self.license_name = Some(name.into());
203 self
204 }
205
206 pub fn license_url(mut self, url: impl Into<String>) -> Self {
208 self.license_url = Some(url.into());
209 self
210 }
211
212 pub fn server(mut self, url: impl Into<String>, description: impl Into<String>) -> Self {
215 let description = description.into();
216 let description = if description.is_empty() {
217 None
218 } else {
219 Some(description)
220 };
221 self.servers.push((url.into(), description));
222 self
223 }
224
225 pub fn bearer_security(self, name: impl Into<String>) -> Self {
234 self.bearer_security_with_format(name, "JWT")
235 }
236
237 pub fn bearer_security_with_format(
245 mut self,
246 name: impl Into<String>,
247 bearer_format: impl Into<String>,
248 ) -> Self {
249 let scheme = SecurityScheme::Http(
250 HttpBuilder::new()
251 .scheme(HttpAuthScheme::Bearer)
252 .bearer_format(bearer_format)
253 .build(),
254 );
255 self.security_schemes.push((name.into(), scheme));
256 self
257 }
258
259 pub fn security_scheme(mut self, name: impl Into<String>, scheme: SecurityScheme) -> Self {
262 self.security_schemes.push((name.into(), scheme));
263 self
264 }
265
266 pub fn oauth2_security(
279 mut self,
280 name: impl Into<String>,
281 flows: impl IntoIterator<Item = Flow>,
282 ) -> Self {
283 self.security_schemes
284 .push((name.into(), SecurityScheme::OAuth2(OAuth2::new(flows))));
285 self
286 }
287
288 pub fn tag(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
298 self.tags.push((name.into(), description.into()));
299 self
300 }
301
302 pub fn tag_group(
316 mut self,
317 name: impl Into<String>,
318 tags: impl IntoIterator<Item = impl Into<String>>,
319 ) -> Self {
320 self.tag_groups
321 .push((name.into(), tags.into_iter().map(Into::into).collect()));
322 self
323 }
324
325 pub fn default_tag_group(mut self, name: impl Into<String>) -> Self {
332 self.default_tag_group = Some(name.into());
333 self
334 }
335
336 pub fn tag_group_delimiter(mut self, delimiter: impl Into<String>) -> Self {
344 self.tag_group_delimiter = Some(delimiter.into());
345 self
346 }
347
348 pub fn schema_tag(mut self, schema: impl Into<String>, tag: impl Into<String>) -> Self {
354 self.schema_tags.push((schema.into(), tag.into()));
355 self
356 }
357
358 pub fn sse_openapi_version(mut self, version: SseSpecVersion) -> Self {
383 self.sse_spec_version = version;
384 self
385 }
386
387 pub fn merge(mut self, openapi: OpenApi) -> Self {
392 match self.base.as_mut() {
393 Some(base) => base.merge(openapi),
394 None => self.base = Some(openapi),
395 }
396 self
397 }
398
399 pub fn try_build(self) -> Result<ApiDoc, BuildError> {
405 let mut doc = self.base.unwrap_or_else(|| OpenApiBuilder::new().build());
406
407 let mut info = InfoBuilder::new()
411 .title(self.title.unwrap_or_else(|| doc.info.title.clone()))
412 .version(self.version.unwrap_or_else(|| doc.info.version.clone()));
413 if let Some(description) = self.description.or(doc.info.description.clone()) {
414 info = info.description(Some(description));
415 }
416 if self.contact_name.is_some() || self.contact_email.is_some() || self.contact_url.is_some()
417 {
418 let mut contact = ContactBuilder::new();
419 if let Some(name) = self.contact_name {
420 contact = contact.name(Some(name));
421 }
422 if let Some(email) = self.contact_email {
423 contact = contact.email(Some(email));
424 }
425 if let Some(url) = self.contact_url {
426 contact = contact.url(Some(url));
427 }
428 info = info.contact(Some(contact.build()));
429 }
430 if let Some(name) = self.license_name {
431 let mut license = License::new(name);
432 if let Some(url) = self.license_url {
433 license.url = Some(url);
434 }
435 info = info.license(Some(license));
436 }
437 doc.info = info.build();
438
439 if !self.servers.is_empty() {
442 let servers = self
443 .servers
444 .into_iter()
445 .map(|(url, description)| {
446 let mut server = ServerBuilder::new().url(url);
447 if let Some(description) = description {
448 server = server.description(Some(description));
449 }
450 server.build()
451 })
452 .collect::<Vec<_>>();
453 doc.servers = Some(servers);
454 }
455
456 if !self.security_schemes.is_empty() {
459 let components = doc
460 .components
461 .get_or_insert_with(utoipa::openapi::Components::new);
462 for (name, scheme) in self.security_schemes {
463 components.security_schemes.insert(name, scheme);
464 }
465 }
466
467 {
476 use utoipa::openapi::tag::TagBuilder;
477
478 let mut discovered: BTreeSet<String> = BTreeSet::new();
480 for path_item in doc.paths.paths.values() {
481 for op in crate::contribution::path_item_operations(path_item) {
482 if let Some(ref tags) = op.tags {
483 discovered.extend(tags.iter().cloned());
484 }
485 }
486 }
487
488 let explicit_descs: HashMap<String, String> = self.tags.iter().cloned().collect();
490 let explicit_order: Vec<String> = self.tags.iter().map(|(n, _)| n.clone()).collect();
491
492 let mut ordered_tags: Vec<String> =
495 Vec::with_capacity(explicit_order.len() + discovered.len());
496 let mut seen: HashSet<&str> = HashSet::with_capacity(ordered_tags.capacity());
497 for name in explicit_order.iter().chain(discovered.iter()) {
498 if seen.insert(name.as_str()) {
499 ordered_tags.push(name.clone());
500 }
501 }
502
503 if !ordered_tags.is_empty() {
505 doc.tags = Some(
506 ordered_tags
507 .iter()
508 .map(|name| {
509 let mut b = TagBuilder::new().name(name);
510 if let Some(desc) = explicit_descs.get(name) {
511 b = b.description(Some(desc.clone()));
512 }
513 b.build()
514 })
515 .collect(),
516 );
517 }
518
519 let groups_json: Vec<serde_json::Value> = if !self.tag_groups.is_empty() {
521 self.tag_groups
523 .into_iter()
524 .map(|(name, tags)| serde_json::json!({ "name": name, "tags": tags }))
525 .collect()
526 } else if !ordered_tags.is_empty() {
527 let delimiter = self.tag_group_delimiter.as_deref().unwrap_or(": ");
529 let default_group = self.default_tag_group.as_deref().unwrap_or("API");
530 auto_tag_groups(&ordered_tags, delimiter, default_group)
531 } else {
532 Vec::new()
533 };
534
535 if !groups_json.is_empty() {
536 use utoipa::openapi::extensions::ExtensionsBuilder;
537 let ext = ExtensionsBuilder::new()
538 .add("x-tagGroups", serde_json::Value::Array(groups_json))
539 .build();
540 match doc.extensions.as_mut() {
541 Some(existing) => existing.merge(ext),
542 None => doc.extensions = Some(ext),
543 }
544 }
545 }
546
547 {
561 use utoipa::PartialSchema;
562 let components = doc
563 .components
564 .get_or_insert_with(utoipa::openapi::Components::new);
565 components
566 .schemas
567 .entry("ApiErrorBody".to_string())
568 .or_insert_with(<crate::ApiErrorBody as utoipa::PartialSchema>::schema);
569 components
570 .schemas
571 .entry("ProblemDetails".to_string())
572 .or_insert_with(crate::ProblemDetails::schema);
573 components
574 .schemas
575 .entry("Value".to_string())
576 .or_insert_with(<serde_json::Value as utoipa::PartialSchema>::schema);
577 }
578
579 {
584 use utoipa::openapi::RefOr;
585
586 let mut schema_tag_map: HashMap<String, BTreeSet<String>> = HashMap::new();
587
588 for (schema, tag) in self.schema_tags {
590 schema_tag_map.entry(schema).or_default().insert(tag);
591 }
592
593 for path_item in doc.paths.paths.values() {
595 for op in crate::contribution::path_item_operations(path_item) {
596 let op_tags = match &op.tags {
597 Some(t) if !t.is_empty() => t,
598 _ => continue,
599 };
600
601 if let Some(ref body) = op.request_body {
603 collect_content_refs(body.content.values(), op_tags, &mut schema_tag_map);
604 }
605
606 for resp in op.responses.responses.values() {
608 if let RefOr::T(ref response) = resp {
609 collect_content_refs(
610 response.content.values(),
611 op_tags,
612 &mut schema_tag_map,
613 );
614 }
615 }
616 }
617 }
618
619 if let Some(ref mut components) = doc.components {
621 for (schema_name, tags) in &schema_tag_map {
622 if let Some(RefOr::T(ref mut schema)) = components.schemas.get_mut(schema_name)
623 {
624 if let Some(slot) = schema_extensions_mut(schema) {
625 let tags_json: Vec<serde_json::Value> = tags
626 .iter()
627 .map(|t| serde_json::Value::String(t.clone()))
628 .collect();
629 let ext = utoipa::openapi::extensions::ExtensionsBuilder::new()
630 .add("x-tags", serde_json::Value::Array(tags_json))
631 .build();
632 match slot.as_mut() {
633 Some(existing) => existing.merge(ext),
634 None => *slot = Some(ext),
635 }
636 }
637 }
638 }
639 }
640 }
641
642 let mut value = serde_json::to_value(&doc).map_err(BuildError::Serialize)?;
651 apply_sse_spec_version(&mut value, self.sse_spec_version);
652 let spec_json = serde_json::to_vec(&value).map_err(BuildError::Serialize)?;
653 Ok(ApiDoc {
654 openapi: Arc::new(doc),
655 spec_json: Bytes::from(spec_json),
656 })
657 }
658
659 pub fn build(self) -> ApiDoc {
663 self.try_build().expect("OpenAPI document serialization")
664 }
665}
666
667fn apply_sse_spec_version(value: &mut serde_json::Value, version: SseSpecVersion) {
689 use serde_json::Value;
690
691 let Some(obj) = value.as_object_mut() else {
692 return;
693 };
694
695 let mut any_sse = false;
696 if let Some(Value::Object(paths)) = obj.get_mut("paths") {
697 for path_item in paths.values_mut() {
698 let Some(path_obj) = path_item.as_object_mut() else {
699 continue;
700 };
701 for op in path_obj.values_mut() {
702 let Some(op_obj) = op.as_object_mut() else {
703 continue;
704 };
705 let Some(Value::Object(responses)) = op_obj.get_mut("responses") else {
706 continue;
707 };
708 for resp in responses.values_mut() {
709 let Some(resp_obj) = resp.as_object_mut() else {
710 continue;
711 };
712 let Some(Value::Object(content)) = resp_obj.get_mut("content") else {
713 continue;
714 };
715 let Some(Value::Object(sse_entry)) = content.get_mut("text/event-stream")
716 else {
717 continue;
718 };
719
720 if !matches!(sse_entry.remove("x-sse-stream"), Some(Value::Bool(true))) {
725 continue;
726 }
727 any_sse = true;
728
729 if matches!(version, SseSpecVersion::V3_2) {
730 if let Some(schema) = sse_entry.remove("schema") {
731 sse_entry.insert("itemSchema".to_string(), schema);
732 }
733 }
734 }
735 }
736 }
737 }
738
739 if any_sse && matches!(version, SseSpecVersion::V3_2) {
740 obj.insert("openapi".to_string(), Value::String("3.2.0".to_string()));
741 }
742}
743
744fn auto_tag_groups(
751 tags: &[String],
752 delimiter: &str,
753 default_group: &str,
754) -> Vec<serde_json::Value> {
755 let mut group_order: Vec<String> = Vec::new();
757 let mut group_map: HashMap<String, Vec<String>> = HashMap::new();
758
759 for tag in tags {
760 let group_name = match tag.find(delimiter) {
761 Some(idx) => &tag[..idx],
762 None => default_group,
763 };
764 let entry = group_map.entry(group_name.to_string()).or_insert_with(|| {
765 group_order.push(group_name.to_string());
766 Vec::new()
767 });
768 entry.push(tag.clone());
769 }
770
771 group_order
772 .into_iter()
773 .map(|name| {
774 let tags = group_map.remove(&name).unwrap_or_default();
775 serde_json::json!({ "name": name, "tags": tags })
776 })
777 .collect()
778}
779
780fn collect_content_refs<V>(
786 content: impl IntoIterator<Item = V>,
787 op_tags: &[String],
788 out: &mut HashMap<String, BTreeSet<String>>,
789) where
790 V: std::borrow::Borrow<utoipa::openapi::content::Content>,
791{
792 use utoipa::openapi::RefOr;
793
794 for c in content {
795 let c = c.borrow();
796 let schema = match &c.schema {
797 Some(s) => s,
798 None => continue,
799 };
800 if let RefOr::Ref(r) = schema {
801 if let Some(name) = r.ref_location.strip_prefix("#/components/schemas/") {
802 let entry = out.entry(name.to_string()).or_default();
803 entry.extend(op_tags.iter().cloned());
804 }
805 }
806 }
807}
808
809fn schema_extensions_mut(
815 schema: &mut utoipa::openapi::schema::Schema,
816) -> Option<&mut Option<utoipa::openapi::extensions::Extensions>> {
817 use utoipa::openapi::schema::Schema;
818 match schema {
819 Schema::Object(o) => Some(&mut o.extensions),
820 Schema::Array(a) => Some(&mut a.extensions),
821 Schema::OneOf(o) => Some(&mut o.extensions),
822 Schema::AllOf(a) => Some(&mut a.extensions),
823 Schema::AnyOf(a) => Some(&mut a.extensions),
824 _ => None,
825 }
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 #[test]
833 fn build_minimal_document() {
834 let doc = ApiDocBuilder::new().title("test").version("1.2.3").build();
835 assert_eq!(doc.openapi.info.title, "test");
836 assert_eq!(doc.openapi.info.version, "1.2.3");
837 assert!(!doc.spec_json.is_empty());
839 let _: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
840 }
841
842 #[test]
843 fn description_appears_in_serialized_spec() {
844 let doc = ApiDocBuilder::new()
845 .title("test")
846 .version("0.1")
847 .description("hello world")
848 .build();
849 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
850 assert_eq!(parsed["info"]["description"], "hello world");
851 }
852
853 #[test]
854 fn server_entry_is_recorded() {
855 let doc = ApiDocBuilder::new()
856 .title("test")
857 .version("0.1")
858 .server("/api", "primary")
859 .build();
860 let servers = doc.openapi.servers.as_ref().unwrap();
861 assert_eq!(servers.len(), 1);
862 assert_eq!(servers[0].url, "/api");
863 assert_eq!(servers[0].description.as_deref(), Some("primary"));
864 }
865
866 #[test]
867 fn bearer_security_scheme_is_registered() {
868 let doc = ApiDocBuilder::new()
869 .title("test")
870 .version("0.1")
871 .bearer_security("bearer")
872 .build();
873 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
874 let schemes = &parsed["components"]["securitySchemes"]["bearer"];
875 assert_eq!(schemes["type"], "http");
876 assert_eq!(schemes["scheme"], "bearer");
877 }
878
879 #[test]
880 fn bearer_security_defaults_to_jwt_format() {
881 let doc = ApiDocBuilder::new()
882 .title("test")
883 .version("0.1")
884 .bearer_security("bearer")
885 .build();
886 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
887 let schemes = &parsed["components"]["securitySchemes"]["bearer"];
888 assert_eq!(schemes["bearerFormat"], "JWT");
889 }
890
891 #[test]
892 fn bearer_security_with_format_overrides_bearer_format() {
893 let doc = ApiDocBuilder::new()
894 .title("test")
895 .version("0.1")
896 .bearer_security_with_format("jwt", "opaque")
897 .build();
898 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
899 let schemes = &parsed["components"]["securitySchemes"]["jwt"];
900 assert_eq!(schemes["type"], "http");
901 assert_eq!(schemes["scheme"], "bearer");
902 assert_eq!(schemes["bearerFormat"], "opaque");
903 }
904
905 #[test]
906 fn merge_preserves_paths_from_base() {
907 use utoipa::openapi::{
909 path::{HttpMethod, OperationBuilder},
910 PathItem, PathsBuilder,
911 };
912 let path_item = PathItem::new(HttpMethod::Get, OperationBuilder::new().build());
913 let paths = PathsBuilder::new().path("/example", path_item).build();
914 let base = OpenApiBuilder::new().paths(paths).build();
915 let doc = ApiDocBuilder::new()
916 .title("test")
917 .version("0.1")
918 .merge(base)
919 .build();
920 assert!(doc.openapi.paths.paths.contains_key("/example"));
921 }
922
923 #[test]
924 fn license_appears_in_serialized_spec() {
925 let doc = ApiDocBuilder::new()
926 .title("test")
927 .version("0.1")
928 .license("MIT")
929 .license_url("https://opensource.org/licenses/MIT")
930 .build();
931 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
932 assert_eq!(parsed["info"]["license"]["name"], "MIT");
933 assert_eq!(
934 parsed["info"]["license"]["url"],
935 "https://opensource.org/licenses/MIT"
936 );
937 }
938
939 #[test]
940 fn contact_block_appears_when_any_field_set() {
941 let doc = ApiDocBuilder::new()
942 .title("test")
943 .version("0.1")
944 .contact_name("Ops")
945 .contact_email("ops@example.com")
946 .contact_url("https://example.com/contact")
947 .build();
948 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
949 assert_eq!(parsed["info"]["contact"]["name"], "Ops");
950 assert_eq!(parsed["info"]["contact"]["email"], "ops@example.com");
951 assert_eq!(
952 parsed["info"]["contact"]["url"],
953 "https://example.com/contact"
954 );
955 }
956
957 #[test]
958 fn multiple_servers_are_recorded_in_order() {
959 let doc = ApiDocBuilder::new()
960 .title("test")
961 .version("0.1")
962 .server("/", "primary")
963 .server("https://staging.example.com", "staging")
964 .build();
965 let servers = doc.openapi.servers.as_ref().unwrap();
966 assert_eq!(servers.len(), 2);
967 assert_eq!(servers[0].url, "/");
968 assert_eq!(servers[1].url, "https://staging.example.com");
969 assert_eq!(servers[1].description.as_deref(), Some("staging"));
970 }
971
972 #[test]
973 fn merge_then_override_uses_builder_info_fields() {
974 let base = OpenApiBuilder::new()
977 .info(
978 InfoBuilder::new()
979 .title("from-base")
980 .version("9.9.9")
981 .build(),
982 )
983 .build();
984 let doc = ApiDocBuilder::new()
985 .title("from-builder")
986 .version("0.1")
987 .merge(base)
988 .build();
989 assert_eq!(doc.openapi.info.title, "from-builder");
990 assert_eq!(doc.openapi.info.version, "0.1");
991 }
992
993 #[test]
994 fn build_without_title_inherits_from_merged_base() {
995 let base = OpenApiBuilder::new()
998 .info(
999 InfoBuilder::new()
1000 .title("base-title")
1001 .version("3.0.0")
1002 .build(),
1003 )
1004 .build();
1005 let doc = ApiDocBuilder::new().merge(base).build();
1006 assert_eq!(doc.openapi.info.title, "base-title");
1007 assert_eq!(doc.openapi.info.version, "3.0.0");
1008 }
1009
1010 #[test]
1011 fn server_with_empty_description_omits_the_field() {
1012 let doc = ApiDocBuilder::new()
1013 .title("t")
1014 .version("0.1")
1015 .server("/api", "")
1016 .build();
1017 let servers = doc.openapi.servers.as_ref().unwrap();
1018 assert_eq!(servers.len(), 1);
1019 assert!(servers[0].description.is_none());
1020 }
1021
1022 #[test]
1023 fn spec_json_clone_is_shallow() {
1024 let doc = ApiDocBuilder::new().title("t").version("0.1").build();
1025 let cloned = doc.clone();
1026 assert_eq!(doc.spec_json.as_ptr(), cloned.spec_json.as_ptr());
1029 assert!(Arc::ptr_eq(&doc.openapi, &cloned.openapi));
1030 }
1031
1032 #[test]
1033 fn tag_metadata_appears_in_serialized_spec() {
1034 let doc = ApiDocBuilder::new()
1035 .title("test")
1036 .version("0.1")
1037 .tag("Models", "CRUD operations for data models")
1038 .tag("Compute", "Query execution and charting")
1039 .build();
1040 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1041 let tags = parsed["tags"].as_array().expect("tags array present");
1042 assert_eq!(tags.len(), 2);
1043 assert_eq!(tags[0]["name"], "Models");
1044 assert_eq!(tags[0]["description"], "CRUD operations for data models");
1045 assert_eq!(tags[1]["name"], "Compute");
1046 assert_eq!(tags[1]["description"], "Query execution and charting");
1047 }
1048
1049 #[test]
1050 fn tag_order_is_preserved() {
1051 let doc = ApiDocBuilder::new()
1052 .title("t")
1053 .version("0.1")
1054 .tag("Z", "last")
1055 .tag("A", "first")
1056 .build();
1057 let tags = doc.openapi.tags.as_ref().unwrap();
1058 assert_eq!(tags[0].name, "Z");
1059 assert_eq!(tags[1].name, "A");
1060 }
1061
1062 #[test]
1063 fn explicit_tag_groups_disable_auto_grouping() {
1064 let doc = ApiDocBuilder::new()
1065 .title("t")
1066 .version("0.1")
1067 .tag_group("Public", ["Models", "Compute"])
1068 .tag_group("Admin", ["Admin: Models"])
1069 .build();
1070 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1071 let groups = parsed["x-tagGroups"]
1072 .as_array()
1073 .expect("x-tagGroups present");
1074 assert_eq!(groups.len(), 2);
1075 assert_eq!(groups[0]["name"], "Public");
1076 assert_eq!(groups[0]["tags"], serde_json::json!(["Models", "Compute"]));
1077 assert_eq!(groups[1]["name"], "Admin");
1078 assert_eq!(groups[1]["tags"], serde_json::json!(["Admin: Models"]));
1079 }
1080
1081 fn openapi_with_tagged_ops(tag_pairs: &[(&str, &str)]) -> OpenApi {
1083 use utoipa::openapi::path::{HttpMethod, OperationBuilder, PathItem};
1084 use utoipa::openapi::PathsBuilder;
1085
1086 let mut paths = PathsBuilder::new();
1087 for (path, tag) in tag_pairs {
1088 let op = OperationBuilder::new().tag(*tag).build();
1089 paths = paths.path(*path, PathItem::new(HttpMethod::Get, op));
1090 }
1091 OpenApiBuilder::new().paths(paths.build()).build()
1092 }
1093
1094 #[test]
1095 fn auto_discovers_tags_from_operations() {
1096 let base = openapi_with_tagged_ops(&[("/a", "Alpha"), ("/b", "Beta")]);
1097 let doc = ApiDocBuilder::new()
1098 .title("t")
1099 .version("0.1")
1100 .merge(base)
1101 .build();
1102 let tags = doc.openapi.tags.as_ref().expect("tags present");
1103 let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
1104 assert!(names.contains(&"Alpha"));
1105 assert!(names.contains(&"Beta"));
1106 }
1107
1108 #[test]
1109 fn explicit_tags_appear_before_discovered_tags() {
1110 let base = openapi_with_tagged_ops(&[("/a", "Alpha"), ("/b", "Beta")]);
1111 let doc = ApiDocBuilder::new()
1112 .title("t")
1113 .version("0.1")
1114 .tag("Beta", "explicitly first")
1115 .merge(base)
1116 .build();
1117 let tags = doc.openapi.tags.as_ref().expect("tags present");
1118 assert_eq!(tags[0].name, "Beta");
1120 assert_eq!(
1121 tags[0].description.as_deref(),
1122 Some("explicitly first"),
1123 "explicit description wins"
1124 );
1125 assert_eq!(tags[1].name, "Alpha");
1126 assert!(
1127 tags[1].description.is_none(),
1128 "auto-discovered tag has no description"
1129 );
1130 }
1131
1132 #[test]
1133 fn auto_groups_tags_by_colon_delimiter() {
1134 let base = openapi_with_tagged_ops(&[
1135 ("/models", "Models"),
1136 ("/compute", "Compute"),
1137 ("/admin/models", "Admin: Models"),
1138 ("/admin/auth", "Admin: Auth"),
1139 ]);
1140 let doc = ApiDocBuilder::new()
1141 .title("t")
1142 .version("0.1")
1143 .default_tag_group("Public API")
1144 .merge(base)
1145 .build();
1146 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1147 let groups = parsed["x-tagGroups"]
1148 .as_array()
1149 .expect("x-tagGroups present");
1150
1151 assert_eq!(groups.len(), 2);
1154
1155 let group_names: Vec<&str> = groups.iter().map(|g| g["name"].as_str().unwrap()).collect();
1159 assert!(group_names.contains(&"Admin"));
1160 assert!(group_names.contains(&"Public API"));
1161
1162 let admin_group = groups.iter().find(|g| g["name"] == "Admin").unwrap();
1163 let admin_tags = admin_group["tags"].as_array().unwrap();
1164 assert!(admin_tags.iter().any(|t| t == "Admin: Models"));
1165 assert!(admin_tags.iter().any(|t| t == "Admin: Auth"));
1166
1167 let public_group = groups.iter().find(|g| g["name"] == "Public API").unwrap();
1168 let public_tags = public_group["tags"].as_array().unwrap();
1169 assert!(public_tags.iter().any(|t| t == "Compute"));
1170 assert!(public_tags.iter().any(|t| t == "Models"));
1171 }
1172
1173 #[test]
1174 fn custom_delimiter_splits_tags() {
1175 let base = openapi_with_tagged_ops(&[("/a", "team/models"), ("/b", "team/auth")]);
1176 let doc = ApiDocBuilder::new()
1177 .title("t")
1178 .version("0.1")
1179 .tag_group_delimiter("/")
1180 .merge(base)
1181 .build();
1182 let parsed: serde_json::Value = serde_json::from_slice(&doc.spec_json).unwrap();
1183 let groups = parsed["x-tagGroups"]
1184 .as_array()
1185 .expect("x-tagGroups present");
1186 assert_eq!(groups.len(), 1);
1187 assert_eq!(groups[0]["name"], "team");
1188 }
1189}