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!
In this article
Want to know more about GraphQL? Check other posts:
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 argumentreason
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), affecting how the GraphQL server processes an operation. 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.
@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 that can 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 limit its usage. Therefore, you can define your own directives to support as many use cases as possible.
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 the role passed as an HTTP header:
Query 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,
- The
authDataFetcher
first checks if the user has the required role by getting the role from theGraphQlContext
asgraphQlContext.get("role")
. - If the user has the required role, it calls the original data fetcher.
- If the user doesn’t have the required role then it returns
null
. Thus, an unauthorized user sees anull
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 assignsauthDataFetcher
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 behaviour of operation (queries and mutation) with an example.
Imagine the book catalogue 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 the client supplies them 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 behaviour 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 behaviour 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,
- We read the applied directive as dataFetchingEnvironment.getQueryDirectives().getImmediateAppliedDirective(“currency”);
- Then read the argument of the applied directive as
maybeAppliedDirective.get(0).getArgument("currency").getValue()
. Here we have made the assumption that only one directive is applied to the field price. - 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.