///|
/// Target OpenAPI / Swagger document version. mctl emits every mainstream version
/// from one `.api` spec — the same shape moonapi's runtime emitter takes, so a
/// generated service and a hand-built moonapi app document the same way.
pub(all) enum DocVersion {
Swagger20
OpenApi30
OpenApi31
} derive(Eq)
///|
/// (root document key, version string) for a doc version.
fn doc_version_field(v : DocVersion) -> (String, String) {
match v {
Swagger20 => ("swagger", "2.0")
OpenApi30 => ("openapi", "3.0.3")
OpenApi31 => ("openapi", "3.1.0")
}
}
///|
/// Split a URL path on `/`, dropping empty segments (so `/users/:id` → `["users",
/// ":id"]`).
fn path_segments(path : String) -> Array[String] {
let out : Array[String] = []
let n = path.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || path[i] == '/' {
if i > start {
out.push(path[start:i].to_owned())
}
start = i + 1
}
}
out
}
///|
/// Rewrite an `.api` route path into OpenAPI form: a `:name` segment becomes
/// `{name}`, the notation both Swagger 2.0 and OpenAPI 3.x expect.
fn openapi_path(path : String) -> String {
let mut out = "/"
let segs = path_segments(path)
for i = 0; i < segs.length(); i = i + 1 {
if i > 0 {
out = out + "/"
}
let seg = segs[i]
if seg.length() > 0 && seg[0] == ':' {
out = out + "{" + seg[1:].to_owned() + "}"
} else {
out = out + seg
}
}
out
}
///|
/// The top-level `", "` in a `Map[K, V]` type spelling, tracking `[` `]` depth so a
/// nested `Map[Int, Array[String]]` splits on its own comma, not an inner one.
/// Returns the index of the comma, or `-1` if there is none at depth 0.
fn top_comma(s : String) -> Int {
let n = s.length()
let mut depth = 0
for i = 0; i < n; i = i + 1 {
let c = s[i]
if c == '[' {
depth = depth + 1
} else if c == ']' {
depth = depth - 1
} else if c == ',' && depth == 0 {
return i
}
}
-1
}
///|
/// The `$ref` prefix for a component schema: `#/definitions/` in Swagger 2.0,
/// `#/components/schemas/` in OpenAPI 3.x.
fn ref_prefix(v : DocVersion) -> String {
match v {
Swagger20 => "#/definitions/"
_ => "#/components/schemas/"
}
}
///|
/// The JSON Schema for a MoonBit-spelled field type. Scalars carry an OpenAPI
/// `format` where one applies (`int64`, `double`, …); `Array[T]` becomes an array
/// with typed `items`; `Map[K, V]` an object with typed `additionalProperties`; an
/// unrecognised name is treated as another `type` block and emitted as a `$ref`.
fn type_schema(type_ : String, v : DocVersion) -> Json {
let t = trim(type_)
if starts_with(t, "Array[") && t[t.length() - 1] == ']' {
let inner = t[6:t.length() - 1].to_owned()
let schema : Map[String, Json] = Map([
("type", "array".to_json()),
("items", type_schema(inner, v)),
])
return schema.to_json()
}
if starts_with(t, "Map[") && t[t.length() - 1] == ']' {
let body = t[4:t.length() - 1].to_owned()
let comma = top_comma(body)
let value = if comma >= 0 {
trim(body[comma + 1:].to_owned())
} else {
"String"
}
let schema : Map[String, Json] = Map([
("type", "object".to_json()),
("additionalProperties", type_schema(value, v)),
])
return schema.to_json()
}
match t {
"String" =>
(Map([("type", "string".to_json())]) : Map[String, Json]).to_json()
"Bool" =>
(Map([("type", "boolean".to_json())]) : Map[String, Json]).to_json()
"Int" | "UInt" =>
(
Map([("type", "integer".to_json()), ("format", "int32".to_json())]) :
Map[String, Json]).to_json()
"Int64" | "UInt64" =>
(
Map([("type", "integer".to_json()), ("format", "int64".to_json())]) :
Map[String, Json]).to_json()
"Double" =>
(
Map([("type", "number".to_json()), ("format", "double".to_json())]) :
Map[String, Json]).to_json()
"Float" =>
(
Map([("type", "number".to_json()), ("format", "float".to_json())]) :
Map[String, Json]).to_json()
"Byte" | "Bytes" =>
(Map([("type", "string".to_json())]) : Map[String, Json]).to_json()
_ =>
(Map([("$ref", (ref_prefix(v) + t).to_json())]) : Map[String, Json]).to_json()
}
}
///|
/// A `:name` path parameter as an OpenAPI parameter object. Swagger 2.0 puts the
/// type inline; OpenAPI 3.x nests it under `schema` — the one structural
/// difference between the two.
fn path_param(name : String, v : DocVersion) -> Json {
let param : Map[String, Json] = Map([
("name", name.to_json()),
("in", "path".to_json()),
("required", true.to_json()),
])
match v {
Swagger20 => param["type"] = "string".to_json()
_ =>
param["schema"] = (Map([("type", "string".to_json())]) : Map[String, Json]).to_json()
}
param.to_json()
}
///|
/// The path parameters of a route, in order, as OpenAPI parameter objects.
fn route_params(path : String, v : DocVersion) -> Array[Json] {
let out : Array[Json] = []
for seg in path_segments(path) {
if seg.length() > 0 && seg[0] == ':' {
out.push(path_param(seg[1:].to_owned(), v))
}
}
out
}
///|
/// Build the OpenAPI / Swagger document for `spec` as a `Json` value. Routes fold
/// into `paths` → HTTP method → operation (with `operationId`, any `:name` path
/// parameters, and a 200 response); every `type` block becomes a component schema
/// (`definitions` in 2.0, `components/schemas` in 3.x). `version` selects the
/// document dialect; `title`/`api_version` fill the `info` block.
pub fn openapi_document(
spec : Spec,
version? : DocVersion = OpenApi31,
title? : String = "",
api_version? : String = "0.1.0",
) -> Json {
let doc_title = if title == "" { spec.service } else { title }
let paths : Map[String, Json] = Map([])
for r in spec.routes {
let op : Map[String, Json] = Map([("operationId", r.handler.to_json())])
if r.summary != "" {
op["summary"] = r.summary.to_json()
}
let params = route_params(r.path, version)
if params.length() > 0 {
op["parameters"] = params.to_json()
}
let ok : Map[String, Json] = Map([("description", "OK".to_json())])
op["responses"] = (Map([("200", ok.to_json())]) : Map[String, Json]).to_json()
let key = openapi_path(r.path)
let item = match paths.get(key) {
Some(Object(m)) => m
_ => Map([])
}
item[r.verb.to_lower()] = op.to_json()
paths[key] = item.to_json()
}
let defs : Map[String, Json] = Map([])
for t in spec.types {
let props : Map[String, Json] = Map([])
for f in t.fields {
props[f.name] = type_schema(f.type_, version)
}
let schema : Map[String, Json] = Map([
("type", "object".to_json()),
("properties", props.to_json()),
])
defs[t.name] = schema.to_json()
}
let info : Map[String, Json] = Map([
("title", doc_title.to_json()),
("version", api_version.to_json()),
])
let (root_key, version_str) = doc_version_field(version)
let doc : Map[String, Json] = Map([
(root_key, version_str.to_json()),
("info", info.to_json()),
("paths", paths.to_json()),
])
if not_empty(defs) {
match version {
Swagger20 => doc["definitions"] = defs.to_json()
_ =>
doc["components"] = (
Map([("schemas", defs.to_json())]) : Map[String, Json]).to_json()
}
}
doc.to_json()
}
///|
/// Whether a `Map` has any entries. (`Map::is_empty` needs `Eq` on the value; a
/// direct length check does not.)
fn not_empty(m : Map[String, Json]) -> Bool {
m.length() > 0
}
///|
/// Generate an OpenAPI / Swagger document from `spec`, stringified with two-space
/// indentation. `version` picks the dialect (Swagger 2.0 / OpenAPI 3.0 / 3.1).
pub fn generate_doc(
spec : Spec,
version? : DocVersion = OpenApi31,
title? : String = "",
api_version? : String = "0.1.0",
) -> String {
openapi_document(spec, version~, title~, api_version~).stringify(indent=2)
}
///|
/// A self-contained Swagger UI page for the document served at `spec_url`. The
/// same stub moonapi ships, so a generated service and a live app render alike.
pub fn swagger_ui_stub(
spec_url? : String = "/openapi.json",
title? : String = "API Docs",
) -> String {
"\n\n\n\n" +
title +
"\n\n\n\n\n\n\n\n\n"
}