GraphQL
Class-based resolvers with injection and typed arguments, organised by feature.
@sukujs/graphql brings GraphQL to Suku in the same shape as REST. A resolver class looks like
a @Routes class: services through the constructor, one field per resolver,
and a schema that types the arguments.
bun add @sukujs/graphql graphql graphql-yogagraphql and GraphQL Yoga are peer
dependencies. An app that does not use GraphQL installs none of this.
A resolver class
import { GraphqlResolver, mutation, query } from '@sukujs/graphql';
import { z } from 'zod';
@GraphqlResolver({
typeDefs: /* GraphQL */ `
type Book {
id: ID!
title: String!
}
extend type Query {
books: [Book!]!
book(id: ID!): Book
}
extend type Mutation {
addBook(title: String!): Book!
}
`,
})
export class BooksResolver {
constructor(private readonly books: BooksService) {}
books = query({ handle: () => this.books.all() });
book = query({
args: z.object({ id: z.string() }),
handle: ({ args }) => this.books.find(args.id) ?? null,
});
addBook = mutation({
args: z.object({ title: z.string().min(2) }),
handle: ({ args }) => this.books.add(args.title),
});
}Side by side with the REST class for the same feature:
@Routes('/books')
export class BooksRoutes {
constructor(private readonly books: BooksService) {}
list = route.get('/', { handle: () => this.books.all() });
create = route.post('/', {
body: z.object({ title: z.string().min(2) }),
handle: ({ body }) => this.books.add(body.title),
});
}Adding it to an app
A resolver class is registered like any service. The graphql() route finds every registered
resolver by itself.
import { graphql } from '@sukujs/graphql';
export const BooksFeature = feature('books', {
routes: [BooksRoutes],
services: [BooksService, BooksResolver],
});
const app = await Suku.create({
features: [BooksFeature],
routes: [graphql()],
adapter: nodeAdapter(),
});The schema follows your features. Add a feature and its fields appear; remove it and they are gone, exactly as its routes would be.
Writing the schema
Each class owns one slice of the schema, in GraphQL's own language.
- Declare your types normally:
type Book { ... }. - Add to the root types with
extend type Query,extend type Mutationandextend type Subscription. Suku declares the root types themselves. - A field's name is the class property's name. Use
namewhen they must differ.
allBooks = query({ name: 'books', handle: () => this.books.all() });Every field you resolve must appear in a class's typeDefs, and a field defined twice is an
error.
What a resolver receives
One object, as in a handler.
| Field | Value |
|---|---|
args | The field's arguments: the schema's output, or the raw arguments without one. |
parent | The object the field belongs to. undefined for queries and mutations. |
ctx | The request context: cookies, headers, shared state. |
info | GraphQL's resolve info. |
| services | One field per entry in services. |
Arguments
Give a resolver an args schema and they are validated and typed.
addBook = mutation({
args: z.object({ title: z.string().min(2), year: z.number().int().optional() }),
handle: ({ args }) => this.books.add(args.title, args.year), // string, number | undefined
});GraphQL already checks the argument types declared in the schema. The args schema adds what
GraphQL cannot express, such as a minimum length. A failure is returned as a GraphQL error:
{
"errors": [{
"message": "Invalid arguments for Mutation.addBook",
"extensions": { "code": "BAD_USER_INPUT", "issues": [{ "path": "title", "message": "..." }] }
}]
}Fields on your own types
Use objectType for a field that needs its own resolver, such as a relation. The type
parameter is the shape of the parent object.
import { objectType } from '@sukujs/graphql';
const BookType = objectType<Book>('Book');
@GraphqlResolver({ typeDefs: 'extend type Book { author: Author! } type Author { id: ID! name: String! }' })
export class BookAuthorResolver {
author = BookType.field({
services: { authors: AuthorLoader },
handle: ({ parent, authors }) => authors.load(parent.authorId),
});
}Services
Services the whole class needs come through the constructor. A resolver class is a singleton,
so request-scoped services are asked for per resolver, the same rule as a @Routes class.
me = query({
services: { currentUser: CurrentUser }, // scoped: one per request
handle: ({ currentUser }) => currentUser.profile(),
});A scoped service is created once per GraphQL request and shared by every resolver in it. That is the lifetime a data loader needs: batch and cache within one request, start fresh on the next.
export const AuthorLoader = service('author-loader', {
lifetime: 'scoped',
services: { db: Database },
create: ({ db }) => new DataLoader((ids: readonly string[]) => db.authors.findMany(ids)),
});Authentication
Authenticate in a Suku middleware, the one your REST routes use, and read the result in resolvers.
const authenticate = middleware(async (ctx, next) => {
const token = bearerToken(ctx.headers.get('authorization'));
if (token) ctx.set('viewer', await tokens.verify(token).catch(() => undefined));
return next();
});
Suku.create({ features, routes: [graphql()], middlewares: [authenticate] });import { GraphQLError } from 'graphql';
addBook = mutation({
args: AddBook,
handle: ({ args, ctx }) => {
if (ctx.get<Viewer>('viewer')?.role !== 'editor') {
throw new GraphQLError('Only editors can add books', { extensions: { code: 'FORBIDDEN' } });
}
return this.books.add(args.title);
},
});Cookies and headers
ctx works as it does in a handler.
login = mutation({
args: Login,
services: { sessions: Sessions },
handle: async ({ args, ctx, sessions }) => {
const session = await sessions.create(args.email, args.password);
ctx.cookies.set('session', session.id, { maxAge: 604_800 });
return true;
},
});Subscriptions
The handler is an async generator, and each value it yields is one event.
countdown = subscription({
args: z.object({ from: z.number().int().min(0).max(60) }),
handle: async function* ({ args }) {
for (let i = args.from; i >= 0; i--) yield i;
},
});They are served over server-sent events. A client asks with Accept: text/event-stream.
Errors
GraphQL has its own error model. Suku's error handlers are not involved inside a GraphQL operation.
| Situation | What the client receives |
|---|---|
| An invalid query | 200, with errors in the body |
A resolver throws GraphQLError | 200, with your message and extensions |
| Arguments fail their schema | 200, with code BAD_USER_INPUT and the issues |
| A resolver throws anything else | 200, with the detail hidden |
| A middleware rejects before GraphQL runs | The normal Suku error response |
Throw GraphQLError for errors a client should see. Anything else is treated as a bug and
masked.
Options
graphql({
path: '/graphql',
graphiql: false,
maskedErrors: true,
yoga: { plugins: [] },
});| Option | Default | Meaning |
|---|---|---|
path | /graphql | Where the API is served. |
graphiql | off in production | The in-browser IDE. |
maskedErrors | true | Hide unexpected errors from clients. |
yoga | Further GraphQL Yoga options, such as plugins. |
GraphiQL and strict security headers
GraphiQL is an HTML page with inline scripts. The default policy of
securityHeaders forbids those, so the page will be
blank. Loosen the policy in development, or leave GraphiQL off.
Failing at startup
The schema is built on the first request. To catch a mistake before the server listens, build it yourself.
import { createGraphqlSchema } from '@sukujs/graphql';
const app = await Suku.create({ features, routes: [graphql()], adapter });
createGraphqlSchema(app.provider); // throws GraphqlSetupError if anything is wrong
await app.listen(3000);It reports a resolver with no fields, a field defined twice, an unregistered service, and a schema that does not match its resolvers.
What applies and what does not
| Suku feature | With GraphQL |
|---|---|
| Services and lifetimes | Work, through the constructor and services. |
| Features | Work. The schema is assembled from registered resolvers. |
| App-level middlewares | Work: authentication, CORS, rate limiting, security headers. |
ctx.cookies, ctx.setHeader | Work, from any resolver. |
Feature-level middlewares and onError | Do not apply. GraphQL is one route. |
bodyLimit | Does not apply. Yoga reads the request itself. |
| The route table | One entry for the whole GraphQL API. |
rateLimit counts requests, and one GraphQL request can do a
great deal of work. Add a query depth or complexity limit through yoga.plugins.
Limits
- Schema-first only. The schema is written in SDL. There is no code-first type system and there are no decorators on fields.
- Resolver classes are singletons. A scoped resolver class is rejected with an error
pointing to the
servicesoption. - Return types are not checked. TypeScript does not verify that a resolver returns what the SDL declares. A GraphQL code generator can add that.
- Server-sent events only for subscriptions. WebSocket transports are a separate server.
Without the package
@sukujs/graphql is a thin layer. Any GraphQL server that speaks Fetch mounts on a route by
hand, which is also how to use a different GraphQL library.
const yoga = createYoga<{ ctx: RequestContext }>({ schema, graphqlEndpoint: '/graphql', cors: false });
export const graphqlRoute = route.all('/graphql', {
handle: ({ request, ctx }) => yoga.fetch(request, { ctx }),
});Leave the route without a body schema, since the server must read the request itself.
examples/graphql in the repository is a complete app built this way.