Skip to Content

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-yoga

graphql and GraphQL Yoga are peer dependencies. An app that does not use GraphQL installs none of this.

A resolver class

books/books.resolver.ts
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:

books/books.routes.ts
@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 Mutation and extend type Subscription. Suku declares the root types themselves.
  • A field's name is the class property's name. Use name when 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.

FieldValue
argsThe field's arguments: the schema's output, or the raw arguments without one.
parentThe object the field belongs to. undefined for queries and mutations.
ctxThe request context: cookies, headers, shared state.
infoGraphQL's resolve info.
servicesOne 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.

SituationWhat the client receives
An invalid query200, with errors in the body
A resolver throws GraphQLError200, with your message and extensions
Arguments fail their schema200, with code BAD_USER_INPUT and the issues
A resolver throws anything else200, with the detail hidden
A middleware rejects before GraphQL runsThe 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: [] },
});
OptionDefaultMeaning
path/graphqlWhere the API is served.
graphiqloff in productionThe in-browser IDE.
maskedErrorstrueHide unexpected errors from clients.
yogaFurther 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 featureWith GraphQL
Services and lifetimesWork, through the constructor and services.
FeaturesWork. The schema is assembled from registered resolvers.
App-level middlewaresWork: authentication, CORS, rate limiting, security headers.
ctx.cookies, ctx.setHeaderWork, from any resolver.
Feature-level middlewares and onErrorDo not apply. GraphQL is one route.
bodyLimitDoes not apply. Yoga reads the request itself.
The route tableOne 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 services option.
  • 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.

On this page