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:
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:
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:
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:
Construct an
HTTPGraphQLRequest
object from the incoming requestExecute the GraphQL request using Apollo Server
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:
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:
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
:
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:
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:
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:
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.
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.
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.