In this post, I'm going to walk through how you can add custom directives to implementing services' schemas when using Apollo Federation.
Most of what follows in this post has been adapted from various pages in the Apollo documentation, but I thought it would be helpful to consolidate that information as fully-realized demo (with some additional context added for good measure).
The API we'll work with throughout this post is based on one that I built out in a previous tutorial detailing the basics of Apollo Federation. If you haven't read through that post yet, I encourage you to take a look at it before proceeding (and I especially encourage you to do so if you're new to Apollo Federation). You can find the complete code from that post here.
Do note that in this follow-up tutorial we'll be using updated versions of the following Apollo packages:
@apollo/federation@0.18.0
@apollo/gateway@0.18.0
apollo-server@2.16.0
Custom Directive Support with a Gateway API
Custom directives are now supported in two different ways with Apollo Federation. We can use both type system directives and executable directives.
Type system directives are likely what you're most familiar with if you've used custom directives with Apollo Server before. These directives are applied directly to the schema and can be added in a variety of locations. For example:
directive @date(defaultFormat: String = "mmmm d, yyyy") on FIELD_DEFINITION
type Person {
dateOfBirth: String @date
}
Though it may seem counterintuitive at first, according to the Apollo docs the gateway API provides support for type system directive by stripping them from the composed schema. The definitions and uses of any type system directives remain intact in the implementing services' schemas though, so these directives are ultimately managed on a per-service basis.
An executable directive, on the other hand, would be defined in a schema but applied in the operation sent from the client:
query {
person(id: "1") {
name @allCaps
}
}
Type system directives and executable directives are supported in different locations, so you should take a look at the GraphQL spec for more details on this. For the @allCaps
directive, we would see in its corresponding schema that it had been applied on the FIELD
location rather than the FIELD_DEFINITION
location as the previous example has been.
Executable directives are also handled differently from type system directives at the gateway API level. When working with executable directives, there are stricter rules about how they are implemented with Apollo Federation. The Apollo docs caution that we must ensure all implementing services define the same set of executable directives. In other words, the executable directives must exist in all implementing services and specify the same locations, arguments, and arguments types (if not, a composition error will occur).
The Apollo documentation also indicates that while executable directives are supported by Apollo Gateway, they are not (currently) supported by a standard Apollo Server. Further, their support in Apollo Gateway is largely intended to be used with implementing services that are not created with Apollo Server. For these reasons, we will be working with type system directives in this tutorial.
What We're Building
We're going to add a custom @date
directive much like the one outlined in this example in the Apollo docs. Our goal will be to create a directive that can be applied to a date field where a default format for that date string can be specified as an argument.
The @date
directive definition will look like this:
directive @date(defaultFormat: String = "mmmm d, yyyy") on FIELD_DEFINITION
This directive will make it possible to take a not-so-human-friendly date string saved in a database and convert it to a format that's a little easier on the eyes when a date-related field is returned from a query. Where the directive is defined, we set a defaultFormat
for the date string that will be used for the entire implementing service's schema in the event that one isn't provided when the @date
directive is applied to a specific field.
In practice, if we applied the @date
directive to field like this...
dateOfBirth: String @date
...then we would expect to get back a date such as "January 1, 1970" (as specified by the defaultFormat
argument on the directive) whenever we query this field.
We'll take our demo a step further and provide a format
argument on a date-related field that can override the defaultFormat
of the @date
directive if the client querying the field wishes to do:
releaseDate(format: String): String @date
Again, the format will be "January 1, 1970" unless the querying client overrides this format by including a format
argument for this field.
Lastly, we could even combine a format
field with special defaultFormat
for the specific field:
releaseDate(format: String): String @date(defaultFormat: "d mmmm yyyy")
In the example above, we can expect that the date string will use the format
argument on the field first and will default to the defaultFormat
specified for the @date
directive as a fallback (and in this case, the schema-wide defaultFormat
for the directive will be ignored).
Create the @date
Directive
First, we'll need to update the existing data.js
file in our project to include a dateOfBirth
field for people and a releaseDate
field for films. We'll add all of the date values as ISO 8601 strings but we'll transform them into a more readable format with our directive later on:
export const people = [
{
id: "1",
name: "Steven Spielberg",
dateOfBirth: "1946-12-18T00:00:00+00:00" // NEW!
},
{
id: "2",
name: "Richard Dreyfuss",
dateOfBirth: "1947-10-29T00:00:00+00:00" // NEW!
},
{
id: "3",
name: "Harrison Ford",
dateOfBirth: "1942-07-13T00:00:00+00:00" // NEW!
}
];
export const films = [
{
id: "1",
title: "Jaws",
actors: ["2"],
director: "1",
releaseDate: "1975-06-20T00:00:00+00:00" // NEW!
},
{
id: "2",
title: "Close Encounters of the Third Kind",
actors: ["2"],
director: "1",
releaseDate: "1977-11-15T00:00:00+00:00" // NEW!
},
{
id: "3",
title: "Raiders of the Lost Ark",
actors: ["3"],
director: "1",
releaseDate: "1981-06-21T00:00:00+00:00" // NEW!
}
];
Next, we'll create a shared
directory that we'll use to organize the custom directives that we'll reuse across implementing services and we'll also add a file to it called FormattableDateDirective.js
:
mkdir shared && touch shared/FormattableDateDirective.js
To assist with date string formatting, we'll need to install the dateformat package in our project too:
npm i dateformat@3.0.3
Now we can set up our custom directive. Add the following code to shared/FormattableDateDirective.js
:
import { defaultFieldResolver, GraphQLString } from "graphql";
import { SchemaDirectiveVisitor } from "apollo-server";
import formatDate from "dateformat";
class FormattableDateDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
// date argument handling code will go here...
}
}
export default FormattableDateDirective;
Above, we can see that Apollo Server provides a handy class called SchemaDirectiveVisitor
that we can extend to create our custom schema directives. We also need the defaultFieldResolver
and GraphQLString
imports from graphql
, and the formatDate
function imported from dateformat
.
We set up our FormattableDateDirective
by overriding the visitFieldDefinition
method of the parent SchemaDirectiveVisitor
class. This method corresponds to the FIELD_DEFINITION
location we'll apply our custom directive to in the schemas shortly. Now we can implement the date-handling logic inside visitFieldDefinition
:
// ...
class FormattableDateDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve = defaultFieldResolver } = field;
const { defaultFormat } = this.args;
field.args.push({
name: "format",
type: GraphQLString
});
field.resolve = async function (
source,
{ format, ...otherArgs },
context,
info
) {
const date = await resolve.call(this, source, otherArgs, context, info);
return formatDate(date, format || defaultFormat);
};
} // UPDATED!
}
export default FormattableDateDirective;
The code we just added to the visitFieldDefinition
may seem a bit dense at first, but in a nutshell, if the field is queried with a format
argument, then that date format will be applied to the resolved field value. If the format
argument doesn't exist, then the defaultFormat
specified for the @date
directive will be used (and the defaultFormat
may be applied at the field level or where the directive is defined in the schema).
Use the @date
Directive in the People Service
Next, we'll update people/index.js
by importing the new custom directive along with SchemaDirectiveVisitor
from Apollo Server:
import { ApolloServer, gql, SchemaDirectiveVisitor } from "apollo-server"; // UPDATED!
import { buildFederatedSchema } from "@apollo/federation";
import { people } from "../data.js";
import FormattableDateDirective from "../shared/FomattableDateDirective"; // NEW!
// ...
We need to import the SchemaDirectiveVisitor
class in this file as well because we need to add our custom directives to this implementing service's schema in a slightly different way than we would if we were building a vanilla Apollo Server. (We'll see how this is done in just a moment...)
Below the imports, we'll add our custom directive to the schema, add the dateOfBirth
field, and apply the @date
directive to it:
// ...
const typeDefs = gql`
directive @date(defaultFormat: String = "mmmm d, yyyy") on FIELD_DEFINITION # NEW!
type Person @key(fields: "id") {
id: ID!
dateOfBirth: String @date # NEW!
name: String
}
extend type Query {
person(id: ID!): Person
people: [Person]
}
`;
// ...
Now we need to let Apollo Server know about the definition of our custom directive. If you've added custom directives to an Apollo Server without federation before, then you're likely familiar with the schemaDirectives
option that we would set inside of its constructor.
However, instead of setting the schemaDirectives
option in the ApolloServer
constructor, we'll refactor our code to call the visitSchemaDirectives
method on the SchemaDirectiveVisitor
class and pass in the schema
and an object containing our directives. Note that we call this function on our schema before passing it into ApolloServer
:
// ...
const schema = buildFederatedSchema([{ typeDefs, resolvers }]); // NEW!
const directives = { date: FormattableDateDirective }; // NEW!
SchemaDirectiveVisitor.visitSchemaDirectives(schema, directives); // NEW!
const server = new ApolloServer({ schema }); // UPDATED!
server.listen({ port }).then(({ url }) => {
console.log(`People service ready at ${url}`);
});
Let's run npm run dev
to start up our API now and test it out. Head over GraphQL Playground at http://localhost:4000/graphql and run the following query:
query {
person(id: "1") {
name
dateOfBirth
}
}
You should see that the dateOfBirth
string is in the format specified by our custom directive, rather than in an ISO 8601 format as it is in the mocked data:
{
"data": {
"person": {
"name": "Steven Spielberg",
"dateOfBirth": "December 17, 1946"
}
}
}
Update the Films Service to Use the @date
Directive
Let's reuse our custom directive in our films service now too. We'll start by importing SchemaDirectiveVisitor
and the FormattableDateDirective
into films/index.js
this time:
import { ApolloServer, gql, SchemaDirectiveVisitor } from "apollo-server"; // UPDATED!
import { buildFederatedSchema } from "@apollo/federation";
import { films } from "../data.js";
import FormattableDateDirective from "../shared/FomattableDateDirective"; // NEW!
// ...
Next, we'll add the @date
directive to this service's type definitions as well and a releaseDate
field to the Film
object type. We'll make this field a bit fancier than the dateOfBirth
field is by adding a format
argument to the field and specifying a defaultFormat
for the @date
directive applied to this field that's different from the defaultFormat
specified for the schema as a whole:
const typeDefs = gql`
directive @date(defaultFormat: String = "mmmm d, yyyy") on FIELD_DEFINITION # NEW!
type Film {
id: ID!
title: String
actors: [Person]
director: Person
releaseDate(format: String): String @date(defaultFormat: "shortDate") # NEW!
}
# ...
`;
// ...
The dateformat package has several named formats that we can use, so we use the shortDate
to return a date string in a "01/01/70" format by default. Also, note that despite adding a format
argument to this query we don't need to modify our resolvers because we handled it in our FormattableDateDirective
class.
Next, we'll update how we instantiate the ApolloServer
for the films service just as we did for the people service before:
// ...
const schema = buildFederatedSchema([{ typeDefs, resolvers }]); // NEW!
const directives = { date: FormattableDateDirective }; // NEW!
SchemaDirectiveVisitor.visitSchemaDirectives(schema, directives); // NEW!
const server = new ApolloServer({ schema }); // UPDATED!
server.listen({ port }).then(({ url }) => {
console.log(`Films service ready at ${url}`);
});
Now we can head back over to GraphQL Playground and test out our new and improved schema. Try running the film
query with the releaseDate
field:
query {
film(id: "1") {
title
releaseDate
}
}
You should see the releaseDate
formatted as follows:
{
"data": {
"film": {
"title": "Jaws",
"releaseDate": "6/19/75"
}
}
}
Now try running a query with format
argument:
query {
film(id: "1") {
title
releaseDate(format: "yyyy")
}
}
And you'll see that the date format specified by the format
argument overrides the defaultFormat
that was set in the @date
directive applied to this field:
{
"data": {
"film": {
"title": "Jaws",
"releaseDate": "1975"
}
}
}
Can Custom Directives Be Use with Extended Types Too?
Yes! We can define a custom directive in an implementing service and apply it to a field for a type that has been extended from another service.
We'll walk through a final example to see this in action. We'll add a new custom directive that can convert a field with a name of title
to all caps. (I know, it's a bit contrived, but bear with me!)
First, we'll create a new file called AllCapsTitleDirective.js
in the shared
directory:
touch shared/AllCapsTitleDirective.js
Next, we'll define our custom directive much as we did before, but this time we'll map over an array of film objects and convert the value of the title
property to all uppercase letters:
import { defaultFieldResolver } from "graphql";
import { SchemaDirectiveVisitor } from "apollo-server";
class AllCapsTitleDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const { resolve = defaultFieldResolver } = field;
field.resolve = async function (...args) {
const result = await resolve.apply(this, args);
if (result.length) {
return result.map(res => ({ ...res, title: res.title.toUpperCase() }));
}
return result;
};
}
}
export default AllCapsTitleDirective;
Next, we'll add our new directive to films/index.js
:
import { ApolloServer, gql, SchemaDirectiveVisitor } from "apollo-server";
import { buildFederatedSchema } from "@apollo/federation";
import { films } from "../data.js";
import AllCapsTitleDirective from "../shared/AllCapsTitleDirective"; // NEW!
import FormattableDateDirective from "../shared/FomattableDateDirective";
// ...
Then we'll add the @allCapsTitle
to the directed
field:
// ...
const typeDefs = gql`
directive @allCapsTitle on FIELD_DEFINITION # NEW!
directive @date(defaultFormat: String = "mmmm d, yyyy") on FIELD_DEFINITION
# ...
extend type Person @key(fields: "id") {
id: ID! @external
appearedIn: [Film]
directed: [Film] @allCapsTitle # UPDATED!
}
# ...
`;
// ...
Lastly, we'll add the AllCapsTitleDirective
to the directives
object that is passed into SchemaDirectiveVisitor.visitSchemaDirectives
:
// ...
const schema = buildFederatedSchema([{ typeDefs, resolvers }]);
const directives = {
date: FormattableDateDirective,
allCapsTitle: AllCapsTitleDirective
}; // UPDATED!
SchemaDirectiveVisitor.visitSchemaDirectives(schema, directives);
// ...
Now we can try querying for a single person again:
query {
person(id: 1) {
name
directed {
title
}
}
}
And we'll see that the titles of the films they directed have been successfully converted to all caps:
{
"data": {
"person": {
"name": "Steven Spielberg",
"directed": [
{
"title": "JAWS"
},
{
"title": "CLOSE ENCOUNTERS OF THE THIRD KIND"
},
{
"title": "RAIDERS OF THE LOST ARK"
}
]
}
}
}
Summary
In this post, we added custom directives to a GraphQL API built using Apollo Federation with two implementing services. We were able to reuse a @date
directive in both services, and we were also able to apply an @allCapsTitle
directive to a field of a type that was extended from another service.
As I mentioned, much of what I presented in this post was adapted and consolidated from examples in the official Apollo documentation, so you may want to check out these links for further context:
- Apollo Federation: The gateway – Custom directive support
- Apollo Federation: Implementing Services – Defining custom directives
- Implementing directives – Examples – Formatting date strings
You can clone or download the completed code for this tutorial here.
Top comments (1)
This was really helpful. Any idea how we could implement directives across services?
Like how @requires works.