1#![allow(clippy::doc_markdown)]
5
6#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
73
74use std::collections::BTreeMap;
75
76#[cfg(feature = "openapi")]
77use serde::{Deserialize, Serialize};
78
79pub const OPENAPI_UNAVAILABLE_MARKER: &str = "[autumn:openapi-unavailable] ";
94
95pub const OPENAPI_UNAVAILABLE_FEATURE: &str = "this binary was built without the `openapi` feature";
98
99pub const OPENAPI_UNAVAILABLE_UNCONFIGURED: &str =
102 "this app never called `.openapi(OpenApiConfig::new(..))`";
103
104#[derive(Clone, Debug, Default)]
114#[allow(clippy::struct_excessive_bools)]
119pub struct ApiDoc {
120 pub method: &'static str,
122 pub path: &'static str,
124 pub operation_id: &'static str,
126 pub summary: Option<&'static str>,
128 pub description: Option<&'static str>,
130 pub tags: &'static [&'static str],
132 pub path_params: &'static [&'static str],
136 pub request_body: Option<SchemaEntry>,
139 pub response: Option<SchemaEntry>,
142 pub success_status: u16,
144 pub hidden: bool,
146 pub query_schema: Option<SchemaEntry>,
148 pub secured: bool,
150 pub required_roles: &'static [&'static str],
152 pub required_scopes: &'static [&'static str],
155 pub register_schemas: Option<fn(&mut SchemaRegistry)>,
158 pub api_version: Option<&'static str>,
160 pub sunset_opt_out: bool,
162 pub has_policy: bool,
164 pub authorize_bindings: &'static [AuthorizeBinding],
173 pub pools: &'static [&'static str],
182 pub public: bool,
189 pub module_path: &'static str,
193 pub source_file: &'static str,
198 pub source_line: u32,
201 pub mcp_tool: bool,
204 pub mcp_exclude: bool,
210 pub mcp_stream: bool,
218 pub mcp_task: bool,
222 pub mcp_core: bool,
227 pub webmcp_tool: bool,
231 pub webmcp_exclude: bool,
234 pub agent_authority: Option<&'static crate::agent_authority::AgentAuthority>,
250}
251
252#[derive(Copy, Clone, Debug, PartialEq, Eq)]
266pub struct AuthorizeBinding {
267 pub action: &'static str,
270 pub resource: &'static str,
272}
273
274#[derive(Copy, Clone, Debug)]
276pub struct SchemaEntry {
277 pub name: &'static str,
280 pub kind: SchemaKind,
283 pub identity: Option<fn() -> &'static str>,
306}
307
308impl SchemaEntry {
309 #[must_use]
312 pub fn identity_key(&self) -> &'static str {
313 self.identity.map_or(self.name, |f| f())
314 }
315}
316
317impl PartialEq for SchemaEntry {
324 fn eq(&self, other: &Self) -> bool {
325 self.name == other.name
326 && self.kind == other.kind
327 && self.identity_key() == other.identity_key()
328 }
329}
330
331impl Eq for SchemaEntry {}
332
333#[must_use]
341pub fn type_name_of<T: ?Sized>() -> &'static str {
342 core::any::type_name::<T>()
343}
344
345#[must_use]
353pub fn component_key(raw: &str) -> String {
354 let dotted = raw.replace("::", ".");
355 dotted
356 .chars()
357 .map(|c| {
358 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
359 c
360 } else {
361 '_'
362 }
363 })
364 .collect()
365}
366
367#[derive(Copy, Clone, Debug, PartialEq, Eq)]
369pub enum SchemaKind {
370 Ref,
372 Primitive(&'static str),
374 Array(&'static SchemaEntry),
380 Nullable(&'static SchemaEntry),
383}
384
385#[cfg(feature = "openapi")]
394#[derive(Clone)]
395pub struct OpenApiConfig {
396 pub title: String,
398 pub version: String,
400 pub description: Option<String>,
402 pub openapi_json_path: String,
404 pub swagger_ui_path: Option<String>,
407 pub session_cookie_name: String,
412 pub additional_schemas: BTreeMap<String, serde_json::Value>,
414 pub api_versions: Vec<crate::app::ApiVersion>,
416}
417
418#[cfg(feature = "openapi")]
419impl OpenApiConfig {
420 #[must_use]
422 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
423 Self {
424 title: title.into(),
425 version: version.into(),
426 description: None,
427 openapi_json_path: "/openapi.json".to_owned(),
428 swagger_ui_path: Some("/swagger-ui".to_owned()),
429 session_cookie_name: "autumn.sid".to_owned(),
430 additional_schemas: BTreeMap::new(),
431 api_versions: Vec::new(),
432 }
433 }
434
435 #[must_use]
437 pub fn description(mut self, description: impl Into<String>) -> Self {
438 self.description = Some(description.into());
439 self
440 }
441
442 #[must_use]
444 pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
445 self.openapi_json_path = path.into();
446 self
447 }
448
449 #[must_use]
451 pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
452 self.swagger_ui_path = path;
453 self
454 }
455
456 #[must_use]
458 pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
459 self.session_cookie_name = name.into();
460 self
461 }
462
463 #[must_use]
466 pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
467 self.additional_schemas.insert(name.into(), schema);
468 self
469 }
470}
471
472pub trait OpenApiSchema {
487 fn schema_name() -> &'static str;
489
490 fn schema() -> serde_json::Value;
492}
493
494pub use autumn_macros::OpenApiSchema;
509
510macro_rules! impl_primitive_schema {
511 ($ty:ty, $name:literal, $json:literal) => {
512 impl OpenApiSchema for $ty {
513 fn schema_name() -> &'static str {
514 $name
515 }
516 fn schema() -> serde_json::Value {
517 serde_json::json!({ "type": $json })
518 }
519 }
520 };
521}
522
523impl_primitive_schema!(bool, "boolean", "boolean");
524impl_primitive_schema!(String, "string", "string");
525impl_primitive_schema!(&'static str, "string", "string");
526impl_primitive_schema!(i8, "integer", "integer");
527impl_primitive_schema!(i16, "integer", "integer");
528impl_primitive_schema!(i32, "integer", "integer");
529impl_primitive_schema!(i64, "integer", "integer");
530impl_primitive_schema!(u8, "integer", "integer");
531impl_primitive_schema!(u16, "integer", "integer");
532impl_primitive_schema!(u32, "integer", "integer");
533impl_primitive_schema!(u64, "integer", "integer");
534impl_primitive_schema!(f32, "number", "number");
535impl_primitive_schema!(f64, "number", "number");
536impl_primitive_schema!(serde_json::Value, "object", "object");
537
538macro_rules! impl_scalar_schema {
539 ($ty:ty, $name:literal, $schema:expr) => {
540 impl OpenApiSchema for $ty {
541 fn schema_name() -> &'static str {
542 $name
543 }
544 fn schema() -> serde_json::Value {
545 $schema
546 }
547 }
548 };
549}
550
551impl_scalar_schema!(
552 uuid::Uuid,
553 "uuid",
554 serde_json::json!({ "type": "string", "format": "uuid" })
555);
556impl_scalar_schema!(
557 chrono::DateTime<chrono::Utc>,
558 "date-time",
559 serde_json::json!({ "type": "string", "format": "date-time" })
560);
561impl_scalar_schema!(
562 chrono::NaiveDate,
563 "date",
564 serde_json::json!({ "type": "string", "format": "date" })
565);
566impl_scalar_schema!(
568 chrono::NaiveDateTime,
569 "naive-date-time",
570 serde_json::json!({
571 "type": "string",
572 "description": "ISO 8601 date-time with no UTC offset, e.g. 2026-09-06T18:00:00",
573 })
574);
575impl_scalar_schema!(rust_decimal::Decimal, "decimal", decimal_scalar_schema());
576
577#[must_use]
584pub fn decimal_scalar_schema() -> serde_json::Value {
585 match serde_json::to_value(rust_decimal::Decimal::new(12345, 2)) {
586 Ok(serde_json::Value::String(_)) => {
587 serde_json::json!({ "type": "string", "format": "decimal" })
588 }
589 _ => serde_json::json!({ "type": "number" }),
590 }
591}
592
593pub struct DerivedSchemaDescriptor {
614 pub name: &'static str,
616 pub identity: fn() -> &'static str,
622 pub schema: fn() -> serde_json::Value,
624}
625
626inventory::collect!(DerivedSchemaDescriptor);
627
628#[must_use]
636pub fn registered_derived_schema(identity: &str) -> Option<serde_json::Value> {
637 inventory::iter::<DerivedSchemaDescriptor>
638 .into_iter()
639 .find(|descriptor| (descriptor.identity)() == identity)
640 .map(|descriptor| (descriptor.schema)())
641}
642
643#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
650pub enum SchemaDirection {
651 Request,
653 Response,
655}
656
657#[doc(hidden)]
664pub struct DirectionalSchemaDescriptor {
665 pub identity: fn() -> &'static str,
667 pub direction: SchemaDirection,
669 pub schema: Option<fn() -> serde_json::Value>,
672}
673
674inventory::collect!(DirectionalSchemaDescriptor);
675
676#[must_use]
682pub fn registered_derived_schema_for(
683 identity: &str,
684 direction: SchemaDirection,
685) -> Option<serde_json::Value> {
686 inventory::iter::<DirectionalSchemaDescriptor>
687 .into_iter()
688 .find(|descriptor| descriptor.direction == direction && (descriptor.identity)() == identity)
689 .map_or_else(
690 || registered_derived_schema(identity),
691 |descriptor| descriptor.schema.map(|schema| schema()),
692 )
693}
694
695#[doc(hidden)]
700pub fn remove_schema_properties(schema: &mut serde_json::Value, names: &[&str]) {
701 let Some(object) = schema.as_object_mut() else {
702 return;
703 };
704 if let Some(properties) = object
705 .get_mut("properties")
706 .and_then(serde_json::Value::as_object_mut)
707 {
708 for name in names {
709 properties.remove(*name);
710 }
711 }
712 if let Some(required) = object
713 .get_mut("required")
714 .and_then(serde_json::Value::as_array_mut)
715 {
716 required.retain(|entry| entry.as_str().is_none_or(|name| !names.contains(&name)));
717 if required.is_empty() {
718 object.remove("required");
719 }
720 }
721}
722
723#[derive(Default)]
729pub struct SchemaRegistry {
730 schemas: BTreeMap<String, serde_json::Value>,
731}
732
733impl SchemaRegistry {
734 pub fn register<T: OpenApiSchema>(&mut self) {
737 let name = T::schema_name().to_owned();
738 self.schemas.entry(name).or_insert_with(T::schema);
739 }
740
741 pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
743 self.schemas.insert(name.into(), schema);
744 }
745
746 #[must_use]
748 pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
749 self.schemas
750 }
751
752 #[must_use]
754 pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
755 &self.schemas
756 }
757}
758
759#[cfg(feature = "openapi")]
770#[derive(Debug, Serialize, Deserialize)]
772pub struct OpenApiSpec {
773 pub openapi: String,
775 pub info: Info,
777 pub paths: BTreeMap<String, PathItem>,
779 #[serde(skip_serializing_if = "Option::is_none")]
781 pub components: Option<Components>,
782}
783
784#[cfg(feature = "openapi")]
785#[derive(Debug, Serialize, Deserialize)]
787pub struct Info {
788 pub title: String,
790 pub version: String,
792 #[serde(skip_serializing_if = "Option::is_none")]
794 pub description: Option<String>,
795}
796
797#[cfg(feature = "openapi")]
798#[derive(Default, Debug, Serialize, Deserialize)]
800pub struct PathItem {
801 #[serde(skip_serializing_if = "Option::is_none")]
803 pub get: Option<Operation>,
804 #[serde(skip_serializing_if = "Option::is_none")]
806 pub post: Option<Operation>,
807 #[serde(skip_serializing_if = "Option::is_none")]
809 pub put: Option<Operation>,
810 #[serde(skip_serializing_if = "Option::is_none")]
812 pub delete: Option<Operation>,
813 #[serde(skip_serializing_if = "Option::is_none")]
815 pub patch: Option<Operation>,
816}
817
818#[cfg(feature = "openapi")]
819#[derive(Debug, Serialize, Deserialize)]
821pub struct Operation {
822 #[serde(rename = "operationId")]
824 pub operation_id: String,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 pub summary: Option<String>,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 pub description: Option<String>,
831 #[serde(skip_serializing_if = "Vec::is_empty")]
833 pub tags: Vec<String>,
834 #[serde(skip_serializing_if = "Vec::is_empty")]
836 pub parameters: Vec<Parameter>,
837 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
839 pub request_body: Option<RequestBody>,
840 pub responses: BTreeMap<String, Response>,
842 #[serde(skip_serializing_if = "Vec::is_empty")]
844 pub security: Vec<BTreeMap<String, Vec<String>>>,
845 #[serde(skip_serializing_if = "Option::is_none")]
847 pub deprecated: Option<bool>,
848 #[serde(rename = "x-required-scopes", skip_serializing_if = "Vec::is_empty")]
851 pub x_required_scopes: Vec<String>,
852}
853
854#[cfg(feature = "openapi")]
855#[derive(Debug, Default, Serialize, Deserialize)]
862pub struct Parameter {
863 pub name: String,
865 #[serde(rename = "in")]
867 pub location: String,
868 pub required: bool,
870 pub schema: serde_json::Value,
872 #[serde(skip_serializing_if = "Option::is_none")]
882 pub style: Option<String>,
883 #[serde(skip_serializing_if = "Option::is_none")]
887 pub explode: Option<bool>,
888 #[serde(skip_serializing_if = "Option::is_none")]
891 pub description: Option<String>,
892}
893
894#[cfg(feature = "openapi")]
895#[derive(Debug, Serialize, Deserialize)]
897pub struct RequestBody {
898 pub required: bool,
900 pub content: BTreeMap<String, MediaType>,
902}
903
904#[cfg(feature = "openapi")]
905#[derive(Debug, Serialize, Deserialize)]
907pub struct Response {
908 pub description: String,
910 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
912 pub content: BTreeMap<String, MediaType>,
913}
914
915#[cfg(feature = "openapi")]
916#[derive(Debug, Serialize, Deserialize)]
918pub struct MediaType {
919 pub schema: serde_json::Value,
921}
922
923#[cfg(feature = "openapi")]
924#[derive(Debug, Serialize, Deserialize)]
926pub struct Components {
927 pub schemas: BTreeMap<String, serde_json::Value>,
929 #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
931 pub security_schemes: BTreeMap<String, serde_json::Value>,
932}
933
934#[cfg(feature = "openapi")]
949pub fn write_openapi_spec_to_dist(
950 spec: &OpenApiSpec,
951 dist_dir: &std::path::Path,
952) -> std::io::Result<()> {
953 std::fs::create_dir_all(dist_dir)?;
954
955 let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
956 std::fs::write(dist_dir.join("openapi.json"), &json)?;
957
958 let yaml = serde_saphyr::to_string(spec).map_err(std::io::Error::other)?;
959 std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
960
961 Ok(())
962}
963
964#[cfg(feature = "openapi")]
975#[derive(Default, Debug, Clone)]
976pub struct SchemaComponentIndex {
977 by_identity: BTreeMap<String, String>,
979 request_by_identity: BTreeMap<String, String>,
982 request_only: std::collections::BTreeSet<String>,
985}
986
987#[cfg(feature = "openapi")]
988impl SchemaComponentIndex {
989 #[must_use]
994 pub fn display_key(&self, entry: &SchemaEntry) -> String {
995 let identity = entry.identity_key();
996 self.by_identity
997 .get(identity)
998 .cloned()
999 .unwrap_or_else(|| component_key(entry.name))
1000 }
1001
1002 #[must_use]
1007 pub fn display_key_in(&self, entry: &SchemaEntry, direction: SchemaDirection) -> String {
1008 match direction {
1009 SchemaDirection::Request => self
1010 .request_by_identity
1011 .get(entry.identity_key())
1012 .cloned()
1013 .unwrap_or_else(|| self.display_key(entry)),
1014 SchemaDirection::Response => self.display_key(entry),
1015 }
1016 }
1017
1018 fn display_key_for_identity(&self, identity: &str, direction: SchemaDirection) -> Option<&str> {
1024 if direction == SchemaDirection::Request
1025 && let Some(key) = self.request_by_identity.get(identity)
1026 {
1027 return Some(key);
1028 }
1029 self.by_identity.get(identity).map(String::as_str)
1030 }
1031
1032 fn components(&self) -> impl Iterator<Item = (&String, &String, SchemaDirection)> {
1035 let main = self
1036 .by_identity
1037 .iter()
1038 .map(|(identity, key)| (identity, key, self.main_direction(identity)));
1039 let request = self
1040 .request_by_identity
1041 .iter()
1042 .map(|(identity, key)| (identity, key, SchemaDirection::Request));
1043 main.chain(request)
1044 }
1045
1046 fn main_direction(&self, identity: &str) -> SchemaDirection {
1048 if self.request_only.contains(identity) {
1049 SchemaDirection::Request
1050 } else {
1051 SchemaDirection::Response
1052 }
1053 }
1054}
1055
1056#[cfg(feature = "openapi")]
1059fn qualified_suffix_key(identity: &str, depth: usize) -> String {
1060 let segments: Vec<&str> = identity.split("::").collect();
1061 let start = segments.len().saturating_sub(depth);
1062 component_key(&segments[start..].join("::"))
1063}
1064
1065#[cfg(feature = "openapi")]
1072#[must_use]
1073pub fn build_schema_component_index(routes: &[&ApiDoc]) -> SchemaComponentIndex {
1074 build_schema_component_index_reserving(routes, &BTreeMap::new())
1075}
1076
1077#[cfg(feature = "openapi")]
1081#[must_use]
1082pub fn schema_component_index_for(
1083 config: &OpenApiConfig,
1084 routes: &[&ApiDoc],
1085) -> SchemaComponentIndex {
1086 seeded_registry_and_index(config, routes).1
1087}
1088
1089#[cfg(feature = "openapi")]
1092fn seeded_registry_and_index(
1093 config: &OpenApiConfig,
1094 routes: &[&ApiDoc],
1095) -> (SchemaRegistry, SchemaComponentIndex) {
1096 let mut registry = SchemaRegistry::default();
1097 for (name, schema) in &config.additional_schemas {
1098 registry.insert(name.clone(), schema.clone());
1099 }
1100 registry.insert("ProblemDetails", problem_details_schema());
1101 for api_doc in routes {
1102 if !api_doc.hidden
1103 && let Some(register) = api_doc.register_schemas
1104 {
1105 (register)(&mut registry);
1106 }
1107 }
1108 let index = build_schema_component_index_reserving(routes, registry.schemas());
1109 (registry, index)
1110}
1111
1112#[cfg(feature = "openapi")]
1115fn build_schema_component_index_reserving(
1116 routes: &[&ApiDoc],
1117 registered: &BTreeMap<String, serde_json::Value>,
1118) -> SchemaComponentIndex {
1119 let mut graph = IdentityGraph::default();
1122 for api_doc in routes {
1123 if api_doc.hidden {
1124 continue;
1125 }
1126 for (entry, direction) in [
1127 (api_doc.request_body.as_ref(), SchemaDirection::Request),
1128 (api_doc.response.as_ref(), SchemaDirection::Response),
1129 (api_doc.query_schema.as_ref(), SchemaDirection::Request),
1130 ] {
1131 let Some(entry) = entry else { continue };
1132 for e in flatten_ref_entries(entry) {
1133 graph.add(e.identity_key(), component_key(e.name), direction);
1134 }
1135 }
1136 }
1137
1138 let mut registered_refs: Vec<String> = Vec::new();
1144 for body in registered.values() {
1145 collect_body_ref_identities(body, &mut registered_refs);
1146 }
1147 let by_identity = loop {
1148 graph.close();
1149 let by_identity = assign_display_keys(&graph.refs);
1150 let mut grew = false;
1151 for target in ®istered_refs {
1152 let identity = if graph.seen.contains(target) {
1153 Some(target.clone())
1154 } else {
1155 by_identity
1156 .iter()
1157 .find(|(_, key)| *key == target)
1158 .map(|(identity, _)| identity.clone())
1159 };
1160 if let Some(identity) = identity {
1161 grew |= graph.add_use(identity, SchemaDirection::Response);
1162 }
1163 }
1164 if !grew {
1165 break by_identity;
1166 }
1167 };
1168
1169 let request_only = graph
1170 .seen
1171 .iter()
1172 .filter(|identity| {
1173 !graph
1174 .uses
1175 .contains(&((*identity).clone(), SchemaDirection::Response))
1176 })
1177 .cloned()
1178 .collect();
1179 let fixed: std::collections::BTreeSet<String> = by_identity
1182 .iter()
1183 .filter(|(_, key)| registered.contains_key(*key))
1184 .map(|(identity, _)| identity.clone())
1185 .collect();
1186 let split = split_identities(&graph.uses, &graph.bodies, &fixed);
1187 let reserved: std::collections::BTreeSet<String> = registered.keys().cloned().collect();
1188 let request_by_identity = assign_request_keys(&split, &by_identity, &reserved);
1189
1190 SchemaComponentIndex {
1191 by_identity,
1192 request_by_identity,
1193 request_only,
1194 }
1195}
1196
1197#[cfg(feature = "openapi")]
1199#[derive(Default)]
1200struct IdentityGraph {
1201 refs: Vec<(String, String)>,
1203 seen: std::collections::BTreeSet<String>,
1204 uses: std::collections::BTreeSet<(String, SchemaDirection)>,
1205 queue: Vec<(String, SchemaDirection)>,
1206 bodies: BTreeMap<(String, SchemaDirection), Option<serde_json::Value>>,
1207}
1208
1209#[cfg(feature = "openapi")]
1210impl IdentityGraph {
1211 fn add(&mut self, identity: &str, base: String, direction: SchemaDirection) {
1213 if self.seen.insert(identity.to_owned()) {
1214 self.refs.push((identity.to_owned(), base));
1215 }
1216 self.add_use(identity.to_owned(), direction);
1217 }
1218
1219 fn add_use(&mut self, identity: String, direction: SchemaDirection) -> bool {
1221 let new = self.uses.insert((identity.clone(), direction));
1222 if new {
1223 self.queue.push((identity, direction));
1224 }
1225 new
1226 }
1227
1228 fn close(&mut self) {
1236 while let Some((identity, direction)) = self.queue.pop() {
1237 let body = registered_derived_schema_for(&identity, direction);
1238 if let Some(body) = &body {
1239 let mut nested: Vec<String> = Vec::new();
1240 collect_body_ref_identities(body, &mut nested);
1241 for n in nested {
1242 let base = base_display_for_identity(&n);
1243 self.add(&n, base, direction);
1244 }
1245 }
1246 self.bodies.insert((identity, direction), body);
1247 }
1248 }
1249}
1250
1251#[cfg(feature = "openapi")]
1258fn split_identities(
1259 uses: &std::collections::BTreeSet<(String, SchemaDirection)>,
1260 bodies: &BTreeMap<(String, SchemaDirection), Option<serde_json::Value>>,
1261 fixed: &std::collections::BTreeSet<String>,
1262) -> std::collections::BTreeSet<String> {
1263 let body = |identity: &str, direction| {
1264 bodies
1265 .get(&(identity.to_owned(), direction))
1266 .and_then(Option::as_ref)
1267 };
1268 let both: Vec<&str> = uses
1269 .iter()
1270 .filter(|(identity, direction)| {
1271 *direction == SchemaDirection::Request
1272 && uses.contains(&(identity.clone(), SchemaDirection::Response))
1273 && !fixed.contains(identity)
1274 })
1275 .map(|(identity, _)| identity.as_str())
1276 .collect();
1277 let mut split: std::collections::BTreeSet<String> = both
1278 .iter()
1279 .filter(|identity| {
1280 body(identity, SchemaDirection::Request) != body(identity, SchemaDirection::Response)
1281 })
1282 .map(|identity| (*identity).to_owned())
1283 .collect();
1284 loop {
1285 let before = split.len();
1286 for identity in &both {
1287 if split.contains(*identity) {
1288 continue;
1289 }
1290 let mut nested: Vec<String> = Vec::new();
1291 if let Some(b) = body(identity, SchemaDirection::Request) {
1292 collect_body_ref_identities(b, &mut nested);
1293 }
1294 if nested.iter().any(|n| split.contains(n)) {
1295 split.insert((*identity).to_owned());
1296 }
1297 }
1298 if split.len() == before {
1299 return split;
1300 }
1301 }
1302}
1303
1304#[cfg(feature = "openapi")]
1308fn assign_request_keys(
1309 split: &std::collections::BTreeSet<String>,
1310 by_identity: &BTreeMap<String, String>,
1311 reserved: &std::collections::BTreeSet<String>,
1312) -> BTreeMap<String, String> {
1313 let mut used: std::collections::BTreeSet<String> = by_identity
1314 .values()
1315 .cloned()
1316 .chain(reserved.iter().cloned())
1317 .collect();
1318 let mut out = BTreeMap::new();
1319 for identity in split {
1320 let Some(main) = by_identity.get(identity) else {
1321 continue;
1322 };
1323 let base = format!("{main}Request");
1324 let mut key = base.clone();
1325 let mut n = 2u32;
1326 while used.contains(&key) {
1327 key = format!("{base}-{n}");
1328 n += 1;
1329 }
1330 used.insert(key.clone());
1331 out.insert(identity.clone(), key);
1332 }
1333 out
1334}
1335
1336#[cfg(feature = "openapi")]
1353fn assign_display_keys(refs: &[(String, String)]) -> BTreeMap<String, String> {
1354 let mut by_base: BTreeMap<String, std::collections::BTreeSet<&str>> = BTreeMap::new();
1356 for (identity, base) in refs {
1357 by_base
1358 .entry(base.clone())
1359 .or_default()
1360 .insert(identity.as_str());
1361 }
1362
1363 let mut by_identity: BTreeMap<String, String> = BTreeMap::new();
1364 let mut used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1365
1366 for (identity, base) in refs {
1370 let collides = by_base[base].len() > 1 && identity.contains("::");
1371 if !collides {
1372 by_identity.entry(identity.clone()).or_insert_with(|| {
1373 used.insert(base.clone());
1374 base.clone()
1375 });
1376 }
1377 }
1378
1379 let mut pending: Vec<&String> = refs
1385 .iter()
1386 .map(|(identity, _)| identity)
1387 .filter(|identity| !by_identity.contains_key(*identity))
1388 .collect();
1389 pending.sort_unstable();
1390 for identity in pending {
1391 let depth_max = identity.split("::").count();
1392 let mut display = (2..=depth_max)
1393 .map(|depth| qualified_suffix_key(identity, depth))
1394 .find(|candidate| !used.contains(candidate))
1395 .unwrap_or_else(|| component_key(identity));
1396 if used.contains(&display) {
1397 let base = display.clone();
1398 let mut n = 2u32;
1399 loop {
1400 let candidate = format!("{base}-{n}");
1401 if !used.contains(&candidate) {
1402 display = candidate;
1403 break;
1404 }
1405 n += 1;
1406 }
1407 }
1408 used.insert(display.clone());
1409 by_identity.insert(identity.clone(), display);
1410 }
1411
1412 by_identity
1413}
1414
1415#[cfg(feature = "openapi")]
1421fn base_display_for_identity(identity: &str) -> String {
1422 let without_generics = identity.split('<').next().unwrap_or(identity);
1423 let last = without_generics
1424 .rsplit("::")
1425 .next()
1426 .unwrap_or(without_generics);
1427 component_key(last)
1428}
1429
1430#[cfg(feature = "openapi")]
1437fn collect_body_ref_identities(value: &serde_json::Value, out: &mut Vec<String>) {
1438 match value {
1439 serde_json::Value::Object(map) => {
1440 if let Some(serde_json::Value::String(reference)) = map.get("$ref")
1441 && let Some(id) = reference.strip_prefix("#/components/schemas/")
1442 {
1443 out.push(id.to_owned());
1444 }
1445 for v in map.values() {
1446 collect_body_ref_identities(v, out);
1447 }
1448 }
1449 serde_json::Value::Array(items) => {
1450 for v in items {
1451 collect_body_ref_identities(v, out);
1452 }
1453 }
1454 _ => {}
1455 }
1456}
1457
1458#[cfg(feature = "openapi")]
1466fn rewrite_component_body_refs(
1467 components: &mut BTreeMap<String, serde_json::Value>,
1468 index: &SchemaComponentIndex,
1469) {
1470 let directions: BTreeMap<String, SchemaDirection> = index
1473 .components()
1474 .map(|(_, key, direction)| (key.clone(), direction))
1475 .collect();
1476 for (key, schema) in components.iter_mut() {
1477 let direction = directions
1478 .get(key)
1479 .copied()
1480 .unwrap_or(SchemaDirection::Response);
1481 rewrite_identity_refs(schema, index, direction);
1482 }
1483}
1484
1485#[cfg(feature = "openapi")]
1486fn rewrite_identity_refs(
1487 value: &mut serde_json::Value,
1488 index: &SchemaComponentIndex,
1489 direction: SchemaDirection,
1490) {
1491 match value {
1492 serde_json::Value::Object(map) => {
1493 if let Some(serde_json::Value::String(reference)) = map.get_mut("$ref") {
1494 let replacement = reference
1495 .strip_prefix("#/components/schemas/")
1496 .and_then(|identity| index.display_key_for_identity(identity, direction))
1497 .map(|display| format!("#/components/schemas/{display}"));
1498 if let Some(new_ref) = replacement {
1499 *reference = new_ref;
1500 }
1501 }
1502 for v in map.values_mut() {
1503 rewrite_identity_refs(v, index, direction);
1504 }
1505 }
1506 serde_json::Value::Array(items) => {
1507 for v in items {
1508 rewrite_identity_refs(v, index, direction);
1509 }
1510 }
1511 _ => {}
1512 }
1513}
1514
1515#[cfg(feature = "openapi")]
1521const OPTION_TYPE_NAME_PREFIXES: [&str; 2] = ["core::option::Option<", "std::option::Option<"];
1522#[cfg(feature = "openapi")]
1523const VEC_TYPE_NAME_PREFIXES: [&str; 2] = ["alloc::vec::Vec<", "std::vec::Vec<"];
1524
1525#[cfg(feature = "openapi")]
1537fn wrapper_is_impostor(entry: &SchemaEntry) -> bool {
1538 let prefixes: &[&str] = match entry.kind {
1539 SchemaKind::Array(_) => &VEC_TYPE_NAME_PREFIXES,
1540 SchemaKind::Nullable(_) => &OPTION_TYPE_NAME_PREFIXES,
1541 SchemaKind::Ref | SchemaKind::Primitive(_) => return false,
1542 };
1543 entry.identity.is_some_and(|resolve| {
1544 let identity = resolve();
1545 !prefixes.iter().any(|prefix| identity.starts_with(prefix))
1546 })
1547}
1548
1549#[cfg(feature = "openapi")]
1551const SERDE_JSON_VALUE_IDENTITY: &str = "serde_json::value::Value";
1552
1553#[cfg(feature = "openapi")]
1559fn arbitrary_json_schema() -> serde_json::Value {
1560 serde_json::json!({
1561 "description": "Arbitrary JSON: an object, array, string, number, boolean or null.",
1562 })
1563}
1564
1565#[cfg(feature = "openapi")]
1578fn entry_is_arbitrary_json(entry: &SchemaEntry) -> bool {
1579 matches!(entry.kind, SchemaKind::Ref)
1580 && entry
1581 .identity
1582 .is_some_and(|resolve| resolve() == SERDE_JSON_VALUE_IDENTITY)
1583}
1584
1585#[cfg(feature = "openapi")]
1588fn flatten_ref_entries(entry: &SchemaEntry) -> Vec<&SchemaEntry> {
1589 if wrapper_is_impostor(entry) {
1592 return vec![entry];
1593 }
1594 if entry_is_arbitrary_json(entry) {
1597 return Vec::new();
1598 }
1599 match entry.kind {
1600 SchemaKind::Ref => vec![entry],
1601 SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => flatten_ref_entries(inner),
1602 SchemaKind::Primitive(_) => Vec::new(),
1603 }
1604}
1605
1606#[cfg(feature = "openapi")]
1611#[must_use]
1612pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
1613 generate_spec_at(config, routes, crate::time::ambient_now())
1614}
1615
1616#[cfg(feature = "openapi")]
1617#[must_use]
1618pub fn generate_spec_at(
1619 config: &OpenApiConfig,
1620 routes: &[&ApiDoc],
1621 now: chrono::DateTime<chrono::Utc>,
1622) -> OpenApiSpec {
1623 let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
1624
1625 let (mut registry, index) = seeded_registry_and_index(config, routes);
1630
1631 let mut any_secured = false;
1632 let mut any_scoped = false;
1633
1634 for api_doc in routes {
1635 if api_doc.hidden {
1636 continue;
1637 }
1638 if api_doc.secured {
1639 any_secured = true;
1640 }
1641 if !api_doc.required_scopes.is_empty() {
1642 any_scoped = true;
1643 }
1644
1645 let operation = operation_for(api_doc, &config.api_versions, now, &index);
1646 let entry = paths.entry(api_doc.path.to_owned()).or_default();
1647 match api_doc.method {
1648 "GET" => entry.get = Some(operation),
1649 "POST" => entry.post = Some(operation),
1650 "PUT" => entry.put = Some(operation),
1651 "DELETE" => entry.delete = Some(operation),
1652 "PATCH" => entry.patch = Some(operation),
1653 _ => {}
1656 }
1657 }
1658
1659 for (identity, display_key, direction) in index.components() {
1668 if !registry.schemas().contains_key(display_key) {
1669 let schema = registered_derived_schema_for(identity, direction).unwrap_or_else(|| {
1670 serde_json::json!({
1671 "type": "object",
1672 "title": display_key,
1673 })
1674 });
1675 registry.insert(display_key.clone(), schema);
1676 }
1677 }
1678
1679 let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
1681 if any_secured {
1682 security_schemes.insert(
1683 "SessionAuth".to_owned(),
1684 serde_json::json!({
1685 "type": "apiKey",
1686 "in": "cookie",
1687 "name": config.session_cookie_name.clone(),
1688 "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
1689 }),
1690 );
1691 }
1692 if any_scoped {
1693 security_schemes.insert(
1694 "BearerAuth".to_owned(),
1695 serde_json::json!({
1696 "type": "http",
1697 "scheme": "bearer",
1698 "description": "API bearer token. Scope-secured routes require a valid token whose scopes include all required values.",
1699 }),
1700 );
1701 }
1702
1703 let mut components_map = registry.into_map();
1704 rewrite_component_body_refs(&mut components_map, &index);
1711 let components = if !components_map.is_empty() || !security_schemes.is_empty() {
1712 Some(Components {
1713 schemas: components_map,
1714 security_schemes,
1715 })
1716 } else {
1717 None
1718 };
1719
1720 OpenApiSpec {
1721 openapi: "3.1.0".to_owned(),
1722 info: Info {
1723 title: config.title.clone(),
1724 version: config.version.clone(),
1725 description: config.description.clone(),
1726 },
1727 paths,
1728 components,
1729 }
1730}
1731
1732#[cfg(feature = "openapi")]
1733#[allow(clippy::too_many_lines)]
1734fn operation_for(
1735 api_doc: &ApiDoc,
1736 api_versions: &[crate::app::ApiVersion],
1737 now: chrono::DateTime<chrono::Utc>,
1738 index: &SchemaComponentIndex,
1739) -> Operation {
1740 let mut tags = if api_doc.tags.is_empty() {
1741 default_tag(api_doc.path)
1742 .map(|t| vec![t.to_owned()])
1743 .unwrap_or_default()
1744 } else {
1745 api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
1746 };
1747
1748 if let Some(version) = api_doc.api_version {
1749 tags.push(version.to_string());
1750 }
1751
1752 let is_deprecated = api_doc.api_version.is_some_and(|version| {
1753 api_versions
1754 .iter()
1755 .find(|av| av.version == version)
1756 .is_some_and(|av| {
1757 let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
1758 let is_sun = av.sunset_at.is_some_and(|s| now >= s);
1759 is_dep || is_sun
1760 })
1761 });
1762 let deprecated = if is_deprecated { Some(true) } else { None };
1763
1764 let mut parameters: Vec<Parameter> = api_doc
1766 .path_params
1767 .iter()
1768 .map(|name| Parameter {
1769 name: (*name).to_owned(),
1770 location: "path".to_owned(),
1771 required: true,
1772 schema: serde_json::json!({ "type": "string" }),
1773 style: None,
1774 explode: None,
1775 description: None,
1776 })
1777 .collect();
1778
1779 if let Some(query_entry) = &api_doc.query_schema {
1783 parameters.extend(query_parameters_for(query_entry, index));
1784 }
1785
1786 let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
1787 required: true,
1788 content: std::iter::once((
1789 "application/json".to_owned(),
1790 MediaType {
1791 schema: schema_value_for(entry, index, SchemaDirection::Request),
1792 },
1793 ))
1794 .collect(),
1795 });
1796
1797 let mut responses: BTreeMap<String, Response> = BTreeMap::new();
1798 let status = if api_doc.success_status == 0 {
1799 200
1800 } else {
1801 api_doc.success_status
1802 };
1803 let response_content = api_doc
1804 .response
1805 .as_ref()
1806 .map(|entry| {
1807 let mut content = BTreeMap::new();
1808 content.insert(
1809 "application/json".to_owned(),
1810 MediaType {
1811 schema: schema_value_for(entry, index, SchemaDirection::Response),
1812 },
1813 );
1814 content
1815 })
1816 .unwrap_or_default();
1817 responses.insert(
1818 status.to_string(),
1819 Response {
1820 description: status_description(status).to_owned(),
1821 content: response_content,
1822 },
1823 );
1824 insert_problem_responses(&mut responses);
1825
1826 let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
1828 api_versions
1829 .iter()
1830 .find(|av| av.version == version)
1831 .is_some_and(|av| av.sunset_at.is_some())
1832 && !api_doc.sunset_opt_out
1833 });
1834
1835 if is_subject_to_sunset {
1836 responses.entry("410".to_owned()).or_insert_with(|| {
1837 let mut content = BTreeMap::new();
1838 content.insert(
1839 "application/problem+json".to_owned(),
1840 MediaType {
1841 schema: serde_json::json!({
1842 "$ref": "#/components/schemas/ProblemDetails",
1843 }),
1844 },
1845 );
1846 Response {
1847 description: status_description(410).to_owned(),
1848 content,
1849 }
1850 });
1851 }
1852
1853 let security = if api_doc.secured {
1860 let mut req = BTreeMap::new();
1861 if !api_doc.required_scopes.is_empty() {
1862 req.insert("BearerAuth".to_owned(), Vec::<String>::new());
1863 }
1864 if api_doc.required_scopes.is_empty() || !api_doc.required_roles.is_empty() {
1865 req.insert("SessionAuth".to_owned(), Vec::<String>::new());
1866 }
1867 vec![req]
1868 } else {
1869 Vec::new()
1870 };
1871
1872 Operation {
1873 operation_id: api_doc.operation_id.to_owned(),
1874 summary: api_doc.summary.map(str::to_owned),
1875 description: api_doc.description.map(str::to_owned),
1876 tags,
1877 parameters,
1878 request_body,
1879 responses,
1880 security,
1881 deprecated,
1882 x_required_scopes: api_doc
1883 .required_scopes
1884 .iter()
1885 .map(ToString::to_string)
1886 .collect(),
1887 }
1888}
1889
1890#[cfg(feature = "openapi")]
1898fn query_parameters_for(query_entry: &SchemaEntry, index: &SchemaComponentIndex) -> Vec<Parameter> {
1899 if matches!(query_entry.kind, SchemaKind::Ref)
1900 && let Some(body) =
1901 registered_derived_schema_for(query_entry.identity_key(), SchemaDirection::Request)
1902 && let Some(fields) = query_parameters_from_body(&body, index)
1903 {
1904 return fields;
1905 }
1906 vec![Parameter {
1907 name: query_entry.name.to_owned(),
1908 location: "query".to_owned(),
1909 required: false,
1910 schema: schema_value_for(query_entry, index, SchemaDirection::Request),
1911 style: Some("form".to_owned()),
1912 explode: Some(true),
1913 description: None,
1914 }]
1915}
1916
1917#[cfg(feature = "openapi")]
1921fn query_parameters_from_body(
1922 body: &serde_json::Value,
1923 index: &SchemaComponentIndex,
1924) -> Option<Vec<Parameter>> {
1925 let object = body.as_object()?;
1926 if object.get("type").and_then(serde_json::Value::as_str) != Some("object") {
1927 return None;
1928 }
1929 let properties = object.get("properties")?.as_object()?;
1930 let required = object.get("required").and_then(serde_json::Value::as_array);
1931 let is_required =
1932 |name: &str| required.is_some_and(|r| r.iter().any(|v| v.as_str() == Some(name)));
1933
1934 Some(
1935 properties
1936 .iter()
1937 .map(|(name, field_schema)| {
1938 let (style, explode, description) = query_field_style(field_schema);
1939 let mut schema = field_schema.clone();
1944 rewrite_identity_refs(&mut schema, index, SchemaDirection::Request);
1945 Parameter {
1946 name: name.clone(),
1947 location: "query".to_owned(),
1948 required: is_required(name),
1949 schema,
1950 style,
1951 explode,
1952 description,
1953 }
1954 })
1955 .collect(),
1956 )
1957}
1958
1959#[cfg(feature = "openapi")]
1962fn query_field_style(schema: &serde_json::Value) -> (Option<String>, Option<bool>, Option<String>) {
1963 match query_field_shape(schema) {
1964 QueryFieldShape::Flat => (Some("form".to_owned()), Some(true), None),
1965 QueryFieldShape::Object => (Some("deepObject".to_owned()), Some(true), None),
1966 QueryFieldShape::ObjectArray => (
1967 None,
1968 None,
1969 Some(
1970 "Bracketed nested-array query encoding, e.g. \
1971 ?field[0][prop]=value for an array of objects, or \
1972 ?field[0][0]=value for an array of arrays. No OpenAPI \
1973 style expresses this; see the query-string guide."
1974 .to_owned(),
1975 ),
1976 ),
1977 }
1978}
1979
1980#[cfg(feature = "openapi")]
1982enum QueryFieldShape {
1983 Flat,
1985 Object,
1987 ObjectArray,
1989}
1990
1991#[cfg(feature = "openapi")]
1992fn query_field_shape(schema: &serde_json::Value) -> QueryFieldShape {
1993 let effective = unwrap_nullable_schema(schema);
1994 if let Some(identity) = ref_target(effective) {
1995 return if ref_is_object(identity) {
1996 QueryFieldShape::Object
1997 } else {
1998 QueryFieldShape::Flat
1999 };
2000 }
2001 if effective.get("type").and_then(serde_json::Value::as_str) == Some("object") {
2005 return QueryFieldShape::Object;
2006 }
2007 if effective.get("type").and_then(serde_json::Value::as_str) == Some("array")
2008 && let Some(items) = effective.get("items")
2009 {
2010 let items = unwrap_nullable_schema(items);
2011 let items_are_objects = ref_target(items).map_or_else(
2012 || items.get("type").and_then(serde_json::Value::as_str) == Some("array"),
2013 ref_is_object,
2014 );
2015 if items_are_objects {
2016 return QueryFieldShape::ObjectArray;
2017 }
2018 }
2019 QueryFieldShape::Flat
2020}
2021
2022#[cfg(feature = "openapi")]
2027fn unwrap_nullable_schema(schema: &serde_json::Value) -> &serde_json::Value {
2028 let mut current = schema;
2029 while let Some(real) = current
2030 .get("oneOf")
2031 .and_then(serde_json::Value::as_array)
2032 .and_then(|branches| branches.first())
2033 {
2034 current = real;
2035 }
2036 current
2037}
2038
2039#[cfg(feature = "openapi")]
2041fn ref_target(schema: &serde_json::Value) -> Option<&str> {
2042 schema
2043 .get("$ref")
2044 .and_then(serde_json::Value::as_str)
2045 .and_then(|r| r.strip_prefix("#/components/schemas/"))
2046}
2047
2048#[cfg(feature = "openapi")]
2061fn ref_is_object(identity: &str) -> bool {
2062 registered_derived_schema(identity).is_some_and(|schema| {
2063 schema.get("type").and_then(serde_json::Value::as_str) == Some("object")
2064 })
2065}
2066
2067#[cfg(feature = "openapi")]
2080#[must_use]
2081pub fn schema_entry_to_value(
2082 entry: &SchemaEntry,
2083 index: &SchemaComponentIndex,
2084) -> serde_json::Value {
2085 schema_value_for(entry, index, SchemaDirection::Response)
2086}
2087
2088#[cfg(feature = "openapi")]
2090#[must_use]
2091pub fn schema_entry_to_value_in(
2092 entry: &SchemaEntry,
2093 index: &SchemaComponentIndex,
2094 direction: SchemaDirection,
2095) -> serde_json::Value {
2096 schema_value_for(entry, index, direction)
2097}
2098
2099#[cfg(feature = "openapi")]
2100fn schema_value_for(
2101 entry: &SchemaEntry,
2102 index: &SchemaComponentIndex,
2103 direction: SchemaDirection,
2104) -> serde_json::Value {
2105 if wrapper_is_impostor(entry) {
2108 return serde_json::json!({
2109 "$ref": format!("#/components/schemas/{}", index.display_key_in(entry, direction))
2110 });
2111 }
2112 if entry_is_arbitrary_json(entry) {
2113 return arbitrary_json_schema();
2114 }
2115 match entry.kind {
2116 SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
2117 SchemaKind::Ref => {
2118 serde_json::json!({
2119 "$ref": format!("#/components/schemas/{}", index.display_key_in(entry, direction))
2120 })
2121 }
2122 SchemaKind::Array(items) => serde_json::json!({
2123 "type": "array",
2124 "items": schema_value_for(items, index, direction),
2125 }),
2126 SchemaKind::Nullable(inner) => {
2127 if entry_is_arbitrary_json(inner) {
2139 return arbitrary_json_schema();
2140 }
2141 match inner.kind {
2142 SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
2143 serde_json::json!({
2144 "oneOf": [
2145 schema_value_for(inner, index, direction),
2146 { "type": "null" },
2147 ],
2148 })
2149 }
2150 SchemaKind::Primitive(base_type) => {
2151 serde_json::json!({ "type": [base_type, "null"] })
2152 }
2153 }
2154 }
2155 }
2156}
2157
2158#[cfg(feature = "openapi")]
2159fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
2160 for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
2161 responses.entry(status.to_string()).or_insert_with(|| {
2162 let mut content = BTreeMap::new();
2163 content.insert(
2164 "application/problem+json".to_owned(),
2165 MediaType {
2166 schema: serde_json::json!({
2167 "$ref": "#/components/schemas/ProblemDetails",
2168 }),
2169 },
2170 );
2171 Response {
2172 description: status_description(status).to_owned(),
2173 content,
2174 }
2175 });
2176 }
2177}
2178
2179#[cfg(feature = "openapi")]
2180fn problem_details_schema() -> serde_json::Value {
2181 serde_json::json!({
2182 "type": "object",
2183 "additionalProperties": false,
2184 "required": [
2185 "type",
2186 "title",
2187 "status",
2188 "detail",
2189 "instance",
2190 "code",
2191 "request_id",
2192 "errors",
2193 ],
2194 "properties": {
2195 "type": {
2196 "type": "string",
2197 "format": "uri-reference",
2198 },
2199 "title": {
2200 "type": "string",
2201 },
2202 "status": {
2203 "type": "integer",
2204 "minimum": 400,
2205 "maximum": 599,
2206 },
2207 "detail": {
2208 "type": "string",
2209 },
2210 "instance": {
2211 "type": ["string", "null"],
2212 },
2213 "code": {
2214 "type": "string",
2215 "pattern": "^autumn\\.[a-z0-9_]+$",
2216 },
2217 "request_id": {
2218 "type": ["string", "null"],
2219 },
2220 "errors": {
2221 "type": "array",
2222 "items": {
2223 "type": "object",
2224 "additionalProperties": false,
2225 "required": ["field", "messages"],
2226 "properties": {
2227 "field": {
2228 "type": "string",
2229 },
2230 "messages": {
2231 "type": "array",
2232 "items": {
2233 "type": "string",
2234 },
2235 },
2236 },
2237 },
2238 },
2239 },
2240 })
2241}
2242
2243#[cfg(feature = "openapi")]
2244fn default_tag(path: &str) -> Option<&str> {
2245 path.trim_start_matches('/')
2246 .split('/')
2247 .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
2248}
2249
2250#[cfg(feature = "openapi")]
2251const fn status_description(status: u16) -> &'static str {
2252 match status {
2253 200 => "OK",
2254 201 => "Created",
2255 202 => "Accepted",
2256 204 => "No Content",
2257 301 => "Moved Permanently",
2258 302 => "Found",
2259 400 => "Bad Request",
2260 401 => "Unauthorized",
2261 403 => "Forbidden",
2262 404 => "Not Found",
2263 409 => "Conflict",
2264 413 => "Payload Too Large",
2265 415 => "Unsupported Media Type",
2266 422 => "Unprocessable Entity",
2267 500 => "Internal Server Error",
2268 503 => "Service Unavailable",
2269 _ => "Response",
2270 }
2271}
2272
2273#[cfg(feature = "openapi")]
2278pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
2279#[cfg(feature = "openapi")]
2280pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
2281#[cfg(feature = "openapi")]
2282pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
2283 include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
2284#[cfg(feature = "openapi")]
2285const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
2286#[cfg(feature = "openapi")]
2287const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
2288#[cfg(feature = "openapi")]
2289const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
2290
2291#[cfg(feature = "openapi")]
2293#[must_use]
2294pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
2295 [
2296 swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
2297 swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
2298 swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
2299 ]
2300}
2301
2302#[cfg(feature = "openapi")]
2303#[must_use]
2304fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
2305 let base = swagger_path.trim_end_matches('/');
2306 if base.is_empty() || base == "/" {
2307 format!("/{asset_file}")
2308 } else {
2309 format!("{base}/{asset_file}")
2310 }
2311}
2312
2313#[cfg(feature = "openapi")]
2315#[must_use]
2316pub fn swagger_ui_html(
2317 title: &str,
2318 css_url: &str,
2319 bundle_url: &str,
2320 initializer_url: &str,
2321) -> String {
2322 let title = html_escape(title);
2323 let css_url = html_escape(css_url);
2324 let bundle_url = html_escape(bundle_url);
2325 let initializer_url = html_escape(initializer_url);
2326 let mut out = String::with_capacity(1024);
2327 out.push_str("<!DOCTYPE html>\n");
2328 out.push_str("<html lang=\"en\">\n");
2329 out.push_str(" <head>\n");
2330 out.push_str(" <meta charset=\"utf-8\" />\n");
2331 out.push_str(" <title>");
2332 out.push_str(&title);
2333 out.push_str("</title>\n");
2334 out.push_str(" <link rel=\"stylesheet\" href=\"");
2335 out.push_str(&css_url);
2336 out.push_str("\" />\n");
2337 out.push_str(" </head>\n");
2338 out.push_str(" <body>\n");
2339 out.push_str(" <div id=\"swagger-ui\"></div>\n");
2340 out.push_str(" <script src=\"");
2341 out.push_str(&bundle_url);
2342 out.push_str("\" charset=\"UTF-8\"></script>\n");
2343 out.push_str(" <script src=\"");
2344 out.push_str(&initializer_url);
2345 out.push_str("\" charset=\"UTF-8\"></script>\n");
2346 out.push_str(" </body>\n");
2347 out.push_str("</html>\n");
2348 out
2349}
2350
2351#[cfg(feature = "openapi")]
2354#[must_use]
2355pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
2356 let spec_url = serde_json::to_string(spec_url)
2357 .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
2358 let mut out = String::with_capacity(256);
2359 out.push_str("window.onload = function() {\n");
2360 out.push_str(" window.ui = SwaggerUIBundle({\n");
2361 out.push_str(" url: ");
2362 out.push_str(&spec_url);
2363 out.push_str(",\n");
2364 out.push_str(" dom_id: \"#swagger-ui\",\n");
2365 out.push_str(" deepLinking: true\n");
2366 out.push_str(" });\n");
2367 out.push_str("};\n");
2368 out
2369}
2370
2371#[cfg(feature = "openapi")]
2372fn html_escape(s: &str) -> String {
2373 s.replace('&', "&")
2374 .replace('<', "<")
2375 .replace('>', ">")
2376 .replace('"', """)
2377}
2378
2379#[cfg(feature = "openapi")]
2386const PLACEHOLDER_KEYS: [&str; 3] = ["type", "title", "description"];
2387
2388#[cfg(feature = "openapi")]
2406#[must_use]
2407pub fn is_opaque_object_schema(schema: &serde_json::Value) -> bool {
2408 if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
2409 return false;
2410 }
2411 schema.as_object().is_none_or(|map| {
2412 map.keys()
2413 .all(|key| PLACEHOLDER_KEYS.contains(&key.as_str()))
2414 })
2415}
2416
2417#[cfg(feature = "openapi")]
2426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2427pub struct OpaqueSchema {
2428 pub schema: String,
2430 pub referenced_by: Vec<String>,
2433}
2434
2435#[cfg(feature = "openapi")]
2443#[must_use]
2444pub fn opaque_component_schemas(spec: &OpenApiSpec) -> Vec<OpaqueSchema> {
2445 let Some(components) = spec.components.as_ref() else {
2446 return Vec::new();
2447 };
2448
2449 let opaque: std::collections::BTreeSet<&str> = components
2450 .schemas
2451 .iter()
2452 .filter(|(_, schema)| is_opaque_object_schema(schema))
2453 .map(|(name, _)| name.as_str())
2454 .collect();
2455 if opaque.is_empty() {
2456 return Vec::new();
2457 }
2458
2459 let component_refs: BTreeMap<&str, Vec<String>> = components
2466 .schemas
2467 .iter()
2468 .map(|(name, schema)| {
2469 let mut out = Vec::new();
2470 collect_body_ref_identities(schema, &mut out);
2471 (name.as_str(), out)
2472 })
2473 .collect();
2474
2475 let mut refs: BTreeMap<&str, std::collections::BTreeSet<String>> = BTreeMap::new();
2481 for (path, item) in &spec.paths {
2482 for (method, operation) in path_item_operations(item) {
2483 let Ok(value) = serde_json::to_value(operation) else {
2484 continue;
2485 };
2486 let mut frontier = Vec::new();
2487 collect_body_ref_identities(&value, &mut frontier);
2488
2489 let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2492 while let Some(identity) = frontier.pop() {
2493 if !seen.insert(identity.clone()) {
2494 continue;
2495 }
2496 if let Some(name) = opaque.get(identity.as_str()) {
2497 refs.entry(name)
2498 .or_default()
2499 .insert(format!("{method} {path}"));
2500 }
2501 if let Some(nested) = component_refs.get(identity.as_str()) {
2502 frontier.extend(nested.iter().cloned());
2503 }
2504 }
2505 }
2506 }
2507
2508 opaque
2509 .into_iter()
2510 .map(|schema| OpaqueSchema {
2511 schema: schema.to_owned(),
2512 referenced_by: refs
2513 .get(schema)
2514 .map(|set| set.iter().cloned().collect())
2515 .unwrap_or_default(),
2516 })
2517 .collect()
2518}
2519
2520#[cfg(feature = "openapi")]
2522fn path_item_operations(item: &PathItem) -> Vec<(&'static str, &Operation)> {
2523 [
2524 ("GET", item.get.as_ref()),
2525 ("POST", item.post.as_ref()),
2526 ("PUT", item.put.as_ref()),
2527 ("PATCH", item.patch.as_ref()),
2528 ("DELETE", item.delete.as_ref()),
2529 ]
2530 .into_iter()
2531 .filter_map(|(method, op)| op.map(|op| (method, op)))
2532 .collect()
2533}
2534
2535#[cfg(all(test, feature = "openapi"))]
2540mod tests {
2541 use super::*;
2542
2543 #[test]
2546 fn remove_schema_properties_drops_the_property_and_its_required_entry() {
2547 let mut schema = serde_json::json!({
2548 "type": "object",
2549 "properties": { "a": {}, "b": {} },
2550 "required": ["a", "b"],
2551 });
2552 remove_schema_properties(&mut schema, &["b"]);
2553 assert_eq!(
2554 schema,
2555 serde_json::json!({
2556 "type": "object",
2557 "properties": { "a": {} },
2558 "required": ["a"],
2559 })
2560 );
2561 remove_schema_properties(&mut schema, &["a"]);
2562 assert!(schema.get("required").is_none(), "{schema}");
2563 }
2564
2565 #[test]
2566 fn request_key_skips_taken_and_reserved_keys() {
2567 let by_identity: BTreeMap<String, String> = [
2568 ("app::Widget".to_owned(), "Widget".to_owned()),
2569 ("app::WidgetRequest".to_owned(), "WidgetRequest".to_owned()),
2570 ]
2571 .into_iter()
2572 .collect();
2573 let split = std::iter::once("app::Widget".to_owned()).collect();
2574 let reserved = std::iter::once("WidgetRequest-2".to_owned()).collect();
2575 let keys = assign_request_keys(&split, &by_identity, &reserved);
2576 assert_eq!(keys["app::Widget"], "WidgetRequest-3");
2577 }
2578
2579 #[test]
2580 fn split_follows_a_nested_split() {
2581 let both = |id: &str| {
2582 [
2583 (id.to_owned(), SchemaDirection::Request),
2584 (id.to_owned(), SchemaDirection::Response),
2585 ]
2586 };
2587 let uses: std::collections::BTreeSet<(String, SchemaDirection)> = both("app::Outer")
2588 .into_iter()
2589 .chain(both("app::Inner"))
2590 .chain(both("app::Plain"))
2591 .collect();
2592 let outer = serde_json::json!({
2593 "properties": { "inner": { "$ref": "#/components/schemas/app::Inner" } }
2594 });
2595 let plain = serde_json::json!({ "type": "object" });
2596 let bodies: BTreeMap<(String, SchemaDirection), Option<serde_json::Value>> = [
2597 (
2598 ("app::Outer".to_owned(), SchemaDirection::Request),
2599 Some(outer.clone()),
2600 ),
2601 (
2602 ("app::Outer".to_owned(), SchemaDirection::Response),
2603 Some(outer),
2604 ),
2605 (
2606 ("app::Inner".to_owned(), SchemaDirection::Request),
2607 Some(serde_json::json!({ "required": ["secret"] })),
2608 ),
2609 (
2610 ("app::Inner".to_owned(), SchemaDirection::Response),
2611 Some(serde_json::json!({})),
2612 ),
2613 (
2614 ("app::Plain".to_owned(), SchemaDirection::Request),
2615 Some(plain.clone()),
2616 ),
2617 (
2618 ("app::Plain".to_owned(), SchemaDirection::Response),
2619 Some(plain),
2620 ),
2621 ]
2622 .into_iter()
2623 .collect();
2624 let split = split_identities(&uses, &bodies, &std::collections::BTreeSet::new());
2625 let split: Vec<&str> = split.iter().map(String::as_str).collect();
2626 assert_eq!(split, vec!["app::Inner", "app::Outer"]);
2627 }
2628
2629 fn make_doc() -> ApiDoc {
2630 ApiDoc {
2631 method: "GET",
2632 path: "/users/{id}",
2633 operation_id: "get_user",
2634 summary: Some("Fetch a user"),
2635 description: None,
2636 tags: &[],
2637 path_params: &["id"],
2638 request_body: None,
2639 response: None,
2640 success_status: 200,
2641 hidden: false,
2642 query_schema: None,
2643 secured: false,
2644 required_roles: &[],
2645 register_schemas: None,
2646 api_version: None,
2647 ..Default::default()
2648 }
2649 }
2650
2651 #[test]
2652 fn config_builder_methods_work() {
2653 let config = OpenApiConfig::new("Demo", "1.0.0")
2654 .description("A cool API")
2655 .openapi_json_path("/api.json")
2656 .swagger_ui_path(None)
2657 .session_cookie_name("demo.sid");
2658
2659 assert_eq!(config.title, "Demo");
2660 assert_eq!(config.version, "1.0.0");
2661 assert_eq!(config.description.unwrap(), "A cool API");
2662 assert_eq!(config.openapi_json_path, "/api.json");
2663 assert_eq!(config.swagger_ui_path, None);
2664 assert_eq!(config.session_cookie_name, "demo.sid");
2665 }
2666
2667 #[test]
2668 fn secured_spec_uses_configured_session_cookie_name() {
2669 let mut doc = make_doc();
2670 doc.path = "/protected";
2671 doc.operation_id = "protected";
2672 doc.path_params = &[];
2673 doc.secured = true;
2674
2675 let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
2676 let spec = generate_spec(&config, &[&doc]);
2677 let scheme = &spec
2678 .components
2679 .as_ref()
2680 .expect("secured routes emit security components")
2681 .security_schemes["SessionAuth"];
2682
2683 assert_eq!(scheme["type"], "apiKey");
2684 assert_eq!(scheme["in"], "cookie");
2685 assert_eq!(scheme["name"], "demo.sid");
2686 }
2687
2688 #[test]
2689 fn generate_spec_builds_path_with_parameters() {
2690 let doc = make_doc();
2691 let config = OpenApiConfig::new("Demo", "1.0.0");
2692 let spec = generate_spec(&config, &[&doc]);
2693
2694 assert_eq!(spec.openapi, "3.1.0");
2695 assert_eq!(spec.info.title, "Demo");
2696 assert!(spec.paths.contains_key("/users/{id}"));
2697
2698 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
2699 assert_eq!(op.operation_id, "get_user");
2700 assert_eq!(op.parameters.len(), 1);
2701 assert_eq!(op.parameters[0].name, "id");
2702 assert_eq!(op.parameters[0].location, "path");
2703 assert_eq!(op.tags, vec!["users".to_owned()]);
2704 }
2705
2706 #[test]
2707 fn generate_spec_skips_hidden_routes() {
2708 let mut doc = make_doc();
2709 doc.hidden = true;
2710 let config = OpenApiConfig::new("Demo", "1.0.0");
2711 let spec = generate_spec(&config, &[&doc]);
2712 assert!(spec.paths.is_empty());
2713 }
2714
2715 #[test]
2716 fn generate_spec_writes_request_body_ref() {
2717 let mut doc = make_doc();
2718 doc.method = "POST";
2719 doc.path = "/users";
2720 doc.operation_id = "create_user";
2721 doc.path_params = &[];
2722 doc.request_body = Some(SchemaEntry {
2723 name: "CreateUser",
2724 kind: SchemaKind::Ref,
2725 identity: None,
2726 });
2727 doc.success_status = 201;
2728
2729 let config = OpenApiConfig::new("Demo", "1.0.0");
2730 let spec = generate_spec(&config, &[&doc]);
2731 let op = spec.paths["/users"].post.as_ref().unwrap();
2732 let body = op.request_body.as_ref().unwrap();
2733 assert!(body.required);
2734 let media = body.content.get("application/json").unwrap();
2735 assert_eq!(
2736 media.schema,
2737 serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
2738 );
2739 assert!(op.responses.contains_key("201"));
2740 }
2741
2742 #[test]
2743 fn generate_spec_inlines_primitive_response() {
2744 let mut doc = make_doc();
2745 doc.response = Some(SchemaEntry {
2746 name: "string",
2747 kind: SchemaKind::Primitive("string"),
2748 identity: None,
2749 });
2750 let config = OpenApiConfig::new("Demo", "1.0.0");
2751 let spec = generate_spec(&config, &[&doc]);
2752 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
2753 let media = op.responses["200"].content.get("application/json").unwrap();
2754 assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
2755 }
2756
2757 #[test]
2758 fn swagger_ui_html_uses_same_origin_assets() {
2759 let html = swagger_ui_html(
2760 "Demo",
2761 "/swagger-ui/swagger-ui.css",
2762 "/swagger-ui/swagger-ui-bundle.js",
2763 "/swagger-ui/swagger-initializer.js",
2764 );
2765 assert!(html.contains("/swagger-ui/swagger-ui.css"));
2766 assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
2767 assert!(html.contains("/swagger-ui/swagger-initializer.js"));
2768 assert!(!html.contains("unpkg.com"));
2769 assert!(!html.contains("window.onload = function()"));
2770 }
2771
2772 #[test]
2773 fn swagger_ui_initializer_js_references_spec_url() {
2774 let js = swagger_ui_initializer_js("/openapi.json");
2775 assert!(js.contains("SwaggerUIBundle"));
2776 assert!(js.contains(r#""/openapi.json""#));
2777 }
2778
2779 #[test]
2780 fn generate_spec_includes_additional_schemas() {
2781 let doc = make_doc();
2782 let config = OpenApiConfig::new("Demo", "1.0.0")
2783 .register_schema("Foo", serde_json::json!({ "type": "object" }));
2784 let spec = generate_spec(&config, &[&doc]);
2785 let components = spec.components.unwrap();
2786 assert!(components.schemas.contains_key("Foo"));
2787 }
2788
2789 #[test]
2790 fn generate_spec_back_fills_unregistered_ref_schemas() {
2791 let mut doc = make_doc();
2795 doc.method = "POST";
2796 doc.path = "/users";
2797 doc.path_params = &[];
2798 doc.request_body = Some(SchemaEntry {
2799 name: "CreateUser",
2800 kind: SchemaKind::Ref,
2801 identity: None,
2802 });
2803 doc.response = Some(SchemaEntry {
2804 name: "User",
2805 kind: SchemaKind::Ref,
2806 identity: None,
2807 });
2808
2809 let config = OpenApiConfig::new("Demo", "1.0.0");
2810 let spec = generate_spec(&config, &[&doc]);
2811 let components = spec.components.expect("components must be emitted");
2812 let create = components
2813 .schemas
2814 .get("CreateUser")
2815 .expect("CreateUser should be back-filled");
2816 let user = components
2817 .schemas
2818 .get("User")
2819 .expect("User should be back-filled");
2820 assert_eq!(create["type"], "object");
2821 assert_eq!(create["title"], "CreateUser");
2822 assert_eq!(user["type"], "object");
2823 assert_eq!(user["title"], "User");
2824 }
2825
2826 #[test]
2827 fn generate_spec_preserves_user_registered_schemas_over_backfill() {
2828 let mut doc = make_doc();
2829 doc.response = Some(SchemaEntry {
2830 name: "User",
2831 kind: SchemaKind::Ref,
2832 identity: None,
2833 });
2834
2835 let user_schema = serde_json::json!({
2836 "type": "object",
2837 "properties": {"id": {"type": "integer"}},
2838 });
2839 let config =
2840 OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
2841 let spec = generate_spec(&config, &[&doc]);
2842 let components = spec.components.unwrap();
2843 let stored = components.schemas.get("User").unwrap();
2844 assert_eq!(stored, &user_schema, "user schema must not be overwritten");
2845 }
2846
2847 #[test]
2848 fn status_description_returns_correct_strings() {
2849 assert_eq!(status_description(200), "OK");
2850 assert_eq!(status_description(201), "Created");
2851 assert_eq!(status_description(202), "Accepted");
2852 assert_eq!(status_description(204), "No Content");
2853 assert_eq!(status_description(301), "Moved Permanently");
2854 assert_eq!(status_description(302), "Found");
2855 assert_eq!(status_description(400), "Bad Request");
2856 assert_eq!(status_description(401), "Unauthorized");
2857 assert_eq!(status_description(403), "Forbidden");
2858 assert_eq!(status_description(404), "Not Found");
2859 assert_eq!(status_description(409), "Conflict");
2860 assert_eq!(status_description(413), "Payload Too Large");
2861 assert_eq!(status_description(415), "Unsupported Media Type");
2862 assert_eq!(status_description(422), "Unprocessable Entity");
2863 assert_eq!(status_description(500), "Internal Server Error");
2864 assert_eq!(status_description(503), "Service Unavailable");
2865 assert_eq!(status_description(418), "Response");
2866 }
2867
2868 #[test]
2869 fn default_tag_picks_first_static_segment() {
2870 assert_eq!(default_tag("/users/{id}"), Some("users"));
2871 assert_eq!(default_tag("/api/v1/users"), Some("api"));
2872 assert_eq!(default_tag("/"), None);
2873 assert_eq!(default_tag("/{id}"), None);
2874 }
2875
2876 #[test]
2879 fn spec_version_is_3_1_0() {
2880 let config = OpenApiConfig::new("Demo", "1.0.0");
2881 let spec = generate_spec(&config, &[]);
2882 assert_eq!(
2883 spec.openapi, "3.1.0",
2884 "Autumn must emit OpenAPI 3.1.0, not {}",
2885 spec.openapi
2886 );
2887 }
2888
2889 #[test]
2890 fn nullable_ref_uses_openapi_3_1_one_of() {
2891 static INNER: SchemaEntry = SchemaEntry {
2895 name: "User",
2896 kind: SchemaKind::Ref,
2897 identity: None,
2898 };
2899 let entry = SchemaEntry {
2900 name: "nullable",
2901 kind: SchemaKind::Nullable(&INNER),
2902 identity: None,
2903 };
2904 let value = schema_value_for(
2905 &entry,
2906 &SchemaComponentIndex::default(),
2907 SchemaDirection::Response,
2908 );
2909 assert!(
2910 value.get("nullable").is_none(),
2911 "3.1 must not emit `nullable: true` (that is 3.0 only)"
2912 );
2913 assert!(
2914 value.get("allOf").is_none(),
2915 "3.1 must not use allOf for nullable refs"
2916 );
2917 let one_of = value["oneOf"]
2918 .as_array()
2919 .expect("3.1 nullable ref must use oneOf");
2920 assert_eq!(one_of.len(), 2);
2921 assert_eq!(
2922 one_of[0]["$ref"], "#/components/schemas/User",
2923 "first oneOf branch must be the $ref"
2924 );
2925 assert_eq!(
2926 one_of[1]["type"], "null",
2927 "second oneOf branch must be {{type: null}}"
2928 );
2929 }
2930
2931 #[test]
2932 fn nullable_primitive_uses_type_array() {
2933 static INNER: SchemaEntry = SchemaEntry {
2936 name: "integer",
2937 kind: SchemaKind::Primitive("integer"),
2938 identity: None,
2939 };
2940 let entry = SchemaEntry {
2941 name: "nullable",
2942 kind: SchemaKind::Nullable(&INNER),
2943 identity: None,
2944 };
2945 let value = schema_value_for(
2946 &entry,
2947 &SchemaComponentIndex::default(),
2948 SchemaDirection::Response,
2949 );
2950 assert!(
2951 value.get("nullable").is_none(),
2952 "3.1 must not emit `nullable: true`"
2953 );
2954 let types = value["type"]
2955 .as_array()
2956 .expect("3.1 nullable primitive must use a type array");
2957 assert!(
2958 types.contains(&serde_json::Value::String("integer".to_owned())),
2959 "type array must include the base type"
2960 );
2961 assert!(
2962 types.contains(&serde_json::Value::String("null".to_owned())),
2963 "type array must include null"
2964 );
2965 }
2966
2967 #[test]
2968 fn write_openapi_spec_to_dist_creates_json_file() {
2969 let tmp = tempfile::TempDir::new().unwrap();
2970 let dist = tmp.path().join("dist");
2971 std::fs::create_dir_all(&dist).unwrap();
2972
2973 let config = OpenApiConfig::new("TestAPI", "2.0.0");
2974 let spec = generate_spec(&config, &[]);
2975
2976 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
2977
2978 let json_path = dist.join("openapi.json");
2979 assert!(json_path.exists(), "dist/openapi.json must be written");
2980
2981 let content = std::fs::read_to_string(&json_path).unwrap();
2982 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
2983 assert_eq!(parsed["openapi"], "3.1.0");
2984 assert_eq!(parsed["info"]["title"], "TestAPI");
2985 }
2986
2987 #[test]
2988 fn write_openapi_spec_to_dist_creates_yaml_file() {
2989 let tmp = tempfile::TempDir::new().unwrap();
2990 let dist = tmp.path().join("dist");
2991 std::fs::create_dir_all(&dist).unwrap();
2992
2993 let config = OpenApiConfig::new("TestAPI", "2.0.0");
2994 let spec = generate_spec(&config, &[]);
2995
2996 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
2997
2998 let yaml_path = dist.join("openapi.yaml");
2999 assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
3000
3001 let content = std::fs::read_to_string(&yaml_path).unwrap();
3002 assert!(
3003 content.contains("openapi:"),
3004 "YAML must include the openapi field"
3005 );
3006 assert!(content.contains("3.1.0"), "YAML must include the version");
3007 assert!(content.contains("TestAPI"), "YAML must include the title");
3008 }
3009
3010 #[test]
3011 fn schema_registry_into_map_returns_all_schemas() {
3012 let mut registry = SchemaRegistry::default();
3013 registry.insert("Foo", serde_json::json!({ "type": "string" }));
3014 registry.insert("Bar", serde_json::json!({ "type": "integer" }));
3015
3016 let map = registry.into_map();
3017 assert_eq!(map.len(), 2);
3018 assert_eq!(
3019 map.get("Foo").unwrap(),
3020 &serde_json::json!({ "type": "string" })
3021 );
3022 assert_eq!(
3023 map.get("Bar").unwrap(),
3024 &serde_json::json!({ "type": "integer" })
3025 );
3026 }
3027
3028 #[test]
3029 fn schema_registry_deduplicates() {
3030 struct Foo;
3031 impl OpenApiSchema for Foo {
3032 fn schema_name() -> &'static str {
3033 "Foo"
3034 }
3035 fn schema() -> serde_json::Value {
3036 serde_json::json!({ "type": "object", "title": "Foo" })
3037 }
3038 }
3039
3040 let mut registry = SchemaRegistry::default();
3041 registry.register::<Foo>();
3042 registry.register::<Foo>();
3043 assert_eq!(registry.schemas().len(), 1);
3044 }
3045
3046 #[test]
3047 fn primitive_impls_cover_common_types() {
3048 assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
3049 assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
3050 assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
3051 assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
3052 }
3053
3054 #[test]
3055 fn swagger_ui_html_embeds_spec_url() {
3056 let html = swagger_ui_html(
3057 "My API",
3058 "/swagger-ui/swagger-ui.css",
3059 "/swagger-ui/swagger-ui-bundle.js",
3060 "/swagger-ui/swagger-initializer.js",
3061 );
3062 assert!(html.contains("/swagger-ui/swagger-ui.css"));
3063 assert!(html.contains("My API"));
3064 }
3065
3066 #[test]
3067 fn swagger_ui_html_escapes_attributes() {
3068 let html = swagger_ui_html(
3069 "A \"cool\" & fun API",
3070 "/swagger-ui/swagger-ui.css?x=<y>",
3071 "/swagger-ui/swagger-ui-bundle.js",
3072 "/swagger-ui/swagger-initializer.js",
3073 );
3074 assert!(html.contains("/swagger-ui/swagger-ui.css?x=<y>"));
3075 assert!(html.contains("A "cool" & fun API"));
3076 }
3077
3078 fn make_secured_doc(
3081 secured: bool,
3082 required_roles: &'static [&'static str],
3083 required_scopes: &'static [&'static str],
3084 ) -> ApiDoc {
3085 let mut doc = make_doc();
3086 doc.path = "/secured";
3087 doc.operation_id = "secured_op";
3088 doc.path_params = &[];
3089 doc.secured = secured;
3090 doc.required_roles = required_roles;
3091 doc.required_scopes = required_scopes;
3092 doc
3093 }
3094
3095 #[test]
3096 fn unsecured_route_has_no_security_requirement() {
3097 let doc = make_secured_doc(false, &[], &[]);
3098 let config = OpenApiConfig::new("Demo", "1.0.0");
3099 let spec = generate_spec(&config, &[&doc]);
3100 let op = spec.paths["/secured"].get.as_ref().unwrap();
3101 assert!(op.security.is_empty());
3102 }
3103
3104 #[test]
3105 fn bare_secured_uses_session_auth() {
3106 let doc = make_secured_doc(true, &[], &[]);
3107 let config = OpenApiConfig::new("Demo", "1.0.0");
3108 let spec = generate_spec(&config, &[&doc]);
3109 let op = spec.paths["/secured"].get.as_ref().unwrap();
3110 assert_eq!(op.security.len(), 1);
3111 assert!(op.security[0].contains_key("SessionAuth"));
3112 assert!(!op.security[0].contains_key("BearerAuth"));
3113 }
3114
3115 #[test]
3116 fn role_only_uses_session_auth() {
3117 let doc = make_secured_doc(true, &["admin"], &[]);
3118 let config = OpenApiConfig::new("Demo", "1.0.0");
3119 let spec = generate_spec(&config, &[&doc]);
3120 let op = spec.paths["/secured"].get.as_ref().unwrap();
3121 assert_eq!(op.security.len(), 1);
3122 assert!(op.security[0].contains_key("SessionAuth"));
3123 assert!(!op.security[0].contains_key("BearerAuth"));
3124 }
3125
3126 #[test]
3127 fn scope_only_uses_bearer_auth_with_empty_array() {
3128 let doc = make_secured_doc(true, &[], &["posts:write"]);
3129 let config = OpenApiConfig::new("Demo", "1.0.0");
3130 let spec = generate_spec(&config, &[&doc]);
3131 let op = spec.paths["/secured"].get.as_ref().unwrap();
3132 assert_eq!(op.security.len(), 1);
3133 assert!(op.security[0].contains_key("BearerAuth"));
3134 assert!(!op.security[0].contains_key("SessionAuth"));
3135 assert!(op.security[0]["BearerAuth"].is_empty());
3137 let schemes = &spec.components.as_ref().unwrap().security_schemes;
3139 assert!(schemes.contains_key("BearerAuth"));
3140 assert_eq!(schemes["BearerAuth"]["scheme"], "bearer");
3141 }
3142
3143 #[test]
3144 fn mixed_role_and_scope_uses_both_auth_schemes() {
3145 let doc = make_secured_doc(true, &["admin"], &["posts:write"]);
3146 let config = OpenApiConfig::new("Demo", "1.0.0");
3147 let spec = generate_spec(&config, &[&doc]);
3148 let op = spec.paths["/secured"].get.as_ref().unwrap();
3149 assert_eq!(op.security.len(), 1);
3150 assert!(op.security[0].contains_key("SessionAuth"));
3152 assert!(op.security[0].contains_key("BearerAuth"));
3153 }
3154
3155 #[test]
3156 fn bearer_auth_scheme_registered_only_for_scoped_routes() {
3157 let unscoped = make_secured_doc(true, &["admin"], &[]);
3158 let config = OpenApiConfig::new("Demo", "1.0.0");
3159 let spec = generate_spec(&config, &[&unscoped]);
3160 let schemes = &spec.components.as_ref().unwrap().security_schemes;
3161 assert!(!schemes.contains_key("BearerAuth"));
3162 }
3163
3164 #[test]
3174 fn colliding_fallback_keys_are_disambiguated() {
3175 let refs = vec![
3176 ("a::x::Args".to_owned(), "Args".to_owned()),
3177 ("x::Args".to_owned(), "Args".to_owned()),
3178 ];
3179 let by_identity = assign_display_keys(&refs);
3180
3181 let a = by_identity.get("a::x::Args").expect("a::x::Args assigned");
3182 let b = by_identity.get("x::Args").expect("x::Args assigned");
3183 assert_ne!(
3184 a, b,
3185 "distinct identities must map to distinct display keys, got {a} == {b}"
3186 );
3187 assert_eq!(a, "x.Args");
3191 assert_eq!(b, "x.Args-2");
3192 for key in [a, b] {
3194 assert!(
3195 key.chars()
3196 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
3197 "display key {key} is not a valid component key"
3198 );
3199 }
3200 }
3201
3202 #[test]
3205 fn assign_display_keys_is_order_independent() {
3206 let forward = vec![
3207 ("app::app::Args".to_owned(), "Args".to_owned()),
3208 ("app::Args".to_owned(), "Args".to_owned()),
3209 ("other::mod::Args".to_owned(), "Args".to_owned()),
3210 ];
3211 let mut reversed = forward.clone();
3212 reversed.reverse();
3213 assert_eq!(
3214 assign_display_keys(&forward),
3215 assign_display_keys(&reversed),
3216 "display-key assignment must not depend on input order"
3217 );
3218 }
3219}
3220
3221#[cfg(test)]
3223mod scalar_schema_tests {
3224 use super::*;
3225
3226 #[test]
3227 fn scalar_impls_document_strings_with_a_format() {
3228 use serde_json::json;
3229 let cases = [
3230 (
3231 <uuid::Uuid as OpenApiSchema>::schema(),
3232 json!({"type": "string", "format": "uuid"}),
3233 ),
3234 (
3235 <chrono::DateTime<chrono::Utc> as OpenApiSchema>::schema(),
3236 json!({"type": "string", "format": "date-time"}),
3237 ),
3238 (
3239 <chrono::NaiveDate as OpenApiSchema>::schema(),
3240 json!({"type": "string", "format": "date"}),
3241 ),
3242 ];
3243 for (got, want) in cases {
3244 assert_eq!(got, want);
3245 }
3246 let naive = <chrono::NaiveDateTime as OpenApiSchema>::schema();
3247 assert_eq!(naive["type"], "string");
3248 assert!(
3249 naive.get("format").is_none(),
3250 "naive has no offset: {naive}"
3251 );
3252 }
3253
3254 #[test]
3255 fn decimal_schema_matches_its_serde_output() {
3256 let wire = serde_json::to_value(rust_decimal::Decimal::new(12345, 2)).unwrap();
3257 let schema = <rust_decimal::Decimal as OpenApiSchema>::schema();
3258 if wire.is_string() {
3259 assert_eq!(
3260 schema,
3261 serde_json::json!({"type": "string", "format": "decimal"})
3262 );
3263 } else {
3264 assert_eq!(schema, serde_json::json!({"type": "number"}));
3265 }
3266 }
3267}