#![allow(clippy::doc_markdown)]
use std::collections::BTreeMap;
#[cfg(feature = "openapi")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default)]
#[allow(clippy::struct_excessive_bools)]
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 required_scopes: &'static [&'static str],
pub register_schemas: Option<fn(&mut SchemaRegistry)>,
pub api_version: Option<&'static str>,
pub sunset_opt_out: bool,
pub has_policy: bool,
pub public: bool,
pub module_path: &'static str,
pub mcp_tool: bool,
pub mcp_exclude: bool,
pub mcp_stream: bool,
}
#[derive(Copy, Clone, Debug)]
pub struct SchemaEntry {
pub name: &'static str,
pub kind: SchemaKind,
pub identity: Option<fn() -> &'static str>,
}
impl SchemaEntry {
#[must_use]
pub fn identity_key(&self) -> &'static str {
self.identity.map_or(self.name, |f| f())
}
}
impl PartialEq for SchemaEntry {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.kind == other.kind
&& self.identity_key() == other.identity_key()
}
}
impl Eq for SchemaEntry {}
#[must_use]
pub fn type_name_of<T: ?Sized>() -> &'static str {
core::any::type_name::<T>()
}
#[must_use]
pub fn component_key(raw: &str) -> String {
let dotted = raw.replace("::", ".");
dotted
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect()
}
#[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>,
pub api_versions: Vec<crate::app::ApiVersion>,
}
#[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(),
api_versions: Vec::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;
}
pub use autumn_macros::OpenApiSchema;
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");
pub struct DerivedSchemaDescriptor {
pub name: &'static str,
pub identity: fn() -> &'static str,
pub schema: fn() -> serde_json::Value,
}
inventory::collect!(DerivedSchemaDescriptor);
#[must_use]
pub fn registered_derived_schema(identity: &str) -> Option<serde_json::Value> {
inventory::iter::<DerivedSchemaDescriptor>
.into_iter()
.find(|descriptor| (descriptor.identity)() == identity)
.map(|descriptor| (descriptor.schema)())
}
#[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>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecated: Option<bool>,
#[serde(rename = "x-required-scopes", skip_serializing_if = "Vec::is_empty")]
pub x_required_scopes: 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")]
#[derive(Default, Debug, Clone)]
pub struct SchemaComponentIndex {
by_identity: BTreeMap<String, String>,
}
#[cfg(feature = "openapi")]
impl SchemaComponentIndex {
#[must_use]
pub fn display_key(&self, entry: &SchemaEntry) -> String {
let identity = entry.identity_key();
self.by_identity
.get(identity)
.cloned()
.unwrap_or_else(|| component_key(entry.name))
}
fn display_key_for_identity(&self, identity: &str) -> Option<&str> {
self.by_identity.get(identity).map(String::as_str)
}
fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.by_identity.iter()
}
}
#[cfg(feature = "openapi")]
fn qualified_suffix_key(identity: &str, depth: usize) -> String {
let segments: Vec<&str> = identity.split("::").collect();
let start = segments.len().saturating_sub(depth);
component_key(&segments[start..].join("::"))
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn build_schema_component_index(routes: &[&ApiDoc]) -> SchemaComponentIndex {
let mut refs: Vec<(String, String)> = Vec::new();
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for api_doc in routes {
if api_doc.hidden {
continue;
}
for entry in [
api_doc.request_body.as_ref(),
api_doc.response.as_ref(),
api_doc.query_schema.as_ref(),
]
.into_iter()
.flatten()
{
for e in flatten_ref_entries(entry) {
let identity = e.identity_key().to_owned();
if seen.insert(identity.clone()) {
refs.push((identity, component_key(e.name)));
}
}
}
}
let mut queue: Vec<String> = refs.iter().map(|(id, _)| id.clone()).collect();
while let Some(identity) = queue.pop() {
let Some(body) = registered_derived_schema(&identity) else {
continue;
};
let mut nested: Vec<String> = Vec::new();
collect_body_ref_identities(&body, &mut nested);
for n in nested {
if seen.insert(n.clone()) {
let base = base_display_for_identity(&n);
refs.push((n.clone(), base));
queue.push(n);
}
}
}
SchemaComponentIndex {
by_identity: assign_display_keys(&refs),
}
}
#[cfg(feature = "openapi")]
fn assign_display_keys(refs: &[(String, String)]) -> BTreeMap<String, String> {
let mut by_base: BTreeMap<String, std::collections::BTreeSet<&str>> = BTreeMap::new();
for (identity, base) in refs {
by_base
.entry(base.clone())
.or_default()
.insert(identity.as_str());
}
let mut by_identity: BTreeMap<String, String> = BTreeMap::new();
let mut used: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (identity, base) in refs {
let collides = by_base[base].len() > 1 && identity.contains("::");
if !collides {
by_identity.entry(identity.clone()).or_insert_with(|| {
used.insert(base.clone());
base.clone()
});
}
}
let mut pending: Vec<&String> = refs
.iter()
.map(|(identity, _)| identity)
.filter(|identity| !by_identity.contains_key(*identity))
.collect();
pending.sort_unstable();
for identity in pending {
let depth_max = identity.split("::").count();
let mut display = (2..=depth_max)
.map(|depth| qualified_suffix_key(identity, depth))
.find(|candidate| !used.contains(candidate))
.unwrap_or_else(|| component_key(identity));
if used.contains(&display) {
let base = display.clone();
let mut n = 2u32;
loop {
let candidate = format!("{base}-{n}");
if !used.contains(&candidate) {
display = candidate;
break;
}
n += 1;
}
}
used.insert(display.clone());
by_identity.insert(identity.clone(), display);
}
by_identity
}
#[cfg(feature = "openapi")]
fn base_display_for_identity(identity: &str) -> String {
let without_generics = identity.split('<').next().unwrap_or(identity);
let last = without_generics
.rsplit("::")
.next()
.unwrap_or(without_generics);
component_key(last)
}
#[cfg(feature = "openapi")]
fn collect_body_ref_identities(value: &serde_json::Value, out: &mut Vec<String>) {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(reference)) = map.get("$ref")
&& let Some(id) = reference.strip_prefix("#/components/schemas/")
{
out.push(id.to_owned());
}
for v in map.values() {
collect_body_ref_identities(v, out);
}
}
serde_json::Value::Array(items) => {
for v in items {
collect_body_ref_identities(v, out);
}
}
_ => {}
}
}
#[cfg(feature = "openapi")]
fn rewrite_component_body_refs(
components: &mut BTreeMap<String, serde_json::Value>,
index: &SchemaComponentIndex,
) {
for schema in components.values_mut() {
rewrite_identity_refs(schema, index);
}
}
#[cfg(feature = "openapi")]
fn rewrite_identity_refs(value: &mut serde_json::Value, index: &SchemaComponentIndex) {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(reference)) = map.get_mut("$ref") {
let replacement = reference
.strip_prefix("#/components/schemas/")
.and_then(|identity| index.display_key_for_identity(identity))
.map(|display| format!("#/components/schemas/{display}"));
if let Some(new_ref) = replacement {
*reference = new_ref;
}
}
for v in map.values_mut() {
rewrite_identity_refs(v, index);
}
}
serde_json::Value::Array(items) => {
for v in items {
rewrite_identity_refs(v, index);
}
}
_ => {}
}
}
#[cfg(feature = "openapi")]
fn flatten_ref_entries(entry: &SchemaEntry) -> Vec<&SchemaEntry> {
match entry.kind {
SchemaKind::Ref => vec![entry],
SchemaKind::Array(inner) | SchemaKind::Nullable(inner) => flatten_ref_entries(inner),
SchemaKind::Primitive(_) => Vec::new(),
}
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn generate_spec(config: &OpenApiConfig, routes: &[&ApiDoc]) -> OpenApiSpec {
generate_spec_at(config, routes, chrono::Utc::now())
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn generate_spec_at(
config: &OpenApiConfig,
routes: &[&ApiDoc],
now: chrono::DateTime<chrono::Utc>,
) -> 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 index = build_schema_component_index(routes);
let mut any_secured = false;
let mut any_scoped = false;
for api_doc in routes {
if api_doc.hidden {
continue;
}
if api_doc.secured {
any_secured = true;
}
if !api_doc.required_scopes.is_empty() {
any_scoped = true;
}
if let Some(register) = api_doc.register_schemas {
(register)(&mut registry);
}
let operation = operation_for(api_doc, &config.api_versions, now, &index);
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 (identity, display_key) in index.iter() {
if !registry.schemas().contains_key(display_key) {
let schema = registered_derived_schema(identity).unwrap_or_else(|| {
serde_json::json!({
"type": "object",
"title": display_key,
})
});
registry.insert(display_key.clone(), schema);
}
}
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.",
}),
);
}
if any_scoped {
security_schemes.insert(
"BearerAuth".to_owned(),
serde_json::json!({
"type": "http",
"scheme": "bearer",
"description": "API bearer token. Scope-secured routes require a valid token whose scopes include all required values.",
}),
);
}
let mut components_map = registry.into_map();
rewrite_component_body_refs(&mut components_map, &index);
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")]
#[allow(clippy::too_many_lines)]
fn operation_for(
api_doc: &ApiDoc,
api_versions: &[crate::app::ApiVersion],
now: chrono::DateTime<chrono::Utc>,
index: &SchemaComponentIndex,
) -> Operation {
let mut 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()
};
if let Some(version) = api_doc.api_version {
tags.push(version.to_string());
}
let is_deprecated = api_doc.api_version.is_some_and(|version| {
api_versions
.iter()
.find(|av| av.version == version)
.is_some_and(|av| {
let is_dep = av.deprecated_at.is_some_and(|d| now >= d);
let is_sun = av.sunset_at.is_some_and(|s| now >= s);
is_dep || is_sun
})
});
let deprecated = if is_deprecated { Some(true) } else { None };
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, index),
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, index),
},
))
.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, index),
},
);
content
})
.unwrap_or_default();
responses.insert(
status.to_string(),
Response {
description: status_description(status).to_owned(),
content: response_content,
},
);
insert_problem_responses(&mut responses);
let is_subject_to_sunset = api_doc.api_version.is_some_and(|version| {
api_versions
.iter()
.find(|av| av.version == version)
.is_some_and(|av| av.sunset_at.is_some())
&& !api_doc.sunset_opt_out
});
if is_subject_to_sunset {
responses.entry("410".to_owned()).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(410).to_owned(),
content,
}
});
}
let security = if api_doc.secured {
let mut req = BTreeMap::new();
if !api_doc.required_scopes.is_empty() {
req.insert("BearerAuth".to_owned(), Vec::<String>::new());
}
if api_doc.required_scopes.is_empty() || !api_doc.required_roles.is_empty() {
req.insert("SessionAuth".to_owned(), Vec::<String>::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,
deprecated,
x_required_scopes: api_doc
.required_scopes
.iter()
.map(ToString::to_string)
.collect(),
}
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn schema_entry_to_value(
entry: &SchemaEntry,
index: &SchemaComponentIndex,
) -> serde_json::Value {
schema_value_for(entry, index)
}
#[cfg(feature = "openapi")]
fn schema_value_for(entry: &SchemaEntry, index: &SchemaComponentIndex) -> 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/{}", index.display_key(entry)) })
}
SchemaKind::Array(items) => serde_json::json!({
"type": "array",
"items": schema_value_for(items, index),
}),
SchemaKind::Nullable(inner) => {
match inner.kind {
SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
serde_json::json!({
"oneOf": [
schema_value_for(inner, index),
{ "type": "null" },
],
})
}
SchemaKind::Primitive(base_type) => {
serde_json::json!({ "type": [base_type, "null"] })
}
}
}
}
}
#[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,
api_version: None,
..Default::default()
}
}
#[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,
identity: None,
});
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"),
identity: None,
});
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,
identity: None,
});
doc.response = Some(SchemaEntry {
name: "User",
kind: SchemaKind::Ref,
identity: None,
});
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,
identity: None,
});
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,
identity: None,
};
let entry = SchemaEntry {
name: "nullable",
kind: SchemaKind::Nullable(&INNER),
identity: None,
};
let value = schema_value_for(&entry, &SchemaComponentIndex::default());
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"),
identity: None,
};
let entry = SchemaEntry {
name: "nullable",
kind: SchemaKind::Nullable(&INNER),
identity: None,
};
let value = schema_value_for(&entry, &SchemaComponentIndex::default());
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"));
}
fn make_secured_doc(
secured: bool,
required_roles: &'static [&'static str],
required_scopes: &'static [&'static str],
) -> ApiDoc {
let mut doc = make_doc();
doc.path = "/secured";
doc.operation_id = "secured_op";
doc.path_params = &[];
doc.secured = secured;
doc.required_roles = required_roles;
doc.required_scopes = required_scopes;
doc
}
#[test]
fn unsecured_route_has_no_security_requirement() {
let doc = make_secured_doc(false, &[], &[]);
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/secured"].get.as_ref().unwrap();
assert!(op.security.is_empty());
}
#[test]
fn bare_secured_uses_session_auth() {
let doc = make_secured_doc(true, &[], &[]);
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/secured"].get.as_ref().unwrap();
assert_eq!(op.security.len(), 1);
assert!(op.security[0].contains_key("SessionAuth"));
assert!(!op.security[0].contains_key("BearerAuth"));
}
#[test]
fn role_only_uses_session_auth() {
let doc = make_secured_doc(true, &["admin"], &[]);
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/secured"].get.as_ref().unwrap();
assert_eq!(op.security.len(), 1);
assert!(op.security[0].contains_key("SessionAuth"));
assert!(!op.security[0].contains_key("BearerAuth"));
}
#[test]
fn scope_only_uses_bearer_auth_with_empty_array() {
let doc = make_secured_doc(true, &[], &["posts:write"]);
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/secured"].get.as_ref().unwrap();
assert_eq!(op.security.len(), 1);
assert!(op.security[0].contains_key("BearerAuth"));
assert!(!op.security[0].contains_key("SessionAuth"));
assert!(op.security[0]["BearerAuth"].is_empty());
let schemes = &spec.components.as_ref().unwrap().security_schemes;
assert!(schemes.contains_key("BearerAuth"));
assert_eq!(schemes["BearerAuth"]["scheme"], "bearer");
}
#[test]
fn mixed_role_and_scope_uses_both_auth_schemes() {
let doc = make_secured_doc(true, &["admin"], &["posts:write"]);
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&doc]);
let op = spec.paths["/secured"].get.as_ref().unwrap();
assert_eq!(op.security.len(), 1);
assert!(op.security[0].contains_key("SessionAuth"));
assert!(op.security[0].contains_key("BearerAuth"));
}
#[test]
fn bearer_auth_scheme_registered_only_for_scoped_routes() {
let unscoped = make_secured_doc(true, &["admin"], &[]);
let config = OpenApiConfig::new("Demo", "1.0.0");
let spec = generate_spec(&config, &[&unscoped]);
let schemes = &spec.components.as_ref().unwrap().security_schemes;
assert!(!schemes.contains_key("BearerAuth"));
}
#[test]
fn colliding_fallback_keys_are_disambiguated() {
let refs = vec![
("a::x::Args".to_owned(), "Args".to_owned()),
("x::Args".to_owned(), "Args".to_owned()),
];
let by_identity = assign_display_keys(&refs);
let a = by_identity.get("a::x::Args").expect("a::x::Args assigned");
let b = by_identity.get("x::Args").expect("x::Args assigned");
assert_ne!(
a, b,
"distinct identities must map to distinct display keys, got {a} == {b}"
);
assert_eq!(a, "x.Args");
assert_eq!(b, "x.Args-2");
for key in [a, b] {
assert!(
key.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
"display key {key} is not a valid component key"
);
}
}
#[test]
fn assign_display_keys_is_order_independent() {
let forward = vec![
("app::app::Args".to_owned(), "Args".to_owned()),
("app::Args".to_owned(), "Args".to_owned()),
("other::mod::Args".to_owned(), "Args".to_owned()),
];
let mut reversed = forward.clone();
reversed.reverse();
assert_eq!(
assign_display_keys(&forward),
assign_display_keys(&reversed),
"display-key assignment must not depend on input order"
);
}
}