// The plugin protocol, spoken the way goctl speaks it. goctl runs the plugin as
// ` ` and writes it one JSON object on stdin — `{Api, ApiFilePath,
// Style, Dir}`: the parsed spec, the file it came from, the `--style` template and
// the output directory. A plugin needs all four; the spec alone cannot tell it
// where to write or how to spell what it writes. The reply is a JSON list of
// `{path, content}` files on stdout, which mctl writes out. The wire shapes and
// their (de)serialisers live here, in pure all-backend code; only the subprocess
// spawn (in the `mctl` binary) is native.
///|
/// One file a plugin asks mctl to write: a destination `path` (relative to the
/// output directory) and its full `content`.
pub(all) struct GenFile {
path : String
content : String
}
///|
/// What a plugin is handed on stdin (← goctl's `plugin.Plugin`): the parsed spec
/// under `api`, the `.api` file it was read from, the `--style` naming template,
/// and the directory the generated tree goes under.
pub(all) struct Plugin {
api : Spec
api_file_path : String
style : String
dir : String
}
///|
/// Split a plugin invocation into the program to run and the arguments to run it
/// with, the way goctl reads its `-plugin` value: the first whitespace-separated
/// word is the executable, the rest are its argv. A quoted word keeps its spaces.
pub fn plugin_argv(invocation : String) -> (String, Array[String]) {
let toks = tokens(invocation)
if toks.length() == 0 {
return ("", [])
}
let args : Array[String] = []
for i = 1; i < toks.length(); i = i + 1 {
args.push(toks[i])
}
(toks[0], args)
}
///|
/// A `Group` as JSON: the `@server` annotations under goctl's own key names, with
/// the group name under `name`.
fn group_to_json(g : Group) -> Json {
let extra : Array[Json] = []
for kv in g.extra {
let m : Map[String, Json] = Map([
("name", kv.0.to_json()),
("value", kv.1.to_json()),
])
extra.push(m.to_json())
}
let m : Map[String, Json] = Map([
("name", g.name.to_json()),
("prefix", g.prefix.to_json()),
("jwt", g.jwt.to_json()),
("middleware", g.middleware.to_json()),
("maxBytes", g.max_bytes.to_double().to_json()),
("timeout", g.timeout.to_json()),
("signature", g.signature.to_json()),
("extra", extra.to_json()),
])
m.to_json()
}
///|
/// Serialise a `Spec` into the plugin request JSON. The shape mirrors the `Spec`
/// itself — `service`, `routes` (`verb`/`path`/`handler`/`summary`, plus the
/// `group` the route was declared under), `types` (`name` plus `fields` of
/// `name`/`type`) and `groups` — so a plugin in any language parses it with an
/// ordinary JSON reader. `type_` is written as `type`, the name a caller expects.
pub fn spec_to_json(spec : Spec) -> Json {
let routes : Array[Json] = []
for r in spec.routes {
let m : Map[String, Json] = Map([
("verb", r.verb.to_json()),
("path", r.path.to_json()),
("handler", r.handler.to_json()),
("summary", r.summary.to_json()),
("req", r.req.to_json()),
("resp", r.resp.to_json()),
])
match r.group {
Some(g) => m["group"] = group_to_json(g)
None => ()
}
routes.push(m.to_json())
}
let types : Array[Json] = []
for t in spec.types {
let fields : Array[Json] = []
for f in t.fields {
let fm : Map[String, Json] = Map([
("name", f.name.to_json()),
("type", f.type_.to_json()),
("tag", f.tag.to_json()),
])
fields.push(fm.to_json())
}
let tm : Map[String, Json] = Map([
("name", t.name.to_json()),
("fields", fields.to_json()),
("embeds", t.embeds.to_json()),
])
types.push(tm.to_json())
}
let info : Array[Json] = []
for kv in spec.info {
let m : Map[String, Json] = Map([
("name", kv.0.to_json()),
("value", kv.1.to_json()),
])
info.push(m.to_json())
}
let groups : Array[Json] = []
for g in spec.groups {
groups.push(group_to_json(g))
}
let doc : Map[String, Json] = Map([
("service", spec.service.to_json()),
("routes", routes.to_json()),
("types", types.to_json()),
("info", info.to_json()),
("groups", groups.to_json()),
])
doc.to_json()
}
///|
/// The plugin request as a two-space-indented JSON string, ready to write to the
/// plugin's stdin: goctl's four keys — the spec under `Api`, plus the file it was
/// read from, the `--style` template and the output directory, which is everything
/// a plugin needs to decide where its output goes and what to call it.
pub fn plugin_request(
spec : Spec,
api_file_path? : String = "",
style? : String = "gozero",
dir? : String = "",
) -> String {
let doc : Map[String, Json] = Map([
("Api", spec_to_json(spec)),
("ApiFilePath", api_file_path.to_json()),
("Style", style.to_json()),
("Dir", dir.to_json()),
])
doc.to_json().stringify(indent=2)
}
///|
/// Read the request a plugin was handed on stdin. Missing members degrade to empty
/// rather than raising, so a hand-written request still parses.
pub fn plugin_from_json(j : Json) -> Plugin {
let m = match j {
Object(m) => m
_ => Map([])
}
{
api: spec_from_json(j),
api_file_path: json_str(m, "ApiFilePath"),
style: match json_str(m, "Style") {
"" => "gozero"
s => s
},
dir: json_str(m, "Dir"),
}
}
///|
/// A `Group` read back from the request JSON, or `None` when the member is absent
/// or is not an object.
fn group_from_json(j : Json?) -> Group? {
let m = match j {
Some(Object(m)) => m
_ => return None
}
let middleware : Array[String] = []
match m.get("middleware") {
Some(Array(a)) =>
for it in a {
match it {
String(s) => middleware.push(s)
_ => ()
}
}
_ => ()
}
let extra : Array[(String, String)] = []
match m.get("extra") {
Some(Array(a)) =>
for it in a {
match it {
Object(em) =>
extra.push((json_str(em, "name"), json_str(em, "value")))
_ => ()
}
}
_ => ()
}
let max_bytes = match m.get("maxBytes") {
Some(Number(n, ..)) => n.to_int64()
Some(String(s)) =>
match to_i64(s) {
Some(v) => v
None => 0L
}
_ => 0L
}
let signature = match m.get("signature") {
Some(True) => true
_ => false
}
Some({
name: json_str(m, "name"),
prefix: json_str(m, "prefix"),
jwt: json_str(m, "jwt"),
middleware,
max_bytes,
timeout: json_str(m, "timeout"),
signature,
extra,
})
}
///|
/// Reconstruct a `Spec` from the request JSON — the reader a plugin written in
/// MoonBit uses on its stdin. The whole request (`{Api, ApiFilePath, Style, Dir}`)
/// and a bare spec are both accepted, so a plugin that wants nothing but the routes
/// need not unwrap the envelope. Missing or mistyped members degrade to empty
/// rather than raising, so a partial request still parses.
pub fn spec_from_json(j : Json) -> Spec {
let j = match j {
Object(m) =>
match m.get("Api") {
Some(api) => api
None => j
}
_ => j
}
let service = match j {
Object(m) =>
match m.get("service") {
Some(String(s)) => s
_ => "app"
}
_ => "app"
}
let routes : Array[Route] = []
let types : Array[TypeDef] = []
let info : Array[(String, String)] = []
let groups : Array[Group] = []
match j {
Object(m) => {
match m.get("groups") {
Some(Array(arr)) =>
for item in arr {
match group_from_json(Some(item)) {
Some(g) => groups.push(g)
None => ()
}
}
_ => ()
}
match m.get("routes") {
Some(Array(arr)) =>
for item in arr {
match item {
Object(rm) =>
routes.push({
verb: json_str(rm, "verb"),
path: json_str(rm, "path"),
handler: json_str(rm, "handler"),
summary: json_str(rm, "summary"),
req: json_str(rm, "req"),
resp: json_str(rm, "resp"),
group: group_from_json(rm.get("group")),
})
_ => ()
}
}
_ => ()
}
match m.get("types") {
Some(Array(arr)) =>
for item in arr {
match item {
Object(tm) => {
let fields : Array[Field] = []
match tm.get("fields") {
Some(Array(fa)) =>
for f in fa {
match f {
Object(fm) =>
fields.push({
name: json_str(fm, "name"),
type_: json_str(fm, "type"),
tag: json_str(fm, "tag"),
})
_ => ()
}
}
_ => ()
}
let embeds : Array[String] = []
match tm.get("embeds") {
Some(Array(ea)) =>
for e in ea {
match e {
String(s) => embeds.push(s)
_ => ()
}
}
_ => ()
}
types.push({ name: json_str(tm, "name"), fields, embeds, })
}
_ => ()
}
}
_ => ()
}
match m.get("info") {
Some(Array(arr)) =>
for item in arr {
match item {
Object(im) =>
info.push((json_str(im, "name"), json_str(im, "value")))
_ => ()
}
}
_ => ()
}
}
_ => ()
}
// A plugin is handed a spec whose imports have already been merged in, so the
// request carries no import list to read back.
{ service, routes, types, info, groups, imports: [], }
}
///|
/// Read a string member of a JSON object, or `""` if absent or non-string.
fn json_str(m : Map[String, Json], key : String) -> String {
match m.get(key) {
Some(String(s)) => s
_ => ""
}
}
///|
/// Parse a plugin's stdout into the files to write. Two shapes are accepted: a bare
/// array `[{path, content}, …]`, or an object `{"files": [ … ]}` (goctl-style
/// envelope). An element missing `path` or `content` is skipped. The list preserves
/// the plugin's order.
pub fn parse_gen_files(j : Json) -> Array[GenFile] {
let out : Array[GenFile] = []
let arr = match j {
Array(a) => a
Object(m) =>
match m.get("files") {
Some(Array(a)) => a
_ => []
}
_ => []
}
for item in arr {
match item {
Object(fm) => {
let path = json_str(fm, "path")
let content = json_str(fm, "content")
if path != "" {
out.push({ path, content, })
}
}
_ => ()
}
}
out
}
///|
/// Serialise a list of generated files into the plugin-reply JSON — the writer a
/// MoonBit plugin uses on its stdout. Emits the `{"files": [...]}` envelope.
pub fn gen_files_to_json(files : Array[GenFile]) -> Json {
let arr : Array[Json] = []
for f in files {
let m : Map[String, Json] = Map([
("path", f.path.to_json()),
("content", f.content.to_json()),
])
arr.push(m.to_json())
}
let doc : Map[String, Json] = Map([("files", arr.to_json())])
doc.to_json()
}