1#![allow(clippy::doc_markdown)]
5
6use std::collections::BTreeMap;
65
66#[cfg(feature = "openapi")]
67use serde::{Deserialize, Serialize};
68
69#[derive(Clone, Debug, Default)]
79#[allow(clippy::struct_excessive_bools)]
84pub struct ApiDoc {
85 pub method: &'static str,
87 pub path: &'static str,
89 pub operation_id: &'static str,
91 pub summary: Option<&'static str>,
93 pub description: Option<&'static str>,
95 pub tags: &'static [&'static str],
97 pub path_params: &'static [&'static str],
101 pub request_body: Option<SchemaEntry>,
104 pub response: Option<SchemaEntry>,
107 pub success_status: u16,
109 pub hidden: bool,
111 pub query_schema: Option<SchemaEntry>,
113 pub secured: bool,
115 pub required_roles: &'static [&'static str],
117 pub required_scopes: &'static [&'static str],
120 pub register_schemas: Option<fn(&mut SchemaRegistry)>,
123 pub api_version: Option<&'static str>,
125 pub sunset_opt_out: bool,
127 pub has_policy: bool,
129 pub public: bool,
136 pub module_path: &'static str,
140 pub mcp_tool: bool,
143 pub mcp_exclude: bool,
149 pub mcp_stream: bool,
157}
158
159#[derive(Copy, Clone, Debug)]
161pub struct SchemaEntry {
162 pub name: &'static str,
165 pub kind: SchemaKind,
168 pub identity: Option<fn() -> &'static str>,
184}
185
186impl SchemaEntry {
187 #[must_use]
190 pub fn identity_key(&self) -> &'static str {
191 self.identity.map_or(self.name, |f| f())
192 }
193}
194
195impl PartialEq for SchemaEntry {
202 fn eq(&self, other: &Self) -> bool {
203 self.name == other.name
204 && self.kind == other.kind
205 && self.identity_key() == other.identity_key()
206 }
207}
208
209impl Eq for SchemaEntry {}
210
211#[must_use]
219pub fn type_name_of<T: ?Sized>() -> &'static str {
220 core::any::type_name::<T>()
221}
222
223#[must_use]
231pub fn component_key(raw: &str) -> String {
232 let dotted = raw.replace("::", ".");
233 dotted
234 .chars()
235 .map(|c| {
236 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
237 c
238 } else {
239 '_'
240 }
241 })
242 .collect()
243}
244
245#[derive(Copy, Clone, Debug, PartialEq, Eq)]
247pub enum SchemaKind {
248 Ref,
250 Primitive(&'static str),
252 Array(&'static SchemaEntry),
258 Nullable(&'static SchemaEntry),
261}
262
263#[cfg(feature = "openapi")]
272#[derive(Clone)]
273pub struct OpenApiConfig {
274 pub title: String,
276 pub version: String,
278 pub description: Option<String>,
280 pub openapi_json_path: String,
282 pub swagger_ui_path: Option<String>,
285 pub session_cookie_name: String,
290 pub additional_schemas: BTreeMap<String, serde_json::Value>,
292 pub api_versions: Vec<crate::app::ApiVersion>,
294}
295
296#[cfg(feature = "openapi")]
297impl OpenApiConfig {
298 #[must_use]
300 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
301 Self {
302 title: title.into(),
303 version: version.into(),
304 description: None,
305 openapi_json_path: "/openapi.json".to_owned(),
306 swagger_ui_path: Some("/swagger-ui".to_owned()),
307 session_cookie_name: "autumn.sid".to_owned(),
308 additional_schemas: BTreeMap::new(),
309 api_versions: Vec::new(),
310 }
311 }
312
313 #[must_use]
315 pub fn description(mut self, description: impl Into<String>) -> Self {
316 self.description = Some(description.into());
317 self
318 }
319
320 #[must_use]
322 pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
323 self.openapi_json_path = path.into();
324 self
325 }
326
327 #[must_use]
329 pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
330 self.swagger_ui_path = path;
331 self
332 }
333
334 #[must_use]
336 pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
337 self.session_cookie_name = name.into();
338 self
339 }
340
341 #[must_use]
344 pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
345 self.additional_schemas.insert(name.into(), schema);
346 self
347 }
348}
349
350pub trait OpenApiSchema {
365 fn schema_name() -> &'static str;
367
368 fn schema() -> serde_json::Value;
370}
371
372pub use autumn_macros::OpenApiSchema;
387
388macro_rules! impl_primitive_schema {
389 ($ty:ty, $name:literal, $json:literal) => {
390 impl OpenApiSchema for $ty {
391 fn schema_name() -> &'static str {
392 $name
393 }
394 fn schema() -> serde_json::Value {
395 serde_json::json!({ "type": $json })
396 }
397 }
398 };
399}
400
401impl_primitive_schema!(bool, "boolean", "boolean");
402impl_primitive_schema!(String, "string", "string");
403impl_primitive_schema!(&'static str, "string", "string");
404impl_primitive_schema!(i8, "integer", "integer");
405impl_primitive_schema!(i16, "integer", "integer");
406impl_primitive_schema!(i32, "integer", "integer");
407impl_primitive_schema!(i64, "integer", "integer");
408impl_primitive_schema!(u8, "integer", "integer");
409impl_primitive_schema!(u16, "integer", "integer");
410impl_primitive_schema!(u32, "integer", "integer");
411impl_primitive_schema!(u64, "integer", "integer");
412impl_primitive_schema!(f32, "number", "number");
413impl_primitive_schema!(f64, "number", "number");
414impl_primitive_schema!(serde_json::Value, "object", "object");
415
416pub struct DerivedSchemaDescriptor {
437 pub name: &'static str,
439 pub identity: fn() -> &'static str,
445 pub schema: fn() -> serde_json::Value,
447}
448
449inventory::collect!(DerivedSchemaDescriptor);
450
451#[must_use]
459pub fn registered_derived_schema(identity: &str) -> Option<serde_json::Value> {
460 inventory::iter::<DerivedSchemaDescriptor>
461 .into_iter()
462 .find(|descriptor| (descriptor.identity)() == identity)
463 .map(|descriptor| (descriptor.schema)())
464}
465
466#[derive(Default)]
472pub struct SchemaRegistry {
473 schemas: BTreeMap<String, serde_json::Value>,
474}
475
476impl SchemaRegistry {
477 pub fn register<T: OpenApiSchema>(&mut self) {
480 let name = T::schema_name().to_owned();
481 self.schemas.entry(name).or_insert_with(T::schema);
482 }
483
484 pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
486 self.schemas.insert(name.into(), schema);
487 }
488
489 #[must_use]
491 pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
492 self.schemas
493 }
494
495 #[must_use]
497 pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
498 &self.schemas
499 }
500}
501
502#[cfg(feature = "openapi")]
513#[derive(Debug, Serialize, Deserialize)]
515pub struct OpenApiSpec {
516 pub openapi: String,
518 pub info: Info,
520 pub paths: BTreeMap<String, PathItem>,
522 #[serde(skip_serializing_if = "Option::is_none")]
524 pub components: Option<Components>,
525}
526
527#[cfg(feature = "openapi")]
528#[derive(Debug, Serialize, Deserialize)]
530pub struct Info {
531 pub title: String,
533 pub version: String,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub description: Option<String>,
538}
539
540#[cfg(feature = "openapi")]
541#[derive(Default, Debug, Serialize, Deserialize)]
543pub struct PathItem {
544 #[serde(skip_serializing_if = "Option::is_none")]
546 pub get: Option<Operation>,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub post: Option<Operation>,
550 #[serde(skip_serializing_if = "Option::is_none")]
552 pub put: Option<Operation>,
553 #[serde(skip_serializing_if = "Option::is_none")]
555 pub delete: Option<Operation>,
556 #[serde(skip_serializing_if = "Option::is_none")]
558 pub patch: Option<Operation>,
559}
560
561#[cfg(feature = "openapi")]
562#[derive(Debug, Serialize, Deserialize)]
564pub struct Operation {
565 #[serde(rename = "operationId")]
567 pub operation_id: String,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 pub summary: Option<String>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 pub description: Option<String>,
574 #[serde(skip_serializing_if = "Vec::is_empty")]
576 pub tags: Vec<String>,
577 #[serde(skip_serializing_if = "Vec::is_empty")]
579 pub parameters: Vec<Parameter>,
580 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
582 pub request_body: Option<RequestBody>,
583 pub responses: BTreeMap<String, Response>,
585 #[serde(skip_serializing_if = "Vec::is_empty")]
587 pub security: Vec<BTreeMap<String, Vec<String>>>,
588 #[serde(skip_serializing_if = "Option::is_none")]
590 pub deprecated: Option<bool>,
591 #[serde(rename = "x-required-scopes", skip_serializing_if = "Vec::is_empty")]
594 pub x_required_scopes: Vec<String>,
595}
596
597#[cfg(feature = "openapi")]
598#[derive(Debug, Serialize, Deserialize)]
600pub struct Parameter {
601 pub name: String,
603 #[serde(rename = "in")]
605 pub location: String,
606 pub required: bool,
608 pub schema: serde_json::Value,
610 #[serde(skip_serializing_if = "Option::is_none")]
613 pub style: Option<String>,
614 #[serde(skip_serializing_if = "Option::is_none")]
617 pub explode: Option<bool>,
618}
619
620#[cfg(feature = "openapi")]
621#[derive(Debug, Serialize, Deserialize)]
623pub struct RequestBody {
624 pub required: bool,
626 pub content: BTreeMap<String, MediaType>,
628}
629
630#[cfg(feature = "openapi")]
631#[derive(Debug, Serialize, Deserialize)]
633pub struct Response {
634 pub description: String,
636 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
638 pub content: BTreeMap<String, MediaType>,
639}
640
641#[cfg(feature = "openapi")]
642#[derive(Debug, Serialize, Deserialize)]
644pub struct MediaType {
645 pub schema: serde_json::Value,
647}
648
649#[cfg(feature = "openapi")]
650#[derive(Debug, Serialize, Deserialize)]
652pub struct Components {
653 pub schemas: BTreeMap<String, serde_json::Value>,
655 #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
657 pub security_schemes: BTreeMap<String, serde_json::Value>,
658}
659
660#[cfg(feature = "openapi")]
675pub fn write_openapi_spec_to_dist(
676 spec: &OpenApiSpec,
677 dist_dir: &std::path::Path,
678) -> std::io::Result<()> {
679 std::fs::create_dir_all(dist_dir)?;
680
681 let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
682 std::fs::write(dist_dir.join("openapi.json"), &json)?;
683
684 let yaml = serde_yaml::to_string(spec).map_err(std::io::Error::other)?;
685 std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
686
687 Ok(())
688}
689
690#[cfg(feature = "openapi")]
701#[derive(Default, Debug, Clone)]
702pub struct SchemaComponentIndex {
703 by_identity: BTreeMap<String, String>,
705}
706
707#[cfg(feature = "openapi")]
708impl SchemaComponentIndex {
709 #[must_use]
714 pub fn display_key(&self, entry: &SchemaEntry) -> String {
715 let identity = entry.identity_key();
716 self.by_identity
717 .get(identity)
718 .cloned()
719 .unwrap_or_else(|| component_key(entry.name))
720 }
721
722 fn display_key_for_identity(&self, identity: &str) -> Option<&str> {
728 self.by_identity.get(identity).map(String::as_str)
729 }
730
731 fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
734 self.by_identity.iter()
735 }
736}
737
738#[cfg(feature = "openapi")]
741fn qualified_suffix_key(identity: &str, depth: usize) -> String {
742 let segments: Vec<&str> = identity.split("::").collect();
743 let start = segments.len().saturating_sub(depth);
744 component_key(&segments[start..].join("::"))
745}
746
747#[cfg(feature = "openapi")]
752#[must_use]
753pub fn build_schema_component_index(routes: &[&ApiDoc]) -> SchemaComponentIndex {
754 let mut refs: Vec<(String, String)> = Vec::new();
758 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
759 for api_doc in routes {
760 if api_doc.hidden {
761 continue;
762 }
763 for entry in [
764 api_doc.request_body.as_ref(),
765 api_doc.response.as_ref(),
766 api_doc.query_schema.as_ref(),
767 ]
768 .into_iter()
769 .flatten()
770 {
771 for e in flatten_ref_entries(entry) {
772 let identity = e.identity_key().to_owned();
773 if seen.insert(identity.clone()) {
774 refs.push((identity, component_key(e.name)));
775 }
776 }
777 }
778 }
779
780 let mut queue: Vec<String> = refs.iter().map(|(id, _)| id.clone()).collect();
786 while let Some(identity) = queue.pop() {
787 let Some(body) = registered_derived_schema(&identity) else {
788 continue;
789 };
790 let mut nested: Vec<String> = Vec::new();
791 collect_body_ref_identities(&body, &mut nested);
792 for n in nested {
793 if seen.insert(n.clone()) {
794 let base = base_display_for_identity(&n);
795 refs.push((n.clone(), base));
796 queue.push(n);
797 }
798 }
799 }
800
801 SchemaComponentIndex {
802 by_identity: assign_display_keys(&refs),
803 }
804}
805
806#[cfg(feature = "openapi")]
823fn assign_display_keys(refs: &[(String, String)]) -> BTreeMap<String, String> {
824 let mut by_base: BTreeMap<String, std::collections::BTreeSet<&str>> = BTreeMap::new();
826 for (identity, base) in refs {
827 by_base
828 .entry(base.clone())
829 .or_default()
830 .insert(identity.as_str());
831 }
832
833 let mut by_identity: BTreeMap<String, String> = BTreeMap::new();
834 let mut used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
835
836 for (identity, base) in refs {
840 let collides = by_base[base].len() > 1 && identity.contains("::");
841 if !collides {
842 by_identity.entry(identity.clone()).or_insert_with(|| {
843 used.insert(base.clone());
844 base.clone()
845 });
846 }
847 }
848
849 let mut pending: Vec<&String> = refs
855 .iter()
856 .map(|(identity, _)| identity)
857 .filter(|identity| !by_identity.contains_key(*identity))
858 .collect();
859 pending.sort_unstable();
860 for identity in pending {
861 let depth_max = identity.split("::").count();
862 let mut display = (2..=depth_max)
863 .map(|depth| qualified_suffix_key(identity, depth))
864 .find(|candidate| !used.contains(candidate))
865 .unwrap_or_else(|| component_key(identity));
866 if used.contains(&display) {
867 let base = display.clone();
868 let mut n = 2u32;
869 loop {
870 let candidate = format!("{base}-{n}");
871 if !used.contains(&candidate) {
872 display = candidate;
873 break;
874 }
875 n += 1;
876 }
877 }
878 used.insert(display.clone());
879 by_identity.insert(identity.clone(), display);
880 }
881
882 by_identity
883}
884
885#[cfg(feature = "openapi")]
891fn base_display_for_identity(identity: &str) -> String {
892 let without_generics = identity.split('<').next().unwrap_or(identity);
893 let last = without_generics
894 .rsplit("::")
895 .next()
896 .unwrap_or(without_generics);
897 component_key(last)
898}
899
900#[cfg(feature = "openapi")]
907fn collect_body_ref_identities(value: &serde_json::Value, out: &mut Vec<String>) {
908 match value {
909 serde_json::Value::Object(map) => {
910 if let Some(serde_json::Value::String(reference)) = map.get("$ref")
911 && let Some(id) = reference.strip_prefix("#/components/schemas/")
912 {
913 out.push(id.to_owned());
914 }
915 for v in map.values() {
916 collect_body_ref_identities(v, out);
917 }
918 }
919 serde_json::Value::Array(items) => {
920 for v in items {
921 collect_body_ref_identities(v, out);
922 }
923 }
924 _ => {}
925 }
926}
927
928#[cfg(feature = "openapi")]
936fn rewrite_component_body_refs(
937 components: &mut BTreeMap<String, serde_json::Value>,
938 index: &SchemaComponentIndex,
939) {
940 for schema in components.values_mut() {
941 rewrite_identity_refs(schema, index);
942 }
943}
944
945#[cfg(feature = "openapi")]
946fn rewrite_identity_refs(value: &mut serde_json::Value, index: &SchemaComponentIndex) {
947 match value {
948 serde_json::Value::Object(map) => {
949 if let Some(serde_json::Value::String(reference)) = map.get_mut("$ref") {
950 let replacement = reference
951 .strip_prefix("#/components/schemas/")
952 .and_then(|identity| index.display_key_for_identity(identity))
953 .map(|display| format!("#/components/schemas/{display}"));
954 if let Some(new_ref) = replacement {
955 *reference = new_ref;
956 }
957 }
958 for v in map.values_mut() {
959 rewrite_identity_refs(v, index);
960 }
961 }
962 serde_json::Value::Array(items) => {
963 for v in items {
964 rewrite_identity_refs(v, index);
965 }
966 }
967 _ => {}
968 }
969}
970
971#[cfg(feature = "openapi")]
974fn flatten_ref_entries(entry: &SchemaEntry) -> Vec<&SchemaEntry> {
975 match entry.kind {
976 SchemaKind::Ref => vec![entry],
977 SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => flatten_ref_entries(inner),
978 SchemaKind::Primitive(_) => Vec::new(),
979 }
980}
981
982#[cfg(feature = "openapi")]
987#[must_use]
988pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
989 generate_spec_at(config, routes, chrono::Utc::now())
990}
991
992#[cfg(feature = "openapi")]
993#[must_use]
994pub fn generate_spec_at(
995 config: &OpenApiConfig,
996 routes: &[&ApiDoc],
997 now: chrono::DateTime<chrono::Utc>,
998) -> OpenApiSpec {
999 let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
1000 let mut registry = SchemaRegistry::default();
1001
1002 for (name, schema) in &config.additional_schemas {
1003 registry.insert(name.clone(), schema.clone());
1004 }
1005 registry.insert("ProblemDetails", problem_details_schema());
1006
1007 let index = build_schema_component_index(routes);
1011
1012 let mut any_secured = false;
1013 let mut any_scoped = false;
1014
1015 for api_doc in routes {
1016 if api_doc.hidden {
1017 continue;
1018 }
1019 if api_doc.secured {
1020 any_secured = true;
1021 }
1022 if !api_doc.required_scopes.is_empty() {
1023 any_scoped = true;
1024 }
1025 if let Some(register) = api_doc.register_schemas {
1026 (register)(&mut registry);
1027 }
1028
1029 let operation = operation_for(api_doc, &config.api_versions, now, &index);
1030 let entry = paths.entry(api_doc.path.to_owned()).or_default();
1031 match api_doc.method {
1032 "GET" => entry.get = Some(operation),
1033 "POST" => entry.post = Some(operation),
1034 "PUT" => entry.put = Some(operation),
1035 "DELETE" => entry.delete = Some(operation),
1036 "PATCH" => entry.patch = Some(operation),
1037 _ => {}
1040 }
1041 }
1042
1043 for (identity, display_key) in index.iter() {
1051 if !registry.schemas().contains_key(display_key) {
1052 let schema = registered_derived_schema(identity).unwrap_or_else(|| {
1053 serde_json::json!({
1054 "type": "object",
1055 "title": display_key,
1056 })
1057 });
1058 registry.insert(display_key.clone(), schema);
1059 }
1060 }
1061
1062 let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1064 if any_secured {
1065 security_schemes.insert(
1066 "SessionAuth".to_owned(),
1067 serde_json::json!({
1068 "type": "apiKey",
1069 "in": "cookie",
1070 "name": config.session_cookie_name.clone(),
1071 "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
1072 }),
1073 );
1074 }
1075 if any_scoped {
1076 security_schemes.insert(
1077 "BearerAuth".to_owned(),
1078 serde_json::json!({
1079 "type": "http",
1080 "scheme": "bearer",
1081 "description": "API bearer token. Scope-secured routes require a valid token whose scopes include all required values.",
1082 }),
1083 );
1084 }
1085
1086 let mut components_map = registry.into_map();
1087 rewrite_component_body_refs(&mut components_map, &index);
1094 let components = if !components_map.is_empty() || !security_schemes.is_empty() {
1095 Some(Components {
1096 schemas: components_map,
1097 security_schemes,
1098 })
1099 } else {
1100 None
1101 };
1102
1103 OpenApiSpec {
1104 openapi: "3.1.0".to_owned(),
1105 info: Info {
1106 title: config.title.clone(),
1107 version: config.version.clone(),
1108 description: config.description.clone(),
1109 },
1110 paths,
1111 components,
1112 }
1113}
1114
1115#[cfg(feature = "openapi")]
1116#[allow(clippy::too_many_lines)]
1117fn operation_for(
1118 api_doc: &ApiDoc,
1119 api_versions: &[crate::app::ApiVersion],
1120 now: chrono::DateTime<chrono::Utc>,
1121 index: &SchemaComponentIndex,
1122) -> Operation {
1123 let mut tags = if api_doc.tags.is_empty() {
1124 default_tag(api_doc.path)
1125 .map(|t| vec![t.to_owned()])
1126 .unwrap_or_default()
1127 } else {
1128 api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
1129 };
1130
1131 if let Some(version) = api_doc.api_version {
1132 tags.push(version.to_string());
1133 }
1134
1135 let is_deprecated = api_doc.api_version.is_some_and(|version| {
1136 api_versions
1137 .iter()
1138 .find(|av| av.version == version)
1139 .is_some_and(|av| {
1140 let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
1141 let is_sun = av.sunset_at.is_some_and(|s| now >= s);
1142 is_dep || is_sun
1143 })
1144 });
1145 let deprecated = if is_deprecated { Some(true) } else { None };
1146
1147 let mut parameters: Vec<Parameter> = api_doc
1149 .path_params
1150 .iter()
1151 .map(|name| Parameter {
1152 name: (*name).to_owned(),
1153 location: "path".to_owned(),
1154 required: true,
1155 schema: serde_json::json!({ "type": "string" }),
1156 style: None,
1157 explode: None,
1158 })
1159 .collect();
1160
1161 if let Some(query_entry) = &api_doc.query_schema {
1166 parameters.push(Parameter {
1167 name: query_entry.name.to_owned(),
1168 location: "query".to_owned(),
1169 required: false,
1170 schema: schema_value_for(query_entry, index),
1171 style: Some("form".to_owned()),
1172 explode: Some(true),
1173 });
1174 }
1175
1176 let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
1177 required: true,
1178 content: std::iter::once((
1179 "application/json".to_owned(),
1180 MediaType {
1181 schema: schema_value_for(entry, index),
1182 },
1183 ))
1184 .collect(),
1185 });
1186
1187 let mut responses: BTreeMap<String, Response> = BTreeMap::new();
1188 let status = if api_doc.success_status == 0 {
1189 200
1190 } else {
1191 api_doc.success_status
1192 };
1193 let response_content = api_doc
1194 .response
1195 .as_ref()
1196 .map(|entry| {
1197 let mut content = BTreeMap::new();
1198 content.insert(
1199 "application/json".to_owned(),
1200 MediaType {
1201 schema: schema_value_for(entry, index),
1202 },
1203 );
1204 content
1205 })
1206 .unwrap_or_default();
1207 responses.insert(
1208 status.to_string(),
1209 Response {
1210 description: status_description(status).to_owned(),
1211 content: response_content,
1212 },
1213 );
1214 insert_problem_responses(&mut responses);
1215
1216 let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
1218 api_versions
1219 .iter()
1220 .find(|av| av.version == version)
1221 .is_some_and(|av| av.sunset_at.is_some())
1222 && !api_doc.sunset_opt_out
1223 });
1224
1225 if is_subject_to_sunset {
1226 responses.entry("410".to_owned()).or_insert_with(|| {
1227 let mut content = BTreeMap::new();
1228 content.insert(
1229 "application/problem+json".to_owned(),
1230 MediaType {
1231 schema: serde_json::json!({
1232 "$ref": "#/components/schemas/ProblemDetails",
1233 }),
1234 },
1235 );
1236 Response {
1237 description: status_description(410).to_owned(),
1238 content,
1239 }
1240 });
1241 }
1242
1243 let security = if api_doc.secured {
1250 let mut req = BTreeMap::new();
1251 if !api_doc.required_scopes.is_empty() {
1252 req.insert("BearerAuth".to_owned(), Vec::<String>::new());
1253 }
1254 if api_doc.required_scopes.is_empty() || !api_doc.required_roles.is_empty() {
1255 req.insert("SessionAuth".to_owned(), Vec::<String>::new());
1256 }
1257 vec![req]
1258 } else {
1259 Vec::new()
1260 };
1261
1262 Operation {
1263 operation_id: api_doc.operation_id.to_owned(),
1264 summary: api_doc.summary.map(str::to_owned),
1265 description: api_doc.description.map(str::to_owned),
1266 tags,
1267 parameters,
1268 request_body,
1269 responses,
1270 security,
1271 deprecated,
1272 x_required_scopes: api_doc
1273 .required_scopes
1274 .iter()
1275 .map(ToString::to_string)
1276 .collect(),
1277 }
1278}
1279
1280#[cfg(feature = "openapi")]
1290#[must_use]
1291pub fn schema_entry_to_value(
1292 entry: &SchemaEntry,
1293 index: &SchemaComponentIndex,
1294) -> serde_json::Value {
1295 schema_value_for(entry, index)
1296}
1297
1298#[cfg(feature = "openapi")]
1299fn schema_value_for(entry: &SchemaEntry, index: &SchemaComponentIndex) -> serde_json::Value {
1300 match entry.kind {
1301 SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
1302 SchemaKind::Ref => {
1303 serde_json::json!({ "$ref": format!("#/components/schemas/{}", index.display_key(entry)) })
1304 }
1305 SchemaKind::Array(items) => serde_json::json!({
1306 "type": "array",
1307 "items": schema_value_for(items, index),
1308 }),
1309 SchemaKind::Nullable(inner) => {
1310 match inner.kind {
1318 SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
1319 serde_json::json!({
1320 "oneOf": [
1321 schema_value_for(inner, index),
1322 { "type": "null" },
1323 ],
1324 })
1325 }
1326 SchemaKind::Primitive(base_type) => {
1327 serde_json::json!({ "type": [base_type, "null"] })
1328 }
1329 }
1330 }
1331 }
1332}
1333
1334#[cfg(feature = "openapi")]
1335fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
1336 for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
1337 responses.entry(status.to_string()).or_insert_with(|| {
1338 let mut content = BTreeMap::new();
1339 content.insert(
1340 "application/problem+json".to_owned(),
1341 MediaType {
1342 schema: serde_json::json!({
1343 "$ref": "#/components/schemas/ProblemDetails",
1344 }),
1345 },
1346 );
1347 Response {
1348 description: status_description(status).to_owned(),
1349 content,
1350 }
1351 });
1352 }
1353}
1354
1355#[cfg(feature = "openapi")]
1356fn problem_details_schema() -> serde_json::Value {
1357 serde_json::json!({
1358 "type": "object",
1359 "additionalProperties": false,
1360 "required": [
1361 "type",
1362 "title",
1363 "status",
1364 "detail",
1365 "instance",
1366 "code",
1367 "request_id",
1368 "errors",
1369 ],
1370 "properties": {
1371 "type": {
1372 "type": "string",
1373 "format": "uri-reference",
1374 },
1375 "title": {
1376 "type": "string",
1377 },
1378 "status": {
1379 "type": "integer",
1380 "minimum": 400,
1381 "maximum": 599,
1382 },
1383 "detail": {
1384 "type": "string",
1385 },
1386 "instance": {
1387 "type": ["string", "null"],
1388 },
1389 "code": {
1390 "type": "string",
1391 "pattern": "^autumn\\.[a-z0-9_]+$",
1392 },
1393 "request_id": {
1394 "type": ["string", "null"],
1395 },
1396 "errors": {
1397 "type": "array",
1398 "items": {
1399 "type": "object",
1400 "additionalProperties": false,
1401 "required": ["field", "messages"],
1402 "properties": {
1403 "field": {
1404 "type": "string",
1405 },
1406 "messages": {
1407 "type": "array",
1408 "items": {
1409 "type": "string",
1410 },
1411 },
1412 },
1413 },
1414 },
1415 },
1416 })
1417}
1418
1419#[cfg(feature = "openapi")]
1420fn default_tag(path: &str) -> Option<&str> {
1421 path.trim_start_matches('/')
1422 .split('/')
1423 .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
1424}
1425
1426#[cfg(feature = "openapi")]
1427const fn status_description(status: u16) -> &'static str {
1428 match status {
1429 200 => "OK",
1430 201 => "Created",
1431 202 => "Accepted",
1432 204 => "No Content",
1433 301 => "Moved Permanently",
1434 302 => "Found",
1435 400 => "Bad Request",
1436 401 => "Unauthorized",
1437 403 => "Forbidden",
1438 404 => "Not Found",
1439 409 => "Conflict",
1440 413 => "Payload Too Large",
1441 415 => "Unsupported Media Type",
1442 422 => "Unprocessable Entity",
1443 500 => "Internal Server Error",
1444 503 => "Service Unavailable",
1445 _ => "Response",
1446 }
1447}
1448
1449#[cfg(feature = "openapi")]
1454pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
1455#[cfg(feature = "openapi")]
1456pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
1457#[cfg(feature = "openapi")]
1458pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
1459 include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
1460#[cfg(feature = "openapi")]
1461const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
1462#[cfg(feature = "openapi")]
1463const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
1464#[cfg(feature = "openapi")]
1465const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
1466
1467#[cfg(feature = "openapi")]
1469#[must_use]
1470pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
1471 [
1472 swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
1473 swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
1474 swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
1475 ]
1476}
1477
1478#[cfg(feature = "openapi")]
1479#[must_use]
1480fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
1481 let base = swagger_path.trim_end_matches('/');
1482 if base.is_empty() || base == "/" {
1483 format!("/{asset_file}")
1484 } else {
1485 format!("{base}/{asset_file}")
1486 }
1487}
1488
1489#[cfg(feature = "openapi")]
1491#[must_use]
1492pub fn swagger_ui_html(
1493 title: &str,
1494 css_url: &str,
1495 bundle_url: &str,
1496 initializer_url: &str,
1497) -> String {
1498 let title = html_escape(title);
1499 let css_url = html_escape(css_url);
1500 let bundle_url = html_escape(bundle_url);
1501 let initializer_url = html_escape(initializer_url);
1502 let mut out = String::with_capacity(1024);
1503 out.push_str("<!DOCTYPE html>\n");
1504 out.push_str("<html lang=\"en\">\n");
1505 out.push_str(" <head>\n");
1506 out.push_str(" <meta charset=\"utf-8\" />\n");
1507 out.push_str(" <title>");
1508 out.push_str(&title);
1509 out.push_str("</title>\n");
1510 out.push_str(" <link rel=\"stylesheet\" href=\"");
1511 out.push_str(&css_url);
1512 out.push_str("\" />\n");
1513 out.push_str(" </head>\n");
1514 out.push_str(" <body>\n");
1515 out.push_str(" <div id=\"swagger-ui\"></div>\n");
1516 out.push_str(" <script src=\"");
1517 out.push_str(&bundle_url);
1518 out.push_str("\" charset=\"UTF-8\"></script>\n");
1519 out.push_str(" <script src=\"");
1520 out.push_str(&initializer_url);
1521 out.push_str("\" charset=\"UTF-8\"></script>\n");
1522 out.push_str(" </body>\n");
1523 out.push_str("</html>\n");
1524 out
1525}
1526
1527#[cfg(feature = "openapi")]
1530#[must_use]
1531pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
1532 let spec_url = serde_json::to_string(spec_url)
1533 .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
1534 let mut out = String::with_capacity(256);
1535 out.push_str("window.onload = function() {\n");
1536 out.push_str(" window.ui = SwaggerUIBundle({\n");
1537 out.push_str(" url: ");
1538 out.push_str(&spec_url);
1539 out.push_str(",\n");
1540 out.push_str(" dom_id: \"#swagger-ui\",\n");
1541 out.push_str(" deepLinking: true\n");
1542 out.push_str(" });\n");
1543 out.push_str("};\n");
1544 out
1545}
1546
1547#[cfg(feature = "openapi")]
1548fn html_escape(s: &str) -> String {
1549 s.replace('&', "&")
1550 .replace('<', "<")
1551 .replace('>', ">")
1552 .replace('"', """)
1553}
1554
1555#[cfg(all(test, feature = "openapi"))]
1560mod tests {
1561 use super::*;
1562
1563 fn make_doc() -> ApiDoc {
1564 ApiDoc {
1565 method: "GET",
1566 path: "/users/{id}",
1567 operation_id: "get_user",
1568 summary: Some("Fetch a user"),
1569 description: None,
1570 tags: &[],
1571 path_params: &["id"],
1572 request_body: None,
1573 response: None,
1574 success_status: 200,
1575 hidden: false,
1576 query_schema: None,
1577 secured: false,
1578 required_roles: &[],
1579 register_schemas: None,
1580 api_version: None,
1581 ..Default::default()
1582 }
1583 }
1584
1585 #[test]
1586 fn config_builder_methods_work() {
1587 let config = OpenApiConfig::new("Demo", "1.0.0")
1588 .description("A cool API")
1589 .openapi_json_path("/api.json")
1590 .swagger_ui_path(None)
1591 .session_cookie_name("demo.sid");
1592
1593 assert_eq!(config.title, "Demo");
1594 assert_eq!(config.version, "1.0.0");
1595 assert_eq!(config.description.unwrap(), "A cool API");
1596 assert_eq!(config.openapi_json_path, "/api.json");
1597 assert_eq!(config.swagger_ui_path, None);
1598 assert_eq!(config.session_cookie_name, "demo.sid");
1599 }
1600
1601 #[test]
1602 fn secured_spec_uses_configured_session_cookie_name() {
1603 let mut doc = make_doc();
1604 doc.path = "/protected";
1605 doc.operation_id = "protected";
1606 doc.path_params = &[];
1607 doc.secured = true;
1608
1609 let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
1610 let spec = generate_spec(&config, &[&doc]);
1611 let scheme = &spec
1612 .components
1613 .as_ref()
1614 .expect("secured routes emit security components")
1615 .security_schemes["SessionAuth"];
1616
1617 assert_eq!(scheme["type"], "apiKey");
1618 assert_eq!(scheme["in"], "cookie");
1619 assert_eq!(scheme["name"], "demo.sid");
1620 }
1621
1622 #[test]
1623 fn generate_spec_builds_path_with_parameters() {
1624 let doc = make_doc();
1625 let config = OpenApiConfig::new("Demo", "1.0.0");
1626 let spec = generate_spec(&config, &[&doc]);
1627
1628 assert_eq!(spec.openapi, "3.1.0");
1629 assert_eq!(spec.info.title, "Demo");
1630 assert!(spec.paths.contains_key("/users/{id}"));
1631
1632 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1633 assert_eq!(op.operation_id, "get_user");
1634 assert_eq!(op.parameters.len(), 1);
1635 assert_eq!(op.parameters[0].name, "id");
1636 assert_eq!(op.parameters[0].location, "path");
1637 assert_eq!(op.tags, vec!["users".to_owned()]);
1638 }
1639
1640 #[test]
1641 fn generate_spec_skips_hidden_routes() {
1642 let mut doc = make_doc();
1643 doc.hidden = true;
1644 let config = OpenApiConfig::new("Demo", "1.0.0");
1645 let spec = generate_spec(&config, &[&doc]);
1646 assert!(spec.paths.is_empty());
1647 }
1648
1649 #[test]
1650 fn generate_spec_writes_request_body_ref() {
1651 let mut doc = make_doc();
1652 doc.method = "POST";
1653 doc.path = "/users";
1654 doc.operation_id = "create_user";
1655 doc.path_params = &[];
1656 doc.request_body = Some(SchemaEntry {
1657 name: "CreateUser",
1658 kind: SchemaKind::Ref,
1659 identity: None,
1660 });
1661 doc.success_status = 201;
1662
1663 let config = OpenApiConfig::new("Demo", "1.0.0");
1664 let spec = generate_spec(&config, &[&doc]);
1665 let op = spec.paths["/users"].post.as_ref().unwrap();
1666 let body = op.request_body.as_ref().unwrap();
1667 assert!(body.required);
1668 let media = body.content.get("application/json").unwrap();
1669 assert_eq!(
1670 media.schema,
1671 serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
1672 );
1673 assert!(op.responses.contains_key("201"));
1674 }
1675
1676 #[test]
1677 fn generate_spec_inlines_primitive_response() {
1678 let mut doc = make_doc();
1679 doc.response = Some(SchemaEntry {
1680 name: "string",
1681 kind: SchemaKind::Primitive("string"),
1682 identity: None,
1683 });
1684 let config = OpenApiConfig::new("Demo", "1.0.0");
1685 let spec = generate_spec(&config, &[&doc]);
1686 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1687 let media = op.responses["200"].content.get("application/json").unwrap();
1688 assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
1689 }
1690
1691 #[test]
1692 fn swagger_ui_html_uses_same_origin_assets() {
1693 let html = swagger_ui_html(
1694 "Demo",
1695 "/swagger-ui/swagger-ui.css",
1696 "/swagger-ui/swagger-ui-bundle.js",
1697 "/swagger-ui/swagger-initializer.js",
1698 );
1699 assert!(html.contains("/swagger-ui/swagger-ui.css"));
1700 assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
1701 assert!(html.contains("/swagger-ui/swagger-initializer.js"));
1702 assert!(!html.contains("unpkg.com"));
1703 assert!(!html.contains("window.onload = function()"));
1704 }
1705
1706 #[test]
1707 fn swagger_ui_initializer_js_references_spec_url() {
1708 let js = swagger_ui_initializer_js("/openapi.json");
1709 assert!(js.contains("SwaggerUIBundle"));
1710 assert!(js.contains(r#""/openapi.json""#));
1711 }
1712
1713 #[test]
1714 fn generate_spec_includes_additional_schemas() {
1715 let doc = make_doc();
1716 let config = OpenApiConfig::new("Demo", "1.0.0")
1717 .register_schema("Foo", serde_json::json!({ "type": "object" }));
1718 let spec = generate_spec(&config, &[&doc]);
1719 let components = spec.components.unwrap();
1720 assert!(components.schemas.contains_key("Foo"));
1721 }
1722
1723 #[test]
1724 fn generate_spec_back_fills_unregistered_ref_schemas() {
1725 let mut doc = make_doc();
1729 doc.method = "POST";
1730 doc.path = "/users";
1731 doc.path_params = &[];
1732 doc.request_body = Some(SchemaEntry {
1733 name: "CreateUser",
1734 kind: SchemaKind::Ref,
1735 identity: None,
1736 });
1737 doc.response = Some(SchemaEntry {
1738 name: "User",
1739 kind: SchemaKind::Ref,
1740 identity: None,
1741 });
1742
1743 let config = OpenApiConfig::new("Demo", "1.0.0");
1744 let spec = generate_spec(&config, &[&doc]);
1745 let components = spec.components.expect("components must be emitted");
1746 let create = components
1747 .schemas
1748 .get("CreateUser")
1749 .expect("CreateUser should be back-filled");
1750 let user = components
1751 .schemas
1752 .get("User")
1753 .expect("User should be back-filled");
1754 assert_eq!(create["type"], "object");
1755 assert_eq!(create["title"], "CreateUser");
1756 assert_eq!(user["type"], "object");
1757 assert_eq!(user["title"], "User");
1758 }
1759
1760 #[test]
1761 fn generate_spec_preserves_user_registered_schemas_over_backfill() {
1762 let mut doc = make_doc();
1763 doc.response = Some(SchemaEntry {
1764 name: "User",
1765 kind: SchemaKind::Ref,
1766 identity: None,
1767 });
1768
1769 let user_schema = serde_json::json!({
1770 "type": "object",
1771 "properties": {"id": {"type": "integer"}},
1772 });
1773 let config =
1774 OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
1775 let spec = generate_spec(&config, &[&doc]);
1776 let components = spec.components.unwrap();
1777 let stored = components.schemas.get("User").unwrap();
1778 assert_eq!(stored, &user_schema, "user schema must not be overwritten");
1779 }
1780
1781 #[test]
1782 fn status_description_returns_correct_strings() {
1783 assert_eq!(status_description(200), "OK");
1784 assert_eq!(status_description(201), "Created");
1785 assert_eq!(status_description(202), "Accepted");
1786 assert_eq!(status_description(204), "No Content");
1787 assert_eq!(status_description(301), "Moved Permanently");
1788 assert_eq!(status_description(302), "Found");
1789 assert_eq!(status_description(400), "Bad Request");
1790 assert_eq!(status_description(401), "Unauthorized");
1791 assert_eq!(status_description(403), "Forbidden");
1792 assert_eq!(status_description(404), "Not Found");
1793 assert_eq!(status_description(409), "Conflict");
1794 assert_eq!(status_description(413), "Payload Too Large");
1795 assert_eq!(status_description(415), "Unsupported Media Type");
1796 assert_eq!(status_description(422), "Unprocessable Entity");
1797 assert_eq!(status_description(500), "Internal Server Error");
1798 assert_eq!(status_description(503), "Service Unavailable");
1799 assert_eq!(status_description(418), "Response");
1800 }
1801
1802 #[test]
1803 fn default_tag_picks_first_static_segment() {
1804 assert_eq!(default_tag("/users/{id}"), Some("users"));
1805 assert_eq!(default_tag("/api/v1/users"), Some("api"));
1806 assert_eq!(default_tag("/"), None);
1807 assert_eq!(default_tag("/{id}"), None);
1808 }
1809
1810 #[test]
1813 fn spec_version_is_3_1_0() {
1814 let config = OpenApiConfig::new("Demo", "1.0.0");
1815 let spec = generate_spec(&config, &[]);
1816 assert_eq!(
1817 spec.openapi, "3.1.0",
1818 "Autumn must emit OpenAPI 3.1.0, not {}",
1819 spec.openapi
1820 );
1821 }
1822
1823 #[test]
1824 fn nullable_ref_uses_openapi_3_1_one_of() {
1825 static INNER: SchemaEntry = SchemaEntry {
1829 name: "User",
1830 kind: SchemaKind::Ref,
1831 identity: None,
1832 };
1833 let entry = SchemaEntry {
1834 name: "nullable",
1835 kind: SchemaKind::Nullable(&INNER),
1836 identity: None,
1837 };
1838 let value = schema_value_for(&entry, &SchemaComponentIndex::default());
1839 assert!(
1840 value.get("nullable").is_none(),
1841 "3.1 must not emit `nullable: true` (that is 3.0 only)"
1842 );
1843 assert!(
1844 value.get("allOf").is_none(),
1845 "3.1 must not use allOf for nullable refs"
1846 );
1847 let one_of = value["oneOf"]
1848 .as_array()
1849 .expect("3.1 nullable ref must use oneOf");
1850 assert_eq!(one_of.len(), 2);
1851 assert_eq!(
1852 one_of[0]["$ref"], "#/components/schemas/User",
1853 "first oneOf branch must be the $ref"
1854 );
1855 assert_eq!(
1856 one_of[1]["type"], "null",
1857 "second oneOf branch must be {{type: null}}"
1858 );
1859 }
1860
1861 #[test]
1862 fn nullable_primitive_uses_type_array() {
1863 static INNER: SchemaEntry = SchemaEntry {
1866 name: "integer",
1867 kind: SchemaKind::Primitive("integer"),
1868 identity: None,
1869 };
1870 let entry = SchemaEntry {
1871 name: "nullable",
1872 kind: SchemaKind::Nullable(&INNER),
1873 identity: None,
1874 };
1875 let value = schema_value_for(&entry, &SchemaComponentIndex::default());
1876 assert!(
1877 value.get("nullable").is_none(),
1878 "3.1 must not emit `nullable: true`"
1879 );
1880 let types = value["type"]
1881 .as_array()
1882 .expect("3.1 nullable primitive must use a type array");
1883 assert!(
1884 types.contains(&serde_json::Value::String("integer".to_owned())),
1885 "type array must include the base type"
1886 );
1887 assert!(
1888 types.contains(&serde_json::Value::String("null".to_owned())),
1889 "type array must include null"
1890 );
1891 }
1892
1893 #[test]
1894 fn write_openapi_spec_to_dist_creates_json_file() {
1895 let tmp = tempfile::TempDir::new().unwrap();
1896 let dist = tmp.path().join("dist");
1897 std::fs::create_dir_all(&dist).unwrap();
1898
1899 let config = OpenApiConfig::new("TestAPI", "2.0.0");
1900 let spec = generate_spec(&config, &[]);
1901
1902 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1903
1904 let json_path = dist.join("openapi.json");
1905 assert!(json_path.exists(), "dist/openapi.json must be written");
1906
1907 let content = std::fs::read_to_string(&json_path).unwrap();
1908 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1909 assert_eq!(parsed["openapi"], "3.1.0");
1910 assert_eq!(parsed["info"]["title"], "TestAPI");
1911 }
1912
1913 #[test]
1914 fn write_openapi_spec_to_dist_creates_yaml_file() {
1915 let tmp = tempfile::TempDir::new().unwrap();
1916 let dist = tmp.path().join("dist");
1917 std::fs::create_dir_all(&dist).unwrap();
1918
1919 let config = OpenApiConfig::new("TestAPI", "2.0.0");
1920 let spec = generate_spec(&config, &[]);
1921
1922 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1923
1924 let yaml_path = dist.join("openapi.yaml");
1925 assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
1926
1927 let content = std::fs::read_to_string(&yaml_path).unwrap();
1928 assert!(
1929 content.contains("openapi:"),
1930 "YAML must include the openapi field"
1931 );
1932 assert!(content.contains("3.1.0"), "YAML must include the version");
1933 assert!(content.contains("TestAPI"), "YAML must include the title");
1934 }
1935
1936 #[test]
1937 fn schema_registry_into_map_returns_all_schemas() {
1938 let mut registry = SchemaRegistry::default();
1939 registry.insert("Foo", serde_json::json!({ "type": "string" }));
1940 registry.insert("Bar", serde_json::json!({ "type": "integer" }));
1941
1942 let map = registry.into_map();
1943 assert_eq!(map.len(), 2);
1944 assert_eq!(
1945 map.get("Foo").unwrap(),
1946 &serde_json::json!({ "type": "string" })
1947 );
1948 assert_eq!(
1949 map.get("Bar").unwrap(),
1950 &serde_json::json!({ "type": "integer" })
1951 );
1952 }
1953
1954 #[test]
1955 fn schema_registry_deduplicates() {
1956 struct Foo;
1957 impl OpenApiSchema for Foo {
1958 fn schema_name() -> &'static str {
1959 "Foo"
1960 }
1961 fn schema() -> serde_json::Value {
1962 serde_json::json!({ "type": "object", "title": "Foo" })
1963 }
1964 }
1965
1966 let mut registry = SchemaRegistry::default();
1967 registry.register::<Foo>();
1968 registry.register::<Foo>();
1969 assert_eq!(registry.schemas().len(), 1);
1970 }
1971
1972 #[test]
1973 fn primitive_impls_cover_common_types() {
1974 assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
1975 assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
1976 assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
1977 assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
1978 }
1979
1980 #[test]
1981 fn swagger_ui_html_embeds_spec_url() {
1982 let html = swagger_ui_html(
1983 "My API",
1984 "/swagger-ui/swagger-ui.css",
1985 "/swagger-ui/swagger-ui-bundle.js",
1986 "/swagger-ui/swagger-initializer.js",
1987 );
1988 assert!(html.contains("/swagger-ui/swagger-ui.css"));
1989 assert!(html.contains("My API"));
1990 }
1991
1992 #[test]
1993 fn swagger_ui_html_escapes_attributes() {
1994 let html = swagger_ui_html(
1995 "A \"cool\" & fun API",
1996 "/swagger-ui/swagger-ui.css?x=<y>",
1997 "/swagger-ui/swagger-ui-bundle.js",
1998 "/swagger-ui/swagger-initializer.js",
1999 );
2000 assert!(html.contains("/swagger-ui/swagger-ui.css?x=<y>"));
2001 assert!(html.contains("A "cool" & fun API"));
2002 }
2003
2004 fn make_secured_doc(
2007 secured: bool,
2008 required_roles: &'static [&'static str],
2009 required_scopes: &'static [&'static str],
2010 ) -> ApiDoc {
2011 let mut doc = make_doc();
2012 doc.path = "/secured";
2013 doc.operation_id = "secured_op";
2014 doc.path_params = &[];
2015 doc.secured = secured;
2016 doc.required_roles = required_roles;
2017 doc.required_scopes = required_scopes;
2018 doc
2019 }
2020
2021 #[test]
2022 fn unsecured_route_has_no_security_requirement() {
2023 let doc = make_secured_doc(false, &[], &[]);
2024 let config = OpenApiConfig::new("Demo", "1.0.0");
2025 let spec = generate_spec(&config, &[&doc]);
2026 let op = spec.paths["/secured"].get.as_ref().unwrap();
2027 assert!(op.security.is_empty());
2028 }
2029
2030 #[test]
2031 fn bare_secured_uses_session_auth() {
2032 let doc = make_secured_doc(true, &[], &[]);
2033 let config = OpenApiConfig::new("Demo", "1.0.0");
2034 let spec = generate_spec(&config, &[&doc]);
2035 let op = spec.paths["/secured"].get.as_ref().unwrap();
2036 assert_eq!(op.security.len(), 1);
2037 assert!(op.security[0].contains_key("SessionAuth"));
2038 assert!(!op.security[0].contains_key("BearerAuth"));
2039 }
2040
2041 #[test]
2042 fn role_only_uses_session_auth() {
2043 let doc = make_secured_doc(true, &["admin"], &[]);
2044 let config = OpenApiConfig::new("Demo", "1.0.0");
2045 let spec = generate_spec(&config, &[&doc]);
2046 let op = spec.paths["/secured"].get.as_ref().unwrap();
2047 assert_eq!(op.security.len(), 1);
2048 assert!(op.security[0].contains_key("SessionAuth"));
2049 assert!(!op.security[0].contains_key("BearerAuth"));
2050 }
2051
2052 #[test]
2053 fn scope_only_uses_bearer_auth_with_empty_array() {
2054 let doc = make_secured_doc(true, &[], &["posts:write"]);
2055 let config = OpenApiConfig::new("Demo", "1.0.0");
2056 let spec = generate_spec(&config, &[&doc]);
2057 let op = spec.paths["/secured"].get.as_ref().unwrap();
2058 assert_eq!(op.security.len(), 1);
2059 assert!(op.security[0].contains_key("BearerAuth"));
2060 assert!(!op.security[0].contains_key("SessionAuth"));
2061 assert!(op.security[0]["BearerAuth"].is_empty());
2063 let schemes = &spec.components.as_ref().unwrap().security_schemes;
2065 assert!(schemes.contains_key("BearerAuth"));
2066 assert_eq!(schemes["BearerAuth"]["scheme"], "bearer");
2067 }
2068
2069 #[test]
2070 fn mixed_role_and_scope_uses_both_auth_schemes() {
2071 let doc = make_secured_doc(true, &["admin"], &["posts:write"]);
2072 let config = OpenApiConfig::new("Demo", "1.0.0");
2073 let spec = generate_spec(&config, &[&doc]);
2074 let op = spec.paths["/secured"].get.as_ref().unwrap();
2075 assert_eq!(op.security.len(), 1);
2076 assert!(op.security[0].contains_key("SessionAuth"));
2078 assert!(op.security[0].contains_key("BearerAuth"));
2079 }
2080
2081 #[test]
2082 fn bearer_auth_scheme_registered_only_for_scoped_routes() {
2083 let unscoped = make_secured_doc(true, &["admin"], &[]);
2084 let config = OpenApiConfig::new("Demo", "1.0.0");
2085 let spec = generate_spec(&config, &[&unscoped]);
2086 let schemes = &spec.components.as_ref().unwrap().security_schemes;
2087 assert!(!schemes.contains_key("BearerAuth"));
2088 }
2089
2090 #[test]
2100 fn colliding_fallback_keys_are_disambiguated() {
2101 let refs = vec![
2102 ("a::x::Args".to_owned(), "Args".to_owned()),
2103 ("x::Args".to_owned(), "Args".to_owned()),
2104 ];
2105 let by_identity = assign_display_keys(&refs);
2106
2107 let a = by_identity.get("a::x::Args").expect("a::x::Args assigned");
2108 let b = by_identity.get("x::Args").expect("x::Args assigned");
2109 assert_ne!(
2110 a, b,
2111 "distinct identities must map to distinct display keys, got {a} == {b}"
2112 );
2113 assert_eq!(a, "x.Args");
2117 assert_eq!(b, "x.Args-2");
2118 for key in [a, b] {
2120 assert!(
2121 key.chars()
2122 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
2123 "display key {key} is not a valid component key"
2124 );
2125 }
2126 }
2127
2128 #[test]
2131 fn assign_display_keys_is_order_independent() {
2132 let forward = vec![
2133 ("app::app::Args".to_owned(), "Args".to_owned()),
2134 ("app::Args".to_owned(), "Args".to_owned()),
2135 ("other::mod::Args".to_owned(), "Args".to_owned()),
2136 ];
2137 let mut reversed = forward.clone();
2138 reversed.reverse();
2139 assert_eq!(
2140 assign_display_keys(&forward),
2141 assign_display_keys(&reversed),
2142 "display-key assignment must not depend on input order"
2143 );
2144 }
2145}