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)]
79pub struct ApiDoc {
80 pub method: &'static str,
82 pub path: &'static str,
84 pub operation_id: &'static str,
86 pub summary: Option<&'static str>,
88 pub description: Option<&'static str>,
90 pub tags: &'static [&'static str],
92 pub path_params: &'static [&'static str],
96 pub request_body: Option<SchemaEntry>,
99 pub response: Option<SchemaEntry>,
102 pub success_status: u16,
104 pub hidden: bool,
106 pub query_schema: Option<SchemaEntry>,
108 pub secured: bool,
110 pub required_roles: &'static [&'static str],
112 pub register_schemas: Option<fn(&mut SchemaRegistry)>,
115}
116
117#[derive(Copy, Clone, Debug, PartialEq, Eq)]
119pub struct SchemaEntry {
120 pub name: &'static str,
122 pub kind: SchemaKind,
125}
126
127#[derive(Copy, Clone, Debug, PartialEq, Eq)]
129pub enum SchemaKind {
130 Ref,
132 Primitive(&'static str),
134 Array(&'static SchemaEntry),
140 Nullable(&'static SchemaEntry),
143}
144
145#[cfg(feature = "openapi")]
154#[derive(Clone)]
155pub struct OpenApiConfig {
156 pub title: String,
158 pub version: String,
160 pub description: Option<String>,
162 pub openapi_json_path: String,
164 pub swagger_ui_path: Option<String>,
167 pub session_cookie_name: String,
172 pub additional_schemas: BTreeMap<String, serde_json::Value>,
174}
175
176#[cfg(feature = "openapi")]
177impl OpenApiConfig {
178 #[must_use]
180 pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
181 Self {
182 title: title.into(),
183 version: version.into(),
184 description: None,
185 openapi_json_path: "/openapi.json".to_owned(),
186 swagger_ui_path: Some("/swagger-ui".to_owned()),
187 session_cookie_name: "autumn.sid".to_owned(),
188 additional_schemas: BTreeMap::new(),
189 }
190 }
191
192 #[must_use]
194 pub fn description(mut self, description: impl Into<String>) -> Self {
195 self.description = Some(description.into());
196 self
197 }
198
199 #[must_use]
201 pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
202 self.openapi_json_path = path.into();
203 self
204 }
205
206 #[must_use]
208 pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
209 self.swagger_ui_path = path;
210 self
211 }
212
213 #[must_use]
215 pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
216 self.session_cookie_name = name.into();
217 self
218 }
219
220 #[must_use]
223 pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
224 self.additional_schemas.insert(name.into(), schema);
225 self
226 }
227}
228
229pub trait OpenApiSchema {
244 fn schema_name() -> &'static str;
246
247 fn schema() -> serde_json::Value;
249}
250
251macro_rules! impl_primitive_schema {
252 ($ty:ty, $name:literal, $json:literal) => {
253 impl OpenApiSchema for $ty {
254 fn schema_name() -> &'static str {
255 $name
256 }
257 fn schema() -> serde_json::Value {
258 serde_json::json!({ "type": $json })
259 }
260 }
261 };
262}
263
264impl_primitive_schema!(bool, "boolean", "boolean");
265impl_primitive_schema!(String, "string", "string");
266impl_primitive_schema!(&'static str, "string", "string");
267impl_primitive_schema!(i8, "integer", "integer");
268impl_primitive_schema!(i16, "integer", "integer");
269impl_primitive_schema!(i32, "integer", "integer");
270impl_primitive_schema!(i64, "integer", "integer");
271impl_primitive_schema!(u8, "integer", "integer");
272impl_primitive_schema!(u16, "integer", "integer");
273impl_primitive_schema!(u32, "integer", "integer");
274impl_primitive_schema!(u64, "integer", "integer");
275impl_primitive_schema!(f32, "number", "number");
276impl_primitive_schema!(f64, "number", "number");
277impl_primitive_schema!(serde_json::Value, "object", "object");
278
279#[derive(Default)]
285pub struct SchemaRegistry {
286 schemas: BTreeMap<String, serde_json::Value>,
287}
288
289impl SchemaRegistry {
290 pub fn register<T: OpenApiSchema>(&mut self) {
293 let name = T::schema_name().to_owned();
294 self.schemas.entry(name).or_insert_with(T::schema);
295 }
296
297 pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
299 self.schemas.insert(name.into(), schema);
300 }
301
302 #[must_use]
304 pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
305 self.schemas
306 }
307
308 #[must_use]
310 pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
311 &self.schemas
312 }
313}
314
315#[cfg(feature = "openapi")]
326#[derive(Debug, Serialize, Deserialize)]
328pub struct OpenApiSpec {
329 pub openapi: String,
331 pub info: Info,
333 pub paths: BTreeMap<String, PathItem>,
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub components: Option<Components>,
338}
339
340#[cfg(feature = "openapi")]
341#[derive(Debug, Serialize, Deserialize)]
343pub struct Info {
344 pub title: String,
346 pub version: String,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 pub description: Option<String>,
351}
352
353#[cfg(feature = "openapi")]
354#[derive(Default, Debug, Serialize, Deserialize)]
356pub struct PathItem {
357 #[serde(skip_serializing_if = "Option::is_none")]
359 pub get: Option<Operation>,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub post: Option<Operation>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub put: Option<Operation>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub delete: Option<Operation>,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub patch: Option<Operation>,
372}
373
374#[cfg(feature = "openapi")]
375#[derive(Debug, Serialize, Deserialize)]
377pub struct Operation {
378 #[serde(rename = "operationId")]
380 pub operation_id: String,
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub summary: Option<String>,
384 #[serde(skip_serializing_if = "Option::is_none")]
386 pub description: Option<String>,
387 #[serde(skip_serializing_if = "Vec::is_empty")]
389 pub tags: Vec<String>,
390 #[serde(skip_serializing_if = "Vec::is_empty")]
392 pub parameters: Vec<Parameter>,
393 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
395 pub request_body: Option<RequestBody>,
396 pub responses: BTreeMap<String, Response>,
398 #[serde(skip_serializing_if = "Vec::is_empty")]
400 pub security: Vec<BTreeMap<String, Vec<String>>>,
401}
402
403#[cfg(feature = "openapi")]
404#[derive(Debug, Serialize, Deserialize)]
406pub struct Parameter {
407 pub name: String,
409 #[serde(rename = "in")]
411 pub location: String,
412 pub required: bool,
414 pub schema: serde_json::Value,
416 #[serde(skip_serializing_if = "Option::is_none")]
419 pub style: Option<String>,
420 #[serde(skip_serializing_if = "Option::is_none")]
423 pub explode: Option<bool>,
424}
425
426#[cfg(feature = "openapi")]
427#[derive(Debug, Serialize, Deserialize)]
429pub struct RequestBody {
430 pub required: bool,
432 pub content: BTreeMap<String, MediaType>,
434}
435
436#[cfg(feature = "openapi")]
437#[derive(Debug, Serialize, Deserialize)]
439pub struct Response {
440 pub description: String,
442 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
444 pub content: BTreeMap<String, MediaType>,
445}
446
447#[cfg(feature = "openapi")]
448#[derive(Debug, Serialize, Deserialize)]
450pub struct MediaType {
451 pub schema: serde_json::Value,
453}
454
455#[cfg(feature = "openapi")]
456#[derive(Debug, Serialize, Deserialize)]
458pub struct Components {
459 pub schemas: BTreeMap<String, serde_json::Value>,
461 #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
463 pub security_schemes: BTreeMap<String, serde_json::Value>,
464}
465
466#[cfg(feature = "openapi")]
481pub fn write_openapi_spec_to_dist(
482 spec: &OpenApiSpec,
483 dist_dir: &std::path::Path,
484) -> std::io::Result<()> {
485 std::fs::create_dir_all(dist_dir)?;
486
487 let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
488 std::fs::write(dist_dir.join("openapi.json"), &json)?;
489
490 let yaml = serde_yaml::to_string(spec).map_err(std::io::Error::other)?;
491 std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
492
493 Ok(())
494}
495
496#[cfg(feature = "openapi")]
501#[must_use]
502pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
503 let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
504 let mut registry = SchemaRegistry::default();
505
506 for (name, schema) in &config.additional_schemas {
507 registry.insert(name.clone(), schema.clone());
508 }
509 registry.insert("ProblemDetails", problem_details_schema());
510
511 let mut referenced_names: std::collections::BTreeSet<&'static str> =
517 std::collections::BTreeSet::new();
518
519 let mut any_secured = false;
520
521 for api_doc in routes {
522 if api_doc.hidden {
523 continue;
524 }
525 if api_doc.secured {
526 any_secured = true;
527 }
528 if let Some(register) = api_doc.register_schemas {
529 (register)(&mut registry);
530 }
531
532 if let Some(entry) = &api_doc.request_body {
533 collect_ref_names(entry, &mut referenced_names);
534 }
535 if let Some(entry) = &api_doc.response {
536 collect_ref_names(entry, &mut referenced_names);
537 }
538 if let Some(entry) = &api_doc.query_schema {
539 collect_ref_names(entry, &mut referenced_names);
540 }
541
542 let operation = operation_for(api_doc);
543 let entry = paths.entry(api_doc.path.to_owned()).or_default();
544 match api_doc.method {
545 "GET" => entry.get = Some(operation),
546 "POST" => entry.post = Some(operation),
547 "PUT" => entry.put = Some(operation),
548 "DELETE" => entry.delete = Some(operation),
549 "PATCH" => entry.patch = Some(operation),
550 _ => {}
553 }
554 }
555
556 for name in referenced_names {
561 if !registry.schemas().contains_key(name) {
562 registry.insert(
563 name,
564 serde_json::json!({
565 "type": "object",
566 "title": name,
567 }),
568 );
569 }
570 }
571
572 let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
574 if any_secured {
575 security_schemes.insert(
576 "SessionAuth".to_owned(),
577 serde_json::json!({
578 "type": "apiKey",
579 "in": "cookie",
580 "name": config.session_cookie_name.clone(),
581 "description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
582 }),
583 );
584 }
585
586 let components_map = registry.into_map();
587 let components = if !components_map.is_empty() || !security_schemes.is_empty() {
588 Some(Components {
589 schemas: components_map,
590 security_schemes,
591 })
592 } else {
593 None
594 };
595
596 OpenApiSpec {
597 openapi: "3.1.0".to_owned(),
598 info: Info {
599 title: config.title.clone(),
600 version: config.version.clone(),
601 description: config.description.clone(),
602 },
603 paths,
604 components,
605 }
606}
607
608#[cfg(feature = "openapi")]
609fn operation_for(api_doc: &ApiDoc) -> Operation {
610 let tags = if api_doc.tags.is_empty() {
611 default_tag(api_doc.path)
612 .map(|t| vec![t.to_owned()])
613 .unwrap_or_default()
614 } else {
615 api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
616 };
617
618 let mut parameters: Vec<Parameter> = api_doc
620 .path_params
621 .iter()
622 .map(|name| Parameter {
623 name: (*name).to_owned(),
624 location: "path".to_owned(),
625 required: true,
626 schema: serde_json::json!({ "type": "string" }),
627 style: None,
628 explode: None,
629 })
630 .collect();
631
632 if let Some(query_entry) = &api_doc.query_schema {
637 parameters.push(Parameter {
638 name: query_entry.name.to_owned(),
639 location: "query".to_owned(),
640 required: false,
641 schema: schema_value_for(query_entry),
642 style: Some("form".to_owned()),
643 explode: Some(true),
644 });
645 }
646
647 let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
648 required: true,
649 content: std::iter::once((
650 "application/json".to_owned(),
651 MediaType {
652 schema: schema_value_for(entry),
653 },
654 ))
655 .collect(),
656 });
657
658 let mut responses: BTreeMap<String, Response> = BTreeMap::new();
659 let status = if api_doc.success_status == 0 {
660 200
661 } else {
662 api_doc.success_status
663 };
664 let response_content = api_doc
665 .response
666 .as_ref()
667 .map(|entry| {
668 let mut content = BTreeMap::new();
669 content.insert(
670 "application/json".to_owned(),
671 MediaType {
672 schema: schema_value_for(entry),
673 },
674 );
675 content
676 })
677 .unwrap_or_default();
678 responses.insert(
679 status.to_string(),
680 Response {
681 description: status_description(status).to_owned(),
682 content: response_content,
683 },
684 );
685 insert_problem_responses(&mut responses);
686
687 insert_problem_responses(&mut responses);
688
689 let security = if api_doc.secured {
691 let mut req = BTreeMap::new();
692 req.insert("SessionAuth".to_owned(), Vec::new());
693 vec![req]
694 } else {
695 Vec::new()
696 };
697
698 Operation {
699 operation_id: api_doc.operation_id.to_owned(),
700 summary: api_doc.summary.map(str::to_owned),
701 description: api_doc.description.map(str::to_owned),
702 tags,
703 parameters,
704 request_body,
705 responses,
706 security,
707 }
708}
709
710#[cfg(feature = "openapi")]
711fn schema_value_for(entry: &SchemaEntry) -> serde_json::Value {
712 match entry.kind {
713 SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
714 SchemaKind::Ref => {
715 serde_json::json!({ "$ref": format!("#/components/schemas/{}", entry.name) })
716 }
717 SchemaKind::Array(items) => serde_json::json!({
718 "type": "array",
719 "items": schema_value_for(items),
720 }),
721 SchemaKind::Nullable(inner) => {
722 match inner.kind {
730 SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
731 serde_json::json!({
732 "oneOf": [
733 schema_value_for(inner),
734 { "type": "null" },
735 ],
736 })
737 }
738 SchemaKind::Primitive(base_type) => {
739 serde_json::json!({ "type": [base_type, "null"] })
740 }
741 }
742 }
743 }
744}
745
746#[cfg(feature = "openapi")]
750fn collect_ref_names(entry: &SchemaEntry, out: &mut std::collections::BTreeSet<&'static str>) {
751 match entry.kind {
752 SchemaKind::Ref => {
753 out.insert(entry.name);
754 }
755 SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => collect_ref_names(inner, out),
756 SchemaKind::Primitive(_) => {}
757 }
758}
759
760#[cfg(feature = "openapi")]
761fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
762 for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
763 responses.entry(status.to_string()).or_insert_with(|| {
764 let mut content = BTreeMap::new();
765 content.insert(
766 "application/problem+json".to_owned(),
767 MediaType {
768 schema: serde_json::json!({
769 "$ref": "#/components/schemas/ProblemDetails",
770 }),
771 },
772 );
773 Response {
774 description: status_description(status).to_owned(),
775 content,
776 }
777 });
778 }
779}
780
781#[cfg(feature = "openapi")]
782fn problem_details_schema() -> serde_json::Value {
783 serde_json::json!({
784 "type": "object",
785 "additionalProperties": false,
786 "required": [
787 "type",
788 "title",
789 "status",
790 "detail",
791 "instance",
792 "code",
793 "request_id",
794 "errors",
795 ],
796 "properties": {
797 "type": {
798 "type": "string",
799 "format": "uri-reference",
800 },
801 "title": {
802 "type": "string",
803 },
804 "status": {
805 "type": "integer",
806 "minimum": 400,
807 "maximum": 599,
808 },
809 "detail": {
810 "type": "string",
811 },
812 "instance": {
813 "type": ["string", "null"],
814 },
815 "code": {
816 "type": "string",
817 "pattern": "^autumn\\.[a-z0-9_]+$",
818 },
819 "request_id": {
820 "type": ["string", "null"],
821 },
822 "errors": {
823 "type": "array",
824 "items": {
825 "type": "object",
826 "additionalProperties": false,
827 "required": ["field", "messages"],
828 "properties": {
829 "field": {
830 "type": "string",
831 },
832 "messages": {
833 "type": "array",
834 "items": {
835 "type": "string",
836 },
837 },
838 },
839 },
840 },
841 },
842 })
843}
844
845#[cfg(feature = "openapi")]
846fn default_tag(path: &str) -> Option<&str> {
847 path.trim_start_matches('/')
848 .split('/')
849 .find(|seg| !seg.is_empty() && !seg.starts_with('{'))
850}
851
852#[cfg(feature = "openapi")]
853const fn status_description(status: u16) -> &'static str {
854 match status {
855 200 => "OK",
856 201 => "Created",
857 202 => "Accepted",
858 204 => "No Content",
859 301 => "Moved Permanently",
860 302 => "Found",
861 400 => "Bad Request",
862 401 => "Unauthorized",
863 403 => "Forbidden",
864 404 => "Not Found",
865 409 => "Conflict",
866 413 => "Payload Too Large",
867 415 => "Unsupported Media Type",
868 422 => "Unprocessable Entity",
869 500 => "Internal Server Error",
870 503 => "Service Unavailable",
871 _ => "Response",
872 }
873}
874
875#[cfg(feature = "openapi")]
880pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
881#[cfg(feature = "openapi")]
882pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
883#[cfg(feature = "openapi")]
884pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
885 include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
886#[cfg(feature = "openapi")]
887const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
888#[cfg(feature = "openapi")]
889const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
890#[cfg(feature = "openapi")]
891const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
892
893#[cfg(feature = "openapi")]
895#[must_use]
896pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
897 [
898 swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
899 swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
900 swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
901 ]
902}
903
904#[cfg(feature = "openapi")]
905#[must_use]
906fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
907 let base = swagger_path.trim_end_matches('/');
908 if base.is_empty() || base == "/" {
909 format!("/{asset_file}")
910 } else {
911 format!("{base}/{asset_file}")
912 }
913}
914
915#[cfg(feature = "openapi")]
917#[must_use]
918pub fn swagger_ui_html(
919 title: &str,
920 css_url: &str,
921 bundle_url: &str,
922 initializer_url: &str,
923) -> String {
924 let title = html_escape(title);
925 let css_url = html_escape(css_url);
926 let bundle_url = html_escape(bundle_url);
927 let initializer_url = html_escape(initializer_url);
928 let mut out = String::with_capacity(1024);
929 out.push_str("<!DOCTYPE html>\n");
930 out.push_str("<html lang=\"en\">\n");
931 out.push_str(" <head>\n");
932 out.push_str(" <meta charset=\"utf-8\" />\n");
933 out.push_str(" <title>");
934 out.push_str(&title);
935 out.push_str("</title>\n");
936 out.push_str(" <link rel=\"stylesheet\" href=\"");
937 out.push_str(&css_url);
938 out.push_str("\" />\n");
939 out.push_str(" </head>\n");
940 out.push_str(" <body>\n");
941 out.push_str(" <div id=\"swagger-ui\"></div>\n");
942 out.push_str(" <script src=\"");
943 out.push_str(&bundle_url);
944 out.push_str("\" charset=\"UTF-8\"></script>\n");
945 out.push_str(" <script src=\"");
946 out.push_str(&initializer_url);
947 out.push_str("\" charset=\"UTF-8\"></script>\n");
948 out.push_str(" </body>\n");
949 out.push_str("</html>\n");
950 out
951}
952
953#[cfg(feature = "openapi")]
956#[must_use]
957pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
958 let spec_url = serde_json::to_string(spec_url)
959 .unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
960 let mut out = String::with_capacity(256);
961 out.push_str("window.onload = function() {\n");
962 out.push_str(" window.ui = SwaggerUIBundle({\n");
963 out.push_str(" url: ");
964 out.push_str(&spec_url);
965 out.push_str(",\n");
966 out.push_str(" dom_id: \"#swagger-ui\",\n");
967 out.push_str(" deepLinking: true\n");
968 out.push_str(" });\n");
969 out.push_str("};\n");
970 out
971}
972
973#[cfg(feature = "openapi")]
974fn html_escape(s: &str) -> String {
975 s.replace('&', "&")
976 .replace('<', "<")
977 .replace('>', ">")
978 .replace('"', """)
979}
980
981#[cfg(all(test, feature = "openapi"))]
986mod tests {
987 use super::*;
988
989 fn make_doc() -> ApiDoc {
990 ApiDoc {
991 method: "GET",
992 path: "/users/{id}",
993 operation_id: "get_user",
994 summary: Some("Fetch a user"),
995 description: None,
996 tags: &[],
997 path_params: &["id"],
998 request_body: None,
999 response: None,
1000 success_status: 200,
1001 hidden: false,
1002 query_schema: None,
1003 secured: false,
1004 required_roles: &[],
1005 register_schemas: None,
1006 }
1007 }
1008
1009 #[test]
1010 fn config_builder_methods_work() {
1011 let config = OpenApiConfig::new("Demo", "1.0.0")
1012 .description("A cool API")
1013 .openapi_json_path("/api.json")
1014 .swagger_ui_path(None)
1015 .session_cookie_name("demo.sid");
1016
1017 assert_eq!(config.title, "Demo");
1018 assert_eq!(config.version, "1.0.0");
1019 assert_eq!(config.description.unwrap(), "A cool API");
1020 assert_eq!(config.openapi_json_path, "/api.json");
1021 assert_eq!(config.swagger_ui_path, None);
1022 assert_eq!(config.session_cookie_name, "demo.sid");
1023 }
1024
1025 #[test]
1026 fn secured_spec_uses_configured_session_cookie_name() {
1027 let mut doc = make_doc();
1028 doc.path = "/protected";
1029 doc.operation_id = "protected";
1030 doc.path_params = &[];
1031 doc.secured = true;
1032
1033 let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
1034 let spec = generate_spec(&config, &[&doc]);
1035 let scheme = &spec
1036 .components
1037 .as_ref()
1038 .expect("secured routes emit security components")
1039 .security_schemes["SessionAuth"];
1040
1041 assert_eq!(scheme["type"], "apiKey");
1042 assert_eq!(scheme["in"], "cookie");
1043 assert_eq!(scheme["name"], "demo.sid");
1044 }
1045
1046 #[test]
1047 fn generate_spec_builds_path_with_parameters() {
1048 let doc = make_doc();
1049 let config = OpenApiConfig::new("Demo", "1.0.0");
1050 let spec = generate_spec(&config, &[&doc]);
1051
1052 assert_eq!(spec.openapi, "3.1.0");
1053 assert_eq!(spec.info.title, "Demo");
1054 assert!(spec.paths.contains_key("/users/{id}"));
1055
1056 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1057 assert_eq!(op.operation_id, "get_user");
1058 assert_eq!(op.parameters.len(), 1);
1059 assert_eq!(op.parameters[0].name, "id");
1060 assert_eq!(op.parameters[0].location, "path");
1061 assert_eq!(op.tags, vec!["users".to_owned()]);
1062 }
1063
1064 #[test]
1065 fn generate_spec_skips_hidden_routes() {
1066 let mut doc = make_doc();
1067 doc.hidden = true;
1068 let config = OpenApiConfig::new("Demo", "1.0.0");
1069 let spec = generate_spec(&config, &[&doc]);
1070 assert!(spec.paths.is_empty());
1071 }
1072
1073 #[test]
1074 fn generate_spec_writes_request_body_ref() {
1075 let mut doc = make_doc();
1076 doc.method = "POST";
1077 doc.path = "/users";
1078 doc.operation_id = "create_user";
1079 doc.path_params = &[];
1080 doc.request_body = Some(SchemaEntry {
1081 name: "CreateUser",
1082 kind: SchemaKind::Ref,
1083 });
1084 doc.success_status = 201;
1085
1086 let config = OpenApiConfig::new("Demo", "1.0.0");
1087 let spec = generate_spec(&config, &[&doc]);
1088 let op = spec.paths["/users"].post.as_ref().unwrap();
1089 let body = op.request_body.as_ref().unwrap();
1090 assert!(body.required);
1091 let media = body.content.get("application/json").unwrap();
1092 assert_eq!(
1093 media.schema,
1094 serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
1095 );
1096 assert!(op.responses.contains_key("201"));
1097 }
1098
1099 #[test]
1100 fn generate_spec_inlines_primitive_response() {
1101 let mut doc = make_doc();
1102 doc.response = Some(SchemaEntry {
1103 name: "string",
1104 kind: SchemaKind::Primitive("string"),
1105 });
1106 let config = OpenApiConfig::new("Demo", "1.0.0");
1107 let spec = generate_spec(&config, &[&doc]);
1108 let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
1109 let media = op.responses["200"].content.get("application/json").unwrap();
1110 assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
1111 }
1112
1113 #[test]
1114 fn swagger_ui_html_uses_same_origin_assets() {
1115 let html = swagger_ui_html(
1116 "Demo",
1117 "/swagger-ui/swagger-ui.css",
1118 "/swagger-ui/swagger-ui-bundle.js",
1119 "/swagger-ui/swagger-initializer.js",
1120 );
1121 assert!(html.contains("/swagger-ui/swagger-ui.css"));
1122 assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
1123 assert!(html.contains("/swagger-ui/swagger-initializer.js"));
1124 assert!(!html.contains("unpkg.com"));
1125 assert!(!html.contains("window.onload = function()"));
1126 }
1127
1128 #[test]
1129 fn swagger_ui_initializer_js_references_spec_url() {
1130 let js = swagger_ui_initializer_js("/openapi.json");
1131 assert!(js.contains("SwaggerUIBundle"));
1132 assert!(js.contains(r#""/openapi.json""#));
1133 }
1134
1135 #[test]
1136 fn generate_spec_includes_additional_schemas() {
1137 let doc = make_doc();
1138 let config = OpenApiConfig::new("Demo", "1.0.0")
1139 .register_schema("Foo", serde_json::json!({ "type": "object" }));
1140 let spec = generate_spec(&config, &[&doc]);
1141 let components = spec.components.unwrap();
1142 assert!(components.schemas.contains_key("Foo"));
1143 }
1144
1145 #[test]
1146 fn generate_spec_back_fills_unregistered_ref_schemas() {
1147 let mut doc = make_doc();
1151 doc.method = "POST";
1152 doc.path = "/users";
1153 doc.path_params = &[];
1154 doc.request_body = Some(SchemaEntry {
1155 name: "CreateUser",
1156 kind: SchemaKind::Ref,
1157 });
1158 doc.response = Some(SchemaEntry {
1159 name: "User",
1160 kind: SchemaKind::Ref,
1161 });
1162
1163 let config = OpenApiConfig::new("Demo", "1.0.0");
1164 let spec = generate_spec(&config, &[&doc]);
1165 let components = spec.components.expect("components must be emitted");
1166 let create = components
1167 .schemas
1168 .get("CreateUser")
1169 .expect("CreateUser should be back-filled");
1170 let user = components
1171 .schemas
1172 .get("User")
1173 .expect("User should be back-filled");
1174 assert_eq!(create["type"], "object");
1175 assert_eq!(create["title"], "CreateUser");
1176 assert_eq!(user["type"], "object");
1177 assert_eq!(user["title"], "User");
1178 }
1179
1180 #[test]
1181 fn generate_spec_preserves_user_registered_schemas_over_backfill() {
1182 let mut doc = make_doc();
1183 doc.response = Some(SchemaEntry {
1184 name: "User",
1185 kind: SchemaKind::Ref,
1186 });
1187
1188 let user_schema = serde_json::json!({
1189 "type": "object",
1190 "properties": {"id": {"type": "integer"}},
1191 });
1192 let config =
1193 OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
1194 let spec = generate_spec(&config, &[&doc]);
1195 let components = spec.components.unwrap();
1196 let stored = components.schemas.get("User").unwrap();
1197 assert_eq!(stored, &user_schema, "user schema must not be overwritten");
1198 }
1199
1200 #[test]
1201 fn status_description_returns_correct_strings() {
1202 assert_eq!(status_description(200), "OK");
1203 assert_eq!(status_description(201), "Created");
1204 assert_eq!(status_description(202), "Accepted");
1205 assert_eq!(status_description(204), "No Content");
1206 assert_eq!(status_description(301), "Moved Permanently");
1207 assert_eq!(status_description(302), "Found");
1208 assert_eq!(status_description(400), "Bad Request");
1209 assert_eq!(status_description(401), "Unauthorized");
1210 assert_eq!(status_description(403), "Forbidden");
1211 assert_eq!(status_description(404), "Not Found");
1212 assert_eq!(status_description(409), "Conflict");
1213 assert_eq!(status_description(413), "Payload Too Large");
1214 assert_eq!(status_description(415), "Unsupported Media Type");
1215 assert_eq!(status_description(422), "Unprocessable Entity");
1216 assert_eq!(status_description(500), "Internal Server Error");
1217 assert_eq!(status_description(503), "Service Unavailable");
1218 assert_eq!(status_description(418), "Response");
1219 }
1220
1221 #[test]
1222 fn default_tag_picks_first_static_segment() {
1223 assert_eq!(default_tag("/users/{id}"), Some("users"));
1224 assert_eq!(default_tag("/api/v1/users"), Some("api"));
1225 assert_eq!(default_tag("/"), None);
1226 assert_eq!(default_tag("/{id}"), None);
1227 }
1228
1229 #[test]
1232 fn spec_version_is_3_1_0() {
1233 let config = OpenApiConfig::new("Demo", "1.0.0");
1234 let spec = generate_spec(&config, &[]);
1235 assert_eq!(
1236 spec.openapi, "3.1.0",
1237 "Autumn must emit OpenAPI 3.1.0, not {}",
1238 spec.openapi
1239 );
1240 }
1241
1242 #[test]
1243 fn nullable_ref_uses_openapi_3_1_one_of() {
1244 static INNER: SchemaEntry = SchemaEntry {
1248 name: "User",
1249 kind: SchemaKind::Ref,
1250 };
1251 let entry = SchemaEntry {
1252 name: "nullable",
1253 kind: SchemaKind::Nullable(&INNER),
1254 };
1255 let value = schema_value_for(&entry);
1256 assert!(
1257 value.get("nullable").is_none(),
1258 "3.1 must not emit `nullable: true` (that is 3.0 only)"
1259 );
1260 assert!(
1261 value.get("allOf").is_none(),
1262 "3.1 must not use allOf for nullable refs"
1263 );
1264 let one_of = value["oneOf"]
1265 .as_array()
1266 .expect("3.1 nullable ref must use oneOf");
1267 assert_eq!(one_of.len(), 2);
1268 assert_eq!(
1269 one_of[0]["$ref"], "#/components/schemas/User",
1270 "first oneOf branch must be the $ref"
1271 );
1272 assert_eq!(
1273 one_of[1]["type"], "null",
1274 "second oneOf branch must be {{type: null}}"
1275 );
1276 }
1277
1278 #[test]
1279 fn nullable_primitive_uses_type_array() {
1280 static INNER: SchemaEntry = SchemaEntry {
1283 name: "integer",
1284 kind: SchemaKind::Primitive("integer"),
1285 };
1286 let entry = SchemaEntry {
1287 name: "nullable",
1288 kind: SchemaKind::Nullable(&INNER),
1289 };
1290 let value = schema_value_for(&entry);
1291 assert!(
1292 value.get("nullable").is_none(),
1293 "3.1 must not emit `nullable: true`"
1294 );
1295 let types = value["type"]
1296 .as_array()
1297 .expect("3.1 nullable primitive must use a type array");
1298 assert!(
1299 types.contains(&serde_json::Value::String("integer".to_owned())),
1300 "type array must include the base type"
1301 );
1302 assert!(
1303 types.contains(&serde_json::Value::String("null".to_owned())),
1304 "type array must include null"
1305 );
1306 }
1307
1308 #[test]
1309 fn write_openapi_spec_to_dist_creates_json_file() {
1310 let tmp = tempfile::TempDir::new().unwrap();
1311 let dist = tmp.path().join("dist");
1312 std::fs::create_dir_all(&dist).unwrap();
1313
1314 let config = OpenApiConfig::new("TestAPI", "2.0.0");
1315 let spec = generate_spec(&config, &[]);
1316
1317 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1318
1319 let json_path = dist.join("openapi.json");
1320 assert!(json_path.exists(), "dist/openapi.json must be written");
1321
1322 let content = std::fs::read_to_string(&json_path).unwrap();
1323 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1324 assert_eq!(parsed["openapi"], "3.1.0");
1325 assert_eq!(parsed["info"]["title"], "TestAPI");
1326 }
1327
1328 #[test]
1329 fn write_openapi_spec_to_dist_creates_yaml_file() {
1330 let tmp = tempfile::TempDir::new().unwrap();
1331 let dist = tmp.path().join("dist");
1332 std::fs::create_dir_all(&dist).unwrap();
1333
1334 let config = OpenApiConfig::new("TestAPI", "2.0.0");
1335 let spec = generate_spec(&config, &[]);
1336
1337 write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
1338
1339 let yaml_path = dist.join("openapi.yaml");
1340 assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
1341
1342 let content = std::fs::read_to_string(&yaml_path).unwrap();
1343 assert!(
1344 content.contains("openapi:"),
1345 "YAML must include the openapi field"
1346 );
1347 assert!(content.contains("3.1.0"), "YAML must include the version");
1348 assert!(content.contains("TestAPI"), "YAML must include the title");
1349 }
1350
1351 #[test]
1352 fn schema_registry_into_map_returns_all_schemas() {
1353 let mut registry = SchemaRegistry::default();
1354 registry.insert("Foo", serde_json::json!({ "type": "string" }));
1355 registry.insert("Bar", serde_json::json!({ "type": "integer" }));
1356
1357 let map = registry.into_map();
1358 assert_eq!(map.len(), 2);
1359 assert_eq!(
1360 map.get("Foo").unwrap(),
1361 &serde_json::json!({ "type": "string" })
1362 );
1363 assert_eq!(
1364 map.get("Bar").unwrap(),
1365 &serde_json::json!({ "type": "integer" })
1366 );
1367 }
1368
1369 #[test]
1370 fn schema_registry_deduplicates() {
1371 struct Foo;
1372 impl OpenApiSchema for Foo {
1373 fn schema_name() -> &'static str {
1374 "Foo"
1375 }
1376 fn schema() -> serde_json::Value {
1377 serde_json::json!({ "type": "object", "title": "Foo" })
1378 }
1379 }
1380
1381 let mut registry = SchemaRegistry::default();
1382 registry.register::<Foo>();
1383 registry.register::<Foo>();
1384 assert_eq!(registry.schemas().len(), 1);
1385 }
1386
1387 #[test]
1388 fn primitive_impls_cover_common_types() {
1389 assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
1390 assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
1391 assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
1392 assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
1393 }
1394
1395 #[test]
1396 fn swagger_ui_html_embeds_spec_url() {
1397 let html = swagger_ui_html(
1398 "My API",
1399 "/swagger-ui/swagger-ui.css",
1400 "/swagger-ui/swagger-ui-bundle.js",
1401 "/swagger-ui/swagger-initializer.js",
1402 );
1403 assert!(html.contains("/swagger-ui/swagger-ui.css"));
1404 assert!(html.contains("My API"));
1405 }
1406
1407 #[test]
1408 fn swagger_ui_html_escapes_attributes() {
1409 let html = swagger_ui_html(
1410 "A \"cool\" & fun API",
1411 "/swagger-ui/swagger-ui.css?x=<y>",
1412 "/swagger-ui/swagger-ui-bundle.js",
1413 "/swagger-ui/swagger-initializer.js",
1414 );
1415 assert!(html.contains("/swagger-ui/swagger-ui.css?x=<y>"));
1416 assert!(html.contains("A "cool" & fun API"));
1417 }
1418}