Apollo GraphQL Custom Directives

A Series Blog for Deep Dive into Apollo GraphQL from Backend to Frontend

E.Y.
7 min readApr 27, 2021
Photo by Eaters Collective on Unsplash

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_VALUE
type 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 to FIELD_DEFINITION or ENUM_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 to FIELD, FRAGMENT_SPREAD, or INLINE_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_DEFINITION
TypeSystemDirectiveLocationSCHEMA
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_DEFINITION
type 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!

--

--

No responses yet