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 register_schemas: Option<fn(&mut SchemaRegistry)>,
120 pub api_version: Option<&'static str>,
122 pub sunset_opt_out: bool,
124 pub has_policy: bool,
126 pub mcp_tool: bool,
129 pub mcp_exclude: bool,
135 pub mcp_stream: bool,
143}
144
145#[derive(Copy, Clone, Debug, PartialEq, Eq)]
147pub struct SchemaEntry {
148 pub name: &'static str,
150 pub kind: SchemaKind,
153}
154
155#[derive(Copy, Clone, Debug, PartialEq, Eq)]
157pub enum SchemaKind {
158 Ref,
160 Primitive(&'static str),
162 Array(&'static SchemaEntry),
168 Nullable(&'static SchemaEntry),
171}
172
173#[cfg(feature = "openapi")]
182#[derive(Clone)]
183pub struct OpenApiConfig {
184 pub title: String,
186 pub version: String,
188 pub description: Option<String>,
190 pub openapi_json_path: String,
192 pub swagger_ui_path: Option<String>,
195 pub session_cookie_name: String,
200 pub additional_schemas: BTreeMap<String, serde_json::Value>,
202 pub api_versions: Vec<crate::app::ApiVersion>,
204}
205
206#[cfg(feature = "openapi")]
207impl OpenApiConfig {
208 #[must_use]
210 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
211 Self {
212 title: title.into(),
213 version: version.into(),
214 description: None,
215 openapi_json_path: "/openapi.json".to_owned(),
216 swagger_ui_path: Some("/swagger-ui".to_owned()),
217 session_cookie_name: "autumn.sid".to_owned(),
218 additional_schemas: BTreeMap::new(),
219 api_versions: Vec::new(),
220 }
221 }
222
223 #[must_use]
225 pub fn description(mut self, description: impl Into<String>) -> Self {
226 self.description = Some(description.into());
227 self
228 }
229
230 #[must_use]
232 pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
233 self.openapi_json_path = path.into();
234 self
235 }
236
237 #[must_use]
239 pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
240 self.swagger_ui_path = path;
241 self
242 }
243
244 #[must_use]
246 pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
247 self.session_cookie_name = name.into();
248 self
249 }
250
251 #[must_use]
254 pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
255 self.additional_schemas.insert(name.into(), schema);
256 self
257 }
258}
259
260pub trait OpenApiSchema {
275 fn schema_name() -> &'static str;
277
278 fn schema() -> serde_json::Value;
280}
281
282macro_rules! impl_primitive_schema {
283 ($ty:ty, $name:literal, $json:literal) => {
284 impl OpenApiSchema for $ty {
285 fn schema_name() -> &'static str {
286 $name
287 }
288 fn schema() -> serde_json::Value {
289 serde_json::json!({ "type": $json })
290 }
291 }
292 };
293}
294
295impl_primitive_schema!(bool, "boolean", "boolean");
296impl_primitive_schema!(String, "string", "string");
297impl_primitive_schema!(&'static str, "string", "string");
298impl_primitive_schema!(i8, "integer", "integer");
299impl_primitive_schema!(i16, "integer", "integer");
300impl_primitive_schema!(i32, "integer", "integer");
301impl_primitive_schema!(i64, "integer", "integer");
302impl_primitive_schema!(u8, "integer", "integer");
303impl_primitive_schema!(u16, "integer", "integer");
304impl_primitive_schema!(u32, "integer", "integer");
305impl_primitive_schema!(u64, "integer", "integer");
306impl_primitive_schema!(f32, "number", "number");
307impl_primitive_schema!(f64, "number", "number");
308impl_primitive_schema!(serde_json::Value, "object", "object");
309
310#[derive(Default)]
316pub struct SchemaRegistry {
317 schemas: BTreeMap<String, serde_json::Value>,
318}
319
320impl SchemaRegistry {
321 pub fn register<T: OpenApiSchema>(&mut self) {
324 let name = T::schema_name().to_owned();
325 self.schemas.entry(name).or_insert_with(T::schema);
326 }
327
328 pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
330 self.schemas.insert(name.into(), schema);
331 }
332
333 #[must_use]
335 pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
336 self.schemas
337 }
338
339 #[must_use]
341 pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
342 &self.schemas
343 }
344}
345
346#[cfg(feature = "openapi")]
357#[derive(Debug, Serialize, Deserialize)]
359pub struct OpenApiSpec {
360 pub openapi: String,
362 pub info: Info,
364 pub paths: BTreeMap<String, PathItem>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub components: Option<Components>,
369}
370
371#[cfg(feature = "openapi")]
372#[derive(Debug, Serialize, Deserialize)]
374pub struct Info {
375 pub title: String,
377 pub version: String,
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub description: Option<String>,
382}
383
384#[cfg(feature = "openapi")]
385#[derive(Default, Debug, Serialize, Deserialize)]
387pub struct PathItem {
388 #[serde(skip_serializing_if = "Option::is_none")]
390 pub get: Option<Operation>,
391 #[serde(skip_serializing_if = "Option::is_none")]
393 pub post: Option<Operation>,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub put: Option<Operation>,
397 #[serde(skip_serializing_if = "Option::is_none")]
399 pub delete: Option<Operation>,
400 #[serde(skip_serializing_if = "Option::is_none")]
402 pub patch: Option<Operation>,
403}
404
405#[cfg(feature = "openapi")]
406#[derive(Debug, Serialize, Deserialize)]
408pub struct Operation {
409 #[serde(rename = "operationId")]
411 pub operation_id: String,
412 #[serde(skip_serializing_if = "Option::is_none")]
414 pub summary: Option<String>,
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub description: Option<String>,
418 #[serde(skip_serializing_if = "Vec::is_empty")]
420 pub tags: Vec<String>,
421 #[serde(skip_serializing_if = "Vec::is_empty")]
423 pub parameters: Vec<Parameter>,
424 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
426 pub request_body: Option<RequestBody>,
427 pub responses: BTreeMap<String, Response>,
429 #[serde(skip_serializing_if = "Vec::is_empty")]
431 pub security: Vec<BTreeMap<String, Vec<String>>>,
432 #[serde(skip_serializing_if = "Option::is_none")]
434 pub deprecated: Option<bool>,
435}
436
437#[cfg(feature = "openapi")]
438#[derive(Debug, Serialize, Deserialize)]
440pub struct Parameter {
441 pub name: String,
443 #[serde(rename = "in")]
445 pub location: String,
446 pub required: bool,
448 pub schema: serde_json::Value,
450 #[serde(skip_serializing_if = "Option::is_none")]
453 pub style: Option<String>,
454 #[serde(skip_serializing_if = "Option::is_none")]
457 pub explode: Option<bool>,
458}
459
460#[cfg(feature = "openapi")]
461#[derive(Debug, Serialize, Deserialize)]
463pub struct RequestBody {
464 pub required: bool,
466 pub content: BTreeMap<String, MediaType>,
468}
469
470#[cfg(feature = "openapi")]
471#[derive(Debug, Serialize, Deserialize)]
473pub struct Response {
474 pub description: String,
476 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
478 pub content: BTreeMap<String, MediaType>,
479}
480
481#[cfg(feature = "openapi")]
482#[derive(Debug, Serialize, Deserialize)]
484pub struct MediaType {
485 pub schema: serde_json::Value,
487}
488
489#[cfg(feature = "openapi")]
490#[derive(Debug, Serialize, Deserialize)]
492pub struct Components {
493 pub schemas: BTreeMap<String, serde_json::Value>,
495 #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
497 pub security_schemes: BTreeMap<String, serde_json::Value>,
498}
499
500#[cfg(feature = "openapi")]
515pub fn write_openapi_spec_to_dist(
516 spec: &OpenApiSpec,
517 dist_dir: &std::path::Path,
518) -> std::io::Result<()> {
519 std::fs::create_dir_all(dist_dir)?;
520
521 let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
522 std::fs::write(dist_dir.join("openapi.json"), &json)?;
523
524 let yaml = serde_yaml::to_string(spec).map_err(std::io::Error::other)?;
525 std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
526
527 Ok(())
528}
529
530#[cfg(feature = "openapi")]
535#[must_use]
536pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
537 generate_spec_at(config, routes, chrono::Utc::now())
538}
539
540#[cfg(feature = "openapi")]
541#[must_use]
542pub fn generate_spec_at(
543 config: &OpenApiConfig,
544 routes: &[&ApiDoc],
545 now: chrono::DateTime<chrono::Utc>,
546) -> OpenApiSpec {
547 let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
548 let mut registry = SchemaRegistry::default();
549
550 for (name, schema) in &config.additional_schemas {
551 registry.insert(name.clone(), schema.clone());
552 }
553 registry.insert("ProblemDetails", problem_details_schema());
554
555 let mut referenced_names: std::collections::BTreeSet<&'static str> =
561 std::collections::BTreeSet::new();
562
563 let mut any_secured = false;
564
565 for api_doc in routes {
566 if api_doc.hidden {
567 continue;
568 }
569 if api_doc.secured {
570 any_secured = true;
571 }
572 if let Some(register) = api_doc.register_schemas {
573 (register)(&mut registry);
574 }
575
576 if let Some(entry) = &api_doc.request_body {
577 collect_ref_names(entry, &mut referenced_names);
578 }
579 if let Some(entry) = &api_doc.response {
580 collect_ref_names(entry, &mut referenced_names);
581 }
582 if let Some(entry) = &api_doc.query_schema {
583 collect_ref_names(entry, &mut referenced_names);
584 }
585
586 let operation = operation_for(api_doc, &config.api_versions, now);
587 let entry = paths.entry(api_doc.path.to_owned()).or_default();
588 match api_doc.method {
589 "GET" => entry.get = Some(operation),
590 "POST" => entry.post = Some(operation),
591 "PUT" => entry.put = Some(operation),
592 "DELETE" => entry.delete = Some(operation),
593 "PATCH" => entry.patch = Some(operation),
594 _ => {}
597 }
598 }
599
600 for name in referenced_names {
605 if !registry.schemas().contains_key(name) {
606 registry.insert(
607 name,
608 serde_json::json!({
609 "type": "object",
610 "title": name,
611 }),
612 );
613 }
614 }
615
616 let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
618 if any_secured {
619 security_schemes.insert(
620 "SessionAuth".to_owned(),
621 serde_json::json!({
622 "type": "apiKey",
623 "in": "cookie",
624 "name": config.session_cookie_name.clone(),
625 "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
626 }),
627 );
628 }
629
630 let components_map = registry.into_map();
631 let components = if !components_map.is_empty() || !security_schemes.is_empty() {
632 Some(Components {
633 schemas: components_map,
634 security_schemes,
635 })
636 } else {
637 None
638 };
639
640 OpenApiSpec {
641 openapi: "3.1.0".to_owned(),
642 info: Info {
643 title: config.title.clone(),
644 version: config.version.clone(),
645 description: config.description.clone(),
646 },
647 paths,
648 components,
649 }
650}
651
652#[cfg(feature = "openapi")]
653#[allow(clippy::too_many_lines)]
654fn operation_for(
655 api_doc: &ApiDoc,
656 api_versions: &[crate::app::ApiVersion],
657 now: chrono::DateTime<chrono::Utc>,
658) -> Operation {
659 let mut tags = if api_doc.tags.is_empty() {
660 default_tag(api_doc.path)
661 .map(|t| vec![t.to_owned()])
662 .unwrap_or_default()
663 } else {
664 api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
665 };
666
667 if let Some(version) = api_doc.api_version {
668 tags.push(version.to_string());
669 }
670
671 let is_deprecated = api_doc.api_version.is_some_and(|version| {
672 api_versions
673 .iter()
674 .find(|av| av.version == version)
675 .is_some_and(|av| {
676 let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
677 let is_sun = av.sunset_at.is_some_and(|s| now >= s);
678 is_dep || is_sun
679 })
680 });
681 let deprecated = if is_deprecated { Some(true) } else { None };
682
683 let mut parameters: Vec<Parameter> = api_doc
685 .path_params
686 .iter()
687 .map(|name| Parameter {
688 name: (*name).to_owned(),
689 location: "path".to_owned(),
690 required: true,
691 schema: serde_json::json!({ "type": "string" }),
692 style: None,
693 explode: None,
694 })
695 .collect();
696
697 if let Some(query_entry) = &api_doc.query_schema {
702 parameters.push(Parameter {
703 name: query_entry.name.to_owned(),
704 location: "query".to_owned(),
705 required: false,
706 schema: schema_value_for(query_entry),
707 style: Some("form".to_owned()),
708 explode: Some(true),
709 });
710 }
711
712 let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
713 required: true,
714 content: std::iter::once((
715 "application/json".to_owned(),
716 MediaType {
717 schema: schema_value_for(entry),
718 },
719 ))
720 .collect(),
721 });
722
723 let mut responses: BTreeMap<String, Response> = BTreeMap::new();
724 let status = if api_doc.success_status == 0 {
725 200
726 } else {
727 api_doc.success_status
728 };
729 let response_content = api_doc
730 .response
731 .as_ref()
732 .map(|entry| {
733 let mut content = BTreeMap::new();
734 content.insert(
735 "application/json".to_owned(),
736 MediaType {
737 schema: schema_value_for(entry),
738 },
739 );
740 content
741 })
742 .unwrap_or_default();
743 responses.insert(
744 status.to_string(),
745 Response {
746 description: status_description(status).to_owned(),
747 content: response_content,
748 },
749 );
750 insert_problem_responses(&mut responses);
751
752 let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
754 api_versions
755 .iter()
756 .find(|av| av.version == version)
757 .is_some_and(|av| av.sunset_at.is_some())
758 && !api_doc.sunset_opt_out
759 });
760
761 if is_subject_to_sunset {
762 responses.entry("410".to_owned()).or_insert_with(|| {
763 let mut content = BTreeMap::new();
764 content.insert(
765 "application/problem+json".to_owned(),
766 MediaType {
767 schema: serde_json::json!({
768 "$ref": "#/components/schemas/ProblemDetails",
769 }),
770 },
771 );
772 Response {
773 description: status_description(410).to_owned(),
774 content,
775 }
776 });
777 }
778
779 let security = if api_doc.secured {
781 let mut req = BTreeMap::new();
782 req.insert("SessionAuth".to_owned(), Vec::new());
783 vec![req]
784 } else {
785 Vec::new()
786 };
787
788 Operation {
789 operation_id: api_doc.operation_id.to_owned(),
790 summary: api_doc.summary.map(str::to_owned),
791 description: api_doc.description.map(str::to_owned),
792 tags,
793 parameters,
794 request_body,
795 responses,
796 security,
797 deprecated,
798 }
799}
800
801#[cfg(feature = "openapi")]
807#[must_use]
808pub fn schema_entry_to_value(entry: &SchemaEntry) -> serde_json::Value {
809 schema_value_for(entry)
810}
811
812#[cfg(feature = "openapi")]
813fn schema_value_for(entry: &SchemaEntry) -> serde_json::Value {
814 match entry.kind {
815 SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
816 SchemaKind::Ref => {
817 serde_json::json!({ "$ref": format!("#/components/schemas/{}", entry.name) })
818 }
819 SchemaKind::Array(items) => serde_json::json!({
820 "type": "array",
821 "items": schema_value_for(items),
822 }),
823 SchemaKind::Nullable(inner) => {
824 match inner.kind {
832 SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
833 serde_json::json!({
834 "oneOf": [
835 schema_value_for(inner),
836 { "type": "null" },
837 ],
838 })
839 }
840 SchemaKind::Primitive(base_type) => {
841 serde_json::json!({ "type": [base_type, "null"] })
842 }
843 }
844 }
845 }
846}
847
848#[cfg(feature = "openapi")]
852fn collect_ref_names(entry: &SchemaEntry, out: &mut std::collections::BTreeSet<&'static str>) {
853 match entry.kind {
854 SchemaKind::Ref => {
855 out.insert(entry.name);
856 }
857 SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => collect_ref_names(inner, out),
858 SchemaKind::Primitive(_) => {}
859 }
860}
861
862#[cfg(feature = "openapi")]
863fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
864 for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
865 responses.entry(status.to_string()).or_insert_with(|| {
866 let mut content = BTreeMap::new();
867 content.insert(
868 "application/problem+json".to_owned(),
869 MediaType {
870 schema: serde_json::json!({
871 "$ref": "#/components/schemas/ProblemDetails",
872 }),
873 },
874 );
875 Response {
876 description: status_description(status).to_owned(),
877 content,
878 }
879 });
880 }
881}
882
883#[cfg(feature = "openapi")]
884fn problem_details_schema() -> serde_json::Value {
885 serde_json::json!({
886 "type": "object",
887 "additionalProperties": false,
888 "required": [
889 "type",
890 "title",
891 "status",
892 "detail",
893 "instance",
894 "code",
895 "request_id",
896 "errors",
897 ],
898 "properties": {
899 "type": {
900 "type": "string",
901 "format": "uri-reference",
902 },
903 "title": {
904 "type": "string",
905 },
906 "status": {
907 "type": "integer",
908 "minimum": 400,
909 "maximum": 599,
910 },
911 "detail": {
912 "type": "string",
913 },
914 "instance": {
915 "type": ["string", "null"],
916 },
917 "code": {
918 "type": "string",
919 "pattern": "^autumn\\.[a-z0-9_]+$",
920 },
921 "request_id": {
922 "type": ["string", "null"],
923 },
924 "errors": {
925 "type": "array",
926 "items": {
927 "type": "object",
928 "additionalProperties": false,
929 "required": ["field", "messages"],
930 "properties": {
931 "field": {
932 "type": "string",
933 },
934 "messages": {
935 "type": "array",
936 "items": {
937 "type": "string",
938 },
939 },
940 },
941 },
942 },
943 },
944 })
945}
946
947#[cfg(feature = "openapi")]
948fn default_tag(path: &str) -> Option<&str> {
949 path.trim_start_matches('/')
950 .split('/')
951 .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
952}
953
954#[cfg(feature = "openapi")]
955const fn status_description(status: u16) -> &'static str {
956 match status {
957 200 => "OK",
958 201 => "Created",
959 202 => "Accepted",
960 204 => "No Content",
961 301 => "Moved Permanently",
962 302 => "Found",
963 400 => "Bad Request",
964 401 => "Unauthorized",
965 403 => "Forbidden",
966 404 => "Not Found",
967 409 => "Conflict",
968 413 => "Payload Too Large",
969 415 => "Unsupported Media Type",
970 422 => "Unprocessable Entity",
971 500 => "Internal Server Error",
972 503 => "Service Unavailable",
973 _ => "Response",
974 }
975}
976
977#[cfg(feature = "openapi")]
982pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
983#[cfg(feature = "openapi")]
984pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
985#[cfg(feature = "openapi")]
986pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
987 include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
988#[cfg(feature = "openapi")]
989const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
990#[cfg(feature = "openapi")]
991const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
992#[cfg(feature = "openapi")]
993const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
994
995#[cfg(feature = "openapi")]
997#[must_use]
998pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
999 [
1000 swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
1001 swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
1002 swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
1003 ]
1004}
1005
1006#[cfg(feature = "openapi")]
1007#[must_use]
1008fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
1009 let base = swagger_path.trim_end_matches('/');
1010 if base.is_empty() || base == "/" {
1011 format!("/{asset_file}")
1012 } else {
1013 format!("{base}/{asset_file}")
1014 }
1015}
1016
1017#[cfg(feature = "openapi")]
1019#[must_use]
1020pub fn swagger_ui_html(
1021 title: &str,
1022 css_url: &str,
1023 bundle_url: &str,
1024 initializer_url: &str,
1025) -> String {
1026 let title = html_escape(title);
1027 let css_url = html_escape(css_url);
1028 let bundle_url = html_escape(bundle_url);
1029 let initializer_url = html_escape(initializer_url);
1030 let mut out = String::with_capacity(1024);
1031 out.push_str("<!DOCTYPE html>\n");
1032 out.push_str("<html lang=\"en\">\n");
1033 out.push_str(" <head>\n");
1034 out.push_str(" <meta charset=\"utf-8\" />\n");
1035 out.push_str(" <title>");
1036 out.push_str(&title);
1037 out.push_str("</title>\n");
1038 out.push_str(" <link rel=\"stylesheet\" href=\"");
1039 out.push_str(&css_url);
1040 out.push_str("\" />\n");
1041 out.push_str(" </head>\n");
1042 out.push_str(" <body>\n");
1043 out.push_str(" <div id=\"swagger-ui\"></div>\n");
1044 out.push_str(" <script src=\"");
1045 out.push_str(&bundle_url);
1046 out.push_str("\" charset=\"UTF-8\"></script>\n");
1047 out.push_str(" <script src=\"");
1048 out.push_str(&initializer_url);
1049 out.push_str("\" charset=\"UTF-8\"></script>\n");
1050 out.push_str(" </body>\n");
1051 out.push_str("</html>\n");
1052 out
1053}
1054
1055#[cfg(feature = "openapi")]
1058#[must_use]
1059pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
1060 let spec_url = serde_json::to_string(spec_url)
1061 .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
1062 let mut out = String::with_capacity(256);
1063 out.push_str("window.onload = function() {\n");
1064 out.push_str(" window.ui = SwaggerUIBundle({\n");
1065 out.push_str(" url: ");
1066 out.push_str(&spec_url);
1067 out.push_str(",\n");
1068 out.push_str(" dom_id: \"#swagger-ui\",\n");
1069 out.push_str(" deepLinking: true\n");
1070 out.push_str(" });\n");
1071 out.push_str("};\n");
1072 out
1073}
1074
1075#[cfg(feature = "openapi")]
1076fn html_escape(s: &str) -> String {
1077 s.replace('&', "&")
1078 .replace('<', "<")
1079 .replace('>', ">")
1080 .replace('"', """)
1081}
1082
1083#[cfg(all(test, feature = "openapi"))]
1088mod tests {
1089 use super::*;
1090
1091 fn make_doc() -> ApiDoc {
1092 ApiDoc {
1093 method: "GET",
1094 path: "/users/{id}",
1095 operation_id: "get_user",
1096 summary: Some("Fetch a user"),
1097 description: None,
1098 tags: &[],
1099 path_params: &["id"],
1100 request_body: None,
1101 response: None,
1102 success_status: 200,
1103 hidden: false,
1104 query_schema: None,
1105 secured: false,
1106 required_roles: &[],
1107 register_schemas: None,
1108 api_version: None,
1109 ..Default::default()
1110 }
1111 }
1112
1113 #[test]
1114 fn config_builder_methods_work() {
1115 let config = OpenApiConfig::new("Demo", "1.0.0")
1116 .description("A cool API")
1117 .openapi_json_path("/api.json")
1118 .swagger_ui_path(None)
1119 .session_cookie_name("demo.sid");
1120
1121 assert_eq!(config.title, "Demo");
1122 assert_eq!(config.version, "1.0.0");
1123 assert_eq!(config.description.unwrap(), "A cool API");
1124 assert_eq!(config.openapi_json_path, "/api.json");
1125 assert_eq!(config.swagger_ui_path, None);
1126 assert_eq!(config.session_cookie_name, "demo.sid");
1127 }
1128
1129 #[test]
1130 fn secured_spec_uses_configured_session_cookie_name() {
1131 let mut doc = make_doc();
1132 doc.path = "/protected";
1133 doc.operation_id = "protected";
1134 doc.path_params = &[];
1135 doc.secured = true;
1136
1137 let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
1138 let spec = generate_spec(&config, &[&doc]);
1139 let scheme = &spec
1140 .components
1141 .as_ref()
1142 .expect("secured routes emit security components")
1143 .security_schemes["SessionAuth"];
1144
1145 assert_eq!(scheme["type"], "apiKey");
1146 assert_eq!(scheme["in"], "cookie");
1147 assert_eq!(scheme["name"], "demo.sid");
1148 }
1149
1150 #[test]
1151 fn generate_spec_builds_path_with_parameters() {
1152 let doc = make_doc();
1153 let config = OpenApiConfig::new("Demo", "1.0.0");
1154 let spec = generate_spec(&config, &[&doc]);
1155
1156 assert_eq!(spec.openapi, "3.1.0");
1157 assert_eq!(spec.info.title, "Demo");
1158 assert!(spec.paths.contains_key("/users/{id}"));
1159
1160 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1161 assert_eq!(op.operation_id, "get_user");
1162 assert_eq!(op.parameters.len(), 1);
1163 assert_eq!(op.parameters[0].name, "id");
1164 assert_eq!(op.parameters[0].location, "path");
1165 assert_eq!(op.tags, vec!["users".to_owned()]);
1166 }
1167
1168 #[test]
1169 fn generate_spec_skips_hidden_routes() {
1170 let mut doc = make_doc();
1171 doc.hidden = true;
1172 let config = OpenApiConfig::new("Demo", "1.0.0");
1173 let spec = generate_spec(&config, &[&doc]);
1174 assert!(spec.paths.is_empty());
1175 }
1176
1177 #[test]
1178 fn generate_spec_writes_request_body_ref() {
1179 let mut doc = make_doc();
1180 doc.method = "POST";
1181 doc.path = "/users";
1182 doc.operation_id = "create_user";
1183 doc.path_params = &[];
1184 doc.request_body = Some(SchemaEntry {
1185 name: "CreateUser",
1186 kind: SchemaKind::Ref,
1187 });
1188 doc.success_status = 201;
1189
1190 let config = OpenApiConfig::new("Demo", "1.0.0");
1191 let spec = generate_spec(&config, &[&doc]);
1192 let op = spec.paths["/users"].post.as_ref().unwrap();
1193 let body = op.request_body.as_ref().unwrap();
1194 assert!(body.required);
1195 let media = body.content.get("application/json").unwrap();
1196 assert_eq!(
1197 media.schema,
1198 serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
1199 );
1200 assert!(op.responses.contains_key("201"));
1201 }
1202
1203 #[test]
1204 fn generate_spec_inlines_primitive_response() {
1205 let mut doc = make_doc();
1206 doc.response = Some(SchemaEntry {
1207 name: "string",
1208 kind: SchemaKind::Primitive("string"),
1209 });
1210 let config = OpenApiConfig::new("Demo", "1.0.0");
1211 let spec = generate_spec(&config, &[&doc]);
1212 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1213 let media = op.responses["200"].content.get("application/json").unwrap();
1214 assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
1215 }
1216
1217 #[test]
1218 fn swagger_ui_html_uses_same_origin_assets() {
1219 let html = swagger_ui_html(
1220 "Demo",
1221 "/swagger-ui/swagger-ui.css",
1222 "/swagger-ui/swagger-ui-bundle.js",
1223 "/swagger-ui/swagger-initializer.js",
1224 );
1225 assert!(html.contains("/swagger-ui/swagger-ui.css"));
1226 assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
1227 assert!(html.contains("/swagger-ui/swagger-initializer.js"));
1228 assert!(!html.contains("unpkg.com"));
1229 assert!(!html.contains("window.onload = function()"));
1230 }
1231
1232 #[test]
1233 fn swagger_ui_initializer_js_references_spec_url() {
1234 let js = swagger_ui_initializer_js("/openapi.json");
1235 assert!(js.contains("SwaggerUIBundle"));
1236 assert!(js.contains(r#""/openapi.json""#));
1237 }
1238
1239 #[test]
1240 fn generate_spec_includes_additional_schemas() {
1241 let doc = make_doc();
1242 let config = OpenApiConfig::new("Demo", "1.0.0")
1243 .register_schema("Foo", serde_json::json!({ "type": "object" }));
1244 let spec = generate_spec(&config, &[&doc]);
1245 let components = spec.components.unwrap();
1246 assert!(components.schemas.contains_key("Foo"));
1247 }
1248
1249 #[test]
1250 fn generate_spec_back_fills_unregistered_ref_schemas() {
1251 let mut doc = make_doc();
1255 doc.method = "POST";
1256 doc.path = "/users";
1257 doc.path_params = &[];
1258 doc.request_body = Some(SchemaEntry {
1259 name: "CreateUser",
1260 kind: SchemaKind::Ref,
1261 });
1262 doc.response = Some(SchemaEntry {
1263 name: "User",
1264 kind: SchemaKind::Ref,
1265 });
1266
1267 let config = OpenApiConfig::new("Demo", "1.0.0");
1268 let spec = generate_spec(&config, &[&doc]);
1269 let components = spec.components.expect("components must be emitted");
1270 let create = components
1271 .schemas
1272 .get("CreateUser")
1273 .expect("CreateUser should be back-filled");
1274 let user = components
1275 .schemas
1276 .get("User")
1277 .expect("User should be back-filled");
1278 assert_eq!(create["type"], "object");
1279 assert_eq!(create["title"], "CreateUser");
1280 assert_eq!(user["type"], "object");
1281 assert_eq!(user["title"], "User");
1282 }
1283
1284 #[test]
1285 fn generate_spec_preserves_user_registered_schemas_over_backfill() {
1286 let mut doc = make_doc();
1287 doc.response = Some(SchemaEntry {
1288 name: "User",
1289 kind: SchemaKind::Ref,
1290 });
1291
1292 let user_schema = serde_json::json!({
1293 "type": "object",
1294 "properties": {"id": {"type": "integer"}},
1295 });
1296 let config =
1297 OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
1298 let spec = generate_spec(&config, &[&doc]);
1299 let components = spec.components.unwrap();
1300 let stored = components.schemas.get("User").unwrap();
1301 assert_eq!(stored, &user_schema, "user schema must not be overwritten");
1302 }
1303
1304 #[test]
1305 fn status_description_returns_correct_strings() {
1306 assert_eq!(status_description(200), "OK");
1307 assert_eq!(status_description(201), "Created");
1308 assert_eq!(status_description(202), "Accepted");
1309 assert_eq!(status_description(204), "No Content");
1310 assert_eq!(status_description(301), "Moved Permanently");
1311 assert_eq!(status_description(302), "Found");
1312 assert_eq!(status_description(400), "Bad Request");
1313 assert_eq!(status_description(401), "Unauthorized");
1314 assert_eq!(status_description(403), "Forbidden");
1315 assert_eq!(status_description(404), "Not Found");
1316 assert_eq!(status_description(409), "Conflict");
1317 assert_eq!(status_description(413), "Payload Too Large");
1318 assert_eq!(status_description(415), "Unsupported Media Type");
1319 assert_eq!(status_description(422), "Unprocessable Entity");
1320 assert_eq!(status_description(500), "Internal Server Error");
1321 assert_eq!(status_description(503), "Service Unavailable");
1322 assert_eq!(status_description(418), "Response");
1323 }
1324
1325 #[test]
1326 fn default_tag_picks_first_static_segment() {
1327 assert_eq!(default_tag("/users/{id}"), Some("users"));
1328 assert_eq!(default_tag("/api/v1/users"), Some("api"));
1329 assert_eq!(default_tag("/"), None);
1330 assert_eq!(default_tag("/{id}"), None);
1331 }
1332
1333 #[test]
1336 fn spec_version_is_3_1_0() {
1337 let config = OpenApiConfig::new("Demo", "1.0.0");
1338 let spec = generate_spec(&config, &[]);
1339 assert_eq!(
1340 spec.openapi, "3.1.0",
1341 "Autumn must emit OpenAPI 3.1.0, not {}",
1342 spec.openapi
1343 );
1344 }
1345
1346 #[test]
1347 fn nullable_ref_uses_openapi_3_1_one_of() {
1348 static INNER: SchemaEntry = SchemaEntry {
1352 name: "User",
1353 kind: SchemaKind::Ref,
1354 };
1355 let entry = SchemaEntry {
1356 name: "nullable",
1357 kind: SchemaKind::Nullable(&INNER),
1358 };
1359 let value = schema_value_for(&entry);
1360 assert!(
1361 value.get("nullable").is_none(),
1362 "3.1 must not emit `nullable: true` (that is 3.0 only)"
1363 );
1364 assert!(
1365 value.get("allOf").is_none(),
1366 "3.1 must not use allOf for nullable refs"
1367 );
1368 let one_of = value["oneOf"]
1369 .as_array()
1370 .expect("3.1 nullable ref must use oneOf");
1371 assert_eq!(one_of.len(), 2);
1372 assert_eq!(
1373 one_of[0]["$ref"], "#/components/schemas/User",
1374 "first oneOf branch must be the $ref"
1375 );
1376 assert_eq!(
1377 one_of[1]["type"], "null",
1378 "second oneOf branch must be {{type: null}}"
1379 );
1380 }
1381
1382 #[test]
1383 fn nullable_primitive_uses_type_array() {
1384 static INNER: SchemaEntry = SchemaEntry {
1387 name: "integer",
1388 kind: SchemaKind::Primitive("integer"),
1389 };
1390 let entry = SchemaEntry {
1391 name: "nullable",
1392 kind: SchemaKind::Nullable(&INNER),
1393 };
1394 let value = schema_value_for(&entry);
1395 assert!(
1396 value.get("nullable").is_none(),
1397 "3.1 must not emit `nullable: true`"
1398 );
1399 let types = value["type"]
1400 .as_array()
1401 .expect("3.1 nullable primitive must use a type array");
1402 assert!(
1403 types.contains(&serde_json::Value::String("integer".to_owned())),
1404 "type array must include the base type"
1405 );
1406 assert!(
1407 types.contains(&serde_json::Value::String("null".to_owned())),
1408 "type array must include null"
1409 );
1410 }
1411
1412 #[test]
1413 fn write_openapi_spec_to_dist_creates_json_file() {
1414 let tmp = tempfile::TempDir::new().unwrap();
1415 let dist = tmp.path().join("dist");
1416 std::fs::create_dir_all(&dist).unwrap();
1417
1418 let config = OpenApiConfig::new("TestAPI", "2.0.0");
1419 let spec = generate_spec(&config, &[]);
1420
1421 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1422
1423 let json_path = dist.join("openapi.json");
1424 assert!(json_path.exists(), "dist/openapi.json must be written");
1425
1426 let content = std::fs::read_to_string(&json_path).unwrap();
1427 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1428 assert_eq!(parsed["openapi"], "3.1.0");
1429 assert_eq!(parsed["info"]["title"], "TestAPI");
1430 }
1431
1432 #[test]
1433 fn write_openapi_spec_to_dist_creates_yaml_file() {
1434 let tmp = tempfile::TempDir::new().unwrap();
1435 let dist = tmp.path().join("dist");
1436 std::fs::create_dir_all(&dist).unwrap();
1437
1438 let config = OpenApiConfig::new("TestAPI", "2.0.0");
1439 let spec = generate_spec(&config, &[]);
1440
1441 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1442
1443 let yaml_path = dist.join("openapi.yaml");
1444 assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
1445
1446 let content = std::fs::read_to_string(&yaml_path).unwrap();
1447 assert!(
1448 content.contains("openapi:"),
1449 "YAML must include the openapi field"
1450 );
1451 assert!(content.contains("3.1.0"), "YAML must include the version");
1452 assert!(content.contains("TestAPI"), "YAML must include the title");
1453 }
1454
1455 #[test]
1456 fn schema_registry_into_map_returns_all_schemas() {
1457 let mut registry = SchemaRegistry::default();
1458 registry.insert("Foo", serde_json::json!({ "type": "string" }));
1459 registry.insert("Bar", serde_json::json!({ "type": "integer" }));
1460
1461 let map = registry.into_map();
1462 assert_eq!(map.len(), 2);
1463 assert_eq!(
1464 map.get("Foo").unwrap(),
1465 &serde_json::json!({ "type": "string" })
1466 );
1467 assert_eq!(
1468 map.get("Bar").unwrap(),
1469 &serde_json::json!({ "type": "integer" })
1470 );
1471 }
1472
1473 #[test]
1474 fn schema_registry_deduplicates() {
1475 struct Foo;
1476 impl OpenApiSchema for Foo {
1477 fn schema_name() -> &'static str {
1478 "Foo"
1479 }
1480 fn schema() -> serde_json::Value {
1481 serde_json::json!({ "type": "object", "title": "Foo" })
1482 }
1483 }
1484
1485 let mut registry = SchemaRegistry::default();
1486 registry.register::<Foo>();
1487 registry.register::<Foo>();
1488 assert_eq!(registry.schemas().len(), 1);
1489 }
1490
1491 #[test]
1492 fn primitive_impls_cover_common_types() {
1493 assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
1494 assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
1495 assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
1496 assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
1497 }
1498
1499 #[test]
1500 fn swagger_ui_html_embeds_spec_url() {
1501 let html = swagger_ui_html(
1502 "My API",
1503 "/swagger-ui/swagger-ui.css",
1504 "/swagger-ui/swagger-ui-bundle.js",
1505 "/swagger-ui/swagger-initializer.js",
1506 );
1507 assert!(html.contains("/swagger-ui/swagger-ui.css"));
1508 assert!(html.contains("My API"));
1509 }
1510
1511 #[test]
1512 fn swagger_ui_html_escapes_attributes() {
1513 let html = swagger_ui_html(
1514 "A \"cool\" & fun API",
1515 "/swagger-ui/swagger-ui.css?x=<y>",
1516 "/swagger-ui/swagger-ui-bundle.js",
1517 "/swagger-ui/swagger-initializer.js",
1518 );
1519 assert!(html.contains("/swagger-ui/swagger-ui.css?x=<y>"));
1520 assert!(html.contains("A "cool" & fun API"));
1521 }
1522}