moongqlGraphQL · MoonBit
Guide

From one type to a served endpoint.

Nine steps, in the order a program grows: a type and a resolver, the schema as text, annotations instead of hand-written builders, then variables, errors, subscriptions, batching, federation, and the endpoint that serves all of it. Every output below is what the matching example in src/examples prints.

00Add it

One line, and the same code runs on wasm, wasm-gc, js and native.

moon add moonbitstack/moongql

01A type, a resolver, a query

A schema is an object you build. A resolver is a function registered under "Type.field"; the field's declared type tells the executor whether to treat the result as a leaf, a list or an object, so a resolver only has to return JSON.

let schema = Schema::new()
let query = schema.object("Query")
query.field("greeting", NonNull(Scalar("String")))
query.field("luckyNumber", NonNull(Scalar("Int")))

let resolvers = Resolvers::new()
resolvers.field("Query", "greeting", fn(_info) { Json("qwq from moongql") })
resolvers.field("Query", "luckyNumber", fn(_info) { Json(233) })

let res = execute(schema, resolvers, "{ greeting luckyNumber }")
println(res.stringify())

prints

{"data":{"greeting":"qwq from moongql","luckyNumber":233}}

02The same schema, as text

to_sdl writes the type system out — descriptions, argument defaults, @deprecated(reason:), directives and the schema block. Reading it back with Schema::of_sdl gives the same schema, so the next print is identical.

let s = Schema::of_sdl(
  #|type Query {
  #|  "Whoever is asking."
  #|  me: User
  #|}
  #|type User {
  #|  id: ID!
  #|  legacyName: String! @deprecated(reason: "use name")
  #|}
)
s.to_sdl()

prints the description and the deprecation back, unchanged

type Query {
  "Whoever is asking."
  me: User
}

type User {
  id: ID!
  legacyName: String! @deprecated(reason: "use name")
}

03Write the types you already have

Instead of building the schema by hand, annotate ordinary MoonBit types. The generator emits register(schema, resolvers), which adds them to a schema you own — the two styles mix, and the SDL either way is byte-for-byte the same.

#gql.type(description="Someone who writes books")
pub struct Author {
  id : Int64
  name : String
  #gql.skip
  email : String
}

#gql.field(description="Books by this author")
fn Author::books(self : Author) -> Array[Book] { ... }

An enum whose constructors each wrap one annotated struct is a union of them, or an interface they implement with the fields it names; every member has those fields, written the same way.

#gql.interface(field="id", field="title")
pub enum Work { Printed(Book); Periodical(Magazine) }

#gql.union
pub enum Hit { Writer(Author); Title(Book) }

register it, then query it

let schema = Schema::new()
let resolvers = Resolvers::new()
register(schema, resolvers)
execute(schema, resolvers, "{ authors { name books { title } } }")

04Variables, fragments, directives

A document can take its values from outside. Variables are declared in the operation and passed as a map; fragments and @skip / @include work as the specification describes.

let vars : Map[String, Json] = { "uid": "1" }
execute(schema, resolvers,
  "query ($uid: ID!) { hero: user(id: $uid) { ...who } } fragment who on User { id name }",
  variables=vars)

prints

{"data":{"hero":{"id":"1","name":"Alice"}}}

05Errors carry their place

A field error is a value, not an exception: it lands in errors with the path into the result and the line and column of the field it is about. A non-null field that fails propagates to its nearest nullable parent, as the specification requires.

{"data":null,"errors":[{"message":"Cannot return null for non-nullable field",
  "locations":[{"line":1,"column":3}],"path":["mustFail"]}]}

{"data":{"boom":null},"errors":[{"message":"plain failure",
  "locations":[{"line":1,"column":3}],"path":["boom"]}]}

A server's own entry rides alongside the result as extensions — a trace id, a timing, whatever the deployment wants to say.

06Subscriptions

A source stream returns the payloads a subscription produces; the executor walks each one with the selection set. Over the wire the same thing is carried by graphql-transport-ws: connection_init, subscribe, then next for each payload and complete at the end.

let stream = create_source_event_stream(resolvers, "Subscription", "countdown", info)
execute_subscription(schema, stream, document)

07Batch the N+1 away

A DataLoader queues the keys a resolution pass asks for, dedupes them, and runs one batch over the distinct set — so a list of twenty authors costs one query, not twenty.

let loader = DataLoader::new(fn(keys) { fetch_authors(keys) })
let a = loader.load("1")
let b = loader.load("2")
loader.dispatch()   // one call for both keys

08Subgraphs that compose

Register entities with @key and their reference resolvers; the subgraph then answers _service { sdl } and _entities(representations:), and a gateway can join it to its siblings.

let fed = Federation::new()
fed.entity("User", "id")   // @key(fields: "id")
fed.reference("User", fn(rep) { find_user(rep) })
fed.install(schema)

09Serve it

graphql_handler turns the schema into a handler on the ASGI seam: a POST reads { query, variables } and answers { data, errors }; a GET without ?query= serves the IDE — the library's own page, with nothing fetched from a CDN. The endpoint defaults to /graphql and the port to 12000.

let app = graphql_app(schema, resolvers)                        // the MoonQL page
let app = graphql_app(schema, resolvers, ide=@moongql.graphiql)
let app = graphql_app(schema, resolvers, ide=@moongql.off)      // no IDE: 405

a POST, and what comes back

content-type: application/json
{"query":"{ user(id: \"1\") { id name } }"}

200 application/json
{"data":{"user":{"id":"1","name":"lfanke"}}}

Run moon run src/examples/22-http-server for the whole transport, or open the showcase to query a schema in the browser.