Apollo GraphQL Custom Directives
A Series Blog for Deep Dive into Apollo GraphQL from Backend to Frontend
This is a 2nd 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.
What are Directives
According to the Doc: a directive decorates part of a GraphQL schema or operation with additional configuration.
A directive is an identifier preceded by a @
character, optionally followed by a list of named arguments.
directive @deprecated(
reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUEtype ExampleType {
newField: String
oldField: String @deprecated(reason: "Use `newField`.")
}
There are 3 default directives specified in the GraphQL specification, @skip
, @include
, and @ deprecated
.That means, if you are implementing your own version of GraphQL, you also need to have these 3 directives.
Types and Location
There are two types of directives:
- Schema directives, which are defined in the SDL and executed when building the schema
- Operation directives, which appear in the query and are executed when resolving the query
Using the default directives as examples:
@deprecated
is a schema type directive used to mark a field as deprecated in the SDL, and can only be added toFIELD_DEFINITION
orENUM_VALUE
locations (more on this below).@skip
and@include
are operation type directives to denote whether or not to execute a section of the query at runtime. And can only be added toFIELD
,FRAGMENT_SPREAD
, orINLINE_FRAGMENT
(more on this below).
Whether or not the directive is Schema or Operation one depends on its location. Instead, it uses directive locations to define where a directive is used.There are two groups of directive locations: executable directive locations (i.e., operation type) and type system directive location (i.e., schema type).
ExecutableDirectiveLocationQUERY
MUTATION
SUBSCRIPTION
FIELD
FRAGMENT_DEFINITION
FRAGMENT_SPREAD
INLINE_FRAGMENT
VARIABLE_DEFINITIONTypeSystemDirectiveLocationSCHEMA
SCALAR
OBJECT
FIELD_DEFINITION
ARGUMENT_DEFINITION
INTERFACE
UNION
ENUM
ENUM_VALUE
INPUT_OBJECT
INPUT_FIELD_DEFINITION
Note that Schema type directives are private. They are implemented inside the server and not exposed to the end user who uses the client. Operation types are public as they can be used by the end user when implementing queries over the client.
In real life, schema type directives are more popular, as it make little sense for client to have extensive operation type directives to modify their queries, they can easily do this before they construct the query. Even if they implement this, the runtime transformation is more costly and error prone comparing to transformation at schema compile time.
If you look at the examples on the doc, almost all of them are schema type based: data fetching, formatting, authorisation, caching, validation, as well as the analytics/logging/metrics.
Schema Directives as middleware
Schema Directives are extremely powerful tool to enhance the capabilities of your SDL without adding too much burden to your resolvers.The very common ones are those implemented on FIELD_DEFINITION
or OBJECT
.
The typical workflow for any schema type directive (field level, e.g.) is to take the field value resolved by the field resolver as input, do its job, and output the result to the next directive on the pipeline or return directly if no directive danach.
query {
field1 @directive1 @directive2 @directive3
}
Isn’t this look familiar?
It is essentially a pipeline! Or thinking bigger, it is a middleware pipeline!
You may ask that the typical middleware is a duplex stream, so how do we fix that?
Well, in that sense, you can put any directive you’d like it to proceed the data again like so:
query {
field1 @directive1 @directive2 @directive3 @directive2 @directive1
}
Custom Directives
To implement a schema directive using SchemaDirectiveVisitor
, simply create a subclass of SchemaDirectiveVisitor
that overrides one or more of the following visitor methods:
visitSchema(schema: GraphQLSchema)
visitScalar(scalar: GraphQLScalarType)
visitObject(object: GraphQLObjectType)
visitFieldDefinition(field: GraphQLField<any, any>)
visitArgumentDefinition(argument: GraphQLArgument)
visitInterface(iface: GraphQLInterfaceType)
visitUnion(union: GraphQLUnionType)
visitEnum(type: GraphQLEnumType)
visitEnumValue(value: GraphQLEnumValue)
visitInputObject(object: GraphQLInputObjectType)
visitInputFieldDefinition(field: GraphQLInputField)
By overriding methods like visitObject
, a subclass of SchemaDirectiveVisitor
expresses interest in certain schema types such as GraphQLObjectType
.
Here is one possible implementation of the @deprecated
directive we saw above:
const { SchemaDirectiveVisitor } = require("apollo-server");class DeprecatedDirective extends SchemaDirectiveVisitor {
public visitFieldDefinition(field: GraphQLField<any, any>) {
field.isDeprecated = true;
field.deprecationReason = this.args.reason;
}public visitEnumValue(value: GraphQLEnumValue) {
value.isDeprecated = true;
value.deprecationReason = this.args.reason;
}
}/===================================================================const { ApolloServer, gql } = require("apollo-server");const typeDefs = gql`
type ExampleType {
newField: String
oldField: String @deprecated(reason: "Use \`newField\`.")
}
`;const server = new ApolloServer({
typeDefs,
resolvers,
schemaDirectives: {
deprecated: DeprecatedDirective
}
});server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});
I’d recommend to checkout the examples on the docs as they are comprehensive enough to provide a starting point for writing your own custom directives.
But here are some other examples to use as a reference (in TypeScript):
DateFormatter
import { SchemaDirectiveVisitor } from "apollo-server";
import { defaultFieldResolver, GraphQLString } from "graphql";
import DateFormatter from "dateformat";
const defaultFormat = "mmmm d, yyyy";export class DateFormatDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
// Extracting the default resolver function:
const { resolve = defaultFieldResolver } = field;
// Assign default format to as instance property to the class
const { format = defaultFormat } = this.args;
// The "args" below are essentially [parent, args, root, info] //that passed to resolver field
field.resolve = async function (...args) {
const date = await resolve.apply(this, args);
return DateFormatter(date, format);
};
field.type = GraphQLString;
}
}export const schemaDirectives = {
date: DateFormatDirective
}==================const typeDefs = gql`
directive @date(format: String) on FIELD_DEFINITION type Capsule {
original_launch: Date @date(format: "HH:MM")
}
`;
StringFormatter:
import { SchemaDirectiveVisitor } from "apollo-server";
import {
defaultFieldResolver,
GraphQLDirective,
DirectiveLocation,
GraphQLField,
GraphQLSchema,
} from "graphql";
import {
camelCase,
capitalize,
upperCase,
lowerCase,
kebabCase,
snakeCase,
trim,
deburr,
} from "lodash";
export type LodashTransform = (arg: string) => string;
const StringDirectiveFactory = (
directiveName: string,
transform: LodashTransform
): typeof SchemaDirectiveVisitor => { class StringDirective extends SchemaDirectiveVisitor { static getDirectiveDeclaration(
directiveName: string,
schema: GraphQLSchema
): GraphQLDirective {
// if currentDirective already exist do nothing
const currentDirective = schema.getDirective(directiveName);
if (currentDirective) {
return currentDirective;
}
// if not, then create a new one as requested
return new GraphQLDirective({
name: directiveName,
locations: [DirectiveLocation.FIELD_DEFINITION],
description: `string directive to manupulate ${this.name}`,
isRepeatable: true, // can be placed multiple times
});
}
visitFieldDefinition(field: GraphQLField<any, any>) {
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (...args) {
const result = await resolve.apply(this, args);
if (typeof result === "string") {
return transform(result);
}
return result;
};
}
}
return StringDirective;
};
export const UpperStringDirective = StringDirectiveFactory("upper", upperCase);
export const LowerStringDirective = StringDirectiveFactory("lower", lowerCase);
export const CamelCaseStringDirective = StringDirectiveFactory(
"camelCase",
camelCase
);
export const DeburrStringDirective = StringDirectiveFactory("deburr", deburr);
export const CapitalizeStringDirective = StringDirectiveFactory(
"capitalize",
capitalize
);
export const KebabCaseStringDirective = StringDirectiveFactory(
"kebabCase",
kebabCase
);
export const TrimStringDirective = StringDirectiveFactory("trim", trim);
export const SnakeCaseStringDirective = StringDirectiveFactory(
"snake",
snakeCase
);
Note that the getDirectiveDeclaration
above is very useful when you need to expose your Directives publicly such as in a npm package, and you would like to control what name of the directives the user decides to declare, and how.
Uuid:
import { SchemaDirectiveVisitor } from "apollo-server";
import { GraphQLID, GraphQLObjectType } from "graphql";
import { createHash } from "crypto";
export class UniqueIdDirective extends SchemaDirectiveVisitor {
visitObject(type: GraphQLObjectType) {
// Extracting name, and from argument from the directive
const { name, from } = this.args;
// Get all fields on this object type
const fields = type.getFields();
if (name in fields) {
// Throw error if there is a field with the same name exists
throw new Error(`Conflicting field name ${name}`);
}
fields[name] = {
name,
type: GraphQLID,
description: "Unique ID",
args: [],
resolve(object) {
const hash = createHash("sha1");
hash.update(type.name);
from.forEach((fieldName) => {
hash.update(String(object[fieldName]));
});
return hash.digest("hex");
},
isDeprecated: false,
extensions: {},
};
}
}export const schemaDirectives = {
uniqueID: UniqueIdDirective
}====================
const typeDefs = gql`
directive @uniqueID(
# The name of the new ID field, "uid" by DEFAULT
name: String = "uid"
# Which fields to include in the new ID:
from: [String] = ["id"]
) on OBJECT type Capsule @uniqueID {
id: ID
landings: Int
type: String
}
`;
When querying the Capsule type on the client end or in GraphQL playground on the server, you will see a newly generated uid
field based on id
field value through the directive.
Limit:
We will use the custom scalars created in the last blog to generate limitation directives:
import { SchemaDirectiveVisitor } from "apollo-server";
import {
GraphQLNonNull,
GraphQLScalarType,
GraphQLInputField,
GraphQLField,
} from "graphql";
import { MaxLength, MinLength, GreaterThan, LessThan } from "../scalars";
export interface CustomScalarClass<T = any> {
new (...args: any[]): T;
}
const LimitDirectiveFactory = (
ScalarType: CustomScalarClass
): typeof SchemaDirectiveVisitor => { class LimitDirective extends SchemaDirectiveVisitor {
visitInputFieldDefinition(
field: GraphQLInputField
): GraphQLInputField | void | null {
this.wrapType(field);
}
visitFieldDefinition(
field: GraphQLField<any, any, any>
): GraphQLField<any, any> | void | null {
this.wrapType(field);
}
wrapType(field: GraphQLField<any, any, any> | GraphQLInputField) {
if (
field.type instanceof GraphQLNonNull &&
field.type.ofType instanceof GraphQLScalarType
/* field type is a custom scalar whose ofType resolve to a scalar */
) {
field.type = new GraphQLNonNull(
new ScalarType(field.type.ofType, this.args["limit"])
);
} else if (
field.type instanceof GraphQLScalarType
/* field type itself is scalar, e.g. Int */
) {
// the args here is the directive arges, e.g. { limit: -1 }
field.type = new ScalarType(field.type, this.args["limit"]);
} else {
throw new Error(`Not a scalar type: ${field.type}`);
}
}
}
return LimitDirective;
};
export const MaxLengthDirective = LimitDirectiveFactory(MaxLength);export const MinLengthDirective = LimitDirectiveFactory(MinLength);
export const GreaterThanDirective = LimitDirectiveFactory(GreaterThan);
export const LessThanDirective = LimitDirectiveFactory(LessThan);export const schemaDirectives = {
greater: GreaterThanDirective,
less: LessThanDirective,
max: MaxLengthDirective,
min: MinLengthDirective,
}====================
const typeDefs = gql`
directive @greater(limit: Int) on
FIELD_DEFINITION | INPUT_FIELD_DEFINITIONtype Capsule {
id: ID
crew_capacity: Int #@greater(limit: 3)
type: String
}
`
Note that it is totally fine to place multiple directives in one field, like a middleware chain, or even using one directive multiple times:
type Capsule @cacheControl(maxAge: 6000) @uniqueID {
id: ID
landings: Int
reuse_count: Int
status: String
type: String
}
Lastly, check out the source code here if you want to know more about directives.
Ok, that’s so much of it!
Happy Reading!