#![allow(clippy::doc_markdown)]
use std::collections::BTreeMap;
#[cfg(feature = "openapi")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default)]
pub struct ApiDoc {
pub method: &'static str,
pub path: &'static str,
pub operation_id: &'static str,
pub summary: Option<&'static str>,
pub description: Option<&'static str>,
pub tags: &'static [&'static str],
pub path_params: &'static [&'static str],
pub request_body: Option<SchemaEntry>,
pub response: Option<SchemaEntry>,
pub success_status: u16,
pub hidden: bool,
pub query_schema: Option<SchemaEntry>,
pub secured: bool,
pub required_roles: &'static [&'static str],
pub register_schemas: Option<fn(&mut SchemaRegistry)>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct SchemaEntry {
pub name: &'static str,
pub kind: SchemaKind,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SchemaKind {
Ref,
Primitive(&'static str),
Array(&'static SchemaEntry),
Nullable(&'static SchemaEntry),
}
#[cfg(feature = "openapi")]
#[derive(Clone)]
pub struct OpenApiConfig {
pub title: String,
pub version: String,
pub description: Option<String>,
pub openapi_json_path: String,
pub swagger_ui_path: Option<String>,
pub session_cookie_name: String,
pub additional_schemas: BTreeMap<String, serde_json::Value>,
}
#[cfg(feature = "openapi")]
impl OpenApiConfig {
#[must_use]
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
title: title.into(),
version: version.into(),
description: None,
openapi_json_path: "/openapi.json".to_owned(),
swagger_ui_path: Some("/swagger-ui".to_owned()),
session_cookie_name: "autumn.sid".to_owned(),
additional_schemas: BTreeMap::new(),
}
}
#[must_use]
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn openapi_json_path(mut self, path: impl Into<String>) -> Self {
self.openapi_json_path = path.into();
self
}
#[must_use]
pub fn swagger_ui_path(mut self, path: Option<String>) -> Self {
self.swagger_ui_path = path;
self
}
#[must_use]
pub fn session_cookie_name(mut self, name: impl Into<String>) -> Self {
self.session_cookie_name = name.into();
self
}
#[must_use]
pub fn register_schema(mut self, name: impl Into<String>, schema: serde_json::Value) -> Self {
self.additional_schemas.insert(name.into(), schema);
self
}
}
pub trait OpenApiSchema {
fn schema_name() -> &'static str;
fn schema() -> serde_json::Value;
}
macro_rules! impl_primitive_schema {
($ty:ty, $name:literal, $json:literal) => {
impl OpenApiSchema for $ty {
fn schema_name() -> &'static str {
$name
}
fn schema() -> serde_json::Value {
serde_json::json!({ "type": $json })
}
}
};
}
impl_primitive_schema!(bool, "boolean", "boolean");
impl_primitive_schema!(String, "string", "string");
impl_primitive_schema!(&'static str, "string", "string");
impl_primitive_schema!(i8, "integer", "integer");
impl_primitive_schema!(i16, "integer", "integer");
impl_primitive_schema!(i32, "integer", "integer");
impl_primitive_schema!(i64, "integer", "integer");
impl_primitive_schema!(u8, "integer", "integer");
impl_primitive_schema!(u16, "integer", "integer");
impl_primitive_schema!(u32, "integer", "integer");
impl_primitive_schema!(u64, "integer", "integer");
impl_primitive_schema!(f32, "number", "number");
impl_primitive_schema!(f64, "number", "number");
impl_primitive_schema!(serde_json::Value, "object", "object");
#[derive(Default)]
pub struct SchemaRegistry {
schemas: BTreeMap<String, serde_json::Value>,
}
impl SchemaRegistry {
pub fn register<T: OpenApiSchema>(&mut self) {
let name = T::schema_name().to_owned();
self.schemas.entry(name).or_insert_with(T::schema);
}
pub fn insert(&mut self, name: impl Into<String>, schema: serde_json::Value) {
self.schemas.insert(name.into(), schema);
}
#[must_use]
pub fn into_map(self) -> BTreeMap<String, serde_json::Value> {
self.schemas
}
#[must_use]
pub const fn schemas(&self) -> &BTreeMap<String, serde_json::Value> {
&self.schemas
}
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct OpenApiSpec {
pub openapi: String,
pub info: Info,
pub paths: BTreeMap<String, PathItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<Components>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Info {
pub title: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[cfg(feature = "openapi")]
#[derive(Default, Debug, Serialize, Deserialize)]
pub struct PathItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub get: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub put: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete: Option<Operation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patch: Option<Operation>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Operation {
#[serde(rename = "operationId")]
pub operation_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub parameters: Vec<Parameter>,
#[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
pub request_body: Option<RequestBody>,
pub responses: BTreeMap<String, Response>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub security: Vec<BTreeMap<String, Vec<String>>>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
#[serde(rename = "in")]
pub location: String,
pub required: bool,
pub schema: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub explode: Option<bool>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct RequestBody {
pub required: bool,
pub content: BTreeMap<String, MediaType>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Response {
pub description: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub content: BTreeMap<String, MediaType>,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct MediaType {
pub schema: serde_json::Value,
}
#[cfg(feature = "openapi")]
#[derive(Debug, Serialize, Deserialize)]
pub struct Components {
pub schemas: BTreeMap<String, serde_json::Value>,
#[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
pub security_schemes: BTreeMap<String, serde_json::Value>,
}
#[cfg(feature = "openapi")]
pub fn write_openapi_spec_to_dist(
spec: &OpenApiSpec,
dist_dir: &std::path::Path,
) -> std::io::Result<()> {
std::fs::create_dir_all(dist_dir)?;
let json = serde_json::to_string_pretty(spec).map_err(std::io::Error::other)?;
std::fs::write(dist_dir.join("openapi.json"), &json)?;
let yaml = serde_yaml::to_string(spec).map_err(std::io::Error::other)?;
std::fs::write(dist_dir.join("openapi.yaml"), yaml)?;
Ok(())
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
let mut registry = SchemaRegistry::default();
for (name, schema) in &config.additional_schemas {
registry.insert(name.clone(), schema.clone());
}
registry.insert("ProblemDetails", problem_details_schema());
let mut referenced_names: std::collections::BTreeSet<&'static str> =
std::collections::BTreeSet::new();
let mut any_secured = false;
for api_doc in routes {
if api_doc.hidden {
continue;
}
if api_doc.secured {
any_secured = true;
}
if let Some(register) = api_doc.register_schemas {
(register)(&mut registry);
}
if let Some(entry) = &api_doc.request_body {
collect_ref_names(entry, &mut referenced_names);
}
if let Some(entry) = &api_doc.response {
collect_ref_names(entry, &mut referenced_names);
}
if let Some(entry) = &api_doc.query_schema {
collect_ref_names(entry, &mut referenced_names);
}
let operation = operation_for(api_doc);
let entry = paths.entry(api_doc.path.to_owned()).or_default();
match api_doc.method {
"GET" => entry.get = Some(operation),
"POST" => entry.post = Some(operation),
"PUT" => entry.put = Some(operation),
"DELETE" => entry.delete = Some(operation),
"PATCH" => entry.patch = Some(operation),
_ => {}
}
}
for name in referenced_names {
if !registry.schemas().contains_key(name) {
registry.insert(
name,
serde_json::json!({
"type": "object",
"title": name,
}),
);
}
}
let mut security_schemes: BTreeMap<String, serde_json::Value> = BTreeMap::new();
if any_secured {
security_schemes.insert(
"SessionAuth".to_owned(),
serde_json::json!({
"type": "apiKey",
"in": "cookie",
"name": config.session_cookie_name.clone(),
"description": "Autumn session cookie. Secured routes check the configured auth.session_key inside the server-side session.",
}),
);
}
let components_map = registry.into_map();
let components = if !components_map.is_empty() || !security_schemes.is_empty() {
Some(Components {
schemas: components_map,
security_schemes,
})
} else {
None
};
OpenApiSpec {
openapi: "3.1.0".to_owned(),
info: Info {
title: config.title.clone(),
version: config.version.clone(),
description: config.description.clone(),
},
paths,
components,
}
}
#[cfg(feature = "openapi")]
fn operation_for(api_doc: &ApiDoc) -> Operation {
let tags = if api_doc.tags.is_empty() {
default_tag(api_doc.path)
.map(|t| vec![t.to_owned()])
.unwrap_or_default()
} else {
api_doc.tags.iter().map(|s| (*s).to_owned()).collect()
};
let mut parameters: Vec<Parameter> = api_doc
.path_params
.iter()
.map(|name| Parameter {
name: (*name).to_owned(),
location: "path".to_owned(),
required: true,
schema: serde_json::json!({ "type": "string" }),
style: None,
explode: None,
})
.collect();
if let Some(query_entry) = &api_doc.query_schema {
parameters.push(Parameter {
name: query_entry.name.to_owned(),
location: "query".to_owned(),
required: false,
schema: schema_value_for(query_entry),
style: Some("form".to_owned()),
explode: Some(true),
});
}
let request_body = api_doc.request_body.as_ref().map(|entry| RequestBody {
required: true,
content: std::iter::once((
"application/json".to_owned(),
MediaType {
schema: schema_value_for(entry),
},
))
.collect(),
});
let mut responses: BTreeMap<String, Response> = BTreeMap::new();
let status = if api_doc.success_status == 0 {
200
} else {
api_doc.success_status
};
let response_content = api_doc
.response
.as_ref()
.map(|entry| {
let mut content = BTreeMap::new();
content.insert(
"application/json".to_owned(),
MediaType {
schema: schema_value_for(entry),
},
);
content
})
.unwrap_or_default();
responses.insert(
status.to_string(),
Response {
description: status_description(status).to_owned(),
content: response_content,
},
);
insert_problem_responses(&mut responses);
insert_problem_responses(&mut responses);
let security = if api_doc.secured {
let mut req = BTreeMap::new();
req.insert("SessionAuth".to_owned(), Vec::new());
vec![req]
} else {
Vec::new()
};
Operation {
operation_id: api_doc.operation_id.to_owned(),
summary: api_doc.summary.map(str::to_owned),
description: api_doc.description.map(str::to_owned),
tags,
parameters,
request_body,
responses,
security,
}
}
#[cfg(feature = "openapi")]
fn schema_value_for(entry: &SchemaEntry) -> serde_json::Value {
match entry.kind {
SchemaKind::Primitive(json_type) => serde_json::json!({ "type": json_type }),
SchemaKind::Ref => {
serde_json::json!({ "$ref": format!("#/components/schemas/{}", entry.name) })
}
SchemaKind::Array(items) => serde_json::json!({
"type": "array",
"items": schema_value_for(items),
}),
SchemaKind::Nullable(inner) => {
match inner.kind {
SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
serde_json::json!({
"oneOf": [
schema_value_for(inner),
{ "type": "null" },
],
})
}
SchemaKind::Primitive(base_type) => {
serde_json::json!({ "type": [base_type, "null"] })
}
}
}
}
}
#[cfg(feature = "openapi")]
fn collect_ref_names(entry: &SchemaEntry, out: &mut std::collections::BTreeSet<&'static str>) {
match entry.kind {
SchemaKind::Ref => {
out.insert(entry.name);
}
SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => collect_ref_names(inner, out),
SchemaKind::Primitive(_) => {}
}
}
#[cfg(feature = "openapi")]
fn insert_problem_responses(responses: &mut BTreeMap<String, Response>) {
for status in [400_u16, 401, 403, 404, 409, 413, 415, 422, 500, 503] {
responses.entry(status.to_string()).or_insert_with(|| {
let mut content = BTreeMap::new();
content.insert(
"application/problem+json".to_owned(),
MediaType {
schema: serde_json::json!({
"$ref": "#/components/schemas/ProblemDetails",
}),
},
);
Response {
description: status_description(status).to_owned(),
content,
}
});
}
}
#[cfg(feature = "openapi")]
fn problem_details_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"required": [
"type",
"title",
"status",
"detail",
"instance",
"code",
"request_id",
"errors",
],
"properties": {
"type": {
"type": "string",
"format": "uri-reference",
},
"title": {
"type": "string",
},
"status": {
"type": "integer",
"minimum": 400,
"maximum": 599,
},
"detail": {
"type": "string",
},
"instance": {
"type": ["string", "null"],
},
"code": {
"type": "string",
"pattern": "^autumn\\.[a-z0-9_]+$",
},
"request_id": {
"type": ["string", "null"],
},
"errors": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["field", "messages"],
"properties": {
"field": {
"type": "string",
},
"messages": {
"type": "array",
"items": {
"type": "string",
},
},
},
},
},
},
})
}
#[cfg(feature = "openapi")]
fn default_tag(path: &str) -> Option<&str> {
path.trim_start_matches('/')
.split('/')
.find(|seg| !seg.is_empty() && !seg.starts_with('{'))
}
#[cfg(feature = "openapi")]
const fn status_description(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
409 => "Conflict",
413 => "Payload Too Large",
415 => "Unsupported Media Type",
422 => "Unprocessable Entity",
500 => "Internal Server Error",
503 => "Service Unavailable",
_ => "Response",
}
}
#[cfg(feature = "openapi")]
pub(crate) const SWAGGER_UI_VERSION: &str = "5.32.4";
#[cfg(feature = "openapi")]
pub(crate) const SWAGGER_UI_CSS: &str = include_str!("../vendor/swagger-ui/swagger-ui.css");
#[cfg(feature = "openapi")]
pub(crate) const SWAGGER_UI_BUNDLE: &[u8] =
include_bytes!("../vendor/swagger-ui/swagger-ui-bundle.js");
#[cfg(feature = "openapi")]
const SWAGGER_UI_CSS_FILE: &str = "swagger-ui.css";
#[cfg(feature = "openapi")]
const SWAGGER_UI_BUNDLE_FILE: &str = "swagger-ui-bundle.js";
#[cfg(feature = "openapi")]
const SWAGGER_UI_INITIALIZER_FILE: &str = "swagger-initializer.js";
#[cfg(feature = "openapi")]
#[must_use]
pub(crate) fn swagger_ui_asset_paths(swagger_path: &str) -> [String; 3] {
[
swagger_ui_asset_path(swagger_path, SWAGGER_UI_CSS_FILE),
swagger_ui_asset_path(swagger_path, SWAGGER_UI_BUNDLE_FILE),
swagger_ui_asset_path(swagger_path, SWAGGER_UI_INITIALIZER_FILE),
]
}
#[cfg(feature = "openapi")]
#[must_use]
fn swagger_ui_asset_path(swagger_path: &str, asset_file: &str) -> String {
let base = swagger_path.trim_end_matches('/');
if base.is_empty() || base == "/" {
format!("/{asset_file}")
} else {
format!("{base}/{asset_file}")
}
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn swagger_ui_html(
title: &str,
css_url: &str,
bundle_url: &str,
initializer_url: &str,
) -> String {
let title = html_escape(title);
let css_url = html_escape(css_url);
let bundle_url = html_escape(bundle_url);
let initializer_url = html_escape(initializer_url);
let mut out = String::with_capacity(1024);
out.push_str("<!DOCTYPE html>\n");
out.push_str("<html lang=\"en\">\n");
out.push_str(" <head>\n");
out.push_str(" <meta charset=\"utf-8\" />\n");
out.push_str(" <title>");
out.push_str(&title);
out.push_str("</title>\n");
out.push_str(" <link rel=\"stylesheet\" href=\"");
out.push_str(&css_url);
out.push_str("\" />\n");
out.push_str(" </head>\n");
out.push_str(" <body>\n");
out.push_str(" <div id=\"swagger-ui\"></div>\n");
out.push_str(" <script src=\"");
out.push_str(&bundle_url);
out.push_str("\" charset=\"UTF-8\"></script>\n");
out.push_str(" <script src=\"");
out.push_str(&initializer_url);
out.push_str("\" charset=\"UTF-8\"></script>\n");
out.push_str(" </body>\n");
out.push_str("</html>\n");
out
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn swagger_ui_initializer_js(spec_url: &str) -> String {
let spec_url = serde_json::to_string(spec_url)
.unwrap_or_else(|e| format!("\"/openapi.json?serialization_error={e}\""));
let mut out = String::with_capacity(256);
out.push_str("window.onload = function() {\n");
out.push_str(" window.ui = SwaggerUIBundle({\n");
out.push_str(" url: ");
out.push_str(&spec_url);
out.push_str(",\n");
out.push_str(" dom_id: \"#swagger-ui\",\n");
out.push_str(" deepLinking: true\n");
out.push_str(" });\n");
out.push_str("};\n");
out
}
#[cfg(feature = "openapi")]
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(all(test, feature = "openapi"))]
mod tests {
use super::*;
fn make_doc() -> ApiDoc {
ApiDoc {
method: "GET",
path: "/users/{id}",
operation_id: "get_user",
summary: Some("Fetch a user"),
description: None,
tags: &[],
path_params: &["id"],
request_body: None,
response: None,
success_status: 200,
hidden: false,
query_schema: None,
secured: false,
required_roles: &[],
register_schemas: None,
}
}
#[test]
fn config_builder_methods_work() {
let config = OpenApiConfig::new("Demo", "1.0.0")
.description("A cool API")
.openapi_json_path("/api.json")
.swagger_ui_path(None)
.session_cookie_name("demo.sid");
assert_eq!(config.title, "Demo");
assert_eq!(config.version, "1.0.0");
assert_eq!(config.description.unwrap(), "A cool API");
assert_eq!(config.openapi_json_path, "/api.json");
assert_eq!(config.swagger_ui_path, None);
assert_eq!(config.session_cookie_name, "demo.sid");
}
#[test]
fn secured_spec_uses_configured_session_cookie_name() {
let mut doc = make_doc();
doc.path = "/protected";
doc.operation_id = "protected";
doc.path_params = &[];
doc.secured = true;
let config = OpenApiConfig::new("Demo", "1.0.0").session_cookie_name("demo.sid");
let spec = generate_spec(&config, &[&doc]);
let scheme = &spec
.components
.as_ref()
.expect("secured routes emit security components")
.security_schemes["SessionAuth"];
assert_eq!(scheme["type"], "apiKey");
assert_eq!(scheme["in"], "cookie");
assert_eq!(scheme["name"], "demo.sid");
}
#[test]
fn generate_spec_builds_path_with_parameters() {
let doc = make_doc();
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
assert_eq!(spec.openapi, "3.1.0");
assert_eq!(spec.info.title, "Demo");
assert!(spec.paths.contains_key("/users/{id}"));
let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
assert_eq!(op.operation_id, "get_user");
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "id");
assert_eq!(op.parameters[0].location, "path");
assert_eq!(op.tags, vec!["users".to_owned()]);
}
#[test]
fn generate_spec_skips_hidden_routes() {
let mut doc = make_doc();
doc.hidden = true;
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
assert!(spec.paths.is_empty());
}
#[test]
fn generate_spec_writes_request_body_ref() {
let mut doc = make_doc();
doc.method = "POST";
doc.path = "/users";
doc.operation_id = "create_user";
doc.path_params = &[];
doc.request_body = Some(SchemaEntry {
name: "CreateUser",
kind: SchemaKind::Ref,
});
doc.success_status = 201;
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/users"].post.as_ref().unwrap();
let body = op.request_body.as_ref().unwrap();
assert!(body.required);
let media = body.content.get("application/json").unwrap();
assert_eq!(
media.schema,
serde_json::json!({ "$ref": "#/components/schemas/CreateUser" }),
);
assert!(op.responses.contains_key("201"));
}
#[test]
fn generate_spec_inlines_primitive_response() {
let mut doc = make_doc();
doc.response = Some(SchemaEntry {
name: "string",
kind: SchemaKind::Primitive("string"),
});
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/users/{id}"].get.as_ref().unwrap();
let media = op.responses["200"].content.get("application/json").unwrap();
assert_eq!(media.schema, serde_json::json!({ "type": "string" }));
}
#[test]
fn swagger_ui_html_uses_same_origin_assets() {
let html = swagger_ui_html(
"Demo",
"/swagger-ui/swagger-ui.css",
"/swagger-ui/swagger-ui-bundle.js",
"/swagger-ui/swagger-initializer.js",
);
assert!(html.contains("/swagger-ui/swagger-ui.css"));
assert!(html.contains("/swagger-ui/swagger-ui-bundle.js"));
assert!(html.contains("/swagger-ui/swagger-initializer.js"));
assert!(!html.contains("unpkg.com"));
assert!(!html.contains("window.onload = function()"));
}
#[test]
fn swagger_ui_initializer_js_references_spec_url() {
let js = swagger_ui_initializer_js("/openapi.json");
assert!(js.contains("SwaggerUIBundle"));
assert!(js.contains(r#""/openapi.json""#));
}
#[test]
fn generate_spec_includes_additional_schemas() {
let doc = make_doc();
let config = OpenApiConfig::new("Demo", "1.0.0")
.register_schema("Foo", serde_json::json!({ "type": "object" }));
let spec = generate_spec(&config, &[&doc]);
let components = spec.components.unwrap();
assert!(components.schemas.contains_key("Foo"));
}
#[test]
fn generate_spec_back_fills_unregistered_ref_schemas() {
let mut doc = make_doc();
doc.method = "POST";
doc.path = "/users";
doc.path_params = &[];
doc.request_body = Some(SchemaEntry {
name: "CreateUser",
kind: SchemaKind::Ref,
});
doc.response = Some(SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
});
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let components = spec.components.expect("components must be emitted");
let create = components
.schemas
.get("CreateUser")
.expect("CreateUser should be back-filled");
let user = components
.schemas
.get("User")
.expect("User should be back-filled");
assert_eq!(create["type"], "object");
assert_eq!(create["title"], "CreateUser");
assert_eq!(user["type"], "object");
assert_eq!(user["title"], "User");
}
#[test]
fn generate_spec_preserves_user_registered_schemas_over_backfill() {
let mut doc = make_doc();
doc.response = Some(SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
});
let user_schema = serde_json::json!({
"type": "object",
"properties": {"id": {"type": "integer"}},
});
let config =
OpenApiConfig::new("Demo", "1.0.0").register_schema("User", user_schema.clone());
let spec = generate_spec(&config, &[&doc]);
let components = spec.components.unwrap();
let stored = components.schemas.get("User").unwrap();
assert_eq!(stored, &user_schema, "user schema must not be overwritten");
}
#[test]
fn status_description_returns_correct_strings() {
assert_eq!(status_description(200), "OK");
assert_eq!(status_description(201), "Created");
assert_eq!(status_description(202), "Accepted");
assert_eq!(status_description(204), "No Content");
assert_eq!(status_description(301), "Moved Permanently");
assert_eq!(status_description(302), "Found");
assert_eq!(status_description(400), "Bad Request");
assert_eq!(status_description(401), "Unauthorized");
assert_eq!(status_description(403), "Forbidden");
assert_eq!(status_description(404), "Not Found");
assert_eq!(status_description(409), "Conflict");
assert_eq!(status_description(413), "Payload Too Large");
assert_eq!(status_description(415), "Unsupported Media Type");
assert_eq!(status_description(422), "Unprocessable Entity");
assert_eq!(status_description(500), "Internal Server Error");
assert_eq!(status_description(503), "Service Unavailable");
assert_eq!(status_description(418), "Response");
}
#[test]
fn default_tag_picks_first_static_segment() {
assert_eq!(default_tag("/users/{id}"), Some("users"));
assert_eq!(default_tag("/api/v1/users"), Some("api"));
assert_eq!(default_tag("/"), None);
assert_eq!(default_tag("/{id}"), None);
}
#[test]
fn spec_version_is_3_1_0() {
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[]);
assert_eq!(
spec.openapi, "3.1.0",
"Autumn must emit OpenAPI 3.1.0, not {}",
spec.openapi
);
}
#[test]
fn nullable_ref_uses_openapi_3_1_one_of() {
static INNER: SchemaEntry = SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
};
let entry = SchemaEntry {
name: "nullable",
kind: SchemaKind::Nullable(&INNER),
};
let value = schema_value_for(&entry);
assert!(
value.get("nullable").is_none(),
"3.1 must not emit `nullable: true` (that is 3.0 only)"
);
assert!(
value.get("allOf").is_none(),
"3.1 must not use allOf for nullable refs"
);
let one_of = value["oneOf"]
.as_array()
.expect("3.1 nullable ref must use oneOf");
assert_eq!(one_of.len(), 2);
assert_eq!(
one_of[0]["$ref"], "#/components/schemas/User",
"first oneOf branch must be the $ref"
);
assert_eq!(
one_of[1]["type"], "null",
"second oneOf branch must be {{type: null}}"
);
}
#[test]
fn nullable_primitive_uses_type_array() {
static INNER: SchemaEntry = SchemaEntry {
name: "integer",
kind: SchemaKind::Primitive("integer"),
};
let entry = SchemaEntry {
name: "nullable",
kind: SchemaKind::Nullable(&INNER),
};
let value = schema_value_for(&entry);
assert!(
value.get("nullable").is_none(),
"3.1 must not emit `nullable: true`"
);
let types = value["type"]
.as_array()
.expect("3.1 nullable primitive must use a type array");
assert!(
types.contains(&serde_json::Value::String("integer".to_owned())),
"type array must include the base type"
);
assert!(
types.contains(&serde_json::Value::String("null".to_owned())),
"type array must include null"
);
}
#[test]
fn write_openapi_spec_to_dist_creates_json_file() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
let config = OpenApiConfig::new("TestAPI", "2.0.0");
let spec = generate_spec(&config, &[]);
write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
let json_path = dist.join("openapi.json");
assert!(json_path.exists(), "dist/openapi.json must be written");
let content = std::fs::read_to_string(&json_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(parsed["openapi"], "3.1.0");
assert_eq!(parsed["info"]["title"], "TestAPI");
}
#[test]
fn write_openapi_spec_to_dist_creates_yaml_file() {
let tmp = tempfile::TempDir::new().unwrap();
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
let config = OpenApiConfig::new("TestAPI", "2.0.0");
let spec = generate_spec(&config, &[]);
write_openapi_spec_to_dist(&spec, &dist).expect("write must succeed");
let yaml_path = dist.join("openapi.yaml");
assert!(yaml_path.exists(), "dist/openapi.yaml must be written");
let content = std::fs::read_to_string(&yaml_path).unwrap();
assert!(
content.contains("openapi:"),
"YAML must include the openapi field"
);
assert!(content.contains("3.1.0"), "YAML must include the version");
assert!(content.contains("TestAPI"), "YAML must include the title");
}
#[test]
fn schema_registry_into_map_returns_all_schemas() {
let mut registry = SchemaRegistry::default();
registry.insert("Foo", serde_json::json!({ "type": "string" }));
registry.insert("Bar", serde_json::json!({ "type": "integer" }));
let map = registry.into_map();
assert_eq!(map.len(), 2);
assert_eq!(
map.get("Foo").unwrap(),
&serde_json::json!({ "type": "string" })
);
assert_eq!(
map.get("Bar").unwrap(),
&serde_json::json!({ "type": "integer" })
);
}
#[test]
fn schema_registry_deduplicates() {
struct Foo;
impl OpenApiSchema for Foo {
fn schema_name() -> &'static str {
"Foo"
}
fn schema() -> serde_json::Value {
serde_json::json!({ "type": "object", "title": "Foo" })
}
}
let mut registry = SchemaRegistry::default();
registry.register::<Foo>();
registry.register::<Foo>();
assert_eq!(registry.schemas().len(), 1);
}
#[test]
fn primitive_impls_cover_common_types() {
assert_eq!(<String as OpenApiSchema>::schema_name(), "string");
assert_eq!(<i32 as OpenApiSchema>::schema_name(), "integer");
assert_eq!(<bool as OpenApiSchema>::schema_name(), "boolean");
assert_eq!(<f64 as OpenApiSchema>::schema_name(), "number");
}
#[test]
fn swagger_ui_html_embeds_spec_url() {
let html = swagger_ui_html(
"My API",
"/swagger-ui/swagger-ui.css",
"/swagger-ui/swagger-ui-bundle.js",
"/swagger-ui/swagger-initializer.js",
);
assert!(html.contains("/swagger-ui/swagger-ui.css"));
assert!(html.contains("My API"));
}
#[test]
fn swagger_ui_html_escapes_attributes() {
let html = swagger_ui_html(
"A \"cool\" & fun API",
"/swagger-ui/swagger-ui.css?x=<y>",
"/swagger-ui/swagger-ui-bundle.js",
"/swagger-ui/swagger-initializer.js",
);
assert!(html.contains("/swagger-ui/swagger-ui.css?x=<y>"));
assert!(html.contains("A "cool" & fun API"));
}
}