Field middleware and plugins
Code that runs around a single field and around a whole request: a field middleware that masks a microchip for anyone who is not staff, and an Apollo plugin, a Nest provider with its dependencies, that names every operation in a response header and a log.
What you'll learn
- Change a field's value on its way out with a FieldMiddleware attached through @Field({ middleware })
- Read the request from a middleware's context, and know what field middleware cannot do (inject dependencies)
- Hook into Apollo Server's request lifecycle with a @Plugin() class that implements ApolloServerPlugin
- Register a plugin as a provider, so that it gets Nest's dependency injection, and set response headers from it
A cat's microchip number is useful to staff and to nobody else. The public may see that a cat is chipped, not the number. The field appears in many queries: the cat list, a single cat, a search, an owner's cats. Checking in every resolver that returns a cat would repeat the rule everywhere and miss the next query someone adds. The rule belongs to the field. Other concerns belong to the request as a whole: logging which operations clients run, timing them, adding headers. Nest's GraphQL layer has a hook for each: field middleware around one field wherever it is resolved, and Apollo plugins around the lifecycle of every request.
Field middleware
A field middleware is a function that receives the resolver's arguments and a next function, which produces the field's value:
export const initialsOnly: FieldMiddleware = async ({ context, source, args, info }: MiddlewareContext, next: NextFn) => {
const value: string = await next();
return value.split(' ').map((part) => part[0] + '.').join(' ');
};
@Field({ middleware: [initialsOnly] })
fullName: string;
What it returns replaces the field's value. If it returns undefined, the value from next() is used unchanged, so forgetting the return means the middleware silently does nothing. context is the same GraphQL context the resolvers see, with the request in context.req. Middleware runs wherever the field is resolved, in every query, and only on object types. buildSchemaOptions: { fieldMiddleware: [...] } applies middleware to every field of every type.
Field middleware is a plain function, not a provider: it cannot inject services. Keep it light. Anything that needs a database or another service belongs in a guard or interceptor on the query, which can store the result in the context for the middleware to read.
Apollo plugins
Apollo Server exposes events through a request's life: the request arrives, is parsed and validated, executes, and its response is about to be sent. A plugin listens to them. In Nest a plugin is a class with @Plugin() from @nestjs/apollo, implementing ApolloServerPlugin from @apollo/server:
@Plugin()
export class TimingPlugin implements ApolloServerPlugin {
constructor(private readonly metrics: MetricsService) {}
async requestDidStart(): Promise<GraphQLRequestListener<any>> {
const started = Date.now();
const metrics = this.metrics;
return {
async willSendResponse({ request, response }) {
metrics.record(request.operationName, Date.now() - started);
response.http.headers.set('server-timing', `total;dur=${Date.now() - started}`);
},
};
}
}
requestDidStart runs once per request and returns the listeners for that request. willSendResponse sees the request (operationName is the name the client gave the operation, null if none) and the response, whose http.headers become HTTP headers. Because the plugin is a provider, listed in a module's providers, Nest creates it with its dependencies, which field middleware never gets. Inside the listener object this is the listener, not the plugin, so keep what you need from the plugin in a variable first.
A plugin sees every operation, including ones that fail validation. Anything it throws fails the whole request with a 500, before any GraphQL error handling.
Your task
Cats have an optional microchip, stored as 985-112-345-678-901. OperationsLog keeps a list of names, which the operations query returns. Staff send the header x-staff-key: whiskers.
- Write
maskMicrochip, a field middleware. Staff see the microchip as it is. Anyone else sees every digit but the last three replaced by*(***-***-***-***-901).nullstaysnull. Attach it toCat.microchip. - Write
OperationNamePlugin. When a response is about to be sent, it records the operation's name, oranonymous, in theOperationsLog, and sends it back in anx-operationheader. - Register the plugin.
Then run a named query in GraphiQL and look at the response headers in the request bar.
When it fails
- Every request is a
500and the console saysUnexpected error processing request: TypeError: Cannot read properties of undefined (reading 'names'): the listener usedthis.operationsLog, andthisthere is the listener. - No
x-operationheader and an empty log: the plugin is not in any module'sproviders, or lost@Plugin(). - The middleware runs but the number is not masked: it computed the masked value and did not return it.
- Staff see the masked number: the middleware reads a different header, or not from
context.req.
Remember
- Field middleware wraps one field wherever it is resolved; what it returns is the value.
- It is a plain function: no injection, keep it cheap, read the request from
context. - An Apollo plugin is a
@Plugin()provider, with dependencies, around every request. requestDidStartreturns the listeners;willSendResponsecan set headers.
Stuck? Show a hint
mask-microchip.middleware.ts: export const maskMicrochip: FieldMiddleware = async ({ context }, next) => { const value = await next(); ... context.req.headers ... }. cat.model.ts: @Field(() => String, { nullable: true, middleware: [maskMicrochip] }). operation-name.plugin.ts: @Plugin() (from @nestjs/apollo) on a class implementing ApolloServerPlugin, OperationsLog in its constructor, requestDidStart() returning { async willSendResponse({ request, response }) { ... response.http.headers.set(...) } }. List the plugin in CatsModule's providers.