Building Web Framework Integrations for Apollo Server


This article is for authors of web framework integrations. Before building a new integration, we recommend seeing if there's an integration for your framework of choice that suits your needs.

One of the driving forces behind Apollo Server 4 is the creation of a stable, well-defined API for processing HTTP requests and responses. Apollo Server 4's API enables external collaborators, like you, to build integrations with Apollo Server in their web framework of choice.

Overview

The primary responsibility of an Apollo Server integration is to translate requests and responses between a web framework's native format to the format used by ApolloServer. This article conceptually covers how to build an integration, using the Express integration (i.e.,expressMiddleware) as an example.

For more examples, see these Apollo Server 4 integrations demos for Fastify and Lambda.

If you are building a serverless integration, we strongly recommend prepending your function name with the word start (e.g., startServerAndCreateLambdaHandler(server)). This naming convention helps maintain Apollo Server's standard that every server uses a function or method whose name contains the word start (such as startStandaloneServer(server).

Main function signature

Let's start by looking at the main function signature. The below snippet uses function overloading to provide the strongest possible types for the ApolloServer instance and the user's context function.

The first two expressMiddleware definitions are the permitted signatures, while the third is the actual implementation:

TypeScript
1interface ExpressMiddlewareOptions<TContext extends BaseContext> {
2  context?: ContextFunction<[ExpressContextFunctionArgument], TContext>;
3}
4
5export function expressMiddleware(
6  server: ApolloServer<BaseContext>,
7  options?: ExpressMiddlewareOptions<BaseContext>,
8): express.RequestHandler;
9export function expressMiddleware<TContext extends BaseContext>(
10  server: ApolloServer<TContext>,
11  options: WithRequired<ExpressMiddlewareOptions<TContext>, 'context'>,
12): express.RequestHandler;
13export function expressMiddleware<TContext extends BaseContext>(
14  server: ApolloServer<TContext>,
15  options?: ExpressMiddlewareOptions<TContext>,
16): express.RequestHandler {
17  // implementation details
18}

In the first expressMiddleware signature above, if a user doesn't provide options, there isn't a user-provided context function to call. The resulting context object is a BaseContext (or {}). So, the first argument's expected type is ApolloServer<BaseContext>.

The second expressMiddleware signature requires that options receives a context property. This means that Apollo Server expects the context object's type to be the same as the user-provided context function's type. Apollo Server uses the TContext type to represent the generic type of the GraphQL context object. Above, both the ApolloServer instance and the user-provided context function share the TContext generic, ensuring users correctly type their server and context function.

Ensure successful startup

For standard integrations, users should await server.start() before passing their server instance to an integration. This ensures that the server starts correctly and enables your integration user to handle any startup errors.

To guarantee a server has started, you can use the assertStarted method on Apollo Server, like so:

TypeScript
1server.assertStarted('expressMiddleware()');

Serverless integrations don't require users to call server.start(); instead, a serverless integration calls the startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests method. Because serverless integrations handle starting their server instances, they also don't need to call the assertStarted method.

Compute GraphQL Context

A request handler can access all kinds of information about an incoming request, which can be useful during GraphQL execution. Integrations should provide a hook to users, enabling them to create their GraphQL context object with values from an incoming request.

If a user provides a context function, it should receive the request object and any other contextual information the handler receives. For example, in Express, the handler receives req and res objects, which it passes to the user's context function.

If a user doesn't provide a context function, an empty GraphQL context object is sufficient (see defaultContext below).

Apollo Server exports a generic ContextFunction type, which can be useful for integrations defining their APIs. Above, the expressMiddleware function signature uses the ContextFunction type in the ExpressMiddlewareOptions interface, giving users a strongly typed context function with correct parameter typings.

The ContextFunction type's first variable specifies which arguments an integration needs to pass to a user's context function. The second variable defines the return type of a user's context function, which should use the same TContext generic that ApolloServer uses:

TypeScript
1interface ExpressContextFunctionArgument {
2  req: express.Request;
3  res: express.Response;
4}
5
6const defaultContext: ContextFunction<
7  [ExpressContextFunctionArgument],
8  any
9> = async () => ({});
10
11const context: ContextFunction<[ExpressContextFunctionArgument], TContext> =
12  options?.context ?? defaultContext;

Note, the context function is called during the execution step.

Handle Requests

We recommend implementing your integration package as either a request handler or a framework plugin. Request handlers typically receive information about each request, including standard HTTP parts (i.e., method, headers, and body) and other useful contextual information.

A request handler has 4 main responsibilities:

  1. Parse the request

  2. Construct an HTTPGraphQLRequest object from the incoming request

  3. Execute the GraphQL request using Apollo Server

  4. Return a well-formed response to the client

Parse the request

Apollo Server responds to a variety of requests via both GET and POST such as standard GraphQL queries, APQs, and landing page requests (e.g., Apollo Sandbox). Fortunately, this is all part of Apollo Server's core logic, and it isn't something integration authors need to worry about.

Integrations are responsible for parsing a request's body and using the values to construct the HTTPGraphQLRequest that Apollo Server expects.

In Apollo Server 4's Express integration, a user sets up the body-parser JSON middleware, which handles parsing JSON request bodies with a content-type of application/json. Integrations can require a similar middleware (or plugin) for their ecosystem, or they can handle body parsing themselves.

For example, a correctly parsed body should have a shape resembling this:

TypeScript
1{
2  query?: string;
3  variables?: Record<string, any>;
4  operationName?: string;
5  extensions?: Record<string, any>;
6}

Your integration should pass along whatever it parses to Apollo Server; Apollo Server will handle validating the parsed request.

Apollo Server also accepts GraphQL queries sent using GET with query string parameters. Apollo Server expects a raw query string for these types of HTTP requests. Apollo Server is indifferent to whether or not the ? is included at the beginning of your query string. Fragments (starting with #) at the end of a URL should not be included.

Apollo Server 4's Express integration computes the query string using the request's full URL, like so:

TypeScript
1import { parse } from 'url';
2
3const search = parse(req.url).search ?? '';

Construct the HTTPGraphQLRequest object

With the request body parsed, we can now construct an HTTPGraphQLRequest:

TypeScript
1interface HTTPGraphQLRequest {
2  method: string;
3  headers: HeaderMap; // the `HeaderMap` class is exported by @apollo/server
4  search: string;
5  body: unknown;
6}

Apollo Server handles the logic of GET vs. POST, relevant headers, and whether to look in body or search for the GraphQL-specific parts of the query. So, we have our method, body, and search properties for the HTTPGraphQLRequest.

Finally, we have to create the headers property because Apollo Server expects headers to be a Map.

In the Express integration, we construct a Map by iterating over the headers object, like so:

TypeScript
1import { HeaderMap } from '@apollo/server';
2
3const headers = new HeaderMap();
4for (const [key, value] of Object.entries(req.headers)) {
5  if (value !== undefined) {
6    headers.set(key, Array.isArray(value) ? value.join(', ') : value);
7  }
8}

Apollo Server expects header keys to be unique and lower-case. If your framework permits duplicate keys, you'll need to merge the values of those extra keys into a single key, joined by , (as shown above).

Express already provides lower-cased header keys in the above code snippet, so the same approach might not be sufficient for your framework.

Now that we have all the parts of an HTTPGraphQLRequest, we can build the object, like so:

TypeScript
1const httpGraphQLRequest: HTTPGraphQLRequest = {
2  method: req.method.toUpperCase(),
3  headers,
4  body: req.body,
5  search: parse(req.url).search ?? '',
6};

Execute the GraphQL request

Using the HTTPGraphQLRequest we created above, we now execute the GraphQL request:

TypeScript
1const result = await server
2  .executeHTTPGraphQLRequest({
3    httpGraphQLRequest,
4    context: () => context({ req, res }),
5  });

In the above code snippet, the httpGraphQLRequest variable is our HTTPGraphQLRequest object. The context function is the one we determined earlier (either given to us by the user or our default context). Note how we pass the req and res objects we received from Express to the context function (as promised by our ExpressContextFunctionArgument type).

Handle errors

The executeHTTPGraphQLRequest method does not throw. Instead, it returns an object containing helpful errors and a specific status when applicable. You should handle this object accordingly, based on the error handling conventions that apply to your framework.

In the Express integration, this doesn't require any special handling. The non-error case handles setting the status code and headers, then responds with the execution result just as it would in the error case.

Send the response

After awaiting the Promise returned by executeHTTPGraphQLRequest, we receive an HTTPGraphQLResponse type. At this point, your handler should respond to the client based on the conventions of your framework.

TypeScript
1interface HTTPGraphQLHead {
2  status?: number;
3  headers: HeaderMap;
4}
5
6type HTTPGraphQLResponseBody =
7  | { kind: 'complete'; string: string }
8  | { kind: 'chunked'; asyncIterator: AsyncIterableIterator<string> };
9
10
11type HTTPGraphQLResponse = HTTPGraphQLHead & {
12  body: HTTPGraphQLResponseBody;
13};

Note that a body can either be "complete" (a complete response that can be sent immediately with a content-length header), or "chunked", in which case the integration should read from the async iterator and send each chunk one at a time. This typically will use transfer-encoding: chunked, though your web framework may handle that for you automatically. If your web environment does not support streaming responses (as in some serverless function environments like AWS Lambda), you can return an error response if a chunked body is received.

The Express implementation uses the res object to update the response with the appropriate status code and headers, and finally sends the body. Note that in Express, res.send will send a complete body (including calculating the content-length header), and res.write will use transfer-encoding: chunked. Express does not have a built-in "flush" method, but the popular compression middleware (which supports accept-encoding: gzip and similar headers) adds a flush method to the response; since response compression typically buffers output until a certain block size it hit, you should ensure that your integration works with your web framework's response compression feature.

TypeScript
1for (const [key, value] of httpGraphQLResponse.headers) {
2  res.setHeader(key, value);
3}
4res.statusCode = httpGraphQLResponse.status || 200;
5
6if (httpGraphQLResponse.body.kind === 'complete') {
7  res.send(httpGraphQLResponse.body.string);
8  return;
9}
10
11for await (const chunk of httpGraphQLResponse.body.asyncIterator) {
12  res.write(chunk);
13  if (typeof (res as any).flush === 'function') {
14    (res as any).flush();
15  }
16}
17res.end();

Additional resources

For those building a new integration library, we'd like to welcome you (and your repository!) to the apollo-server-integrations Github organization alongside other community-maintained Apollo Server integrations. If you participate in our organization, you'll have the option to publish under our community's NPM scope @as-integrations, ensuring your integration is discoverable.

The @apollo/server-integration-testsuite provides a set of Jest tests for authors looking to test their Apollo Server integrations.

Feedback

Edit on GitHub

Forums