GraphQL Directive

A GraphQL directive is one of the most powerful tools to customize and add new functionality to the GraphQL API. It can support many use cases such as access control, input validation, caching, etc.

Like queries and mutation, a GraphQL directive is defined in GraphQL Schema Definition Language (SDL) and it can be used to enhance the behavior of either schema or operation.

In this article, we’ll understand GraphQL directives with code examples in Spring for GraphQL.

Let’s get started!

Code Example

The working code example of this article is listed on GitHub . To run the example, clone the repository, and import graphql-spring-directive as a project in your favorite IDE as a Gradle project.

You can find more information in README.md of the repository.

What are Directives in GraphQL?

A GraphQL directive is a way to add an extra behavior by annotating parts of the GraphQL schema. We can define a directive starting with @ character followed by the name, argument (optional), and execution location.

GraphQL specification defines directive as

DirectiveDefinition:

Descriptionopt directive @ Name ArgumentsDefinitionopt on DirectiveLocations

For example, a built-in directive @deprecated is defined as:


directive @deprecated(
  reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE

Here,

  • The name of the directive is @deprecated.
  • The @deprecated directive takes an optional argument reason with the default value “No longer supported”.
  • And, this directive can be applied to a field definition and enum values.

As the name suggests, using the @deprecated directive we can mark a certain field of the API as deprecated.

For example, if you decide to introduce a more descriptive field called bookName in place of the old name field then you can use @deprecated directive to mark name as deprecated as:


type Book {
    id: ID
    bookName: String
    name: String @deprecated(reason: "Use `bookName`.")
    author: String
    publisher: String
    price: Float
}

GraphQL Directive Type

Based on where directives are applied, we can categorize directives as Schema and Operation directives.

Schema Directive

A schema directive is applied to the schema of GraphQL (specified as TypeSystemDirectiveLocation in GraphQL specification). One example of a schema directive is @deprectaed, which allows us to annotate an API field as deprecated.

A schema directive can be applied to one of the following.


SCHEMA
SCALAR
OBJECT
FIELD_DEFINITION
ARGUMENT_DEFINITION
INTERFACE
UNION
ENUM
ENUM_VALUE
INPUT_OBJECT
INPUT_FIELD_DEFINITION

Operation Directive

An operation directive is applied to the operations (query and mutation) and therefore affects how an operation is processed by the GraphQL server. An example of a built-in operation directive is @skip, defined as:


directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT on FIELD_DEFINITION

An operation directive can be applied to one of the following.


QUERY
MUTATION
SUBSCRIPTION
FIELD
FRAGMENT_DEFINITION
FRAGMENT_SPREAD
INLINE_FRAGMENT

GraphQL built-in Directives

At the time of writing this article, GraphQL spec talks about three built-in directives – @skip, @include, and @deprecated.

@skip

You can use @skip directive on fields, fragment spreads, and inline fragments. This directive allows for conditional exclusion during execution based on the ‘if’ argument.


directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

For example, to conditionally exclude the comment field of ratings for operation GetBooks, you can use @skip as:


query GetBooks($itTest: Boolean!) {
  books {
    id
    name
    author 
    publisher
    price
    ratings {
      id
      rating
      comment @skip(if: $itTest)
    }
  }
}

Where $itTest is a GraphQL variable.

Variables need to be defined as arguments of the operation name before you can refer to them in the query.

In GraphiQL, you can set variable values in the variable section as shown below.


New GraphiQL interface

@include

Similar to @skip, @include is an operation directive defined as :


directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

You can use @include directive on fields, fragment spreads, and inline fragments and it allows for conditional inclusion during execution based on the ‘if’ argument.

@deprecated

The @deprecated directive is a schema directive and it can be used to mark a field or enum value as deprecated.


directive @deprecated(
  reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE

Directive Use Cases

The power of directive comes from the fact that the GraphQL specification doesn’t mandate any restrictions on its usage. Therefore, you are free to define your own directives to support as many use cases as you want.

Let’s look at a couple of use cases of GraphQL directives along with implementation.

Use case 1: Access Control

One of the most common use cases for any public API is to support access control and GraphQL-based public API is no different.

Let’s try to understand, how we can implement access control using directives.

Imagine the type Book has a sensitive field called revenue.


type Book {
    id: ID
    name: String
    author: String
    publisher: String
    price: Float
    revenue: Float
    ratings: [Rating]
}

And, you want to show revenue information only to a user with a manager role.

To handle this use case, you can define a schema directive called @auth and apply this directive to the field revenue as:


directive @auth (
    role: String!
) on FIELD_DEFINITION

type Book {
    id: ID
    name: String
    author: String
    publisher: String
    price: Float
    revenue: Float @auth(role: "manager")
}

When any user queries the revenue field, the service implementation can check if the user has the required role before returning a response back to the user. The role check can be done using claims of user tokens or some other mechanism. For simplicity, we can just assume that role is passed in the HTTP header in the API request.

Running in GraphiQL, with role passed as an HTTP header:

GraphiQL with manager role

Query without manager role:

GraphiQL without manager role

Implementing @auth Directive

To implement any schema directive in Spring for GraphQL, we need to modify the original data fetcher by creating a custom data fetcher as a decorator. The role of the custom data fetcher is to do the authorization check before delegating calls to the original data fetcher.

For that, let’s first define a class AuthDirective that extends SchemaDirectiveWiring and override the onField (as schema directive method @auth is applied on the field) as:


public class AuthDirective implements SchemaDirectiveWiring {

@Override
publicGraphQLFieldDefinition onField(
      SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {
  //
}

Then, we define a new data fetcher authDataFetcher as:


@Override
public GraphQLFieldDefinition onField(
    SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {

  //..............
  var schemaDirectiveRole = environment.getAppliedDirective("auth").getArgument("role").getValue();
  //....................
  DataFetcher<?> authDataFetcher = 
      dataFetchingEnvironment -> {
        var graphQlContext = dataFetchingEnvironment.getGraphQlContext();
          // role is set in context in interceptor code
        String userRole = graphQlContext.get("role");

        if (userRole != null && userRole.equals(schemaDirectiveRole)) {
          return originalDataFetcher.get(dataFetchingEnvironment);
        } else {
          return null;
        }
      };
  //......

}


In the above code,

  1. The authDataFetcher first checks if the user has the required role by getting the role from the GraphQlContext as graphQlContext.get("role").
  2. If the user has the required role then it calls the original data fetcher.
  3. If the user doesn’t have the required role then it returns null. Thus, an unauthorized user sees a null value in the API response.

And the last step is to set authDataFetcher as a new data fetcher for the field revenue and parent type book.


@Override
public GraphQLFieldDefinition onField(
    SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {
  //..............
  // now change the field definition to have the new auth data fetcher
  environment.getCodeRegistry().dataFetcher(parentType, field, authDataFetcher);
  return field;
}


In the above code, we have resolved role information from the graphQlContext as graphQlContext.get("role"). We can use WebGraphQlInterceptor to set the role in the GraphQLContext as:


public class RequestHeaderInterceptor implements WebGraphQlInterceptor {

  @Override
  public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {

    var roleHeader = request.getHeaders().get("role");
    if (roleHeader != null) {
      request.configureExecutionInput(
          (executionInput, builder) ->
              builder.graphQLContext(Collections.singletonMap("role", roleHeader.get(0))).build());
    }
    return chain.next(request);
  }
}

Complete code for AuthDirective:


public class AuthDirective implements SchemaDirectiveWiring {

  @Override
  public GraphQLFieldDefinition onField(
      SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {

    // Skipping fields for which directive auth is not applied as this is called for every field.
    if (environment.getAppliedDirective("auth") == null) {
      return environment.getElement();
    }

    var schemaDirectiveRole = environment.getAppliedDirective("auth").getArgument("role").getValue();
    var field = environment.getElement();
    var parentType = environment.getFieldsContainer();

    // build a data fetcher that first checks authorisation roles before then calling the original data fetcher
    
    var originalDataFetcher = environment.getCodeRegistry().getDataFetcher(parentType, field);
    // at runtime authDataFetcher is called
    DataFetcher<?> authDataFetcher =
        dataFetchingEnvironment -> {
          var graphQlContext = dataFetchingEnvironment.getGraphQlContext();
            // role is set in context in interceptor code
          String userRole = graphQlContext.get("role");

          if (userRole != null && userRole.equals(schemaDirectiveRole)) {
            return originalDataFetcher.get(dataFetchingEnvironment);
          } else {
            return null;
          }
        };
    // now change the field definition to have the new auth data fetcher
    environment.getCodeRegistry().dataFetcher(parentType, field, authDataFetcher);
    return field;
  }
}

Also, we need to make spring aware of the schema directives:


@Configuration
public class AppConfig {
    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return builder -> builder.directiveWiring(new AuthDirective());
    }
}

On application startup, GraphQL java engine calls AuthDirective onField method and assigns authDataFetcher as new Data Fetcher for the fields marked with directive @auth.

Use case 2: Input Validation

Another common use case supported by any API is to validate the user’s input and return helpful error messages. Let’s understand how GraphQL directives can be used to implement this feature.

Consider input type BookInput


input BookInput {
    name: String
    author: String
    publisher: String
    price: Float
}

What if you have business rules that say that the name, author, and publisher fields can be of minimum size 10 and max size 100?

One common approach for implementing such business validation is to define validation in the BookInput object (mostly using javax.validation) as:


public class BookInput {
  @Size(min = 10, max = 100)
  private String name;
  @Size(min = 10, max = 100)
  private String author;
  @Size(min = 10, max = 100)
  private String publisher;
  private Double price;
}

One problem with the above approach is that a client will only discover such validation at runtime after it has made the request. A better approach is to define business validations in SDL, using directives, so that it becomes part of API documentation (and also discoverable by clients using introspection).

For that, we can define schema directives @size as:


directive @Size(
    min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.Size.message"
)
on INPUT_FIELD_DEFINITION

input BookInput {
    name: String! @Size(min : 10, max : 100)
    author: String! @Size(min : 10, max : 100)
    publisher: String! @Size(min : 10, max : 100)
    price: Float
}

Implementing Input Validation Directive

You can implement input validation using graphql-java-extended-validation. For example, to validate the size of input fields we can use graphql-java-extended-validation directive @size on SDL (Schema Definition Language) as:


directive @Size(
    min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.Size.message"
)
on INPUT_FIELD_DEFINITION

input BookInput {
    name: String! @Size(min : 10, max : 100)
    author: String! @Size(min : 10, max : 100)
    publisher: String! @Size(min : 10, max : 100)
    price: Float
}

For this, first, add the dependency on graphql-java-extended-validation in build.gradle as


implementation 'com.graphql-java:graphql-java-extended-validation:18.1-hibernate-validator-6.2.0.Final'

and then create ValidationRules and wire that with RuntimeWiring.Builder as :


@Configuration
public class AppConfig {
  @Bean
  public RuntimeWiringConfigurer runtimeWiringConfigurer() {
    var validationRules =
        ValidationRules.newValidationRules()
            .onValidationErrorStrategy(OnValidationErrorStrategy.RETURN_NULL)
            .build();
    var schemaWiring = new ValidationSchemaWiring(validationRules);
    
    return builder -> {
      builder.directiveWiring(schemaWiring);
      builder.directiveWiring(new AuthDirective());
    };
  }
}

Running in GraphiQL


Use case 3: Adding Functionality to the Query

Let’s try to understand how directives can be used to enhance the behavior of operation (queries and mutation) with an example.

Imagine the book catalog API always returns the price in default currency $.


type Query {
    bookById(id : ID) : Book
}
type Book {
    id : ID
    name : String
    author: String
    publisher: String
    price: Float
}

What if a client wants to show the price in a different currency? How can you build an API allowing clients to dynamically request prices in other currencies?

To solve such use cases, you can define an operation directive @currency that takes the target currency as an argument.


directive @currency(
    currency: String!
) on FIELD

type Query {
    bookById(id : ID) : Book
}

type Book {
    id: ID
    name: String
    author: String
    publisher: String
    price: Float
}

Then, the client can call this API by passing currency as an argument as:


query GetBookById {
  bookById(id:1000) {
    id
    name
    author
    publisher
    price
    priceInr : price @currency(currency: "INR")
  }
}

Notice that the field price @currency(currency: "INR") returns the price in the requested currency INR.

One obvious problem with the operation directive is that there is no stopping client from applying the @currency directive on fields other than the price field.

Implementing Operation Directive

Compared to schema directives, operation directives are complicated to implement as they are supplied by the client with instructions to change the behavior of the operation.

Therefore, the only option we have is to use Data Fetcher call back and add custom behavior based on the directive.

In the default implementation, when the directive @currency is supplied to the field price, the GraphQL engine simply ignores this directive and returns the price based on the default implementation graphql.schema.PropertyDataFetcher(as discussed in earlier article).

We can override this behavior by defining a Data Fetcher for the price as


@SchemaMapping(typeName = "Book")
public Double price(Book book, DataFetchingEnvironment dataFetchingEnvironment) {
  ////
}

And then add additional behavior based on the directive supplied by the client as:


@SchemaMapping(typeName = "Book")
public Double price(Book book, DataFetchingEnvironment dataFetchingEnvironment) {
  double price = book.price();
  var maybeAppliedDirective =
      dataFetchingEnvironment.getQueryDirectives().getImmediateAppliedDirective("currency");

  if (!maybeAppliedDirective.isEmpty()) {
    String currency = maybeAppliedDirective.get(0).getArgument("currency").getValue();
    log.info("Getting price in currency {}", currency);
    var rate = currencyConversionService.convert(DEFAULT_CCY, currency);
    price = price * rate;
  }
  return price;
}

In the above code,

  1. We read the applied directive as dataFetchingEnvironment.getQueryDirectives().getImmediateAppliedDirective(“currency”);
  2. Then read the argument of the applied directive as maybeAppliedDirective.get(0).getArgument("currency").getValue(). Here we have made the assumption then there is only one directive is applied to the field price.
  3. Then call CurrencyConversionService by passing target currency information.

Rather than hardcoding default value in code, you can also define the default value of the currency in the directive definition as:


directive @currency(
    currency: String! = "$"
) on FIELD

and read the default value in the code.

Directive Documentation

Documentation is the first‐class feature of GraphQL and all GraphQL types, fields, arguments and other definitions should provide a description unless they are considered self-descriptive.

We can document directives as:


"""Convert price from one currency to other. Default value is $"""
directive @currency(
    "target currency" currency: String! = "$"
) on FIELD

Then, a client can also fetch all directives supported from the server as:


query AllDirectives {
  __schema {
    directives {
      name
      description
      locations
      args {
        name
        description
        defaultValue
      }
    }
  }
}

In GraphiQL


Summary

  • A GraphQL directive is a way to add an extra behavior by annotating parts of the GraphQL schema. We can define a directive starting with @ character followed by the name, argument (optional), and execution location.
  • Based on where directives are applied, it can be used to either customize the behavior of operation (mutation or queries – known as Operation directive) or schema (known as Schema directive).
  • There can be many use cases of directives – such as authorization, input validation, caching, etc.
Social Share !
Pankaj
Pankaj

Software Architect @ Schlumberger ``` Cloud | Microservices | Programming | Kubernetes | Architecture | Machine Learning | Java | Python ```