Code-first GraphQL for MoonBit.
#gql.type
pub struct User {
id : String
name : String
tags : Array[String]
}
#gql.query
pub fn hello() -> String {
"hello"
}
#gql.query
pub fn user(id : String) -> User? {
lookup(id)
}
let schema = @moongql.Schema::new()
let resolvers = @moongql.Resolvers::new()
register(schema, resolvers)
let sdl = schema.to_sdl()schema {
query: Query
}
type Query {
hello: String!
user(id: String!): User
}
type User {
id: String!
name: String!
tags: [String!]!
}#gql.type(description="Someone who writes books")
pub struct Author {
id : Int64
name : String
#gql.skip
email : String
}
#gql.enum
pub enum Status { Draft; Published }
#gql.input
pub struct NewBook { title : String; author_id : Int64; status : Status? }
#gql.field(description="Their books, at most `first`")
fn Author::books(self : Author, first? : Int) -> Array[Book] { ... }
#gql.query
fn author(id : Int64) -> Author? { ... }
#gql.mutation
fn add_book(input : NewBook) -> Book raise { ... }test {
let s = @moongql.Schema::new()
let r = @moongql.Resolvers::new()
register(s, r)
let app = @moongql.graphql_app(s, r)
}#gql.interface(field="id", field="title")
pub enum Work { Printed(Book); Periodical(Magazine) }
#gql.union
pub enum Hit { Writer(Author); Title(Book) }
#gql.query
fn search(text : String) -> Array[Hit] { ... }| MoonBit | GraphQL |
|---|---|
| String | String! |
| Int | Int! |
| Double | Float! |
| Bool | Boolean! |
| Int64 | Int64!, a scalar written as a string |
| T? | T |
| Array[T] | [T]! |
| an annotated type | its name |
rule(name: "gql", command: "moonx moonbitstack/moongql/cmd/gql@0.10.11 -C \"$mod_dir\" \"$input\" -o \"$output\"")dev_build(rule: "gql", input: "schema.mbt", output: "schema.g.mbt")
formatter(ignore: [ "schema.g.mbt" ])#gql.type(key="id")
pub struct User {
id : String
#gql.field(shareable=true)
name : String
}
#gql.reference
pub fn user_by_id(id : String) -> User? { lookup(id) }
pub fn signed_in(info : @moongql.ResolveInfo) -> Bool { info.ctx is String(_) }
#gql.query(permission="signed_in")
pub fn secret() -> String { "the answer" }
#gql.subscription
pub fn countdown(from : Int32) -> Array[Int32] { Array::makei(from, i => from - i) }#gql.sdl
let shop : String =
#|type Book { id: ID! title: String! author: Author! }
#|type Author { id: ID! name: String! }
#|type Query { book(id: ID!): Book }fn Query::book(id : String) -> Book? raise { ... }
fn Book::author(self : Book) -> Author raise { ... }#|type Subscription {
#| bookAdded: Book!
#|}
fn Subscription::book_added() -> Array[Book] raise { ... }pub fn demo() -> (@moongql.Schema, @moongql.Resolvers) {
let schema = @moongql.Schema::new()
let q = schema.object("Query")
q.field("hello", NonNull(Scalar("String")))
q.field_args("user", [("id", NonNull(Scalar("ID")))], Named("User"))
let u = schema.object("User")
u.field("id", NonNull(Scalar("ID")))
u.field("name", NonNull(Scalar("String")))
u.field("tags", ListOf(NonNull(Scalar("String"))))
let resolvers = @moongql.Resolvers::new()
resolvers.field("Query", "hello", fn(_info) { Json("hello") })
resolvers.field("Query", "user", fn(info) -> Json raise @moongql.FieldError {
let id : String = info.read("id")
let person : Map[String, Json] = {
"id": Json(id),
"name": Json("Alice"),
"tags": Json(["admin"]),
}
Json(person)
})
(schema, resolvers)
}
// Every block below builds on this pair.
let demo_pair : (@moongql.Schema, @moongql.Resolvers) = demo()
let schema : @moongql.Schema = demo_pair.0
let resolvers : @moongql.Resolvers = demo_pair.1
test {
assert_eq(schema.to_sdl().contains("type User {"), true)
}pub(all) struct Person {
id : String
name : String
}
pub impl @moongql.ToGql for Person with fn to_gql(self) {
Json({ "id": Json(self.id), "name": Json(self.name) })
}
pub impl @moongql.FromGql for Person with fn from_gql(v) {
{ id: @moongql.read(v, "id"), name: @moongql.read(v, "name") }
}
// The compiler wants the promotion said out loud: the impls make these methods
// of the type.
pub extend Person with @moongql.ToGql::{to_gql}
pub extend Person with @moongql.FromGql::{from_gql}
let people : Array[Person] = [{ id: "1", name: "Alice" }]
pub(all) struct PersonQuery {}
fn PersonQuery::person(id : String) -> Person? {
people.iter().find_first(p => p.id == id)
}
pub fn people_server() -> (@moongql.Schema, @moongql.Resolvers) {
let s = @moongql.Schema::new()
let person = s.object("Person")
person.field("id", NonNull(Scalar("String")))
person.field("name", NonNull(Scalar("String")))
let query = s.object("Query")
query.field_args("person", [("id", NonNull(Scalar("String")))], Named("Person"))
let r = @moongql.Resolvers::new()
r.field("Query", "person", fn(info) {
@moongql.ToGql::to_gql(PersonQuery::person(info.read("id")))
})
(s, r)
}
let pair : (@moongql.Schema, @moongql.Resolvers) = people_server()
test {
assert_eq(
@moongql.execute(pair.0, pair.1, "{ person(id: \"1\") { name } }").stringify(),
"{\"data\":{\"person\":{\"name\":\"Alice\"}}}",
)
}test {
let parsed = @moongql.Schema::of_sdl(
#|type Query {
#| "The user with this id."
#| user(id: ID!): User
#|}
#|type User {
#| id: ID!
#| name: String!
#| "Use `fullName` instead."
#| legacyName: String! @deprecated(reason: "use name")
#|}
#|,
)
// Print, parse, print: the second print is the first one again.
assert_eq(@moongql.Schema::of_sdl(parsed.to_sdl()).to_sdl(), parsed.to_sdl())
assert_eq(parsed.to_sdl().contains("@deprecated(reason: \"use name\")"), true)
}| Package | What it holds | Depends on |
|---|---|---|
| moongql/lang | the document syntax tree, tokenize, parse, GqlError | nothing |
| moongql/schema | types, directives, the built-in scalars and meta-fields, SDL, and Schema::check | lang |
| moongql/valid | validate | lang, schema |
| moongql/exec | execute, execute_async, subscriptions, introspection | lang, schema, valid |
| moongql/federation | Apollo Federation v1 and v2 | schema, exec |
| moongql/server | the HTTP endpoint and graphql-transport-ws | exec and below, moonasgi |
| moongql/loader | DataLoader | nothing |
| moongql/client | a GraphQL client over HTTP | lang, moonfetch |
| moongql/orm | a pooled database for resolvers: projection, attaching a list's children, a statement budget | exec, moonorm, moondb |
| moongql/orm/sqlite | the same on SQLite | orm, moonsqlite (native) |
| moongql/mcp | the schema offered to an agent as Model Context Protocol tools | schema, exec, moonmcp |
| moongql/cmd/gql | the generator behind the #gql annotations | moonast |
| moongql/cmd/sdl | moongql-sdl on the command line: a document's schema, normalised | schema |
test {
let doc = @moongql.parse(
"query ($id: ID!) { user(id: $id) { name ...fields @skip(if: false) } }",
)
assert_eq(doc.definitions.length(), 1)
// Printing a document gives the canonical form the printer writes, not the
// source text: whitespace and comments are gone, the shape is the same.
assert_eq(doc.to_query().contains("query($id: ID!)"), true)
}// `schema` and `resolvers` are the pair from the section above; a resolver of
// `Query.user` answers the whole object, and the fields under it fall back to the
// default resolver, which reads them off the parent (spec §6.4.4).
let vars : Map[String, Json] = { "uid": Json("1") }
let res = @moongql.execute(
schema,
resolvers,
"query ($uid: ID!) { hero: user(id: $uid) { id name } }",
variables=vars,
)
assert_eq(res.stringify(), "{\"data\":{\"hero\":{\"id\":\"1\",\"name\":\"Alice\"}}}")test {
let invalid = @moongql.execute(
schema,
resolvers,
"query Hero {\n user(id: \"1\") {\n nope\n }\n}",
)
assert_eq(
invalid.stringify(),
"{\"errors\":[{\"message\":\"Cannot query field 'nope' on type 'User'\",\"locations\":[{\"line\":3,\"column\":5}]}]}",
)
}test {
let traced = @moongql.execute(
schema,
resolvers,
"{ user(id: \"1\") { name } }",
extensions={ "traceId": Json("qwq-233") },
)
assert_eq(
traced.stringify(),
"{\"data\":{\"user\":{\"name\":\"Alice\"}},\"extensions\":{\"traceId\":\"qwq-233\"}}",
)
}test {
let shapes = @moongql.Schema::new()
let node = shapes.interface("Node")
node.field("id", NonNull(Scalar("ID")))
let member = shapes.object("User")
member.implements("Node")
member.field("id", NonNull(Scalar("ID")))
let query = shapes.object("Query")
query.field_args("user", [("id", NonNull(Scalar("ID")))], Named("User"))
let filter = shapes.input("UserFilter")
filter.field("name", Scalar("String"))
shapes.enum_("Role", ["ADMIN", "USER"])
let printed = shapes.to_sdl()
assert_eq(printed.contains("type User implements Node"), true)
assert_eq(printed.contains("input UserFilter"), true)
}test {
schema.union("SearchResult", ["User", "Query"])
let with_union = schema.to_sdl()
assert_eq(with_union.contains("union SearchResult = User | Query"), true)
}test {
schema.scalar(
"DateTime",
serialize=fn(j) { j }, // value -> output JSON
parse_value=fn(j) { j }, // input JSON -> value
description="Milliseconds since the epoch.",
)
assert_eq(schema.to_sdl().contains("scalar DateTime"), true)
}test {
let animals = @moongql.Schema::new()
let pet = animals.interface("Pet")
pet.field("name", NonNull(Scalar("String")))
let dog = animals.object("Dog")
dog.implements("Pet")
dog.field("name", NonNull(Scalar("String")))
dog.field("barks", NonNull(Scalar("Boolean")))
let pet_resolvers = @moongql.Resolvers::new()
pet_resolvers.resolve_type("Pet", fn(value, _ctx) {
match value {
Object(m) => if m.get("barks") is Some(_) { Some("Dog") } else { None }
_ => None
}
})
// or, per object type:
pet_resolvers.is_type_of("Dog", fn(value, _ctx) {
value is Object(m) && m.get("barks") is Some(_)
})
}test {
let feed = @moongql.Schema::new()
let message = feed.object("Message")
message.field("id", NonNull(Scalar("ID")))
message.field("body", NonNull(Scalar("String")))
let root = feed.object("Subscription")
root.field("messageAdded", NonNull(Named("Message")))
feed.set_subscription("Subscription")
let subs = @moongql.Subscribers::new()
subs.field("Subscription", "messageAdded", fn(_info) {
// one Json payload per event, in delivery order
[
Json({ "id": Json("1"), "body": Json("hello") }),
Json({ "id": Json("2"), "body": Json("again") }),
]
})
let payloads = @moongql.execute_subscription(
feed,
@moongql.Resolvers::new(),
subs,
"subscription { messageAdded { id body } }",
)
assert_eq(payloads.length(), 2)
assert_eq(
payloads[0].stringify(),
"{\"data\":{\"messageAdded\":{\"id\":\"1\",\"body\":\"hello\"}}}",
)
}test {
let loader : @moongql.DataLoader[Int, String] = @moongql.DataLoader::new(
fn(ids) { ids.map(fn(id) { "author#" + id.to_string() }) },
fn(id) { id.to_string() },
)
loader.load(1)
loader.load(2)
loader.load(1) // three requests, two keys
loader.dispatch() // one batch call for {1, 2}
assert_eq(loader.get(1).unwrap_or(""), "author#1")
}test {
let db = @orm.Database::new(() => @moonsqlite.SqliteDriver::open("blog.db"), max_size=8)
let res = @moongql.Resolvers::new()
res.field("Query", "posts", fn(info) {
Json(db.select(info, "posts", ["id", "title", "views", "author_id"], keep=["author_id"]))
})
}test {
let db = @orm.Database::new(
() => @moonsqlite.SqliteDriver::open("blog.db"),
max_size=8,
)
let res = @moongql.Resolvers::new()
res.field("Query", "posts", fn(info) {
let rows = db.select(info, "posts", ["id", "title", "author_id"], keep=["author_id"])
Json(db.attach(rows, "author", "authors", ["id", "name"], parent_key="author_id"))
})
}test {
let handler = @moongql.graphql_handler(schema, resolvers) // path defaults to /graphql
let app = @moongql.graphql_app(schema, resolvers) // the same, lifted onto an AsgiApp
}test {
let app = @moongql.graphql_app(schema, resolvers) // the MoonQL page
let app = @moongql.graphql_app(schema, resolvers, ide=@moongql.graphiql)
let app = @moongql.graphql_app(schema, resolvers, ide=@moongql.apollo_sandbox)
let app = @moongql.graphql_app(schema, resolvers, ide=@moongql.reference)
let app = @moongql.graphql_app(schema, resolvers, ide=@moongql.off) // no IDE, GET without ?query= is 405
}test {
let page = @moongql.reference.html(endpoint="/graphql", schema=schema)
assert_eq(page.contains("<title>Schema reference</title>"), true)
assert_eq(page.contains("id=\"User\""), true)
assert_eq(page.contains("hello"), true)
}test {
let client = @moonasgi.TestClient::new(@moongql.graphql_handler(schema, resolvers))
let body = @utf8.encode(
"{\"query\":\"{ user(id: \\\"1\\\") { name } }\"}",
)
let resp = client.post("/graphql", body~, headers=[
("content-type", "application/json"),
("accept", "application/graphql-response+json"),
])
// resp.status == 200, resp.text() == {"data":{"user":{"name":"Alice"}}}
}test {
let subs = @moongql.Subscribers::new()
subs.field("Subscription", "messageAdded", fn(_info) { [] })
let ws = @moongql.GqlWs::new(schema, resolvers, subs, init_timeout=3000, ping_interval=12000)
let handler = ws.handler() // a moonasgi WebSocketHandler
let quiet = ws.tick(4_000) // the server's clock, as often as it likes
let frames = ws.tick(20_000)
assert_eq(quiet.length(), 0)
assert_eq(frames.length() > 0, true) // 4408: no connection_init, so it closes
}async test "an asynchronous resolver answers the same response" {
let async_resolvers = @moongql.Resolvers::new()
async_resolvers.field("Query", "hello", fn(_info) { Json("hello") })
async_resolvers.field_async("Query", "user", async fn(info) {
let id : String = info.read("id")
Json({ "id": Json(id), "name": Json("Alice"), "tags": Json([]) })
})
let response = @moongql.execute_async(
schema,
async_resolvers,
"{ hello user(id: \"1\") { name } }",
)
assert_eq(
response.stringify(),
"{\"data\":{\"hello\":\"hello\",\"user\":{\"name\":\"Alice\"}}}",
)
}resolvers.permission("Query", "user", fn(info : @moongql.ResolveInfo) {
info.ctx is Object(m) && m.get("admin") == Some(Json(true))
})test {
let projected = @moongql.Resolvers::new()
projected.field("Query", "user", fn(
info : @moongql.ResolveInfo
) -> Json raise @moongql.FieldError {
let id : String = info.read("id")
// a projection built from what the document asks for
let name = if info.reads("name") { "Alice" } else { "" }
Json({ "id": Json(id), "name": Json(name) })
})
assert_eq(
@moongql.execute(schema, projected, "{ user(id: \"1\") { name } }").stringify(),
"{\"data\":{\"user\":{\"name\":\"Alice\"}}}",
)
}async test "a deferral answers twice" {
let payloads = payloads_of_deferred_user()
assert_eq(payloads.length(), 2)
assert_eq(
payloads[0].stringify(),
"{\"data\":{\"user\":{\"id\":\"1\"}},\"pending\":[{\"id\":\"0\",\"path\":[\"user\"],\"label\":\"late\"}],\"hasNext\":true}",
)
assert_eq(
payloads[1].stringify(),
"{\"incremental\":[{\"id\":\"0\",\"data\":{\"name\":\"Alice\",\"tags\":[\"admin\"]}}],\"completed\":[{\"id\":\"0\"}],\"hasNext\":false}",
)
}
async fn payloads_of_deferred_user() -> Array[Json] {
let s = @moongql.Schema::new()
let query = s.object("Query")
query.field("user", Named("User"))
let user = s.object("User")
user.field("id", NonNull(Scalar("ID")))
user.field("name", NonNull(Scalar("String")))
user.field("tags", ListOf(NonNull(Scalar("String"))))
let r = @moongql.Resolvers::new()
r.field("Query", "user", fn(_info) {
Json({ "id": Json("1"), "name": Json("Alice"), "tags": Json(["admin"]) })
})
@moongql.execute_incremental(
s,
r,
"{ user { id ... @defer(label: \"late\") { name tags } } }",
) catch {
_ => []
}
}test {
let subs = @moongql.Subscribers::new()
let feed = @moongql.Stream::channel()
subs.stream("Subscription", "messageAdded", fn(
_info
) -> @moongql.Stream raise @moongql.FieldError {
feed.0
})
// anywhere else, whenever something happens
feed.1.send(Json({ "id": Json("1"), "body": Json("hello") })) catch {
_ => ()
}
}async test {
let client = @client.Client::new(
url="https://example.com/graphql",
headers=[("authorization", "Bearer ...")],
)
let data = client.query("{ user(id: \"1\") { name } }")
// `request` answers the response as it came, errors and all
let whole = client.request("{ user(id: \"1\") { name } }")
let answers = client.batch(["{ hello }", "{ hello }"])
assert_eq(answers.length(), 2)
assert_eq(data is Object(_), true)
assert_eq(whole is Object(_), true)
}test {
let tools = @mcp.Mcp::of(schema=schema, resolvers=resolvers)
let server = tools.server(name="shop")
// mount it where an agent reaches it
let asgi = server.to_asgi()
let _ = asgi
}test {
let open = @mcp.Mcp::of(
schema=schema,
resolvers=resolvers,
exposure={ prefix: "shop_", mutations: false, depth: 3, width: 20 },
)
let named = open.server(name="shop", version="1.0.0")
let _ = named
}test {
let fed = @moongql.Federation::new()
fed.entity(name="User", key="id", resolve=(rep, _ctx) => {
let id = match rep {
Object(m) =>
match m.get("id") {
Some(String(s)) => s
_ => ""
}
_ => ""
}
// materialise from the key
Json({ "id": Json(id), "name": Json("User#" + id), "tags": Json([]) })
})
fed.apply(schema, resolvers)
// { _service { sdl } } -> the subgraph SDL with `type User @key(fields: "id")`
// _entities(representations: [{ __typename: "User", id: "7" }]) -> the User object
assert_eq(fed.sdl(schema).contains("@key(fields: \"id\")"), true)
}test {
let fed = @moongql.Federation::new(v2=true)
fed.entity(name="Product", key="upc", extends=true, external=["weight"],
resolve=(rep, _ctx) => {
// @requires(fields: "weight"): the gateway ships the external weight in the
// representation, so the resolver can read it to compute the estimate.
let weight = match rep {
Object(m) =>
match m.get("weight") {
Some(Number(w, ..)) => w
_ => 0.0
}
_ => 0.0
}
let upc = match rep {
Object(m) =>
match m.get("upc") {
Some(String(u)) => u
_ => ""
}
_ => ""
}
Json({ "upc": Json(upc), "shippingEstimate": Json(weight * 2.0) })
})
fed.shareable("Product", "name")
fed.override_("Product", "price", from="legacy")
fed.requires("Product", "shippingEstimate", fields="weight")
fed.inaccessible("User", "ssn") // present in the subgraph, hidden from the composed schema
fed.apply(schema, resolvers)
}test {
let fed = @moongql.Federation::new(v2=true)
fed.tag("Product", name="catalog")
fed.tag_field("Product", "weight", name="measured")
}test {
let product_resolvers = @moongql.Resolvers::new()
product_resolvers.field("Product", "shipping", fn(
info : @moongql.ResolveInfo
) -> Json raise @moongql.FieldError {
// `weight` is @external: the gateway sent it with the representation.
let weight = match info.parent {
Object(m) =>
match m.get("weight") {
Some(Number(w, ..)) => w
_ => 0.0
}
_ => 0.0
}
Json(weight * 2.0)
})
}test {
let fed = @moongql.Federation::new(v2=true)
// an empty list means the gateway can compose this subgraph
assert_eq(fed.check(schema).length(), 0)
}test {
let text : @moongql.GqlType = NonNull(Scalar("String"))
schema.directive(
"prefix",
locations=["FIELD"],
args=[("text", text)],
on_field=(value : Json, args : Map[String, Json]) => match
(value, args.get("text")) {
(String(x), Some(String(p))) => Json(p + x)
_ => value
},
)
assert_eq(schema.to_sdl().contains("directive @prefix(text: String!) on FIELD"), true)
}test {
let text : @moongql.GqlType = NonNull(Scalar("String"))
let times : @moongql.GqlType = Scalar("Int")
schema.directive(
"repeat",
locations=["FIELD"],
args=[("text", text), ("times", times)],
defaults=[("times", Json(1))],
description="Repeats the prefix.",
)
assert_eq(
schema.to_sdl().contains("directive @repeat(text: String!, times: Int = 1) on FIELD"),
true,
)
}| Rule | Spec |
|---|---|
| Operation name uniqueness | §5.2.1.1 |
| Lone anonymous operation | §5.2.2.1 |
| Argument uniqueness | §5.4.2 |
| Fragment name uniqueness | §5.5.1.1 |
| Fragments must be used | §5.5.1.4 |
| Fragment spreads must not form cycles | §5.5.2.2 |
| Directives are defined | §5.7.1 |
| Directives are in valid locations | §5.7.2 |
| Directive repeatability | §5.7.3 |
| Variable uniqueness | §5.8.1 |
| All variables used | §5.8.4 |
| Executable definitions (§5.1.1: a document carrying the type system is refused by name) | §5.1.1 |
test {
let checked = @moongql.Schema::new()
checked.object("Query").field("hello", NonNull(Scalar("String")))
// one message per problem, and none at all when the schema holds
assert_eq(checked.check().length(), 0)
}moon build --target native
cd scripts/audit && npm install
node run-audit.mjs # the graphql-http specification audit
node run-differential.mjs # the same corpus through graphql-js, compared
node run-incremental.mjs # deferred and streamed payloads, replayedfn create_source_event_stream(schema : Schema, resolvers : Resolvers, subscribers : Subscribers, query : String, variables? : Map[String, Json], operation_name? : String, root_value? : Json, context? : Json) -> SubscriptionResultfn graphiql_html(endpoint? : String) -> Stringfn graphql_ws_handler(schema : Schema, resolvers : Resolvers, subscribers : Subscribers, root_value? : Json, context? : Json) -> WebSocketHandlerInstall
Download zipCode-first GraphQL for MoonBit.