use actus_controller::{DEFAULT_VERBS, ParamDefault, ParamSource, ParamType, RouteDef, Verb};
use serde_json::{Map, Value, json};
use crate::router::Router;
#[derive(Clone, Debug)]
pub struct Options {
pub title: String,
pub version: String,
pub description: Option<String>,
pub servers: Vec<ServerInfo>,
}
#[derive(Clone, Debug)]
pub struct ServerInfo {
pub url: String,
pub description: Option<String>,
}
impl Options {
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
title: title.into(),
version: version.into(),
description: None,
servers: Vec::new(),
}
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn server(
mut self,
url: impl Into<String>,
description: Option<impl Into<String>>,
) -> Self {
self.servers.push(ServerInfo {
url: url.into(),
description: description.map(Into::into),
});
self
}
}
pub fn generate<F>(router: &Router, options: &Options, filter: F) -> Value
where
F: Fn(&str) -> bool,
{
let mut paths: Map<String, Value> = Map::new();
for (mount, route) in router.routes() {
if !filter(mount.as_str()) {
continue;
}
let path = compose_path(&mount, route.pattern);
let methods = methods_for(&route);
let entry = paths.entry(path.clone()).or_insert_with(|| json!({}));
let entry_obj = entry
.as_object_mut()
.expect("path entry is always a JSON object");
for method in methods {
entry_obj.insert(method.to_string(), build_operation(&path, method, &route));
}
}
let mut info = Map::new();
info.insert("title".into(), Value::String(options.title.clone()));
info.insert("version".into(), Value::String(options.version.clone()));
if let Some(d) = &options.description {
info.insert("description".into(), Value::String(d.clone()));
}
let mut spec = Map::new();
spec.insert("openapi".into(), Value::String("3.1.0".into()));
spec.insert("info".into(), Value::Object(info));
if !options.servers.is_empty() {
let servers: Vec<Value> = options
.servers
.iter()
.map(|s| {
let mut obj = Map::new();
obj.insert("url".into(), Value::String(s.url.clone()));
if let Some(d) = &s.description {
obj.insert("description".into(), Value::String(d.clone()));
}
Value::Object(obj)
})
.collect();
spec.insert("servers".into(), Value::Array(servers));
}
spec.insert("paths".into(), Value::Object(paths));
Value::Object(spec)
}
pub fn to_string_pretty(value: &Value) -> String {
serde_json::to_string_pretty(value).expect("serde_json::Value is always serializable")
}
fn compose_path(mount: &str, pattern: &str) -> String {
let mount = mount.trim_matches('/');
let pattern = pattern.trim_matches('/').replace("{...", "{");
match (mount.is_empty(), pattern.is_empty()) {
(true, true) => "/".to_string(),
(true, false) => format!("/{pattern}"),
(false, true) => format!("/{mount}"),
(false, false) => format!("/{mount}/{pattern}"),
}
}
fn methods_for(route: &RouteDef) -> Vec<&'static str> {
if std::ptr::eq(route.verb, DEFAULT_VERBS) {
return DEFAULT_VERBS.iter().map(verb_method).collect();
}
route.verb.iter().map(verb_method).collect()
}
fn verb_method(v: &Verb) -> &'static str {
match v {
Verb::GET => "get",
Verb::POST => "post",
Verb::PUT => "put",
Verb::DELETE => "delete",
Verb::PATCH => "patch",
Verb::HEAD => "head",
Verb::OPTIONS => "options",
}
}
fn build_operation(path: &str, method: &str, route: &RouteDef) -> Value {
let mut op = Map::new();
op.insert(
"operationId".into(),
Value::String(operation_id(path, method, route.handler)),
);
if let Some(doc) = route.doc {
let trimmed = doc.trim();
if !trimmed.is_empty() {
let summary = trimmed
.lines()
.find(|l| !l.trim().is_empty())
.map(str::trim)
.unwrap_or("");
if !summary.is_empty() {
op.insert("summary".into(), Value::String(summary.to_string()));
}
op.insert("description".into(), Value::String(trimmed.to_string()));
}
}
let (parameters, request_body) = split_params(route);
if !parameters.is_empty() {
op.insert("parameters".into(), Value::Array(parameters));
}
if let Some(body) = request_body {
op.insert("requestBody".into(), body);
}
op.insert(
"responses".into(),
json!({
"default": { "description": "Response from the handler." }
}),
);
Value::Object(op)
}
fn operation_id(path: &str, method: &str, handler: &str) -> String {
let sanitized: String = path
.chars()
.map(|c| match c {
'/' => '_',
'{' | '}' => '_',
other => other,
})
.collect();
let trimmed = sanitized.trim_matches('_');
if trimmed.is_empty() {
format!("{handler}_{method}")
} else {
let mut collapsed = String::with_capacity(trimmed.len());
let mut prev_us = false;
for c in trimmed.chars() {
if c == '_' {
if !prev_us {
collapsed.push('_');
}
prev_us = true;
} else {
collapsed.push(c);
prev_us = false;
}
}
format!("{collapsed}_{handler}_{method}")
}
}
fn split_params(route: &RouteDef) -> (Vec<Value>, Option<Value>) {
let mut params: Vec<Value> = Vec::new();
let mut body: Option<Value> = None;
let pattern_has_rest = route.pattern.contains("{...");
for p in route.params {
match p.source {
ParamSource::Path => {
let mut entry = Map::new();
entry.insert("name".into(), Value::String(p.name.to_string()));
entry.insert("in".into(), Value::String("path".into()));
entry.insert("required".into(), Value::Bool(true));
entry.insert("schema".into(), schema_for(p.ty, p.default.as_ref()));
if pattern_has_rest && matches!(p.ty, ParamType::String) {
if route
.pattern
.contains(&format!("{{...{name}}}", name = p.name))
{
entry.insert("x-actus-rest-param".into(), Value::Bool(true));
entry.insert(
"description".into(),
Value::String(
"Captures the trailing path (slashes included). Not natively \
representable in OpenAPI path templating; treated as a single \
segment here."
.into(),
),
);
}
}
params.push(Value::Object(entry));
}
ParamSource::Query => {
let mut entry = Map::new();
entry.insert("name".into(), Value::String(p.name.to_string()));
entry.insert("in".into(), Value::String("query".into()));
let required = !matches!(p.ty, ParamType::StringArray) && p.default.is_none();
entry.insert("required".into(), Value::Bool(required));
entry.insert("schema".into(), schema_for(p.ty, p.default.as_ref()));
params.push(Value::Object(entry));
}
ParamSource::Body => {
let (content_type, schema): (&str, Value) = match p.ty {
ParamType::Json => ("application/json", json!({})),
ParamType::Bytes => (
"application/octet-stream",
json!({ "type": "string", "format": "binary" }),
),
_ => continue, };
body = Some(json!({
"required": true,
"content": {
content_type: { "schema": schema }
}
}));
}
}
}
(params, body)
}
fn schema_for(ty: ParamType, default: Option<&ParamDefault>) -> Value {
let mut schema = base_schema(ty);
if let Some(d) = default {
let obj = schema
.as_object_mut()
.expect("base schema is always object");
obj.insert("default".into(), default_to_value(d));
}
schema
}
fn base_schema(ty: ParamType) -> Value {
match ty {
ParamType::String => json!({ "type": "string" }),
ParamType::Int => json!({ "type": "integer", "format": "int64" }),
ParamType::U64 => json!({ "type": "integer", "format": "int64", "minimum": 0 }),
ParamType::U32 => json!({ "type": "integer", "format": "int32", "minimum": 0 }),
ParamType::F64 => json!({ "type": "number" }),
ParamType::Bool => json!({ "type": "boolean" }),
ParamType::StringArray => json!({
"type": "array",
"items": { "type": "string" }
}),
ParamType::Json => json!({}), ParamType::Bytes => json!({ "type": "string", "format": "binary" }),
}
}
fn default_to_value(d: &ParamDefault) -> Value {
match d {
ParamDefault::String(s) => Value::String((*s).to_string()),
ParamDefault::Int(i) => Value::from(*i),
ParamDefault::U64(u) => Value::from(*u),
ParamDefault::U32(u) => Value::from(*u),
ParamDefault::F64(f) => Value::from(*f),
ParamDefault::Bool(b) => Value::from(*b),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::RouterBuilder;
use actus_controller::{Controller, ParamDef, Params};
use actus_reply::{Reply, WebError};
use std::sync::Arc;
struct Stub {
routes: &'static [RouteDef],
}
#[actus_controller::async_trait]
impl Controller for Stub {
async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
Err(WebError::NotFound)
}
fn __name(&self) -> &'static str {
"stub"
}
fn actus_describe_routes(&self) -> Vec<RouteDef> {
self.routes.to_vec()
}
}
fn build_router(mounts: &[(&str, &'static [RouteDef])]) -> Router {
let mut b = RouterBuilder::new();
for (mount, routes) in mounts {
b = b.add_route(mount, Arc::new(Stub { routes }));
}
b.build()
}
fn opts() -> Options {
Options::new("Test API", "1.0.0")
}
#[test]
fn shape_basics() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "list",
verb: &[Verb::GET],
params: &[],
doc: None,
}];
let router = build_router(&[("api/users", R)]);
let spec = generate(&router, &opts(), |_| true);
assert_eq!(spec["openapi"], "3.1.0");
assert_eq!(spec["info"]["title"], "Test API");
assert_eq!(spec["info"]["version"], "1.0.0");
assert!(spec["paths"]["/api/users"]["get"].is_object());
assert_eq!(
spec["paths"]["/api/users"]["get"]["operationId"],
"api_users_list_get"
);
assert!(spec["paths"]["/api/users"]["get"]["responses"]["default"].is_object());
}
#[test]
fn mount_filter_excludes_non_matching_controllers() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "h",
verb: &[Verb::GET],
params: &[],
doc: None,
}];
let router = build_router(&[("api/users", R), ("internal/debug", R)]);
let spec = generate(&router, &opts(), |mount| mount.starts_with("api/"));
assert!(spec["paths"]["/api/users"].is_object());
assert!(
spec["paths"]["/internal/debug"].is_null(),
"filter excluded"
);
}
#[test]
fn default_verbs_route_emits_both_get_and_post() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "either",
verb: DEFAULT_VERBS, params: &[],
doc: None,
}];
let router = build_router(&[("api/things", R)]);
let spec = generate(&router, &opts(), |_| true);
assert!(spec["paths"]["/api/things"]["get"].is_object());
assert!(spec["paths"]["/api/things"]["post"].is_object());
}
#[test]
fn path_param_marked_required_and_query_default_marked_optional() {
static R: &[RouteDef] = &[RouteDef {
pattern: "{id}",
handler_id: "handler_0",
handler: "get",
verb: &[Verb::GET],
params: &[
ParamDef {
name: "id",
ty: ParamType::U64,
source: ParamSource::Path,
default: None,
},
ParamDef {
name: "expand",
ty: ParamType::Bool,
source: ParamSource::Query,
default: Some(ParamDefault::Bool(false)),
},
ParamDef {
name: "fields",
ty: ParamType::StringArray,
source: ParamSource::Query,
default: None,
},
],
doc: None,
}];
let router = build_router(&[("api/users", R)]);
let spec = generate(&router, &opts(), |_| true);
let params = spec["paths"]["/api/users/{id}"]["get"]["parameters"]
.as_array()
.expect("parameters array");
let id = ¶ms[0];
assert_eq!(id["name"], "id");
assert_eq!(id["in"], "path");
assert_eq!(id["required"], true);
assert_eq!(id["schema"]["type"], "integer");
assert_eq!(id["schema"]["format"], "int64");
assert_eq!(id["schema"]["minimum"], 0);
let expand = ¶ms[1];
assert_eq!(expand["name"], "expand");
assert_eq!(expand["in"], "query");
assert_eq!(expand["required"], false);
assert_eq!(expand["schema"]["type"], "boolean");
assert_eq!(expand["schema"]["default"], false);
let fields = ¶ms[2];
assert_eq!(fields["required"], false);
assert_eq!(fields["schema"]["type"], "array");
assert_eq!(fields["schema"]["items"]["type"], "string");
}
#[test]
fn rest_param_is_marked_with_extension() {
static R: &[RouteDef] = &[RouteDef {
pattern: "{drive}/{...path}",
handler_id: "handler_0",
handler: "read",
verb: &[Verb::GET],
params: &[
ParamDef {
name: "drive",
ty: ParamType::String,
source: ParamSource::Path,
default: None,
},
ParamDef {
name: "path",
ty: ParamType::String,
source: ParamSource::Path,
default: None,
},
],
doc: None,
}];
let router = build_router(&[("files", R)]);
let spec = generate(&router, &opts(), |_| true);
let op = &spec["paths"]["/files/{drive}/{path}"]["get"];
assert!(
op.is_object(),
"rest token stripped to /files/{{drive}}/{{path}}"
);
let params = op["parameters"].as_array().unwrap();
let drive = ¶ms[0];
let path = ¶ms[1];
assert!(drive["x-actus-rest-param"].is_null());
assert_eq!(path["x-actus-rest-param"], true);
assert!(
path["description"]
.as_str()
.unwrap_or("")
.contains("trailing path"),
);
}
#[test]
fn body_params_become_request_body() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "create",
verb: &[Verb::POST],
params: &[ParamDef {
name: "data",
ty: ParamType::Json,
source: ParamSource::Body,
default: None,
}],
doc: None,
}];
let router = build_router(&[("api/users", R)]);
let spec = generate(&router, &opts(), |_| true);
let body = &spec["paths"]["/api/users"]["post"]["requestBody"];
assert!(body.is_object());
assert_eq!(body["required"], true);
assert!(body["content"]["application/json"]["schema"].is_object());
static R2: &[RouteDef] = &[RouteDef {
pattern: "upload",
handler_id: "handler_0",
handler: "upload",
verb: &[Verb::POST],
params: &[ParamDef {
name: "body",
ty: ParamType::Bytes,
source: ParamSource::Body,
default: None,
}],
doc: None,
}];
let router = build_router(&[("api/files", R2)]);
let spec = generate(&router, &opts(), |_| true);
let body = &spec["paths"]["/api/files/upload"]["post"]["requestBody"];
assert!(body["content"]["application/octet-stream"]["schema"]["format"] == "binary");
}
#[test]
fn doc_becomes_summary_first_line_and_description_full() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "list",
verb: &[Verb::GET],
params: &[],
doc: Some(
" List items.\n\nThe long form: paginated, sorted by creation time.\nUse `?page=`.",
),
}];
let router = build_router(&[("api/items", R)]);
let spec = generate(&router, &opts(), |_| true);
let op = &spec["paths"]["/api/items"]["get"];
assert_eq!(op["summary"], "List items.");
let desc = op["description"].as_str().unwrap();
assert!(desc.starts_with("List items."));
assert!(desc.contains("paginated"));
}
#[test]
fn options_servers_and_description_round_trip() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "h",
verb: &[Verb::GET],
params: &[],
doc: None,
}];
let router = build_router(&[("api", R)]);
let options = Options::new("My API", "2.1.0")
.description("Awesome")
.server("https://api.example.com", Some("prod"))
.server("https://staging.api.example.com", None::<&str>);
let spec = generate(&router, &options, |_| true);
assert_eq!(spec["info"]["description"], "Awesome");
let servers = spec["servers"].as_array().unwrap();
assert_eq!(servers.len(), 2);
assert_eq!(servers[0]["url"], "https://api.example.com");
assert_eq!(servers[0]["description"], "prod");
assert!(servers[1]["description"].is_null());
}
#[test]
fn to_string_pretty_is_deterministic_json() {
static R: &[RouteDef] = &[RouteDef {
pattern: "",
handler_id: "handler_0",
handler: "h",
verb: &[Verb::GET],
params: &[],
doc: None,
}];
let router = build_router(&[("api", R)]);
let spec = generate(&router, &opts(), |_| true);
let pretty = to_string_pretty(&spec);
assert!(pretty.starts_with("{\n"));
assert!(pretty.contains("\"openapi\": \"3.1.0\""));
}
}