DEV Community

loading...

Using Custom Directives with Apollo Federation

mandiwise profile image Mandi Wise Updated on ・10 min read

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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!
  }
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To assist with date string formatting, we'll need to install the dateformat package in our project too:

npm i dateformat@3.0.3
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!

// ...
Enter fullscreen mode Exit fullscreen mode

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]
  }
`;

// ...
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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!

// ...
Enter fullscreen mode Exit fullscreen mode

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!
  }

  # ...
`;

// ...
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

You should see the releaseDate formatted as follows:

{
  "data": {
    "film": {
      "title": "Jaws",
      "releaseDate": "6/19/75"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now try running a query with format argument:

query {
  film(id: "1") {
    title
    releaseDate(format: "yyyy")
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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";

// ...
Enter fullscreen mode Exit fullscreen mode

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!
  }

  # ...
`;

// ...
Enter fullscreen mode Exit fullscreen mode

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);

// ...
Enter fullscreen mode Exit fullscreen mode

Now we can try querying for a single person again:

query {
  person(id: 1) {
    name
    directed {
      title
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

You can clone or download the completed code for this tutorial here.

Discussion (1)

pic
Editor guide
Collapse
harshitkumar31 profile image
Harshit Kumar

This was really helpful. Any idea how we could implement directives across services?
Like how @requires works.