///|
pub suberror OpenApiError {
InvalidPath(String)
InvalidSchema(String)
DuplicateOperation(String)
}
///|
priv struct OpenApiRoute {
url : String
title : String
version : String
components : @json.Json?
}
///|
/// Export the registered routes as an OpenAPI 3.2 document without invoking
/// handlers. Schemas may be objects or booleans; {} remains unconstrained.
/// Query constraints are preserved through an in: querystring parameter.
/// Aggregate path/header constraints are retained in specification extensions.
/// Status codes and media types are unknown to schema(), so responses use
/// default and content uses */*. Pass components when schemas use local $refs.
pub fn Router::generate_openapi(
self : Router,
title~ : String,
version~ : String,
components? : @json.Json,
) -> @json.Json raise OpenApiError {
let paths : Map[String, @json.Json] = Map([])
let templates : Map[String, (String, Array[String])] = Map([])
for route in self.routes {
for
schema in [
route.params_schema,
route.query_schema,
route.headers_schema,
route.body_schema,
route.response_schema,
] {
guard schema is (Object(_) | True | False) else {
raise InvalidSchema(route.path)
}
}
let segments : Array[String] = []
let shape : Array[String] = []
let names : Array[String] = []
let catch_all : Array[Bool] = []
for segment in route.path.split("/").filter(s => !s.is_empty()) {
let capture = if segment is [':', .. name] {
Some((name.to_owned(), false))
} else if segment is ['*', .. name] {
Some((if name.is_empty() { "rest" } else { name.to_owned() }, true))
} else {
None
}
if capture is Some((name, rest)) {
guard !name.is_empty() &&
!name.contains("{") &&
!name.contains("}") &&
!names.contains(name) else {
raise InvalidPath(route.path)
}
names.push(name)
catch_all.push(rest)
segments.push("{\{name}}")
shape.push("{}")
if rest {
break
}
} else {
guard !segment.contains("{") && !segment.contains("}") else {
raise InvalidPath(route.path)
}
segments.push(segment.to_owned())
shape.push(segment.to_owned())
}
}
let shape = "/" + shape.join("/")
// OpenAPI forbids separate paths that differ only in capture names.
// Reuse the first spelling while keeping each operation's input schema.
let (path, parameter_names) = if templates.get(shape) is Some(template) {
template
} else {
let path = "/" + segments.join("/")
templates[shape] = (path, names)
(path, names)
}
let parameters : Array[@json.Json] = []
for index, name in names {
let schema = if route.params_schema is Object(fields) &&
fields.get("properties") is Some(Object(properties)) {
properties.get(name).unwrap_or(@json.Json::empty_object())
} else if route.params_schema is Object(fields) &&
fields.get("prefixItems") is Some(Array(items)) {
items.get(index).unwrap_or(@json.Json::empty_object())
} else if names.length() == 1 && !(route.params_schema is False) {
route.params_schema
} else {
@json.Json::empty_object()
}
let parameter : Map[String, @json.Json] = {
"name": @json.Json::string(parameter_names[index]),
"in": "path",
"required": true,
"schema": schema,
}
if catch_all[index] {
// Standard path templates cannot describe captures containing raw '/'.
parameter["x-moonback-catch-all"] = true
}
parameters.push(@json.Json::object(parameter))
}
if !(route.query_schema is False) {
parameters.push({
"name": "query",
"in": "querystring",
"content": {
"application/x-www-form-urlencoded": { "schema": route.query_schema },
},
})
}
if route.headers_schema is Object(fields) &&
fields.get("properties") is Some(Object(properties)) {
let required = if fields.get("required") is Some(Array(names)) {
names
} else {
[]
}
let seen : Map[String, Unit] = Map([])
for name, schema in properties {
let normalized = name.to_lower()
guard !seen.contains(normalized) else {
raise InvalidSchema("Duplicate header: \{name}")
}
seen[normalized] = ()
parameters.push({
"name": name,
"in": "header",
"required": required.contains(@json.Json::string(name)),
"schema": schema,
})
}
}
let response : Map[String, @json.Json] = { "description": "Response" }
if route.meth != Head && !(route.response_schema is False) {
response["content"] = { "*/*": { "schema": route.response_schema } }
}
let operation : Map[String, @json.Json] = {
"responses": { "default": @json.Json::object(response) },
}
if !parameters.is_empty() {
operation["parameters"] = @json.Json::array(parameters)
}
if !(route.params_schema is False) {
operation["x-path-schema"] = route.params_schema
}
if !(route.headers_schema is False) {
operation["x-headers-schema"] = route.headers_schema
}
if !(route.body_schema is False) {
operation["requestBody"] = {
"required": true,
"content": { "*/*": { "schema": route.body_schema } },
}
}
let item = if paths.get(path) is Some(Object(item)) {
item
} else {
Map([])
}
let verb = match route.meth {
Get => "get"
Head => "head"
Post => "post"
Put => "put"
Delete => "delete"
Options => "options"
Trace => "trace"
Patch => "patch"
Connect => "CONNECT"
}
if route.meth == Connect {
let additional = if item.get("additionalOperations")
is Some(Object(items)) {
items
} else {
Map([])
}
guard !additional.contains(verb) else {
raise DuplicateOperation("\{verb} \{path}")
}
additional[verb] = @json.Json::object(operation)
item["additionalOperations"] = @json.Json::object(additional)
} else {
guard !item.contains(verb) else {
raise DuplicateOperation("\{verb} \{path}")
}
item[verb] = @json.Json::object(operation)
}
paths[path] = @json.Json::object(item)
}
let document : Map[String, @json.Json] = {
"openapi": "3.2.1",
"info": { "title": title, "version": version },
"paths": @json.Json::object(paths),
}
if components is Some(components) {
guard components is Object(_) else {
raise InvalidSchema("components must be an object")
}
document["components"] = components
}
@json.Json::object(document)
}
///|
/// Configure an OpenAPI endpoint. MoonBack initialization generates the
/// document and registers a GET route serving it as application/json.
pub fn Router::openapi(
self : Router,
url : String,
title~ : String,
version~ : String,
components? : @json.Json,
) -> Unit {
self.openapi_routes.push({ url, title, version, components, })
}