Apollo GraphQL Plugin All in One
A Series Blog for Deep Dive into Apollo GraphQL from Backend to Frontend
This is a 3rd of the series blogs on deep dive into Apollo GraphQL from backend to frontend. A lot of information is Apollo GraphQL Doc or GraphQL Doc as well as their source code on Github — all tributes go to them. For my part, I would like to give you my “destructuring” of the original knowledge and my reflection on it, analysis on the code examples/source code as well as some extra examples.
In last blog we dig into custom Directive and its usage as field level middleware. In this blog, we are looking at Plugins.
Plugin🔧 🔨 ⚒ 🛠 ⛏ 🔩 ⚙ ⛓ ?
What are plugins? First, you may ask.
Quoting the doc, plugins enable you to extend Apollo Server’s core functionality by performing custom operations in response to certain events. Currently, these events correspond to individual phases of the GraphQL request lifecycle, and to the startup of Apollo Server itself.
So basically, plugins are actions you can fire across different stages from the start to the end of the Apollo server/request lifecycle, and is event driven.
Note: You can think of Request lifecyle is nested under Server lifecycle, as obviously, you can’t fire a request when the server hasn’t started.
Apollo Server Event Reference
Apollo Server fires two types of events that plugins can hook into: server lifecycle events and request lifecycle events.
Server lifecycle events are high-level events related to the lifecycle of Apollo Server itself: serverWillStart
and requestDidStart
. Request lifecycle events are defined within the response to a requestDidStart
event, as the diagram shows below:
Note: Any event below that can result in “Success” can also result in an error. Whenever an error occurs, the didEncounterErrors
event fires and the remainder of the "Success" path stops.
In the graph above, requestDidStart
and request lifecycle functions accept a requestContext
parameter of type GraphQLRequestContext
, which includes a request
(type GraphQLRequest
), along with a response
field ( type GraphQLResponse
) if it's available.
Deep Dive into Different Events
- Server lifecyle:
serverWillStart
The serverWillStart
event fires when Apollo Server is preparing to start serving GraphQL requests. It helps you prepare other dependencies are available before begin serving requests.
serverWillStop
The serverWillStop
event fires when Apollo Server is starting to shut down when ApolloServer.stop()
is invoked . You can gracefully shutdown other services e.g. disconnect database.
It is defined in the object returned by serverWillStart
handler, because the two handlers usually interact with the same data. Just like the cleanup effect you see in React useEffect
hook.
const server = new ApolloServer({
plugins: [
{
serverWillStart() {
const interval = setInterval(doSomethingPeriodically, 1000);
return {
serverWillStop() {
clearInterval(interval);
}
}
}
}
]
})
requestDidStart
The requestDidStart
fires whenever Apollo Server receives a GraphQL request. It optionally return an object that includes functions for responding to request lifecycle events shown in the diagram above.
const myPlugin = {
requestDidStart(requestContext) {
console.log('Request started!'); return {
parsingDidStart(requestContext) {
console.log('Parsing started!');
}, validationDidStart(requestContext) {
console.log('Validation started!');
} }
},
};
- Request lifecycle events
didResolveSource
The didResolveSource
event is invoked after Apollo Server has determined the String
-representation of the incoming operation that it will act upon. Note that at this stage, there is not a guarantee that the operation is not malformed.
parsingDidStart
The parsingDidStart
event fires whenever Apollo Server will parse a GraphQL request to create its associated document
AST. For caching purpose, if Apollo Server receives a request with a query string that matches a previous request, the associated document
might already be available in Apollo Server's cache. In this case, parsingDidStart
is skipped.
validationDidStart
The validationDidStart
event fires whenever Apollo Server will validate a request's document
AST against GraphQL schema. This event is also skipped if a request's document
is already available in Apollo Server's cache.
didResolveOperation
The didResolveOperation
event fires after the graphql
library successfully determines the operation to execute from a request's document
AST. At this stage, both the operationName
string and operation
AST are available. Note that if the operation is anonymous, then operationName
is null
.
responseForOperation
The responseForOperation
event is fired immediately before GraphQL execution would take place. If its return value resolves to a non-null GraphQLResponse
, that result is used instead of executing the query. Hooks from different plugins are invoked in series, and the first non-null response is used.
executionDidStart
The executionDidStart
event fires whenever Apollo Server begins executing the GraphQL operation specified by a request's document
AST. executionDidStart
may return an "end hook" function. Alternatively, it may return an object with one or both of the methods executionDidEnd
and willResolveField
. executionDidEnd
is treated identically to an end hook: it is called after execution with any errors that occurred.
willResolveField
The willResolveField
event fires whenever Apollo Server is about to resolve a single field during the execution of an operation. The handler is passed an object with four fields (source
, args
, context
, and info
) that correspond to the four positional arguments passed to resolvers. You provide your willResolveField
handler in the object returned by your executionDidStart
handler. Your willResolveField
handler can optionally return an "end hook".
const server = new ApolloServer({
plugins: [
{
requestDidStart(initialRequestContext) {
return {
executionDidStart(executionRequestContext) {
return {
willResolveField({source, args, context, info}) {
const start = process.hrtime.bigint();
return (error, result) => {
const end = process.hrtime.bigint();
console.log(`Field ${info.parentType.name}.${info.fieldName} took ${end - start}ns`);
if (error) {
console.log(`It failed with ${error}`);
} else {
console.log(`It returned ${result}`);
}
};
}
}
}
}
}
}
]
})
didEncounterErrors
The didEncounterErrors
event fires when Apollo Server encounters errors while parsing, validating, or executing a GraphQL operation.
willSendResponse
The willSendResponse
event fires whenever Apollo Server is about to send a response for a GraphQL operation. This event fires even if the GraphQL operation encounters one or more errors.
- End hooks
Event handlers for the following events can optionally return a function that is invoked after the corresponding lifecycle phase ends:
parsingDidStart
validationDidStart
executionDidStart
willResolveField
These end hooks are passed any errors that occurred during the execution of that lifecycle phase. For example, the following plugin logs any errors that occur during any of the above lifecycle events:
const myPlugin = {
requestDidStart() {
return {
parsingDidStart() {
return (err) => {
if (err) {
console.error(err);
}
}
},
validationDidStart() {
// This end hook is unique in that it can receive an array
// of errors which contain all validation errors.
return (errs) => {
if (errs) {
errs.forEach(err => console.error(err));
}
}
},
executionDidStart() {
return (err) => {
if (err) {
console.error(err);
}
}
}
}
}
}
The willResolveField
end hook receives the error thrown by the resolver as the first argument and the result of the resolver as the second argument.
You can take a look at the type definitions for these methods here: (Source)
export interface ApolloServerPlugin<
TContext extends BaseContext = BaseContext
> {
serverWillStart?(
service: GraphQLServiceContext,
): ValueOrPromise<GraphQLServerListener | void>;
requestDidStart?(
requestContext: GraphQLRequestContext<TContext>,
): GraphQLRequestListener<TContext> | void;
}interface GraphQLRequest {
query?: string;
operationName?: string;
variables?: { [name: string]: any };
extensions?: Record<string, any>;
http?: Pick<Request, 'url' | 'method' | 'headers'>;
}interface GraphQLResponse {
data?: Record<string, any> | null;
errors?: ReadonlyArray<GraphQLFormattedError>;
extensions?: Record<string, any>;
http?: Pick<Response, 'headers'> & Partial<Pick<Mutable<Response>, 'status'>>;
}interface GraphQLRequestContext<TContext = Record<string, any>> {
readonly request: GraphQLRequest;
readonly response?: GraphQLResponse;
logger: Logger;
readonly schema: GraphQLSchema;
readonly schemaHash: SchemaHash;
readonly context: TContext;
readonly cache: KeyValueCache;
readonly queryHash?: string;
readonly document?: DocumentNode;
readonly source?: string;
readonly operationName?: string | null;
readonly operation?: OperationDefinitionNode;
readonly errors?: ReadonlyArray<GraphQLError>;
readonly metrics: GraphQLRequestMetrics;
debug?: boolean;
}interface GraphQLRequestMetrics {
captureTraces?: boolean;
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
responseCacheHit?: boolean;
forbiddenOperation?: boolean;
registeredOperation?: boolean;
startHrTime?: [number, number];
queryPlanTrace?: Trace.QueryPlanNode;
}type GraphQLRequestContextDidResolveSource<TContext> =
WithRequired<GraphQLRequestContext<TContext>,
| 'metrics'
| 'source'
| 'queryHash'
>;type GraphQLRequestContextParsingDidStart<TContext> =
GraphQLRequestContextDidResolveSource<TContext>;
export type GraphQLRequestContextValidationDidStart<TContext> =
GraphQLRequestContextParsingDidStart<TContext> &
WithRequired<GraphQLRequestContext<TContext>,
| 'document'
>;type GraphQLRequestContextDidResolveOperation<TContext> =
GraphQLRequestContextValidationDidStart<TContext> &
WithRequired<GraphQLRequestContext<TContext>,
| 'operation'
| 'operationName'
>;type GraphQLRequestContextDidEncounterErrors<TContext> =
WithRequired<GraphQLRequestContext<TContext>,
| 'metrics'
| 'errors'
>;type GraphQLRequestContextResponseForOperation<TContext> =
WithRequired<GraphQLRequestContext<TContext>,
| 'metrics'
| 'source'
| 'document'
| 'operation'
| 'operationName'
>;type GraphQLRequestContextExecutionDidStart<TContext> =
GraphQLRequestContextParsingDidStart<TContext> &
WithRequired<GraphQLRequestContext<TContext>,
| 'document'
| 'operation'
| 'operationName'
>;type GraphQLRequestContextWillSendResponse<TContext> =
GraphQLRequestContextDidResolveSource<TContext> &
WithRequired<GraphQLRequestContext<TContext>,
| 'metrics'
| 'response'
>;
Plugins or Extensions?
In some older versions of Apollo GraphQL Server package, you may see that extensions
is used instead of plugins
. Essentially they are the same thing but of a bit different interface.
interface GraphQLServerOptions<
TContext = Record<string, any>,
TRootValue = any
> {
extensions?: Array<() => GraphQLExtension>;
plugins?: ApolloServerPlugin[];
}
You can also find the definition of extension in /graphql-extensions/src/index.ts
, which is essentially a class interface corresponding to `ApolloServerPlugin
.
Builtin Plugins
There are some builtin plugins by Apollo:
Custom Plugins
The good thing about Plugins is that you can set up fine-grained operation in response to individual phases of the GraphQL request lifecycle. To define a plugin is not difficult as long as it satisfies the shape of `ApolloServerPlugin
interface or a factory function that returns `ApolloServerPlugin
.
export type PluginDefinition = ApolloServerPlugin | (() => ApolloServerPlugin);
Let’s look at a few examples.
Complexity Plugin:
import { GraphQLSchema, separateOperations } from "graphql";
import { PluginDefinition } from "apollo-server-core";
import {
GraphQLRequestContext,
ApolloServerPlugin,
GraphQLServiceContext,
GraphQLServerListener,
} from "apollo-server-plugin-base";
import {
getComplexity,
simpleEstimator,
fieldExtensionsEstimator,
} from "graphql-query-complexity";
import { ValueOrPromise } from "apollo-server-types";
const MAX_ALLOWED_COMPLEXITY = 100;
const ComplexityPlugin: ApolloServerPlugin = {
requestDidStart(requestContext) {
return {
didResolveOperation: ({ request, document, schema }) => {
const complexity = getComplexity({
schema: schema,
query: request.operationName
? separateOperations(document)[request.operationName]
: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
if (complexity > this.maxAllowedComplexity) {
throw new Error(
`Sorry, too complicated query (complexity: ${complexity}, max complexity: ${this.maxAllowedComplexity})`
);
}
},
};
},
};
export default ComplexityPlugin;
Here we are using a package graphql-query-complexity
to calculate the query complexity. We plugin in to the didResolveOperation
stage where we can get the operation that executes a request’s document
AST. And if the query is too complexity, we return an error.
ErrorHandler Plugin:
import type { ApolloServerPlugin } from "apollo-server-plugin-base";
import { Logger, LOG_TYPE } from "../utils/logger";
const logger = new Logger();
const ErrorHandlerPlugin: ApolloServerPlugin = {
requestDidStart({ queryHash, operationName }) {
return {
didEncounterErrors: ({ errors }) =>
errors.forEach((error) => {
logger.log(LOG_TYPE.ERROR, "Error encountered", {
queryHash,
operationName,
error,
});
}),
};
},
};
export default ErrorHandlerPlugin;
This ErrorPlugin will be triggered whenever Apollo Server encounters errors while parsing, validating, or executing a GraphQL operation. It simply add the error to an external logger.
Logger Plugin:
import type { ApolloServerPlugin } from "apollo-server-plugin-base";
import { Logger, LOG_TYPE } from "../utils/logger";
const logger = new Logger();export const LoggerPlugin: ApolloServerPlugin = {
serverWillStart() {
logger.log(LOG_TYPE.INFO, "Server is starting");
},
requestDidStart(requestContext) {
const {
request: { operationName, variables },
} = requestContext;
logger.log(LOG_TYPE.DATA, "Request start", {
operationName: operationName,
variables: variables ?? false,
});
return {
willSendResponse: ({ errors }) => {
logger.log(LOG_TYPE.DATA, "Request end", {
errors: !!errors,
//instead of print it we just need to know if error exist
});
},
};
},
};
export default LoggerPlugin;
This is an extension of ErrorLogger from the earlier example. So instead of plugging into only one request cycle event, the logger logs at serverWillStart
, requestDidStart
and just before a response is sent back on willSendResponse
.
Note here we are grabbing some objects from the requestContext
that is passed down into requestDidStart
. The objects available vary at different stage, e.g. you can get the the document
at ParsingDidStart
, but not at didResolveSource
.
Lastly, here is the template to use if you’d like to have a go :
/* eslint-disable @typescript-eslint/no-empty-function */
import {
ApolloServerPlugin,
GraphQLRequestContext,
GraphQLRequestContextDidEncounterErrors,
GraphQLRequestContextDidResolveOperation,
GraphQLRequestContextDidResolveSource,
GraphQLRequestContextExecutionDidStart,
GraphQLRequestContextParsingDidStart,
GraphQLRequestContextResponseForOperation,
GraphQLRequestContextValidationDidStart,
GraphQLRequestContextWillSendResponse,
GraphQLServiceContext,
} from "apollo-server-plugin-base";
type BaseContext = Record<string, any>;
const templatePlugin: ApolloServerPlugin<BaseContext> = {
async serverWillStart(serviceContext: GraphQLServiceContext) {
return {
serverWillStop: async () => {},
};
},
requestDidStart(requestContext: GraphQLRequestContext<BaseContext>) {
return {
didResolveSource: async (
requsetContext: GraphQLRequestContextDidResolveSource<BaseContext>
) => {},
parsingDidStart: (
requsetContext: GraphQLRequestContextParsingDidStart<BaseContext>
) => {},
validationDidStart: (
requsetContext: GraphQLRequestContextValidationDidStart<BaseContext>
) => {},
didResolveOperation: async (
requsetContext: GraphQLRequestContextDidResolveOperation<BaseContext>
) => {},
didEncounterErrors: async (
requsetContext: GraphQLRequestContextDidEncounterErrors<BaseContext>
) => {},
responseForOperation: async (
requsetContext: GraphQLRequestContextResponseForOperation<BaseContext>
) => null,
executionDidStart: (
requsetContext: GraphQLRequestContextExecutionDidStart<BaseContext>
) => {},
willSendResponse: async (
requsetContext: GraphQLRequestContextWillSendResponse<BaseContext>
) => {},
};
},
};
export default templatePlugin;
That’s all for today!
Happy Reading :)!