Docs
Launch GraphOS Studio

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

that suits your needs.

One of the driving forces behind 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 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

(i.e.,expressMiddleware) as an example.

For more examples, see these 4

.

If you are building a integration, we strongly recommend prepending your function name with the word start (e.g., startServerAndCreateLambdaHandler(server)). This naming convention helps maintain '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

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:

interface ExpressMiddlewareOptions<TContext extends BaseContext> {
context?: ContextFunction<[ExpressContextFunctionArgument], TContext>;
}
export function expressMiddleware(
server: ApolloServer<BaseContext>,
options?: ExpressMiddlewareOptions<BaseContext>,
): express.RequestHandler;
export function expressMiddleware<TContext extends BaseContext>(
server: ApolloServer<TContext>,
options: WithRequired<ExpressMiddlewareOptions<TContext>, 'context'>,
): express.RequestHandler;
export function expressMiddleware<TContext extends BaseContext>(
server: ApolloServer<TContext>,
options?: ExpressMiddlewareOptions<TContext>,
): express.RequestHandler {
// implementation details
}

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 's expected type is ApolloServer<BaseContext>.

The second expressMiddleware signature requires that options receives a context property. This means that expects the context object's type to be the same as the user-provided context function's type. uses the TContext type to represent the generic type of the 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 , like so:

server.assertStarted('expressMiddleware()');

Serverless integrations don't require users to call server.start(); instead, a integration calls the startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests method. Because 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 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 context object is sufficient (see defaultContext below).

exports a generic ContextFunction type, which can be useful for integrations defining their APIs. Above, the

uses the ContextFunction type in the ExpressMiddlewareOptions interface, giving users a strongly typed context function with correct parameter typings.

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

interface ExpressContextFunctionArgument {
req: express.Request;
res: express.Response;
}
const defaultContext: ContextFunction<
[ExpressContextFunctionArgument],
any
> = async () => ({});
const context: ContextFunction<[ExpressContextFunctionArgument], TContext> =
options?.context ?? defaultContext;

Note, the context function is called during the

.

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
  4. Return a well-formed
    response to the client

Parse the request

responds to a variety of requests via both GET and POST such as standard queries, , and landing page requests (e.g., ). Fortunately, this is all part of '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 expects.

In 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:

{
query?: string;
variables?: Record<string, any>;
operationName?: string;
extensions?: Record<string, any>;
}

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

also accepts queries

with string parameters. 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 string. (starting with #) at the end of a URL should not be included.

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

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

Construct the HTTPGraphQLRequest object

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

interface HTTPGraphQLRequest {
method: string;
headers: HeaderMap; // the `HeaderMap` class is exported by @apollo/server
search: string;
body: unknown;
}

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

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

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

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

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:

const httpGraphQLRequest: HTTPGraphQLRequest = {
method: req.method.toUpperCase(),
headers,
body: req.body,
search: parse(req.url).search ?? '',
};

Execute the GraphQL request

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

const result = await server
.executeHTTPGraphQLRequest({
httpGraphQLRequest,
context: () => context({ req, res }),
});

In the above code snippet, the httpGraphQLRequest is our HTTPGraphQLRequest object. The context function is the

(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.

interface HTTPGraphQLHead {
status?: number;
headers: HeaderMap;
}
type HTTPGraphQLResponseBody =
| { kind: 'complete'; string: string }
| { kind: 'chunked'; asyncIterator: AsyncIterableIterator<string> };
type HTTPGraphQLResponse = HTTPGraphQLHead & {
body: HTTPGraphQLResponseBody;
};

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

for (const [key, value] of httpGraphQLResponse.headers) {
res.setHeader(key, value);
}
res.statusCode = httpGraphQLResponse.status || 200;
if (httpGraphQLResponse.body.kind === 'complete') {
res.send(httpGraphQLResponse.body.string);
return;
}
for await (const chunk of httpGraphQLResponse.body.asyncIterator) {
res.write(chunk);
if (typeof (res as any).flush === 'function') {
(res as any).flush();
}
}
res.end();

Additional resources

For those building a new integration library, we'd like to welcome you (and your repository!) to the

Github organization alongside other community-maintained 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

provides a set of Jest tests for authors looking to test their integrations.

Previous
Integrations
Next
MERN stack tutorial
Edit on GitHubEditForumsDiscord

© 2024 Apollo Graph Inc.

Privacy Policy

Company