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.