// The GraphQL service tree, declared with `#gql` annotations.
//
// `moongql`'s generator reads the annotated structs and root fields and writes
// `register(schema, resolvers)` into `schema.g.mbt` on build, so what this
// generator emits is the schema itself rather than a program that builds one. The
// resolvers are the annotated functions: their parameters are the field's
// arguments, and their bodies are where the author works.
///|
/// The package the annotated schema lives in.
let gql_schema_pkg = "internal/schema"
///|
/// How a field of the spec's MoonBit type reaches GraphQL.
priv enum GqlField {
/// The MoonBit type is a GraphQL type already.
Direct
/// `Bytes`: the `Base64` scalar. The field is declared as the text that scalar
/// carries, since `ToGql` has no impl for `Bytes` and none can be written — the
/// trait and the type both belong to another package.
Base64
/// `Map[String, V]`: the `JSON` scalar, with the field declared as a `Json`
/// object for the same reason.
JsonMap
/// A map keyed by something other than a `String`: an object's member names are
/// strings, so there is no shape to give it.
Dropped
}
///|
/// `ty` without the `?` that makes it optional.
fn gql_base(ty : String) -> (String, Bool) {
if @text.ends_with(ty, "?") && ty.length() > 1 {
(ty[0:ty.length() - 1].to_owned(), true)
} else {
(ty, false)
}
}
///|
/// The MoonBit type of a `Map[…]`'s value, or `None` when the key is not a
/// `String` — a GraphQL object's field names are strings, so a map keyed by
/// anything else has no object to become.
fn gql_map_value(ty : String) -> String? {
let close = @text.index_of_str(ty, "]")
if close <= 4 {
return None
}
// `Map[K, V]` — the key is what stands before the first comma of the inside, so
// a value that is itself generic (`Map[String, Array[Int32]]`) still reads right.
let inside = @text.trim(ty[4:close].to_owned())
let comma = @text.index_of_str(inside, ",")
if comma < 0 {
return None
}
let key = @text.trim(inside[0:comma].to_owned())
if key != "String" {
return None
}
Some(@text.trim(inside[comma + 1:].to_owned()))
}
///|
/// The GraphQL shape of a field's MoonBit type. An optional and a slice take
/// their element's shape; `Bytes` becomes the `Base64` scalar and a `Map[String,
/// V]` the `JSON` one; every other name is a type the schema declares.
fn gql_field_kind(ty : String) -> GqlField {
let element = gql_element(ty)
if element == "Bytes" {
return Base64
}
if @text.starts_with(element, "Map[") {
return match gql_map_value(element) {
Some(_) => JsonMap
None => Dropped
}
}
Direct
}
///|
/// Whether the schema can carry `ty` — directly, or through the scalar this
/// generator declares for it.
pub fn gql_expressible(ty : String) -> Bool {
(gql_field_kind(ty) is Dropped) == false
}
///|
/// The annotated struct for one `type` block. A field GraphQL carries directly is
/// the struct field the spec declared; `Bytes` and `Map[String, V]` are declared
/// as the text and the object their scalars carry, with the scalar named on the
/// field, because neither has a `ToGql` impl this generator may write.
///|
/// The annotated struct for one `type` block. A field GraphQL carries directly is
/// a struct field; `Bytes` and `Map[String, V]` are skipped in the struct and
/// reached through a `#gql.field` method returning the text or the object of their
/// scalar, since neither type has a `ToGql` impl this generator may write.
fn gql_struct(t : @api.TypeDef, fields : Array[@api.Field]) -> String {
let mut out = "///|\n/// `" +
t.name +
"`, from the `.api` `type` block.\n#gql.type\npub struct " +
t.name +
" {\n"
for f in fields {
match gql_field_kind(f.type_) {
Direct => out = out + " " + f.mbt_name() + " : " + f.type_ + "\n"
Base64 =>
out = out +
" /// The base64 text of `" +
f.mbt_name() +
"`, which is what the `Base64` scalar carries.\n #gql.field(kind=\"Base64\")\n " +
f.mbt_name() +
" : String\n"
JsonMap =>
out = out +
" /// `" +
f.mbt_name() +
"` as the object the `JSON` scalar carries, one member per key.\n #gql.field(kind=\"JSON\")\n " +
f.mbt_name() +
" : Json\n"
Dropped => ()
}
}
out + "}\n"
}
///|
/// The fields of a `type` block that GraphQL has no type for, as
/// `. ()`, for the command to report.
pub fn gql_dropped_fields(spec : @api.Spec) -> Array[String] {
let out : Array[String] = []
for t in spec.types {
for f in @api.flat_fields(t, spec.types) {
if gql_expressible(f.type_) == false {
out.push(t.name + "." + f.mbt_name() + " (" + f.type_ + ")")
}
}
}
out
}
///|
/// The element type of a field's MoonBit type: a slice's element, or the type
/// itself. `Bytes` and `Map[…]` are what the two scalars are about.
fn gql_element(ty : String) -> String {
let (base, _) = gql_base(ty)
if @text.starts_with(base, "Array[") && @text.ends_with(base, "]") {
base[6:base.length() - 1].to_owned()
} else {
base
}
}
///|
/// Whether any field of the spec is `Bytes`, which is what makes the generated
/// package import the base64 encoder its scalar's text comes from.
fn gql_uses_base64(spec : @api.Spec) -> Bool {
for t in spec.types {
for f in @api.flat_fields(t, spec.types) {
if gql_element(f.type_) == "Bytes" {
return true
}
}
}
false
}
///|
/// The `:name` segments of a route path, in order.
fn gql_path_params(path : String) -> Array[String] {
let out : Array[String] = []
for part in path.split("/") {
let one : String = part.to_owned()
if @text.starts_with(one, ":") {
out.push(one[1:].to_owned())
}
}
out
}
///|
/// A route as a root field: its arguments are the request type's fields (a `path`
/// field's type is the URL segment's), its type is the declared reply, and its
/// body is a stub for the author to fill in.
fn gql_root_field(spec : @api.Spec, r : @api.Route, name : String) -> String {
let args : Array[(String, String)] = []
let req = @api.find_type(spec.types, r.req)
if req is Some(t) {
for f in @api.flat_fields(t, spec.types) {
if gql_expressible(f.type_) {
args.push((f.mbt_name(), f.type_))
}
}
} else {
// No request type: the URL segments are the arguments.
for p in gql_path_params(r.path) {
args.push((p, "String"))
}
}
let declared = r.resp != "" && gql_expressible(r.resp)
// A subscription answers with the events a subscriber receives, one array of
// them, so its type is a list of the declared event type rather than the type.
let reply = if r.stream {
"Array[" + (if declared { r.resp } else { "String" }) + "]"
} else if declared {
r.resp + "?"
} else {
"String"
}
let mut out = "///|\n/// `" +
r.verb +
" " +
r.path +
"` — the " +
(if r.stream { "subscription" } else { "root field" }) +
" `" +
name +
"`" +
(if r.stream {
", one event per `" + (if declared { r.resp } else { "String" }) + "`"
} else {
""
}) +
".\n"
out = out +
(if r.stream {
"#gql.subscription"
} else if r.verb == "get" {
"#gql.query"
} else {
"#gql.mutation"
})
out = out + "\npub fn " + name + "("
if args.length() > 0 {
out = out + "\n"
for a in args {
out = out + " " + a.0 + " : " + a.1 + ",\n"
}
}
out = out + ") -> " + reply + " {\n"
// The parameters keep their names — they are the field's arguments — and the
// stub acknowledges them, so a fresh project has no unused-parameter warning.
let slots : Array[String] = []
for a in args {
slots.push(a.0)
}
if slots.length() == 1 {
out = out + " ignore(" + slots[0] + ")\n"
} else if slots.length() > 1 {
out = out + " ignore((" + slots.join(", ") + "))\n"
}
out = out +
" " +
(if r.stream {
"[]"
} else if reply == "String" {
@text.quote("TODO: " + r.handler)
} else {
"None"
}) +
"\n}\n"
out
}
///|
/// The seeded schema for `spec`: one annotated struct per `type` block, one root
/// field per route.
pub fn gql_schema_for(spec : @api.Spec) -> String {
let mut out = "// Seeded by moonctl from the `.api` spec; edit it as you like.\n//\n// Each `#gql.type` struct is a GraphQL type and each `#gql.query`/\n// `#gql.mutation` a root field, with its parameters as the field's arguments.\n// `register(schema, resolvers)` is generated from these on build.\n"
for t in spec.types {
out = out + "\n" + gql_struct(t, @api.flat_fields(t, spec.types))
}
let taken : Array[String] = []
for r in spec.routes {
let base = r.handler
let mut name = base
let mut n = 2
while taken.contains(name) {
name = base + "_" + n.to_string()
n = n + 1
}
taken.push(name)
out = out + "\n" + gql_root_field(spec, r, name)
}
out
}
///|
/// The two conversions a `Base64` field needs and the schema cannot state: the
/// text a `Bytes` value is published as, and the bytes a client's text means. They
/// are generated rather than left to the author, since the encoding is what makes
/// the field's GraphQL type true.
fn gql_scalars_src() -> String {
let src =
#|// Code generated by moonctl. DO NOT EDIT.
#|
#|///|
#|/// The base64 text the `Base64` scalar carries, from the bytes of a field.
#|pub fn base64_of(bytes : Bytes) -> String {
#| @base64.encode(bytes[:])
#|}
#|
#|///|
#|/// The bytes a base64 text holds: `None` when the text is not base64, so a
#|/// malformed input is refused rather than read as an empty value.
#|pub fn bytes_of_base64(text : String) -> Bytes? {
#| let decoded = @base64.decode(text) catch {
#| _ => return None
#| }
#| Some(decoded)
#|}
src + "\n"
}
///|
/// The entry point of a GraphQL service: build the schema from the annotations,
/// serve it, and print where.
fn gql_main(module_ : String) -> String {
let src =
#|///|
#|/// The %svc service: build the schema from its `#gql` annotations and serve it
#|/// on mooncat.
#|async fn main {
#| let cfg = @config.Config::new()
#| let (schema, resolvers) = @schema.build()
#| let app = @moongql.graphql_app(schema, resolvers)
#| println("%svc serving GraphQL on " + cfg.host + ":" + cfg.port.to_string())
#| @mooncat.serve(app, host=cfg.host, port=cfg.port)
#|}
src.replace_all(old="%svc", new=module_) + "\n"
}
// The generated schema package needs a `build()` the entry point calls: moonmcp's
// `register` composes the schema, and this hands it out with the resolvers.
///|
/// `build()` for the schema package: a fresh schema, the resolvers registered into
/// it, and the pair the application serves.
fn gql_build_src() -> String {
"///|\n/// The schema and resolvers of this service, built from the annotations beside\n/// this function.\npub fn build() -> (@moongql.Schema, @moongql.Resolvers) {\n let schema = @moongql.Schema::new()\n let resolvers = @moongql.Resolvers::new()\n register(schema, resolvers)\n (schema, resolvers)\n}\n"
}
///|
/// The layered GraphQL tree for `spec` under `dir`: `internal/schema` with the
/// annotated types and root fields (seeded once — the resolvers are yours to
/// write), the rule that regenerates the schema from them, `internal/config` and
/// the entry point serving it on 12000.
///
/// Hand the result to `tree_plan` to keep what the author has filled in.
pub fn generate_gql_tree(
spec : @api.Spec,
style? : @style.Style,
dir~ : String = "",
) -> Array[TreeFile] {
let st = style.unwrap_or(@style.Style::gozero())
let mod_ = spec.service
let gql = ("moonbitstack/moongql", "")
let schema_pkg = mod_ + "/" + gql_schema_pkg
// `Bytes` is base64-encoded for its scalar, and a map is rendered to JSON: the
// schema package imports each library only when a field of the spec needs it.
let needs_base64 = gql_uses_base64(spec)
let deps = ["moonbitstack/moongql", "moonbitstack/mooncat"]
if needs_base64 {
deps.push("moonbitstack/moonbase")
}
let schema_imports : Array[(String, String)] = [gql]
if needs_base64 {
schema_imports.push(("moonbitstack/moonbase/base64", "base64"))
}
let out = base_tree(
mod_,
st,
dir,
deps~,
imports=[gql, ("moonbitstack/mooncat", ""), (schema_pkg, "")],
main=gql_main(mod_),
context=false,
rules=[("gql", generator_command("moonbitstack/moongql", "gql"))],
)
out.push({
path: at(dir, gql_schema_pkg + "/moon.pkg"),
content: pkg_file(schema_imports) +
"\ndev_build(rule: \"gql\", input: \"" +
st.format("schema") +
".mbt\", output: \"" +
st.format("schema") +
".g.mbt\")\n\nformatter(ignore: [ \"" +
st.format("schema") +
".g.mbt\" ])\n",
regen: Once,
})
out.push({
path: at(dir, gql_schema_pkg + "/" + st.format("schema") + ".mbt"),
content: gql_schema_for(spec) + "\n" + gql_build_src(),
regen: Once,
})
if needs_base64 {
out.push({
path: at(dir, gql_schema_pkg + "/" + st.format("scalars") + ".mbt"),
content: gql_scalars_src(),
regen: Always,
})
}
// A root field to ask for, whatever the resolvers end up answering: asking the
// schema what it is keeps the seeded test true after the stubs are filled in.
if spec.routes.any(r => r.verb == "get") {
out.push({
path: at(dir, gql_schema_pkg + "/" + st.format("schema") + "_test.mbt"),
content: "///|\ntest \"the schema answers\" {\n let (schema, resolvers) = @schema.build()\n let got = @moongql.execute(schema, resolvers, \"{ __typename }\")\n assert_eq(got, { \"data\": { \"__typename\": \"Query\" } })\n}\n",
regen: Once,
})
}
out
}