use std::borrow::Cow;
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::core::engine::{HttpMethod, ParamLoc, RouteDescriptor};
const OPENAPI_VERSION: &str = "3.0.3";
#[derive(Clone)]
pub enum SecurityScheme {
Bearer { bearer_format: Option<&'static str> },
ApiKey {
location: ApiKeyIn,
name: &'static str,
},
Basic,
}
#[derive(Clone, Copy)]
pub enum ApiKeyIn {
Header,
Query,
Cookie,
}
impl SecurityScheme {
fn to_value(&self) -> Value {
match self {
SecurityScheme::Bearer { bearer_format } => {
let mut v = json!({ "type": "http", "scheme": "bearer" });
if let Some(f) = bearer_format {
v.as_object_mut()
.unwrap()
.insert("bearerFormat".into(), Value::String((*f).into()));
}
v
}
SecurityScheme::ApiKey { location, name } => json!({
"type": "apiKey",
"in": match location { ApiKeyIn::Header => "header", ApiKeyIn::Query => "query", ApiKeyIn::Cookie => "cookie" },
"name": *name,
}),
SecurityScheme::Basic => json!({ "type": "http", "scheme": "basic" }),
}
}
}
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct OpenApiInfo {
pub title: Cow<'static, str>,
pub version: Cow<'static, str>,
pub description: Option<Cow<'static, str>>,
pub terms_of_service: Option<Cow<'static, str>>,
pub contact_name: Option<Cow<'static, str>>,
pub contact_url: Option<Cow<'static, str>>,
pub contact_email: Option<Cow<'static, str>>,
pub license_name: Option<Cow<'static, str>>,
pub license_url: Option<Cow<'static, str>>,
pub servers: Vec<(Cow<'static, str>, Cow<'static, str>)>,
pub security_schemes: Vec<(Cow<'static, str>, SecurityScheme)>,
pub tag_descriptions: Vec<(Cow<'static, str>, Cow<'static, str>)>,
}
impl OpenApiInfo {
pub fn new(title: impl Into<Cow<'static, str>>, version: impl Into<Cow<'static, str>>) -> Self {
Self {
title: title.into(),
version: version.into(),
..Default::default()
}
}
pub fn description(mut self, v: impl Into<Cow<'static, str>>) -> Self {
self.description = Some(v.into());
self
}
pub fn terms_of_service(mut self, v: impl Into<Cow<'static, str>>) -> Self {
self.terms_of_service = Some(v.into());
self
}
pub fn contact_name(mut self, v: impl Into<Cow<'static, str>>) -> Self {
self.contact_name = Some(v.into());
self
}
pub fn contact_url(mut self, v: impl Into<Cow<'static, str>>) -> Self {
self.contact_url = Some(v.into());
self
}
pub fn contact_email(mut self, v: impl Into<Cow<'static, str>>) -> Self {
self.contact_email = Some(v.into());
self
}
pub fn license(
mut self,
name: impl Into<Cow<'static, str>>,
url: impl Into<Cow<'static, str>>,
) -> Self {
self.license_name = Some(name.into());
self.license_url = Some(url.into());
self
}
pub fn server(
mut self,
url: impl Into<Cow<'static, str>>,
description: impl Into<Cow<'static, str>>,
) -> Self {
self.servers.push((url.into(), description.into()));
self
}
pub fn security_scheme(
mut self,
name: impl Into<Cow<'static, str>>,
scheme: SecurityScheme,
) -> Self {
self.security_schemes.push((name.into(), scheme));
self
}
pub fn tag(
mut self,
name: impl Into<Cow<'static, str>>,
description: impl Into<Cow<'static, str>>,
) -> Self {
self.tag_descriptions
.push((name.into(), description.into()));
self
}
}
pub fn build_spec(info: &OpenApiInfo) -> Value {
build_spec_filtered(info, None)
}
pub fn build_spec_filtered(
info: &OpenApiInfo,
allowed_controllers: Option<&std::collections::HashSet<&'static str>>,
) -> Value {
let mut paths: Map<String, Value> = Map::new();
let mut components: Map<String, Value> = Map::new();
components.insert("ProblemDetails".into(), problem_details_schema());
for rt in inventory::iter::<&'static RouteDescriptor> {
if let Some(allowed) = allowed_controllers {
if !rt.controller.is_empty() && !allowed.contains(rt.controller) {
continue;
}
}
let oapi_path = axum_to_openapi_path(rt.path);
let entry = paths
.entry(oapi_path)
.or_insert_with(|| Value::Object(Map::new()));
let mut parameters: Vec<Value> = rt.spec.params.iter().map(|p| {
json!({
"name": p.name,
"in": match p.loc { ParamLoc::Path => "path", ParamLoc::Query => "query", ParamLoc::Header => "header" },
"required": p.required,
"schema": (p.schema)(),
})
}).collect();
if let Some(qfn) = rt.spec.query_schema {
let mut schema = qfn();
harvest_definitions(&mut schema, &mut components);
rewrite_refs(&mut schema);
if let Some(props) = schema.get("properties").and_then(Value::as_object) {
let required: std::collections::HashSet<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
for (name, prop_schema) in props {
parameters.push(json!({
"name": name,
"in": "query",
"required": required.contains(name.as_str()),
"schema": prop_schema,
}));
}
}
}
if rt.spec.idempotent_ttl_secs > 0 {
parameters.push(json!({
"name": "Idempotency-Key",
"in": "header",
"required": false,
"description": format!(
"Optional client-supplied key for safe retries. A repeat \
within {}s replays the stored response \
(`Idempotency-Replayed: true`); a concurrent duplicate \
gets 409.", rt.spec.idempotent_ttl_secs),
"schema": { "type": "string", "maxLength": 255 },
}));
}
let status = rt
.spec
.status_code
.unwrap_or_else(|| default_success(rt.method));
let success_desc = if status == 204 {
"No Content"
} else {
"Successful response"
};
let success = match (rt.spec.response_schema, status == 204) {
(Some(f), false) => {
let mut s = f();
harvest_definitions(&mut s, &mut components);
rewrite_refs(&mut s);
json!({
"description": success_desc,
"content": { "application/json": { "schema": s } }
})
}
_ => json!({ "description": success_desc }),
};
let mut success = success;
{
let mut headers = Map::new();
if rt.spec.idempotent_ttl_secs > 0 {
headers.insert("Idempotency-Replayed".into(), json!({
"description": "Present (true) when this response was replayed from the idempotency store.",
"schema": { "type": "string", "enum": ["true"] },
}));
}
if !rt.spec.sunset.is_empty() {
headers.insert(
"Deprecation".into(),
json!({
"description": "RFC 8594 — this endpoint is deprecated.",
"schema": { "type": "string", "enum": ["true"] },
}),
);
headers.insert(
"Sunset".into(),
json!({
"description": "RFC 8594 — date after which this endpoint may be removed.",
"schema": { "type": "string" },
}),
);
}
if !headers.is_empty() {
success["headers"] = Value::Object(headers);
}
}
let problem_ref = json!({
"description": "",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProblemDetails" } } }
});
let mut responses = Map::new();
responses.insert(status.to_string(), success);
let forbidden_desc = if rt.spec.policies.is_empty() {
"Forbidden".to_string()
} else {
format!(
"Forbidden — requires ABAC policies: {} (default-deny; rules hot-reload at runtime)",
rt.spec.policies.join(", "),
)
};
let mut error_codes: Vec<(String, String)> = vec![
("400".into(), "Bad request".into()),
("401".into(), "Unauthorized".into()),
("403".into(), forbidden_desc),
("404".into(), "Not found".into()),
("422".into(), "Validation failed".into()),
("500".into(), "Internal error".into()),
];
if rt.spec.idempotent_ttl_secs > 0 {
error_codes.push((
"409".into(),
"Conflict — a request with this Idempotency-Key is already in flight".into(),
));
}
if rt.spec.timeout_ms > 0 {
error_codes.push((
"504".into(),
format!(
"Gateway Timeout — handler exceeded its {}ms deadline (work cancelled{})",
rt.spec.timeout_ms,
if rt.spec.transactional {
"; transaction rolled back"
} else {
""
},
),
));
}
for (code, desc) in error_codes {
let mut entry = problem_ref.clone();
entry["description"] = Value::String(desc);
responses.insert(code, entry);
}
let tags: Vec<&'static str> = if rt.spec.tags.is_empty() {
default_tags_from_path(rt.path)
} else {
rt.spec.tags.to_vec()
};
let security: Vec<Value> = rt
.spec
.security
.iter()
.map(|s| {
let mut m = Map::new();
m.insert((*s).into(), Value::Array(vec![]));
Value::Object(m)
})
.collect();
let mut op = json!({
"summary": rt.spec.summary,
"operationId": rt.spec.operation_id,
"tags": tags,
"parameters": parameters,
"responses": responses,
"deprecated": rt.spec.deprecated || !rt.spec.sunset.is_empty(),
});
if !rt.spec.api_version.is_empty() {
op["x-api-version"] = Value::String(rt.spec.api_version.into());
}
if !rt.spec.sunset.is_empty() {
op["x-sunset"] = Value::String(rt.spec.sunset.into());
}
if rt.spec.idempotent_ttl_secs > 0 {
op["x-arcly-idempotent-ttl-secs"] = json!(rt.spec.idempotent_ttl_secs);
}
if !rt.spec.policies.is_empty() {
op["x-arcly-policies"] = json!(rt.spec.policies);
}
if !rt.spec.audit_action.is_empty() {
op["x-arcly-audit"] = json!({
"action": rt.spec.audit_action,
"resource": rt.spec.audit_resource,
});
}
if rt.spec.timeout_ms > 0 {
op["x-arcly-timeout-ms"] = json!(rt.spec.timeout_ms);
}
if rt.spec.transactional {
op["x-arcly-transactional"] = Value::Bool(true);
}
if !rt.spec.mask_fields.is_empty() {
op["x-arcly-masked-fields"] = json!(rt.spec.mask_fields);
}
if !rt.spec.description.is_empty() {
op["description"] = Value::String(rt.spec.description.to_string());
}
if !security.is_empty() {
op["security"] = Value::Array(security);
}
if rt.spec.has_body {
let schema_val = match rt.spec.body_schema {
Some(f) => {
let mut s = f();
harvest_definitions(&mut s, &mut components);
rewrite_refs(&mut s);
s
}
None => json!({ "type": "object" }),
};
op.as_object_mut().unwrap().insert(
"requestBody".into(),
json!({
"required": true,
"content": { "application/json": { "schema": schema_val } }
}),
);
}
let method_key = method_str(rt.method);
entry.as_object_mut().unwrap().insert(method_key.into(), op);
}
let mut info_obj = Map::new();
info_obj.insert(
"title".into(),
Value::String(info.title.clone().into_owned()),
);
info_obj.insert(
"version".into(),
Value::String(info.version.clone().into_owned()),
);
if let Some(d) = &info.description {
info_obj.insert("description".into(), Value::String(d.clone().into_owned()));
}
if let Some(t) = &info.terms_of_service {
info_obj.insert(
"termsOfService".into(),
Value::String(t.clone().into_owned()),
);
}
let mut contact = Map::new();
if let Some(n) = &info.contact_name {
contact.insert("name".into(), Value::String(n.clone().into_owned()));
}
if let Some(u) = &info.contact_url {
contact.insert("url".into(), Value::String(u.clone().into_owned()));
}
if let Some(e) = &info.contact_email {
contact.insert("email".into(), Value::String(e.clone().into_owned()));
}
if !contact.is_empty() {
info_obj.insert("contact".into(), Value::Object(contact));
}
let mut license = Map::new();
if let Some(n) = &info.license_name {
license.insert("name".into(), Value::String(n.clone().into_owned()));
}
if let Some(u) = &info.license_url {
license.insert("url".into(), Value::String(u.clone().into_owned()));
}
if !license.is_empty() {
info_obj.insert("license".into(), Value::Object(license));
}
let servers: Vec<Value> = info
.servers
.iter()
.map(|(url, desc)| {
let mut m = Map::new();
m.insert("url".into(), Value::String(url.clone().into_owned()));
if !desc.is_empty() {
m.insert(
"description".into(),
Value::String(desc.clone().into_owned()),
);
}
Value::Object(m)
})
.collect();
let tags_section: Vec<Value> = info
.tag_descriptions
.iter()
.map(|(name, desc)| json!({ "name": name.as_ref(), "description": desc.as_ref() }))
.collect();
let mut components_obj = Map::new();
components_obj.insert("schemas".into(), Value::Object(components));
if !info.security_schemes.is_empty() {
let mut schemes: BTreeMap<String, Value> = BTreeMap::new();
for (name, sch) in &info.security_schemes {
schemes.insert(name.clone().into_owned(), sch.to_value());
}
components_obj.insert(
"securitySchemes".into(),
Value::Object(schemes.into_iter().collect()),
);
}
let mut doc = Map::new();
doc.insert("openapi".into(), Value::String(OPENAPI_VERSION.into()));
doc.insert("info".into(), Value::Object(info_obj));
if !servers.is_empty() {
doc.insert("servers".into(), Value::Array(servers));
}
if !tags_section.is_empty() {
doc.insert("tags".into(), Value::Array(tags_section));
}
doc.insert("paths".into(), Value::Object(paths));
doc.insert("components".into(), Value::Object(components_obj));
Value::Object(doc)
}
fn problem_details_schema() -> Value {
json!({
"type": "object",
"description": "RFC 7807 ProblemDetails",
"required": ["type", "title", "status", "detail"],
"properties": {
"type": { "type": "string" },
"title": { "type": "string" },
"status": { "type": "integer", "minimum": 100, "maximum": 599 },
"detail": { "type": "string" },
"errors": {
"type": "array",
"items": {
"type": "object",
"required": ["field", "code", "message"],
"properties": {
"field": { "type": "string" },
"code": { "type": "string" },
"message": { "type": "string" }
}
}
}
}
})
}
fn default_success(m: HttpMethod) -> u16 {
match m {
HttpMethod::POST => 201,
HttpMethod::DELETE => 204,
_ => 200,
}
}
fn default_tags_from_path(p: &'static str) -> Vec<&'static str> {
let seg = p.trim_start_matches('/').split('/').next().unwrap_or("");
if seg.is_empty() || seg.starts_with(':') {
Vec::new()
} else {
vec![seg]
}
}
fn harvest_definitions(schema: &mut Value, components: &mut Map<String, Value>) {
let Value::Object(map) = schema else { return };
if let Some(Value::Object(defs)) = map.remove("definitions") {
for (k, mut v) in defs {
rewrite_refs(&mut v);
components.entry(k).or_insert(v);
}
}
}
fn rewrite_refs(v: &mut Value) {
match v {
Value::Object(map) => {
if let Some(Value::String(s)) = map.get_mut("$ref") {
if let Some(name) = s.strip_prefix("#/definitions/") {
*s = format!("#/components/schemas/{name}");
}
}
for (_, child) in map.iter_mut() {
rewrite_refs(child);
}
}
Value::Array(arr) => {
for child in arr {
rewrite_refs(child);
}
}
_ => {}
}
}
#[inline]
fn method_str(m: HttpMethod) -> &'static str {
match m {
HttpMethod::GET => "get",
HttpMethod::POST => "post",
HttpMethod::PUT => "put",
HttpMethod::DELETE => "delete",
HttpMethod::PATCH => "patch",
}
}
fn axum_to_openapi_path(p: &str) -> String {
let p = if p.len() > 1 {
p.trim_end_matches('/')
} else {
p
};
let mut out = String::with_capacity(p.len() + 4);
let mut chars = p.chars().peekable();
while let Some(c) = chars.next() {
if c == ':' {
out.push('{');
while let Some(&n) = chars.peek() {
if n == '/' {
break;
}
out.push(n);
chars.next();
}
out.push('}');
} else {
out.push(c);
}
}
out
}
pub const SWAGGER_UI_HTML: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>arcly-http — API docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: true,
layout: 'BaseLayout',
});
};
</script>
</body>
</html>
"##;
#[cfg(test)]
mod path_tests {
use super::axum_to_openapi_path;
#[test]
fn spec_paths_are_canonical() {
assert_eq!(axum_to_openapi_path("/products/"), "/products");
assert_eq!(axum_to_openapi_path("/products"), "/products");
assert_eq!(axum_to_openapi_path("/users/:id"), "/users/{id}");
assert_eq!(axum_to_openapi_path("/"), "/");
}
}