moongqlGraphQL · MoonBit
Written as
every block below switches with it
Guide

From one type to a served endpoint.

Eleven steps, in the order a program grows: the types, what resolves their fields, the roots, an input object and the errors a resolver can raise, subscriptions, federation, the schema as text, then the endpoint that serves all of it. The code comes from src/examples/guide and the output is what that program prints.

00Add it

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

moon add moonbitstack/moongql

The three packages beside this guide build one server each — the same types, the same answers, three ways of writing them. src/examples/guide/check runs every document below against all three and stops if they disagree, so what you see here is the output of the way you picked.

01One type, three ways

A schema is types with fields. Write them as #gql annotations on the structs you already have, as calls on the schema, or as types with an impl saying how they cross the wire. The switch at the top of the page picks which way every block here is written; all three build the same schema and answer the same documents.

annotations

// A struct's fields become the type's fields. `key=` makes it a federated
// entity; `description=` is what introspection reports.
#gql.type(description="Someone who writes books")
pub struct Author {
  id : String
  name : String
}

#gql.type(key="id")
pub struct Book {
  id : String
  title : String
  author_id : String
}

The generator reads the annotations and writes the calls the other two ways write by hand.

functions

let author = schema.object("Author", description="Someone who writes books")
author.field("id", NonNull(Scalar("String")))
author.field("name", NonNull(Scalar("String")))

let book = schema.object("Book")
book.field("id", NonNull(Scalar("String")))
book.field("title", NonNull(Scalar("String")))
// The author id is data on the type, and what `Author.books` is resolved from.
book.field("authorId", NonNull(Scalar("String")))

Each call names a type and gives it a field; a field's type says how its value is read.

classes

// A struct holds the type's data; the two impls say how it crosses the wire,
// under the names the schema uses.
pub(all) struct Author {
  id : String
  name : String
}

pub impl @moongql.ToGql for Author with fn to_gql(self) {
  { "id": self.id, "name": self.name }
}

pub impl @moongql.FromGql for Author with fn from_gql(v) {
  { id: @moongql.read(v, "id"), name: @moongql.read(v, "name"), }
}

pub extend Author with @moongql.ToGql::{to_gql}

pub extend Author with @moongql.FromGql::{from_gql}

pub(all) struct Book {
  id : String
  title : String
  author_id : String
}

pub impl @moongql.ToGql for Book with fn to_gql(self) {
  { "id": self.id, "title": self.title, "authorId": self.author_id }
}

pub impl @moongql.FromGql for Book with fn from_gql(v) {
  {
    id: @moongql.read(v, "id"),
    title: @moongql.read(v, "title"),
    author_id: @moongql.read(v, "authorId"),
  }
}

pub extend Book with @moongql.ToGql::{to_gql}

pub extend Book with @moongql.FromGql::{from_gql}

The two impls are exactly what the generator writes for an annotated struct; writing them yourself is how a type joins the executor.

02A field that resolves

A field whose value is not already on the parent is resolved by a function: a method on the type, or a closure registered under "Type.field". The field's declared type tells the executor whether what comes back is a leaf, a list or an object.

annotations

// A method is a field of its type, asked on the value it belongs to.
#gql.field(description="Their books, in the order they were added")
fn Author::books(self : Author) -> Array[Book] {
  shelf.filter(b => b.author_id == self.id)
}

functions

author.field("books", NonNull(ListOf(NonNull(Named("Book")))))

resolvers.field("Author", "books", info => {
  let id = key_of(info.parent, "id")
  Json(shelf.filter(b => key_of(b, "authorId") == id))
})

classes

// A method is a field: it is asked on the value it belongs to, and whatever it
// returns is turned back into JSON.
fn Author::books(self : Author) -> Array[Book] {
  shelf.filter(b => b.author_id == self.id)
}

resolvers.field("Author", "books", info => {
  @moongql.ToGql::to_gql(
    Author::books(@moongql.FromGql::from_gql(info.parent)),
  )
})

Only the fields the document selects are resolved, and a resolver's value is read under the names the schema uses — which is why author_id is written out as authorId once, in the conversion.

03Root fields and arguments

Query is a type like any other, and its fields are the entry points. An argument is declared with its type, and a resolver reads it by name.

annotations

// A free function is a root field; its parameters are the field's arguments.
#gql.query
fn author(id : String) -> Author? {
  writers.iter().find_first(a => a.id == id)
}

#gql.query
fn books() -> Array[Book] {
  shelf.copy()
}

functions

let query = schema.object("Query")
query.field_args(
  "author",
  [("id", NonNull(Scalar("String")))],
  Named("Author"),
)
query.field("books", NonNull(ListOf(NonNull(Named("Book")))))

resolvers.field("Query", "author", info => {
  let id = info.arg("id")
  match writers.iter().find_first(a => key_of(a, "id") == id) {
    Some(a) => a
    None => Json::null()
  }
})
resolvers.field("Query", "books", _info => Json(shelf.copy()))

classes

// The roots are types too — the operations hang off them.
pub(all) struct Query {}

fn Query::author(id : String) -> Author? {
  writers.iter().find_first(a => a.id == id)
}

fn Query::books() -> Array[Book] {
  shelf.copy()
}

resolvers.field("Query", "author", info => {
  @moongql.ToGql::to_gql(Query::author(info.read("id")))
})
resolvers.field("Query", "books", _info => {
  @moongql.ToGql::to_gql(Query::books())
})

recorded · the same answer from all three

{"data":{"author":{"name":"Ursula","books":[{"title":"The Dispossessed"},{"title":"Lathe"}]}}}
{"data":{"books":[{"title":"The Dispossessed"},{"title":"Lathe"},{"title":"Exhalation"}]}}

A nullable argument may be left out, and a nullable field may answer null; a non-null field that answers null is a field error, and it takes the nearest nullable parent with it.

04An input object, a mutation, an error

A mutation takes its payload as an input object: a type whose fields are the caller's to send. A resolver that declares raise may fail, and its error becomes a field error carrying the path and the place in the document.

annotations

// What a mutation takes: an input object is a struct too, and every field of it
// is data the caller sends.
#gql.input
pub struct NewBook {
  title : String
  author_id : String
}

`#gql.input` and `#gql.mutation`; the input struct's reading side is generated with it.

functions

let new_book = schema.input("NewBook")
new_book.field("title", NonNull(Scalar("String")))
new_book.field("authorId", NonNull(Scalar("String")))

let mutation = schema.object("Mutation")
mutation.field_args(
  "addBook",
  [("input", NonNull(Named("NewBook")))],
  NonNull(Named("Book")),
)
schema.set_mutation("Mutation")

The input is a type with fields, and the payload is one argument declared with it.

classes

// What a mutation takes: a struct whose fields are the caller's to send.
pub(all) struct NewBook {
  title : String
  author_id : String
}

pub impl @moongql.FromGql for NewBook with fn from_gql(v) {
  { title: @moongql.read(v, "title"), author_id: @moongql.read(v, "authorId"), }
}

pub extend NewBook with @moongql.FromGql::{from_gql}

The input is a struct with the reading half only — nothing sends one back out.

annotations

// A resolver that declares `raise` may fail; the error becomes a field error in
// the response, with the path and the place in the document.
#gql.mutation
fn add_book(input : NewBook) -> Book raise {
  if input.title == "" {
    raise @exec.ResolverError("a book needs a title")
  }
  let book : Book = {
    id: "b\{(shelf.length() + 1)}",
    title: input.title,
    author_id: input.author_id,
  }
  shelf.push(book)
  book
}

functions

resolvers.field("Mutation", "addBook", info => {
  let input = info.arg("input")
  let title = match key_of(input, "title") {
    String(t) => t
    _ => ""
  }
  if title == "" {
    raise @exec.ResolverError("a book needs a title")
  }
  let book : Json = {
    "id": Json("b\{shelf.length() + 1}"),
    "title": Json(title),
    "authorId": key_of(input, "authorId"),
  }
  shelf.push(book)
  book
})

classes

pub(all) struct Mutation {}

fn Mutation::add_book(input : NewBook) -> Book raise {
  if input.title == "" {
    raise @exec.ResolverError("a book needs a title")
  }
  let book : Book = {
    id: "b\{(shelf.length() + 1)}",
    title: input.title,
    author_id: input.author_id,
  }
  shelf.push(book)
  book
}

resolvers.field("Mutation", "addBook", info => {
  let result = Mutation::add_book(info.read("input")) catch {
    e => raise @moongql.field_error(e)
  }
  @moongql.ToGql::to_gql(result)
})

recorded · one book added, one refused

{"data":{"addBook":{"id":"b4","title":"Dune"}}}
{"data":null,"errors":[{"message":"a book needs a title","locations":[{"line":1,"column":12}],"path":["addBook"]}]}

05Subscriptions

A subscription field answers with the stream of its events; the executor resolves the selection set against each one. 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.

annotations

// A subscription returns the events it produces, one array; every event becomes
// one response payload.
#gql.subscription
fn book_added() -> Array[Book] {
  shelf.copy()
}

functions

subscription.field("bookAdded", NonNull(Named("Book")))

classes

pub(all) struct Subscription {}

fn Subscription::book_added() -> Array[Book] {
  shelf.copy()
}

let subscribers = @moongql.Subscribers::new()
subscribers.field("Subscription", "bookAdded", _info => {
  Subscription::book_added().map(b => @moongql.ToGql::to_gql(b))
})

recorded · one payload per event

{"data":{"bookAdded":{"title":"The Dispossessed"}}} {"data":{"bookAdded":{"title":"Lathe"}}} {"data":{"bookAdded":{"title":"Exhalation"}}} {"data":{"bookAdded":{"title":"Dune"}}}

Four events, because the mutation above added a fourth book before this ran. A real source would be an event bus; the shape a subgraph fills in is the same.

06Subgraphs that compose

An entity is a type with a @key and a way to turn a representation — a {__typename, key} pair — back into the object. The subgraph then answers _service { sdl } and _entities(representations:), and a gateway can join it to its siblings.

annotations

// With `key=` on the type, a reference function is all a subgraph needs to turn
// a representation back into the object.
#gql.reference
fn book_by_id(id : String) -> Book? raise {
  shelf.iter().find_first(b => b.id == id)
}

functions

let fed = @moongql.Federation::new(v2=true)
fed.entity(name="Book", key="id", resolve=(rep, _ctx) => {
  let id = key_of(rep, "id")
  match shelf.iter().find_first(b => key_of(b, "id") == id) {
    Some(b) => b
    None => Json::null()
  }
})

classes

let fed = @moongql.Federation::new(v2=true)
fed.entity(name="Book", key="id", resolve=(rep, _ctx) => {
  let id : String = @moongql.read(rep, "id")
  match shelf.iter().find_first(b => b.id == id) {
    Some(b) => @moongql.ToGql::to_gql(b)
    None => Json::null()
  }
})

recorded · a representation resolved

{"data":{"_entities":[{"id":"b2","title":"Lathe"}]}}

What comes back is the object, and the fields named by @requires that the gateway sent along stay on it for this subgraph's resolvers to read.

07The 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")
}

08The schema first

Write the SDL and mark it #gql.sdl, or point #gql.sdl(file="schema.graphql") at a file from a declare fn. The generator writes the structs and enums, and a declare fn for every root field, every field with arguments and every field of an object type. The compiler names the ones you have not written.

#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 { ... }

Declare your own struct Book to keep what the schema does not show, such as the id behind Book::author: the generator binds to it instead of writing one.

09Variables, 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"}}}

A document that carries the type system instead — an SDL definition where an operation belongs — is not executable, and validation says so by name rather than letting the executor walk past it.

10Batch 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

11Serve 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.