#![allow(clippy::doc_markdown)]
#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::collections::BTreeMap;
#[cfg(feature = "openapi")]
use serde::{Deserialize, Serialize};
pub const OPENAPI_UNAVAILABLE_MARKER: &str = "[autumn:openapi-unavailable] ";
pub const OPENAPI_UNAVAILABLE_FEATURE: &str = "this binary was built without the `openapi` feature";
pub const OPENAPI_UNAVAILABLE_UNCONFIGURED: &str =
"this app never called `.openapi(OpenApiConfig::new(..))`";
#[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 authorize_bindings: &'static [AuthorizeBinding],
pub pools: &'static [&'static str],
pub public: bool,
pub module_path: &'static str,
pub source_file: &'static str,
pub source_line: u32,
pub mcp_tool: bool,
pub mcp_exclude: bool,
pub mcp_stream: bool,
pub mcp_task: bool,
pub mcp_core: bool,
pub webmcp_tool: bool,
pub webmcp_exclude: bool,
pub agent_authority: Option<&'static crate::agent_authority::AgentAuthority>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct AuthorizeBinding {
pub action: &'static str,
pub resource: &'static str,
}
#[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");
macro_rules! impl_scalar_schema {
($ty:ty, $name:literal, $schema:expr) => {
impl OpenApiSchema for $ty {
fn schema_name() -> &'static str {
$name
}
fn schema() -> serde_json::Value {
$schema
}
}
};
}
impl_scalar_schema!(
uuid::Uuid,
"uuid",
serde_json::json!({ "type": "string", "format": "uuid" })
);
impl_scalar_schema!(
chrono::DateTime<chrono::Utc>,
"date-time",
serde_json::json!({ "type": "string", "format": "date-time" })
);
impl_scalar_schema!(
chrono::NaiveDate,
"date",
serde_json::json!({ "type": "string", "format": "date" })
);
impl_scalar_schema!(
chrono::NaiveDateTime,
"naive-date-time",
serde_json::json!({
"type": "string",
"description": "ISO 8601 date-time with no UTC offset, e.g. 2026-09-06T18:00:00",
})
);
impl_scalar_schema!(rust_decimal::Decimal, "decimal", decimal_scalar_schema());
#[must_use]
pub fn decimal_scalar_schema() -> serde_json::Value {
match serde_json::to_value(rust_decimal::Decimal::new(12345, 2)) {
Ok(serde_json::Value::String(_)) => {
serde_json::json!({ "type": "string", "format": "decimal" })
}
_ => serde_json::json!({ "type": "number" }),
}
}
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(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum SchemaDirection {
Request,
Response,
}
#[doc(hidden)]
pub struct DirectionalSchemaDescriptor {
pub identity: fn() -> &'static str,
pub direction: SchemaDirection,
pub schema: Option<fn() -> serde_json::Value>,
}
inventory::collect!(DirectionalSchemaDescriptor);
#[must_use]
pub fn registered_derived_schema_for(
identity: &str,
direction: SchemaDirection,
) -> Option<serde_json::Value> {
inventory::iter::<DirectionalSchemaDescriptor>
.into_iter()
.find(|descriptor| descriptor.direction == direction && (descriptor.identity)() == identity)
.map_or_else(
|| registered_derived_schema(identity),
|descriptor| descriptor.schema.map(|schema| schema()),
)
}
#[doc(hidden)]
pub fn remove_schema_properties(schema: &mut serde_json::Value, names: &[&str]) {
let Some(object) = schema.as_object_mut() else {
return;
};
if let Some(properties) = object
.get_mut("properties")
.and_then(serde_json::Value::as_object_mut)
{
for name in names {
properties.remove(*name);
}
}
if let Some(required) = object
.get_mut("required")
.and_then(serde_json::Value::as_array_mut)
{
required.retain(|entry| entry.as_str().is_none_or(|name| !names.contains(&name)));
if required.is_empty() {
object.remove("required");
}
}
}
#[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, Default, 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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[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_saphyr::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>,
request_by_identity: BTreeMap<String, String>,
request_only: std::collections::BTreeSet<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))
}
#[must_use]
pub fn display_key_in(&self, entry: &SchemaEntry, direction: SchemaDirection) -> String {
match direction {
SchemaDirection::Request => self
.request_by_identity
.get(entry.identity_key())
.cloned()
.unwrap_or_else(|| self.display_key(entry)),
SchemaDirection::Response => self.display_key(entry),
}
}
fn display_key_for_identity(&self, identity: &str, direction: SchemaDirection) -> Option<&str> {
if direction == SchemaDirection::Request
&& let Some(key) = self.request_by_identity.get(identity)
{
return Some(key);
}
self.by_identity.get(identity).map(String::as_str)
}
fn components(&self) -> impl Iterator<Item = (&String, &String, SchemaDirection)> {
let main = self
.by_identity
.iter()
.map(|(identity, key)| (identity, key, self.main_direction(identity)));
let request = self
.request_by_identity
.iter()
.map(|(identity, key)| (identity, key, SchemaDirection::Request));
main.chain(request)
}
fn main_direction(&self, identity: &str) -> SchemaDirection {
if self.request_only.contains(identity) {
SchemaDirection::Request
} else {
SchemaDirection::Response
}
}
}
#[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 {
build_schema_component_index_reserving(routes, &BTreeMap::new())
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn schema_component_index_for(
config: &OpenApiConfig,
routes: &[&ApiDoc],
) -> SchemaComponentIndex {
seeded_registry_and_index(config, routes).1
}
#[cfg(feature = "openapi")]
fn seeded_registry_and_index(
config: &OpenApiConfig,
routes: &[&ApiDoc],
) -> (SchemaRegistry, SchemaComponentIndex) {
let mut registry = SchemaRegistry::default();
for (name, schema) in &config.additional_schemas {
registry.insert(name.clone(), schema.clone());
}
registry.insert("ProblemDetails", problem_details_schema());
for api_doc in routes {
if !api_doc.hidden
&& let Some(register) = api_doc.register_schemas
{
(register)(&mut registry);
}
}
let index = build_schema_component_index_reserving(routes, registry.schemas());
(registry, index)
}
#[cfg(feature = "openapi")]
fn build_schema_component_index_reserving(
routes: &[&ApiDoc],
registered: &BTreeMap<String, serde_json::Value>,
) -> SchemaComponentIndex {
let mut graph = IdentityGraph::default();
for api_doc in routes {
if api_doc.hidden {
continue;
}
for (entry, direction) in [
(api_doc.request_body.as_ref(), SchemaDirection::Request),
(api_doc.response.as_ref(), SchemaDirection::Response),
(api_doc.query_schema.as_ref(), SchemaDirection::Request),
] {
let Some(entry) = entry else { continue };
for e in flatten_ref_entries(entry) {
graph.add(e.identity_key(), component_key(e.name), direction);
}
}
}
let mut registered_refs: Vec<String> = Vec::new();
for body in registered.values() {
collect_body_ref_identities(body, &mut registered_refs);
}
let by_identity = loop {
graph.close();
let by_identity = assign_display_keys(&graph.refs);
let mut grew = false;
for target in ®istered_refs {
let identity = if graph.seen.contains(target) {
Some(target.clone())
} else {
by_identity
.iter()
.find(|(_, key)| *key == target)
.map(|(identity, _)| identity.clone())
};
if let Some(identity) = identity {
grew |= graph.add_use(identity, SchemaDirection::Response);
}
}
if !grew {
break by_identity;
}
};
let request_only = graph
.seen
.iter()
.filter(|identity| {
!graph
.uses
.contains(&((*identity).clone(), SchemaDirection::Response))
})
.cloned()
.collect();
let fixed: std::collections::BTreeSet<String> = by_identity
.iter()
.filter(|(_, key)| registered.contains_key(*key))
.map(|(identity, _)| identity.clone())
.collect();
let split = split_identities(&graph.uses, &graph.bodies, &fixed);
let reserved: std::collections::BTreeSet<String> = registered.keys().cloned().collect();
let request_by_identity = assign_request_keys(&split, &by_identity, &reserved);
SchemaComponentIndex {
by_identity,
request_by_identity,
request_only,
}
}
#[cfg(feature = "openapi")]
#[derive(Default)]
struct IdentityGraph {
refs: Vec<(String, String)>,
seen: std::collections::BTreeSet<String>,
uses: std::collections::BTreeSet<(String, SchemaDirection)>,
queue: Vec<(String, SchemaDirection)>,
bodies: BTreeMap<(String, SchemaDirection), Option<serde_json::Value>>,
}
#[cfg(feature = "openapi")]
impl IdentityGraph {
fn add(&mut self, identity: &str, base: String, direction: SchemaDirection) {
if self.seen.insert(identity.to_owned()) {
self.refs.push((identity.to_owned(), base));
}
self.add_use(identity.to_owned(), direction);
}
fn add_use(&mut self, identity: String, direction: SchemaDirection) -> bool {
let new = self.uses.insert((identity.clone(), direction));
if new {
self.queue.push((identity, direction));
}
new
}
fn close(&mut self) {
while let Some((identity, direction)) = self.queue.pop() {
let body = registered_derived_schema_for(&identity, direction);
if let Some(body) = &body {
let mut nested: Vec<String> = Vec::new();
collect_body_ref_identities(body, &mut nested);
for n in nested {
let base = base_display_for_identity(&n);
self.add(&n, base, direction);
}
}
self.bodies.insert((identity, direction), body);
}
}
}
#[cfg(feature = "openapi")]
fn split_identities(
uses: &std::collections::BTreeSet<(String, SchemaDirection)>,
bodies: &BTreeMap<(String, SchemaDirection), Option<serde_json::Value>>,
fixed: &std::collections::BTreeSet<String>,
) -> std::collections::BTreeSet<String> {
let body = |identity: &str, direction| {
bodies
.get(&(identity.to_owned(), direction))
.and_then(Option::as_ref)
};
let both: Vec<&str> = uses
.iter()
.filter(|(identity, direction)| {
*direction == SchemaDirection::Request
&& uses.contains(&(identity.clone(), SchemaDirection::Response))
&& !fixed.contains(identity)
})
.map(|(identity, _)| identity.as_str())
.collect();
let mut split: std::collections::BTreeSet<String> = both
.iter()
.filter(|identity| {
body(identity, SchemaDirection::Request) != body(identity, SchemaDirection::Response)
})
.map(|identity| (*identity).to_owned())
.collect();
loop {
let before = split.len();
for identity in &both {
if split.contains(*identity) {
continue;
}
let mut nested: Vec<String> = Vec::new();
if let Some(b) = body(identity, SchemaDirection::Request) {
collect_body_ref_identities(b, &mut nested);
}
if nested.iter().any(|n| split.contains(n)) {
split.insert((*identity).to_owned());
}
}
if split.len() == before {
return split;
}
}
}
#[cfg(feature = "openapi")]
fn assign_request_keys(
split: &std::collections::BTreeSet<String>,
by_identity: &BTreeMap<String, String>,
reserved: &std::collections::BTreeSet<String>,
) -> BTreeMap<String, String> {
let mut used: std::collections::BTreeSet<String> = by_identity
.values()
.cloned()
.chain(reserved.iter().cloned())
.collect();
let mut out = BTreeMap::new();
for identity in split {
let Some(main) = by_identity.get(identity) else {
continue;
};
let base = format!("{main}Request");
let mut key = base.clone();
let mut n = 2u32;
while used.contains(&key) {
key = format!("{base}-{n}");
n += 1;
}
used.insert(key.clone());
out.insert(identity.clone(), key);
}
out
}
#[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,
) {
let directions: BTreeMap<String, SchemaDirection> = index
.components()
.map(|(_, key, direction)| (key.clone(), direction))
.collect();
for (key, schema) in components.iter_mut() {
let direction = directions
.get(key)
.copied()
.unwrap_or(SchemaDirection::Response);
rewrite_identity_refs(schema, index, direction);
}
}
#[cfg(feature = "openapi")]
fn rewrite_identity_refs(
value: &mut serde_json::Value,
index: &SchemaComponentIndex,
direction: SchemaDirection,
) {
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, direction))
.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, direction);
}
}
serde_json::Value::Array(items) => {
for v in items {
rewrite_identity_refs(v, index, direction);
}
}
_ => {}
}
}
#[cfg(feature = "openapi")]
const OPTION_TYPE_NAME_PREFIXES: [&str; 2] = ["core::option::Option<", "std::option::Option<"];
#[cfg(feature = "openapi")]
const VEC_TYPE_NAME_PREFIXES: [&str; 2] = ["alloc::vec::Vec<", "std::vec::Vec<"];
#[cfg(feature = "openapi")]
fn wrapper_is_impostor(entry: &SchemaEntry) -> bool {
let prefixes: &[&str] = match entry.kind {
SchemaKind::Array(_) => &VEC_TYPE_NAME_PREFIXES,
SchemaKind::Nullable(_) => &OPTION_TYPE_NAME_PREFIXES,
SchemaKind::Ref | SchemaKind::Primitive(_) => return false,
};
entry.identity.is_some_and(|resolve| {
let identity = resolve();
!prefixes.iter().any(|prefix| identity.starts_with(prefix))
})
}
#[cfg(feature = "openapi")]
const SERDE_JSON_VALUE_IDENTITY: &str = "serde_json::value::Value";
#[cfg(feature = "openapi")]
fn arbitrary_json_schema() -> serde_json::Value {
serde_json::json!({
"description": "Arbitrary JSON: an object, array, string, number, boolean or null.",
})
}
#[cfg(feature = "openapi")]
fn entry_is_arbitrary_json(entry: &SchemaEntry) -> bool {
matches!(entry.kind, SchemaKind::Ref)
&& entry
.identity
.is_some_and(|resolve| resolve() == SERDE_JSON_VALUE_IDENTITY)
}
#[cfg(feature = "openapi")]
fn flatten_ref_entries(entry: &SchemaEntry) -> Vec<&SchemaEntry> {
if wrapper_is_impostor(entry) {
return vec![entry];
}
if entry_is_arbitrary_json(entry) {
return Vec::new();
}
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, crate::time::ambient_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, index) = seeded_registry_and_index(config, 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;
}
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, direction) in index.components() {
if !registry.schemas().contains_key(display_key) {
let schema = registered_derived_schema_for(identity, direction).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,
description: None,
})
.collect();
if let Some(query_entry) = &api_doc.query_schema {
parameters.extend(query_parameters_for(query_entry, index));
}
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, SchemaDirection::Request),
},
))
.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, SchemaDirection::Response),
},
);
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")]
fn query_parameters_for(query_entry: &SchemaEntry, index: &SchemaComponentIndex) -> Vec<Parameter> {
if matches!(query_entry.kind, SchemaKind::Ref)
&& let Some(body) =
registered_derived_schema_for(query_entry.identity_key(), SchemaDirection::Request)
&& let Some(fields) = query_parameters_from_body(&body, index)
{
return fields;
}
vec![Parameter {
name: query_entry.name.to_owned(),
location: "query".to_owned(),
required: false,
schema: schema_value_for(query_entry, index, SchemaDirection::Request),
style: Some("form".to_owned()),
explode: Some(true),
description: None,
}]
}
#[cfg(feature = "openapi")]
fn query_parameters_from_body(
body: &serde_json::Value,
index: &SchemaComponentIndex,
) -> Option<Vec<Parameter>> {
let object = body.as_object()?;
if object.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return None;
}
let properties = object.get("properties")?.as_object()?;
let required = object.get("required").and_then(serde_json::Value::as_array);
let is_required =
|name: &str| required.is_some_and(|r| r.iter().any(|v| v.as_str() == Some(name)));
Some(
properties
.iter()
.map(|(name, field_schema)| {
let (style, explode, description) = query_field_style(field_schema);
let mut schema = field_schema.clone();
rewrite_identity_refs(&mut schema, index, SchemaDirection::Request);
Parameter {
name: name.clone(),
location: "query".to_owned(),
required: is_required(name),
schema,
style,
explode,
description,
}
})
.collect(),
)
}
#[cfg(feature = "openapi")]
fn query_field_style(schema: &serde_json::Value) -> (Option<String>, Option<bool>, Option<String>) {
match query_field_shape(schema) {
QueryFieldShape::Flat => (Some("form".to_owned()), Some(true), None),
QueryFieldShape::Object => (Some("deepObject".to_owned()), Some(true), None),
QueryFieldShape::ObjectArray => (
None,
None,
Some(
"Bracketed nested-array query encoding, e.g. \
?field[0][prop]=value for an array of objects, or \
?field[0][0]=value for an array of arrays. No OpenAPI \
style expresses this; see the query-string guide."
.to_owned(),
),
),
}
}
#[cfg(feature = "openapi")]
enum QueryFieldShape {
Flat,
Object,
ObjectArray,
}
#[cfg(feature = "openapi")]
fn query_field_shape(schema: &serde_json::Value) -> QueryFieldShape {
let effective = unwrap_nullable_schema(schema);
if let Some(identity) = ref_target(effective) {
return if ref_is_object(identity) {
QueryFieldShape::Object
} else {
QueryFieldShape::Flat
};
}
if effective.get("type").and_then(serde_json::Value::as_str) == Some("object") {
return QueryFieldShape::Object;
}
if effective.get("type").and_then(serde_json::Value::as_str) == Some("array")
&& let Some(items) = effective.get("items")
{
let items = unwrap_nullable_schema(items);
let items_are_objects = ref_target(items).map_or_else(
|| items.get("type").and_then(serde_json::Value::as_str) == Some("array"),
ref_is_object,
);
if items_are_objects {
return QueryFieldShape::ObjectArray;
}
}
QueryFieldShape::Flat
}
#[cfg(feature = "openapi")]
fn unwrap_nullable_schema(schema: &serde_json::Value) -> &serde_json::Value {
let mut current = schema;
while let Some(real) = current
.get("oneOf")
.and_then(serde_json::Value::as_array)
.and_then(|branches| branches.first())
{
current = real;
}
current
}
#[cfg(feature = "openapi")]
fn ref_target(schema: &serde_json::Value) -> Option<&str> {
schema
.get("$ref")
.and_then(serde_json::Value::as_str)
.and_then(|r| r.strip_prefix("#/components/schemas/"))
}
#[cfg(feature = "openapi")]
fn ref_is_object(identity: &str) -> bool {
registered_derived_schema(identity).is_some_and(|schema| {
schema.get("type").and_then(serde_json::Value::as_str) == Some("object")
})
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn schema_entry_to_value(
entry: &SchemaEntry,
index: &SchemaComponentIndex,
) -> serde_json::Value {
schema_value_for(entry, index, SchemaDirection::Response)
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn schema_entry_to_value_in(
entry: &SchemaEntry,
index: &SchemaComponentIndex,
direction: SchemaDirection,
) -> serde_json::Value {
schema_value_for(entry, index, direction)
}
#[cfg(feature = "openapi")]
fn schema_value_for(
entry: &SchemaEntry,
index: &SchemaComponentIndex,
direction: SchemaDirection,
) -> serde_json::Value {
if wrapper_is_impostor(entry) {
return serde_json::json!({
"$ref": format!("#/components/schemas/{}", index.display_key_in(entry, direction))
});
}
if entry_is_arbitrary_json(entry) {
return arbitrary_json_schema();
}
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_in(entry, direction))
})
}
SchemaKind::Array(items) => serde_json::json!({
"type": "array",
"items": schema_value_for(items, index, direction),
}),
SchemaKind::Nullable(inner) => {
if entry_is_arbitrary_json(inner) {
return arbitrary_json_schema();
}
match inner.kind {
SchemaKind::Ref | SchemaKind::Array(_) | SchemaKind::Nullable(_) => {
serde_json::json!({
"oneOf": [
schema_value_for(inner, index, direction),
{ "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(feature = "openapi")]
const PLACEHOLDER_KEYS: [&str; 3] = ["type", "title", "description"];
#[cfg(feature = "openapi")]
#[must_use]
pub fn is_opaque_object_schema(schema: &serde_json::Value) -> bool {
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
return false;
}
schema.as_object().is_none_or(|map| {
map.keys()
.all(|key| PLACEHOLDER_KEYS.contains(&key.as_str()))
})
}
#[cfg(feature = "openapi")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpaqueSchema {
pub schema: String,
pub referenced_by: Vec<String>,
}
#[cfg(feature = "openapi")]
#[must_use]
pub fn opaque_component_schemas(spec: &OpenApiSpec) -> Vec<OpaqueSchema> {
let Some(components) = spec.components.as_ref() else {
return Vec::new();
};
let opaque: std::collections::BTreeSet<&str> = components
.schemas
.iter()
.filter(|(_, schema)| is_opaque_object_schema(schema))
.map(|(name, _)| name.as_str())
.collect();
if opaque.is_empty() {
return Vec::new();
}
let component_refs: BTreeMap<&str, Vec<String>> = components
.schemas
.iter()
.map(|(name, schema)| {
let mut out = Vec::new();
collect_body_ref_identities(schema, &mut out);
(name.as_str(), out)
})
.collect();
let mut refs: BTreeMap<&str, std::collections::BTreeSet<String>> = BTreeMap::new();
for (path, item) in &spec.paths {
for (method, operation) in path_item_operations(item) {
let Ok(value) = serde_json::to_value(operation) else {
continue;
};
let mut frontier = Vec::new();
collect_body_ref_identities(&value, &mut frontier);
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
while let Some(identity) = frontier.pop() {
if !seen.insert(identity.clone()) {
continue;
}
if let Some(name) = opaque.get(identity.as_str()) {
refs.entry(name)
.or_default()
.insert(format!("{method} {path}"));
}
if let Some(nested) = component_refs.get(identity.as_str()) {
frontier.extend(nested.iter().cloned());
}
}
}
}
opaque
.into_iter()
.map(|schema| OpaqueSchema {
schema: schema.to_owned(),
referenced_by: refs
.get(schema)
.map(|set| set.iter().cloned().collect())
.unwrap_or_default(),
})
.collect()
}
#[cfg(feature = "openapi")]
fn path_item_operations(item: &PathItem) -> Vec<(&'static str, &Operation)> {
[
("GET", item.get.as_ref()),
("POST", item.post.as_ref()),
("PUT", item.put.as_ref()),
("PATCH", item.patch.as_ref()),
("DELETE", item.delete.as_ref()),
]
.into_iter()
.filter_map(|(method, op)| op.map(|op| (method, op)))
.collect()
}
#[cfg(all(test, feature = "openapi"))]
mod tests {
use super::*;
#[test]
fn remove_schema_properties_drops_the_property_and_its_required_entry() {
let mut schema = serde_json::json!({
"type": "object",
"properties": { "a": {}, "b": {} },
"required": ["a", "b"],
});
remove_schema_properties(&mut schema, &["b"]);
assert_eq!(
schema,
serde_json::json!({
"type": "object",
"properties": { "a": {} },
"required": ["a"],
})
);
remove_schema_properties(&mut schema, &["a"]);
assert!(schema.get("required").is_none(), "{schema}");
}
#[test]
fn request_key_skips_taken_and_reserved_keys() {
let by_identity: BTreeMap<String, String> = [
("app::Widget".to_owned(), "Widget".to_owned()),
("app::WidgetRequest".to_owned(), "WidgetRequest".to_owned()),
]
.into_iter()
.collect();
let split = std::iter::once("app::Widget".to_owned()).collect();
let reserved = std::iter::once("WidgetRequest-2".to_owned()).collect();
let keys = assign_request_keys(&split, &by_identity, &reserved);
assert_eq!(keys["app::Widget"], "WidgetRequest-3");
}
#[test]
fn split_follows_a_nested_split() {
let both = |id: &str| {
[
(id.to_owned(), SchemaDirection::Request),
(id.to_owned(), SchemaDirection::Response),
]
};
let uses: std::collections::BTreeSet<(String, SchemaDirection)> = both("app::Outer")
.into_iter()
.chain(both("app::Inner"))
.chain(both("app::Plain"))
.collect();
let outer = serde_json::json!({
"properties": { "inner": { "$ref": "#/components/schemas/app::Inner" } }
});
let plain = serde_json::json!({ "type": "object" });
let bodies: BTreeMap<(String, SchemaDirection), Option<serde_json::Value>> = [
(
("app::Outer".to_owned(), SchemaDirection::Request),
Some(outer.clone()),
),
(
("app::Outer".to_owned(), SchemaDirection::Response),
Some(outer),
),
(
("app::Inner".to_owned(), SchemaDirection::Request),
Some(serde_json::json!({ "required": ["secret"] })),
),
(
("app::Inner".to_owned(), SchemaDirection::Response),
Some(serde_json::json!({})),
),
(
("app::Plain".to_owned(), SchemaDirection::Request),
Some(plain.clone()),
),
(
("app::Plain".to_owned(), SchemaDirection::Response),
Some(plain),
),
]
.into_iter()
.collect();
let split = split_identities(&uses, &bodies, &std::collections::BTreeSet::new());
let split: Vec<&str> = split.iter().map(String::as_str).collect();
assert_eq!(split, vec!["app::Inner", "app::Outer"]);
}
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(),
SchemaDirection::Response,
);
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(),
SchemaDirection::Response,
);
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"
);
}
}
#[cfg(test)]
mod scalar_schema_tests {
use super::*;
#[test]
fn scalar_impls_document_strings_with_a_format() {
use serde_json::json;
let cases = [
(
<uuid::Uuid as OpenApiSchema>::schema(),
json!({"type": "string", "format": "uuid"}),
),
(
<chrono::DateTime<chrono::Utc> as OpenApiSchema>::schema(),
json!({"type": "string", "format": "date-time"}),
),
(
<chrono::NaiveDate as OpenApiSchema>::schema(),
json!({"type": "string", "format": "date"}),
),
];
for (got, want) in cases {
assert_eq!(got, want);
}
let naive = <chrono::NaiveDateTime as OpenApiSchema>::schema();
assert_eq!(naive["type"], "string");
assert!(
naive.get("format").is_none(),
"naive has no offset: {naive}"
);
}
#[test]
fn decimal_schema_matches_its_serde_output() {
let wire = serde_json::to_value(rust_decimal::Decimal::new(12345, 2)).unwrap();
let schema = <rust_decimal::Decimal as OpenApiSchema>::schema();
if wire.is_string() {
assert_eq!(
schema,
serde_json::json!({"type": "string", "format": "decimal"})
);
} else {
assert_eq!(schema, serde_json::json!({"type": "number"}));
}
}
}