// The TypeScript emitter (goctl's `api ts`). One interface per `type` block, one
// `fromJson`/`toJson` pair per interface, one async method per route. The transport
// is a `Fetch` port that defaults to the global `fetch`, so the file needs no
// `npm install` and type-checks with `tsc --strict`.
///|
/// The TypeScript type a MoonBit one maps to: numbers collapse to `number`, a
/// `Map` becomes a `Record`, and a `type` block keeps its name.
fn ts_ty(ty : Ty) -> String {
match ty {
Opt(t) => {
let inner = ts_ty(t)
let wrapped = if @text.index_of_str(inner, "|") >= 0 {
"(" + inner + ")"
} else {
inner
}
wrapped + " | null"
}
List(t) => {
let e = ts_ty(t)
if @text.index_of_str(e, "|") >= 0 || @text.index_of_str(e, "&") >= 0 {
"(" + e + ")[]"
} else {
e + "[]"
}
}
Dict(k, v) =>
match k {
Named(n) =>
"Record<" +
(if n == "String" { "string" } else { "number" }) +
", " +
ts_ty(v) +
">"
_ => "Record"
}
Named(n) =>
match n {
"String" => "string"
"Bool" => "boolean"
"Int"
| "Int32"
| "UInt"
| "UInt32"
| "Byte"
| "Int64"
| "UInt64"
| "Float"
| "Double" => "number"
"Bytes" => "string"
"Json" => "unknown"
"Unit" => "void"
_ => pascal(n)
}
}
}
///|
/// Whether `s` can stand as a property name without quotes.
fn ts_ident(s : String) -> Bool {
if s.length() == 0 {
return false
}
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
let alpha = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
c == '_' ||
c == '$'
let digit = c >= '0' && c <= '9'
if alpha == false && (i == 0 || digit == false) {
return false
}
}
true
}
///|
/// A property name as written in an interface: bare when it is an identifier,
/// quoted otherwise, so `user-name` still lands on the right key.
fn ts_prop(s : String) -> String {
if ts_ident(s) {
s
} else {
"\"" + s + "\""
}
}
///|
/// The value an emitted expression has to be converted through, or the expression
/// itself when the type is already JSON-shaped.
fn ts_encode(ty : Ty, expr : String) -> String {
if mentions_named(ty) == false {
return expr
}
match ty {
Opt(t) => ts_encode(t, expr)
List(t) => expr + ".map((e: any) => " + ts_encode(t, "e") + ")"
Dict(_, v) =>
"Object.fromEntries(Object.entries(" +
expr +
").map(([k, e]) => [k, " +
ts_encode(v, "e") +
"]))"
Named(n) => camel(n) + "ToJson(" + expr + ")"
}
}
///|
/// One `interface` and the pair of functions that move it to and from JSON. The
/// decoder is a plain object literal — `JSON.parse` already gives the right
/// shapes — and the encoder drops the fields the caller left out, so an optional
/// field is absent rather than null.
fn ts_type(t : @api.TypeDef, types : Array[@api.TypeDef]) -> Array[String] {
let out : Array[String] = []
let name = pascal(t.name)
let fields = @api.flat_fields(t, types)
out.push("export interface " + name + " {")
for f in fields {
let decl = if f.optional() { "?" } else { "" }
out.push(
" " +
ts_prop(camel(f.json_name())) +
decl +
": " +
ts_ty(field_ty(f)) +
";",
)
}
out.push("}")
out.push("")
out.push(
"export function " + camel(t.name) + "FromJson(raw: any): " + name + " {",
)
out.push(" return {")
for f in fields {
let key = camel(f.json_name())
let value = if f.optional() {
"raw[\"" + f.json_name() + "\"] ?? undefined"
} else {
"raw[\"" + f.json_name() + "\"]"
}
out.push(" " + ts_prop(key) + ": " + value + ",")
}
out.push(" };")
out.push("}")
out.push("")
out.push(
"export function " + camel(t.name) + "ToJson(value: " + name + "): any {",
)
out.push(" const out: any = {};")
for f in fields {
let key = f.json_name()
let value = ts_encode(field_ty(f), "value." + camel(key))
if f.optional() {
out.push(
" if (value." +
camel(key) +
" !== undefined && value." +
camel(key) +
" !== null) {",
)
out.push(" out[\"" + key + "\"] = " + value + ";")
out.push(" }")
} else {
out.push(" out[\"" + key + "\"] = " + value + ";")
}
}
out.push(" return out;")
out.push("}")
out.push("")
out
}
///|
/// The path a method requests, as one template literal with `${…}` where a `:seg`
/// was.
fn ts_path(op : Op) -> String {
let mut out = "`"
for seg in @text.path_segments(op.path) {
if seg.length() > 0 && seg[0] == ':' {
out = out +
"/" +
interp_open() +
path_value(TypeScript, camel(seg[1:].to_owned())) +
"}"
} else {
out = out + "/" + seg
}
}
if out == "`" {
out = "`/`"
}
out + "`"
}
///|
/// One route as one `async` method: URL, query string, headers, body, and the
/// status check every method repeats.
fn ts_method(op : Op) -> Array[String] {
let out : Array[String] = []
let params : Array[String] = []
for (name, ty, required) in ordered_params(op) {
let t = if name == "body" { ty } else { ts_ty(parse_ty(ty)) }
params.push(name + (if required { "" } else { "?" }) + ": " + t)
}
let ret = if op.resp == "" { "void" } else { op.resp }
if op.summary != "" {
out.push(" /** " + op.summary + " */")
}
out.push(
" async " +
op.handler +
"(" +
params.join(", ") +
"): Promise<" +
ret +
"> {",
)
out.push(" const path = " + ts_path(op) + ";")
if op.query_params.length() > 0 {
out.push(" const query: string[] = [];")
for p in op.query_params {
let opener = if p.optional {
"if (" + p.field + " !== undefined && " + p.field + " !== null) {"
} else {
"{"
}
out.push(" " + opener)
out.push(
" query.push(\"" +
p.wire +
"=\" + " +
path_value(TypeScript, p.field) +
");",
)
out.push(" }")
}
out.push(
" const url = this.baseUrl + path + (query.length > 0 ? \"?\" + query.join(\"&\") : \"\");",
)
} else {
out.push(" const url = this.baseUrl + path;")
}
out.push(
" const headers: Record = { accept: \"application/json\" };",
)
out.push(" if (this.token !== undefined) {")
out.push(" headers[\"authorization\"] = \"Bearer \" + this.token;")
out.push(" }")
for p in op.header_params {
if p.optional {
out.push(" if (" + p.field + " !== undefined) {")
out.push(" headers[\"" + p.wire + "\"] = String(" + p.field + ");")
out.push(" }")
} else {
out.push(" headers[\"" + p.wire + "\"] = String(" + p.field + ");")
}
}
let init = if op.has_body {
out.push(" headers[\"content-type\"] = \"application/json\";")
"{ method: \"" +
op.verb +
"\", headers, body: JSON.stringify(" +
camel(op.req) +
"ToJson(body)) }"
} else {
"{ method: \"" + op.verb + "\", headers }"
}
out.push(" const res = await this.fetchImpl(url, " + init + ");")
out.push(" const text = await res.text();")
out.push(" if (!res.ok) {")
out.push(" throw new ApiError(res.status, text);")
out.push(" }")
if op.resp != "" {
out.push(" return " + camel(op.resp) + "FromJson(JSON.parse(text));")
}
out.push(" }")
out.push("")
out
}
///|
/// The whole file: the error, the transport port, the models, then the client.
fn emit_ts(m : Model) -> String {
let out : Array[String] = []
out.push(
"// Code generated by `mctl api ts` from a `.api` spec. DO NOT EDIT.",
)
out.push("//")
out.push(
"// " +
m.service +
" — a typed client for the MoonAPI routes of the spec.",
)
out.push("//")
out.push(
"// One route is one method: `path:` fields become leading parameters, `form:`",
)
out.push(
"// fields become query parameters, `header:` fields become headers, and the",
)
out.push("// `json:` fields of the request type become the body.")
out.push("//")
out.push(
"// The transport is a port — `new " +
m.class_name +
"(baseUrl, fetchImpl)` — and",
)
out.push(
"// defaults to the global `fetch`, so this file compiles with `tsc --strict` and",
)
out.push("// no dependencies.")
out.push("")
out.push("export interface HttpResponse {")
out.push(" readonly ok: boolean;")
out.push(" readonly status: number;")
out.push(" text(): Promise;")
out.push("}")
out.push("")
out.push("export type Fetch = (")
out.push(" url: string,")
out.push(
" init: { method: string; headers: Record; body?: string },",
)
out.push(") => Promise;")
out.push("")
out.push("/** A response the service did not answer with 2xx. */")
out.push("export class ApiError extends Error {")
out.push(" constructor(")
out.push(" readonly status: number,")
out.push(" readonly body: string,")
out.push(" ) {")
out.push(" super(\"HTTP \" + status);")
out.push(" this.name = \"ApiError\";")
out.push(" }")
out.push("}")
out.push("")
for t in m.types {
for line in ts_type(t, m.types) {
out.push(line)
}
}
out.push("export class " + m.class_name + " {")
out.push(" /** Base URL of the service; a trailing slash is dropped. */")
out.push(" readonly baseUrl: string;")
out.push(" /** Bearer token sent with every request while set. */")
out.push(" token?: string;")
out.push(" private readonly fetchImpl: Fetch;")
out.push("")
out.push(
" constructor(baseUrl: string, fetchImpl?: Fetch, token?: string) {",
)
out.push(" this.baseUrl = baseUrl.replace(/\\/+$/, \"\");")
out.push(
" this.fetchImpl = fetchImpl ?? ((url, init) => fetch(url, init));",
)
out.push(" this.token = token;")
out.push(" }")
out.push("")
for op in m.ops {
for line in ts_method(op) {
out.push(line)
}
}
out.push("}")
@text.join_lines(out)
}