// 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"
///|
/// Whether `moongql` has a GraphQL type for `ty`. A slice or an optional is that
/// of its element; `Bytes` and `Map` have none, and a field of one of those keeps
/// the generated `ToGql` impl from compiling, so it is dropped from the schema and
/// reported instead.
pub fn gql_expressible(ty : String) -> Bool {
let base = if @text.ends_with(ty, "?") {
ty[0:ty.length() - 1].to_owned()
} else {
ty
}
let element = if @text.starts_with(base, "Array[") &&
@text.ends_with(base, "]") {
base[6:base.length() - 1].to_owned()
} else {
base
}
// Everything else the `.api` vocabulary carries — a `type` block's own name
// turned into a `#gql.type` — is expressible; `Bytes` and `Map` are not, and
// there is no scalar to map them onto.
match element {
"Bytes" | "Map" => false
_ => @text.starts_with(element, "Map[") == false
}
}
///|
/// The annotated struct for one `type` block: its fields, minus the ones GraphQL
/// cannot carry. `skipped` collects what was left out so the command can say so.
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 {
if gql_expressible(f.type_) {
out = out + " " + f.mbt_name() + " : " + f.type_ + "\n"
}
}
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 `: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 reply = if r.resp != "" && gql_expressible(r.resp) {
r.resp + "?"
} else {
"String"
}
let mut out = "///|\n/// `" +
r.verb +
" " +
r.path +
"` — the root field `" +
name +
"`.\n"
out = out + (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 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 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
let out = base_tree(
mod_,
st,
dir,
deps=["moonbitstack/moongql", "moonbitstack/mooncat"],
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([gql]) +
"\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,
})
// 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
}