// `mctl api ts|dart|kt|java`: the typed clients goctl ships for TypeScript and
// leaves to plugins for everything else.
//
// One spec, one reading of it — `client_model` — and four emitters walking that
// model, so a route, a field or a type name is spelled the same way in every
// language. A client is typed end to end: the request type of a route becomes a
// model, the `path:` / `form:` / `header:` tags of its fields become method
// parameters, and its response type becomes the return type. Only the call itself
// is generated: transport is a port the caller fills in, so a generated client
// compiles with no third-party dependency and runs over fetch, dio, Ktor or
// anything else.
///|
/// The language a client is generated for: the four goctl generates or that the
/// community asks for.
pub(all) enum Lang {
TypeScript
Dart
Kotlin
Java
}
///|
/// The file extension a client of this language carries (`ts`, `dart`, `kt`,
/// `java`).
pub fn Lang::ext(self : Lang) -> String {
match self {
TypeScript => "ts"
Dart => "dart"
Kotlin => "kt"
Java => "java"
}
}
///|
/// How the language is spelled in a comment, e.g. `TypeScript`.
pub fn Lang::label(self : Lang) -> String {
match self {
TypeScript => "TypeScript"
Dart => "Dart"
Kotlin => "Kotlin"
Java => "Java"
}
}
///|
/// The file a client for `service` lands in: `greet_client.ts` and friends, named
/// after the service so two specs generated into one directory do not collide.
pub fn client_file_name(lang : Lang, service : String) -> String {
let base = if service == "" { "api" } else { service }
match lang {
// `javac` insists a public class live in a file named after it, and the class is
// named `GreetClient`, so the file is too
Java => pascal(base) + "Client.java"
_ => @text.to_snake(base) + "_client." + lang.ext()
}
}
///|
/// A MoonBit type spelling read apart, so one shape drives four emitters:
/// `Array[Map[String, User]?]` becomes `List(Opt(Dict(Named("String"),
/// Named("User"))))` and each emitter renders that tree its own way.
priv enum Ty {
Opt(Ty)
List(Ty)
Dict(Ty, Ty)
Named(String)
}
///|
/// The index of the `", "` between a `Map[K, V]`'s two halves, or `-1`.
fn top_comma(s : String) -> Int32 {
let mut depth = 0
for i = 0; i < s.length(); 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
}
///|
/// Read a MoonBit type spelling apart. Anything this does not recognise stays a
/// `Named` leaf, which every emitter passes through as the name it was given — a
/// type moonctl has no mapping for is better carried across than guessed at.
fn parse_ty(raw : String) -> Ty {
let s = @text.trim(raw)
if s == "" {
return Named("String")
}
if @text.starts_with(s, "Array[") && @text.ends_with(s, "]") {
return List(parse_ty(s[6:s.length() - 1].to_owned()))
}
if @text.starts_with(s, "Map[") && @text.ends_with(s, "]") {
let body = s[4:s.length() - 1].to_owned()
let comma = top_comma(body)
if comma >= 0 {
return Dict(
parse_ty(body[0:comma].to_owned()),
parse_ty(body[comma + 1:].to_owned()),
)
}
return Named(s)
}
if @text.ends_with(s, "?") && s.length() > 1 {
return Opt(parse_ty(s[0:s.length() - 1].to_owned()))
}
Named(norm_name(s))
}
///|
/// A leaf spelled the way a `.api` file may spell it (`string`, `int64`,
/// `float64`), read as the MoonBit scalar it maps to. A spec's types are already
/// mapped by the parser, but the `?` a spec writes by hand — `string?` — skips that
/// pass, so the client normalizes every leaf it meets.
fn norm_name(n : String) -> String {
match n {
"string" => "String"
"bool" => "Bool"
"int" | "int8" | "int16" | "int32" | "rune" => "Int32"
"int64" => "Int64"
"uint" | "uint8" | "uint16" | "uint32" => "UInt32"
"uint64" => "UInt64"
"byte" => "Byte"
"bytes" => "Bytes"
"float32" => "Float"
"float" | "float64" | "double" => "Double"
_ => n
}
}
///|
/// `t` marked optional, without stacking two `Opt`s: `int??` is not a type.
fn opt_of(t : Ty) -> Ty {
match t {
Opt(_) => t
_ => Opt(t)
}
}
///|
/// The type a field is carried as: its declared type, made nullable when its tag
/// says `optional` — a field that may be absent has to hold `null` in every
/// language a client is generated for.
fn field_ty(f : @api.Field) -> Ty {
let ty = parse_ty(f.type_)
if f.optional() {
opt_of(ty)
} else {
ty
}
}
///|
/// Whether a type may be absent: a nullable one, which a language spells `T?`,
/// `T | null` or a boxed `T`.
fn is_nullable(ty : Ty) -> Bool {
match ty {
Opt(_) => true
_ => false
}
}
///|
/// Whether a leaf names one of the scalars every language spells for itself, as
/// opposed to a `type` block of the spec.
fn is_scalar(name : String) -> Bool {
match name {
"String"
| "Bool"
| "Int"
| "Int32"
| "Int64"
| "UInt"
| "UInt32"
| "UInt64"
| "Byte"
| "Bytes"
| "Double"
| "Float"
| "Unit"
| "Json" => true
_ => false
}
}
///|
/// One method parameter: the wire name the value is carried under, the property
/// name of the model field it came from, its MoonBit type, and whether the caller
/// may leave it out.
priv struct Param {
wire : String
field : String
ty : String
optional : Bool
}
///|
/// One route read as a client method.
priv struct Op {
verb : String
path : String
handler : String
summary : String
req : String
resp : String
path_params : Array[Param]
query_params : Array[Param]
header_params : Array[Param]
has_body : Bool
}
///|
/// A spec read as what the four emitters need: the client's name, the models it
/// carries and its methods in spec order.
priv struct Model {
service : String
class_name : String
types : Array[@api.TypeDef]
ops : Array[Op]
}
///|
/// One code point as a one-character string, with the case `f` asks for.
fn chr(c : UInt16) -> String {
match c.to_char() {
Some(ch) => ch.to_string()
None => ""
}
}
///|
/// `c` as a string, lower-cased when it is an ASCII capital.
fn chr_lower(c : UInt16) -> String {
chr(if c >= 'A' && c <= 'Z' { c + 32 } else { c })
}
///|
/// `c` as a string, upper-cased when it is an ASCII lower-case letter.
fn chr_upper(c : UInt16) -> String {
chr(if c >= 'a' && c <= 'z' { c - 32 } else { c })
}
///|
/// `user_name` / `UserName` / `X-Client-Id` → `userName`: the property name a model
/// field takes in every generated language. Any run of non-alphanumeric characters
/// is a word break, so a wire name a language cannot spell still lands on an
/// identifier that language accepts.
fn camel(name : String) -> String {
let mut out = ""
let mut up = false
for i = 0; i < name.length(); i = i + 1 {
let c = name[i]
let ok = (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9')
if ok {
if out.length() == 0 {
out = out + chr_lower(c)
} else if up {
out = out + chr_upper(c)
} else {
out = out + chr(c)
}
up = false
} else {
up = out.length() > 0
}
}
if out == "" {
"field"
} else {
out
}
}
///|
/// The type name of a `type` block or a route's payload: `user_name` → `UserName`,
/// `X-Client-Id` → `XClientId`.
fn pascal(name : String) -> String {
@text.upper_first(camel(name))
}
///|
/// One field as a method parameter, taking the wire name from the field's binding
/// tag so `form:"page_size"` becomes `pageSize`.
fn param_of(f : @api.Field) -> Param {
{
wire: f.json_name(),
field: camel(f.json_name()),
ty: f.type_,
optional: f.optional() || @text.ends_with(@text.trim(f.type_), "?"),
}
}
///|
/// The fields of a route's request type that are bound from the body — what a
/// generated method sends as JSON, as opposed to what it puts in the URL.
fn body_fields(spec : @api.Spec, req : String) -> Array[@api.Field] {
if req == "" {
return []
}
let out : Array[@api.Field] = []
match @api.find_type(spec.types, req) {
None => ()
Some(t) =>
for f in @api.flat_fields(t, spec.types) {
if f.bind() == @api.Body {
out.push(f)
}
}
}
out
}
///|
/// The `:id` segments of a path, in the order they appear.
fn path_segments_raw(path : String) -> Array[String] {
let out : Array[String] = []
for seg in @text.path_segments(path) {
if seg.length() > 1 && seg[0] == ':' {
out.push(seg[1:].to_owned())
}
}
out
}
///|
/// One route as a method. Path parameters take their type from the request type's
/// field of that name, and from `String` when the spec has none — a `:id` nothing
/// binds is a warning in `validate`, not a reason to refuse to generate.
fn op_of(spec : @api.Spec, r : @api.Route) -> Op {
let fields = match @api.find_type(spec.types, r.req) {
None => []
Some(t) => @api.flat_fields(t, spec.types)
}
let path_params : Array[Param] = []
for seg in path_segments_raw(r.path) {
let mut found : Param? = None
for f in fields {
if f.json_name() == seg || f.mbt_name() == seg {
found = Some(param_of(f))
}
}
path_params.push(
match found {
Some(p) => { wire: seg, field: p.field, ty: p.ty, optional: false, }
None => { wire: seg, field: camel(seg), ty: "String", optional: false, }
},
)
}
let query_params : Array[Param] = []
let header_params : Array[Param] = []
for f in fields {
match f.bind() {
@api.Query => query_params.push(param_of(f))
@api.Header => header_params.push(param_of(f))
_ => ()
}
}
{
verb: r.verb.to_upper(),
path: r.path,
handler: camel(r.handler),
summary: r.summary,
req: if r.req == "" {
""
} else {
pascal(r.req)
},
resp: if r.resp == "" {
""
} else {
pascal(r.resp)
},
path_params,
query_params,
header_params,
has_body: body_fields(spec, r.req).length() > 0,
}
}
///|
/// The spec read as a client: `greet` → `GreetClient`, every `type` block as a
/// model, every route as a method.
fn client_model(spec : @api.Spec) -> Model {
let service = spec.service
let base = if service == "" { "api" } else { service }
let ops : Array[Op] = []
for r in spec.routes {
ops.push(op_of(spec, r))
}
{
service: base,
class_name: pascal(base) + "Client",
types: spec.types,
ops,
}
}
///|
/// Generate the client of `lang` for `spec`. The result is one file: the models the
/// spec declares, then the client class, in that order, so a reader meets the data
/// before the calls that carry it.
pub fn generate_client(spec : @api.Spec, lang : Lang) -> String {
let m = client_model(spec)
match lang {
TypeScript => emit_ts(m)
Dart => emit_dart(m)
Kotlin => emit_kt(m)
Java => emit_java(m)
}
}
///|
/// The file name and the source of the client `lang` for `spec`, which is what
/// `mctl api ts ` writes.
pub fn client_file(spec : @api.Spec, lang : Lang) -> (String, String) {
(client_file_name(lang, spec.service), generate_client(spec, lang))
}
///|
/// A method's parameters in the order every emitter writes them: the body first
/// (a client call reads `create(req, id)`), then the path segments, then the
/// optional `query` and `header` ones — required before optional, because
/// TypeScript refuses a required parameter after an optional one.
fn ordered_params(op : Op) -> Array[(String, String, Bool)] {
let out : Array[(String, String, Bool)] = []
if op.has_body {
out.push(("body", op.req, true))
}
for p in op.path_params {
out.push((p.field, p.ty, true))
}
for p in op.query_params {
out.push((p.field, p.ty, p.optional == false))
}
for p in op.header_params {
out.push((p.field, p.ty, p.optional == false))
}
// a required parameter never follows an optional one
let required : Array[(String, String, Bool)] = []
let optional : Array[(String, String, Bool)] = []
for p in out {
if p.2 {
required.push(p)
} else {
optional.push(p)
}
}
required + optional
}
///|
/// The `:id` a path segment is filled from, as an expression for `lang`: the
/// segment's parameter value, escaped for a URL.
fn path_value(lang : Lang, name : String) -> String {
match lang {
TypeScript => "encodeURIComponent(String(" + name + "))"
Dart => "Uri.encodeComponent(" + name + ".toString())"
Kotlin => "enc(" + name + ")"
Java => "enc(" + name + ")"
}
}
///|
/// The two characters `${`, built at run time rather than written as one literal:
/// the JS backend emits a MoonBit string as a template literal, where that pair
/// would start an interpolation. Every generated `${` — a TypeScript template
/// literal, a Dart string — goes through this.
fn interp_open() -> String {
"$" + chr(0x7B)
}
///|
/// Whether a type tree mentions a `type` block, in which case a value of it has to
/// be converted rather than passed on as it is.
fn mentions_named(ty : Ty) -> Bool {
match ty {
Opt(t) => mentions_named(t)
List(t) => mentions_named(t)
Dict(k, v) => mentions_named(k) || mentions_named(v)
Named(n) => is_scalar(n) == false
}
}