///|
/// 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)
///|
pub extend DocVersion with Eq::{equal, not_equal}
///|
/// (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")
}
}
///|
/// 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 = @text.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) -> Int32 {
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 = @text.trim(type_)
if @text.starts_with(t, "Array[") && t[t.length() - 1] == ']' {
let inner = t[6:t.length() - 1].to_owned()
let schema : Map[String, Json] = Map([
("type", Json("array")),
("items", type_schema(inner, v)),
])
return Json(schema)
}
if @text.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 {
@text.trim(body[comma + 1:].to_owned())
} else {
"String"
}
let schema : Map[String, Json] = Map([
("type", Json("object")),
("additionalProperties", type_schema(value, v)),
])
return Json(schema)
}
match t {
"String" => Json((Map([("type", Json("string"))]) : Map[String, Json]))
"Bool" => Json((Map([("type", Json("boolean"))]) : Map[String, Json]))
"Int" | "UInt" =>
Json(
(
Map([("type", Json("integer")), ("format", Json("int32"))]) :
Map[String, Json]),
)
"Int64" | "UInt64" =>
Json(
(
Map([("type", Json("integer")), ("format", Json("int64"))]) :
Map[String, Json]),
)
"Double" =>
Json(
(
Map([("type", Json("number")), ("format", Json("double"))]) :
Map[String, Json]),
)
"Float" =>
Json(
(
Map([("type", Json("number")), ("format", Json("float"))]) :
Map[String, Json]),
)
"Byte" | "Bytes" =>
Json((Map([("type", Json("string"))]) : Map[String, Json]))
_ => Json((Map([("$ref", Json(ref_prefix(v) + t))]) : Map[String, Json]))
}
}
///|
/// 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 param_object(
name : String,
in_ : String,
required : Bool,
schema : Json,
v : DocVersion,
) -> Json {
let param : Map[String, Json] = Map([
("name", Json(name)),
("in", Json(in_)),
("required", Json(required)),
])
match v {
Swagger20 =>
match schema {
Object(m) =>
for kv in m {
param[kv.0] = kv.1
}
_ => param["type"] = Json("string")
}
_ => param["schema"] = schema
}
Json(param)
}
///|
/// A `:name` path parameter as an OpenAPI parameter object.
fn path_param(name : String, v : DocVersion) -> Json {
param_object(
name,
"path",
true,
Json((Map([("type", Json("string"))]) : Map[String, Json])),
v,
)
}
///|
/// Where a non-body field appears in an OpenAPI operation.
fn bind_in(b : @api.Bind) -> String {
match b {
Query => "query"
Path => "path"
Header => "header"
Body => "body"
}
}
///|
/// The parameters a request type declares through its tags: a `form:` field is a
/// query parameter, a `path:` field a path parameter under the name its tag gives,
/// a `header:` field a header. A path parameter is always required; the others
/// follow the field's `optional`.
fn type_params(
t : @api.TypeDef,
types : Array[@api.TypeDef],
v : DocVersion,
) -> Array[Json] {
let out : Array[Json] = []
for f in @api.flat_fields(t, types) {
let b = f.bind()
if b == Body {
continue
}
out.push(
param_object(
f.json_name(),
bind_in(b),
b == Path || f.optional() == false,
field_schema(f, v),
v,
),
)
}
out
}
///|
/// The parameters of a route: the ones its request type's tags declare, then a path
/// parameter for every `:name` segment the type did not already name.
fn route_params(
r : @api.Route,
types : Array[@api.TypeDef],
v : DocVersion,
) -> Array[Json] {
let out : Array[Json] = []
let named : Array[String] = []
match @api.find_type(types, r.req) {
Some(t) => {
for f in @api.flat_fields(t, types) {
if f.bind() != Body {
named.push(f.json_name())
}
}
for p in type_params(t, types, v) {
out.push(p)
}
}
None => ()
}
for seg in @text.path_segments(r.path) {
if seg.length() > 0 && seg[0] == ':' {
let name = seg[1:].to_owned()
if named.contains(name) == false {
out.push(path_param(name, v))
}
}
}
out
}
///|
/// The JSON literal of a `default=` / `options=` value, typed by the field it
/// constrains: a number for a numeric field, a boolean for a `Bool`, a string
/// otherwise.
fn literal_json(type_ : String, raw : String) -> Json {
match type_ {
"Bool" => if raw == "true" { Json(true) } else { Json(false) }
"Int" | "Int64" | "UInt" | "UInt64" | "Double" | "Float" =>
@gen.number_json(raw)
_ => Json(raw)
}
}
///|
/// The schema of one field: its type, plus whatever its tag constrained — the
/// `default=`, the `options=` as an `enum`, and the `range=` as bounds. 3.1 spells
/// an exclusive bound as the number itself, 2.0 and 3.0 as a flag beside an
/// inclusive one.
fn field_schema(f : @api.Field, v : DocVersion) -> Json {
let m = match type_schema(f.type_, v) {
Object(m) => m
other => return other
}
// A `$ref` may not be qualified by siblings, so a nested message carries nothing.
if m.contains("$ref") {
return Json(m)
}
match f.default_() {
Some(d) => m["default"] = literal_json(f.type_, d)
None => ()
}
let allowed = f.options()
if allowed.length() > 0 {
let vals : Array[Json] = []
for one in allowed {
vals.push(literal_json(f.type_, one))
}
m["enum"] = Json(vals)
}
match f.range() {
Some(r) => {
if r.lo != "" {
let lo = literal_json(f.type_, r.lo)
if r.lo_inc {
m["minimum"] = lo
} else {
match v {
OpenApi31 => m["exclusiveMinimum"] = lo
_ => {
m["minimum"] = lo
m["exclusiveMinimum"] = Json(true)
}
}
}
}
if r.hi != "" {
let hi = literal_json(f.type_, r.hi)
if r.hi_inc {
m["maximum"] = hi
} else {
match v {
OpenApi31 => m["exclusiveMaximum"] = hi
_ => {
m["maximum"] = hi
m["exclusiveMaximum"] = Json(true)
}
}
}
}
}
None => ()
}
Json(m)
}
///|
/// Whether a request type has anything left for the body once its `path:`, `form:`
/// and `header:` fields have gone to the parameters. A type the spec never declared
/// counts as a body, since nothing here can say otherwise.
fn has_body(t : @api.TypeDef?, types : Array[@api.TypeDef]) -> Bool {
match t {
None => true
Some(td) => {
for f in @api.flat_fields(td, types) {
if f.bind() == Body {
return true
}
}
false
}
}
}
///|
/// The component schema of a `type` block: an object of its body-bound properties,
/// keyed by wire name, listing the ones no tag marked `optional` as `required`. A
/// block that inlined others is their `allOf` — the shape an embedded Go struct
/// serialises to. `path:`, `form:` and `header:` fields are not in the body, so they
/// are documented as parameters instead of properties.
fn component_schema(t : @api.TypeDef, v : DocVersion) -> Json {
let props : Map[String, Json] = Map([])
let required : Array[Json] = []
for f in t.fields {
if f.bind() != Body {
continue
}
props[f.json_name()] = field_schema(f, v)
if f.optional() == false {
required.push(Json(f.json_name()))
}
}
let own : Map[String, Json] = Map([
("type", Json("object")),
("properties", Json(props)),
])
if required.length() > 0 {
own["required"] = Json(required)
}
if t.embeds.length() == 0 {
return Json(own)
}
let all : Array[Json] = []
for name in t.embeds {
all.push(
Json((Map([("$ref", Json(ref_prefix(v) + name))]) : Map[String, Json])),
)
}
if props.length() > 0 {
all.push(Json(own))
}
Json((Map([("allOf", Json(all))]) : Map[String, Json]))
}
///|
/// The value of an `info(...)` member (← goctl's `info` block), or `None` if the
/// spec declared no such key.
fn info_get(spec : @api.Spec, key : String) -> String? {
for kv in spec.info {
if kv.0 == key {
return Some(kv.1)
}
}
None
}
///|
/// 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 : @api.Spec,
version~ : DocVersion = OpenApi31,
title~ : String = "",
api_version~ : String = "0.1.0",
) -> Json {
let doc_title = if title != "" {
title
} else {
match info_get(spec, "title") {
Some(t) => t
None => spec.service
}
}
let paths : Map[String, Json] = Map([])
for r in spec.routes {
let op : Map[String, Json] = Map([("operationId", Json(r.handler))])
if r.summary != "" {
op["summary"] = Json(r.summary)
}
// A route's `@server` group is its OpenAPI tag — the same grouping goctl gives
// handlers, and what a doc UI sections the operations by.
match r.group {
Some(g) => if g.name != "" { op["tags"] = Json([Json(g.name)]) }
None => ()
}
let params = route_params(r, spec.types, version)
// A modern route's `(Req)` becomes a typed request body (a 2.0 `in: body`
// parameter / a 3.x `requestBody`); its `returns (Resp)` types the 200 response.
// A request type whose fields all bind somewhere else — a query-only filter —
// has no body to document.
if r.req != "" && has_body(@api.find_type(spec.types, r.req), spec.types) {
let schema : Map[String, Json] = Map([
("$ref", Json(ref_prefix(version) + r.req)),
])
match version {
Swagger20 =>
params.push(
Json(
(
Map([
("in", Json("body")),
("name", Json("body")),
("required", Json(true)),
("schema", Json(schema)),
]) : Map[String, Json]),
),
)
_ => {
let media : Map[String, Json] = Map([("schema", Json(schema))])
op["requestBody"] = Json(
(
Map([
("required", Json(true)),
(
"content",
Json(
(
Map([("application/json", Json(media))]) :
Map[String, Json]),
),
),
]) : Map[String, Json]),
)
}
}
}
if params.length() > 0 {
op["parameters"] = Json(params)
}
let ok : Map[String, Json] = Map([("description", Json("OK"))])
if r.resp != "" {
let schema : Map[String, Json] = Map([
("$ref", Json(ref_prefix(version) + r.resp)),
])
match version {
Swagger20 => ok["schema"] = Json(schema)
_ => {
let media : Map[String, Json] = Map([("schema", Json(schema))])
ok["content"] = Json(
(Map([("application/json", Json(media))]) : Map[String, Json]),
)
}
}
}
op["responses"] = Json((Map([("200", Json(ok))]) : Map[String, Json]))
let key = openapi_path(r.path)
let item = match paths.get(key) {
Some(Object(m)) => m
_ => Map([])
}
item[r.verb.to_lower()] = Json(op)
paths[key] = Json(item)
}
let defs : Map[String, Json] = Map([])
for t in spec.types {
defs[t.name] = component_schema(t, version)
}
let doc_version = match info_get(spec, "version") {
Some(v) => v
None => api_version
}
let info : Map[String, Json] = Map([
("title", Json(doc_title)),
("version", Json(doc_version)),
])
match info_get(spec, "desc") {
Some(d) => info["description"] = Json(d)
None =>
match info_get(spec, "description") {
Some(d) => info["description"] = Json(d)
None => ()
}
}
let (root_key, version_str) = doc_version_field(version)
let doc : Map[String, Json] = Map([
(root_key, Json(version_str)),
("info", Json(info)),
("paths", Json(paths)),
])
let tags : Array[Json] = []
for g in spec.groups {
if g.name != "" {
tags.push(Json((Map([("name", Json(g.name))]) : Map[String, Json])))
}
}
if tags.length() > 0 {
doc["tags"] = Json(tags)
}
if not_empty(defs) {
match version {
Swagger20 => doc["definitions"] = Json(defs)
_ =>
doc["components"] = Json(
(Map([("schemas", Json(defs))]) : Map[String, Json]),
)
}
}
Json(doc)
}
///|
/// 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 : @api.Spec,
version~ : DocVersion = OpenApi31,
title~ : String = "",
api_version~ : String = "0.1.0",
) -> String {
@json.dumps(openapi_document(spec, version~, title~, api_version~), 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"
}