Skip to content

Apollo Server interop

Use @nwire/apollo to plug Nwire actions into an existing Apollo Server schema as field resolvers. Same migration story as Express — keep the schema, drop the boilerplate resolvers.

When to use this

You already run Apollo (gateway, federation, persisted queries, custom directives — anything) and you want to start adopting Nwire one resolver at a time. If you're greenfield-building GraphQL, watch for the native transport in @nwire/http instead.

Install

bash
pnpm add @nwire/apollo @apollo/server graphql

Apollo Server v4 + GraphQL ^16 are peer deps. Apollo v3 is EOL and not supported.

The 30-second example

ts
import { z } from "zod";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import gql from "graphql-tag";
import { defineAction, Runtime } from "@nwire/forge";
import { actionResolver, mountNwireOnApollo } from "@nwire/apollo";

const submitAnswer = defineAction({
  name: "submissions.submit-answer",
  input: z.object({ questionId: z.string(), answer: z.string() }),
  handler: async (ctx) => {
    const { input } = ctx;
    return {
      id: "sub-1",
      ...input,
      student: ctx.envelope.userId,
    };
  },
});

const runtime = new Runtime();
runtime.registerHandler(submitAnswer);

const typeDefs = gql`
  input SubmitAnswerInput { questionId: String!, answer: String! }
  type Submission       { id: ID!, questionId: String!, answer: String!, student: String }
  type Mutation         { submitAnswer(input: SubmitAnswerInput!): Submission! }
  type Query            { _: Boolean }
`;

const apollo = new ApolloServer({
  typeDefs,
  resolvers: {
    Mutation: { submitAnswer: actionResolver(submitAnswer) },
  },
  ...mountNwireOnApollo({ runtime }),
});

await startStandaloneServer(apollo, { listen: { port: 4000 } });

That's it. The GraphQL field submitAnswer is now implemented by the Nwire action — same handler that runs from the REST transport, the queue worker, or the CLI.

Pulling user / tenant out of the HTTP request

mountNwireOnApollo accepts an extractEnvelope callback. Apollo's standalone server passes { req, res } to the context factory:

ts
import { seedEnvelope } from "@nwire/envelope";
import { mountNwireOnApollo } from "@nwire/apollo";

const apollo = new ApolloServer({
  typeDefs,
  resolvers,
  ...mountNwireOnApollo<{ req: import("http").IncomingMessage }>({
    runtime,
    extractEnvelope: ({ req }) =>
      seedEnvelope({
        userId: req.headers["x-user-id"] as string | undefined,
        tenant: req.headers["x-tenant-id"] as string | undefined,
      }),
  }),
});

When Apollo runs as Express middleware (@apollo/server/express4), use the same shape — the context arg is { req, res } from Express.

Error contract

@nwire/forge ships defineError(...) — a stable, throwable error value with a code, status, and summary:

ts
import { defineError } from "@nwire/forge";

export const QuestionLocked = defineError({
  code: "QUESTION_LOCKED",
  status: 423,
  summary: "Question is locked for further submissions.",
});

A handler throw QuestionLocked surfaces over GraphQL as:

json
{
  "errors": [{
    "message": "Question is locked for further submissions.",
    "extensions": { "code": "QUESTION_LOCKED", "status": 423 }
  }]
}

mountNwireOnApollo registers formatNwireError for you. Clients can branch on extensions.code — the same code the REST transport sets in its JSON error envelope.

Per-resolver overrides

actionResolver accepts an options bag:

ts
actionResolver(listSubmissions, {
  // Args shape doesn't match the default `input` envelope.
  extractInput: (args) => ({ studentId: args.studentId, status: args.status }),
  // Sometimes the runtime lives on a sub-object of the GQL context.
  resolveRuntime: (ctx) => ctx.deps.runtime,
})

When the defaults don't fit, override one knob at a time. Everything else stays in the action — the resolver is still a one-liner.

See also

MIT licensed.