tools for easy (and finally readable!) directive implementation in Apollo Server or any server using graphql-tools.makeExecutableSchema
This library aims to resolve this quote, and commonly shared opinion, from the Schema Directives docs:
…some of the examples may seem quite complicated. No matter how many tools and best practices you have at your disposal, it can be difficult to implement a non-trivial schema directive in a reliable, reusable way.
Implementing a custom schema directive used to be a very tedious and confusing process. With the addition of the graphql-tools
SchemaVisitor
class a big leap in the direction of usability was made. But there was still a lot of uncertainty about how it could be used, especially for beginners to GraphQL. Many authors opted for simpler alternatives like higher order function resolver wrappers that behaved like directives. These wrappers, while simple, are undocumented in the schema and often require repetitive application and upkeep throughout the codebase.
What are the benefits of implementing directives vs using higher order resolver wrappers?
@directive
tagging Types and Type Fields in your schema that you want to apply it toThis library makes implementing directives as simple as writing any other resolver in your Apollo Server. For those authors who are currently using higher order resolver wrappers transitioning to using directives is trivial.
OBJECT
: directives applied to Type
definitionsFIELD_DEFINITION
: directives applied to Type.field
definitionsQuery.queryName
and Mutation.mutationName
because Query
and Mutation
are considered Object Typestests/
directory. the integration tests also serve as example implementations and can be run with
# all tests
$ npm test
# integration tests
$ npm run test:integration
$ npm install apollo-directive
createDirective
or createSchemaDirectives
directiveConfig
object
const directiveConfig = {
name: string, // required, the directive name
resolverReplacer: function, // required, see details below
hooks: { function, ... }, // optional, see details below
};
const resolverReplacer = (originalResolver, directiveContext) =>
async function directiveResolver(...resolverArgs) {
// implement your directive logic in here
// use any of the original resolver arguments as needed by destructuring
const [root, args, context, info] = resolverArgs;
// use the directive context as needed
// access to information about the directive itself
const {
name, // the name of the directive
objectType, // the Object Type the directive is applied to
field, // the Object Type Field the directive is applied to
// can be aliased to avoid namespace conflicts
args: directiveArgs, // arguments supplied to the directive itself
} = directiveContext;
// you can execute the original resolver (to get its original return value):
const result = originalResolver.apply(this, args);
// or if the original resolver is async / returns a promise use await
// if you use await dont forget to make the directiveResolver async!
const result = await originalResolver.apply(this, args);
// process the result as dictated by your directive
// return a resolved value (this is what is sent back in the API response)
return resolvedValue;
};
resolverReplacer
and directiveResolver
functions are used in a higher order function chain that returns a resolvedValue
resolverReplacer
-> directiveResolver
-> resolvedValue
resolverReplacer
is used internally to replace the original resolver with your directiveResolver
apollo-directive
and your directiveResolver
originalResolver
and directiveContext
parameters into the scope of your directiveResolver
directiveResolver
function receives the original field resolver’s arguments(root, args, context, info)
(...resolverArgs)
to make using the apply()
syntax easier (see below)directiveResolver
must be a function declaration not an arrow functionoriginalResolver
must be done using the apply
syntax
// resolverArgs: [root, args, context, info]
result = originalResolver.apply(this, resolverArgs);
// you can await if the original resolver is async / returns a promise
result = await originalResolver.apply(this, resolverArgs);
// if you dont spread the parameters in the directiveResolver
// meaning you have directiveResolver(root, args, context, info)
// they must be placed into an array in the .apply() call
result = originalResolver.apply(this, [root, args, context, info]);
// export the directiveConfig for use in createSchemaDirectives
module.exports = {
name,
resolverReplacer: (originalResolver, directiveContext) =>
async function directiveResolver(...resolverArgs) {
// implement directive logic
// return the resolved value
},
};
// export the created directive ready to be put into serverConfig.schemaDirectives object
module.exports = createDirective({
name,
resolverReplacer: (originalResolver, directiveContext) =>
async function directiveResolver(...resolverArgs) {
// implement directive logic
// return the resolved value
},
});
serverConfig.schemaDirectives
object<directive name>
from the corresponding directive type definition in the schema
const { ApolloServer } = require("apollo-server-X");
const { createDirective } = require("apollo-directives");
// assumes @admin directive type def has been added to schema
const adminDirectiveConfig = {
name: "admin",
resolverReplacer: requireAdminReplacer,
hooks: { /* optional hooks */ }
};
const adminDirective = createDirective(adminDirectiveConfig);
const server = new ApolloServer({
// typeDefs, resolvers, context, etc.
...
schemaDirectives: {
// the name key must match the directive name in the type defs, @admin in this case
admin: adminDirective,
},
});
config.directiveConfigs
serverConfig.schemaDirectives
in the Apollo Server constructor{ name: directiveResolver, ... }
form
const { ApolloServer } = require("apollo-server-X");
const { createSchemaDirectives } = require("apollo-directives");
// assumes @admin directive type def has been added to schema
const adminDirectiveConfig = {
name: "admin", // must match the name of the directive @<name>
resolverReplacer: requireAdminReplacer,
hooks: { /* optional hooks */ }
};
const server = new ApolloServer({
// typeDefs, resolvers, context, etc.
...
// pass an array of directive config objects to create the schemaDirectives object
schemaDirectives: createSchemaDirectives({
directiveConfigs: [adminDirectiveConfig],
}), // returns { name: directiveResolver, ... }
});
directiveConfig
is validated and will throw an Error for missing or invalid properties
const directiveConfig = {
name: string, // required, see details below
resolverReplacer: function, // required, see signature below
hooks: { function, ... }, // optional, see signatures below
};
createDirective
and the directive logic in the directiveResolver
createDirective
config
parameterasync
directiveResolver
signature (the same as the standard Apollo resolver)
resolverReplacer(originalResolver, directiveContext) ->
directiveResolver(root, args, context, info) -> resolved value
directiveContext
object provides access to information about the directive itselfdirectiveResolver
as needed
const {
name, // the name of the directive
objectType, // the Object Type the directive is applied to
field, // the Object Type Field the directive is applied to
// you can alias the args as directiveArgs to avoid naming conflicts in the directiveResolver
args: directiveArgs, // object of arguments supplied to the directive itself
} = directiveContext;
originalResolver
async
if you need to work with promisesnull
then you must support this rule by not returning undefined
or null
from the directiveResolver
directiveResolver(root, args, context, info) -> resolved value
directiveResolver(...resolverArgs) -> resolved value
_<name>DirectiveApplied
property on the objectType
createSchemaDirectives
utilityschemaDirectives
object@admin
then name = "admin"
directiveContext.field
will be undefined
for this hook
onVisitObject(directiveContext) -> void
onvisitFieldDefinition(directiveContext) -> void
on OBJECT
): called once for each field in the Objecton FIELD_DEFINITION
): called once for the fieldonVisitObject
or onVisitFieldDefinition
is executeddirectiveConfig.name
, the internal method applying the directive will exit early for the following case:onApplyDirective
will not be called a second time for this case due to exiting early
onApplyDirective(directiveContext) -> void;
# only able to tag Object Type Fields
directive @<directive name> on FIELD_DEFINITION
# only able to tag Object Types
directive @<directive name> on OBJECT
# able to tag Object Types and Object Type Fields
directive @<directive name> on FIELD_DEFINITION | OBJECT
# alternate accepted syntax
directive @<directive name> on
| FIELD_DEFINITION
| OBJECT
# adding a description to a directive
"""
directive description
(can be multi-line)
"""
directive @<directive name> on FIELD_DEFINITION | OBJECT
# tagging an Object Type Field
type SomeType {
# the directive resolver is executed when access to the tagged field(s) is made
aTaggedField: String @<directive name>
}
type Query {
queryName: ReturnType @<directive name>
}
# tagging an Object Type
type SomeType @<directive name> {
# the directive is applied to every field in this Type
# the directive resolver is executed when any access to this Type's fields (through queries / mutations / nesting) are made
}
# multiple directives can be tagged, space-separated
type SomeType @firstDirective @secondDirective {
# applying a directive to a list type must be to the right of the closing bracket
aTaggedField: [TypeName] @<directive name>
}
"""
returns all String scalar values in upper case
"""
directive @upperCase on FIELD_DEFINITION | OBJECT
# the Object Type itself is tagged
# all of the fields in this object will have the @upperCase directive applied
type User @upperCase {
id: ID!
username: String!
friends: [User!]!
}
type Dog {
id: ID!
# only Dog.streetAddress will have the directive applied
streetAddress: String! @upperCase
}
requires
argument with an array of Role
enum elementsdirectiveResolver
through directiveContext.args
requires
argument has a default value set as [ADMIN]
@auth
) then this default argument will be provided as ["ADMIN"]
# example of a directive to enforce authentication / authorization
# you can provide a default value just like arguments to any other definition
directive @auth(requires: [Role] = [ADMIN]) on FIELD_DEFINITION | OBJECT
# assumes a ROLE enum has been defined
enum Role {
USER # any authenticated user
SELF # the authenticated user only
ADMIN # admins only
}
# apply the directive to an entire Object Type
# because no argument is provided the default ([ADMIN]) is used
type PaymentInfo @auth {
# all of the fields in this Object Type will have the directive applied requiring ADMIN permissions
}
type User {
# authorization for the authenticated user themself or an admin
email: EmailAddress! @auth(requires: [SELF, ADMIN])
}
Query
and Mutation
Object Types
directive @upperCase on FIELD_DEFINITION | OBJECT
// the resolverReplacer function
const upperCaseReplacer = (originalResolver, directiveContext) =>
// the directiveResolver function
async function upperCaseResolver(...resolverArgs) {
// execute the original resolver to store its output for directive processing below
const result = await originalResolver.apply(this, resolverArgs);
// return the a valid resolved value after directive processing
if (typeof result === "string") {
return result.toUpperCase();
}
return result;
};
module.exports = upperCaseReplacer;
directiveContext
objectJSON.stringify(objectType | field, null, 2)
const {
name,
type,
description,
isDeprecated,
deprecationReason,
astNode, // AST object
_fields, // the Object Type's fields { fieldName: fieldObject }
} = objectType;
const {
name,
type,
args: [{
name,
type,
description,
defaultValue,
astNode,
}, ...],
description,
isDeprecated,
deprecationReason,
astNode, // AST object
} = field;
const {
kind,
description: {
kind,
value,
block,
loc: { start, end },
},
name: {
kind,
value,
loc: { start, end },
},
interfaces: [],
directives: [{
kind,
name: {
kind,
value,
loc: { start, end },
},
arguments: [{
kind,
name: {
kind,
value,
loc: { start, end },
}
}, ...],
}, ...],
fields: [{
type,
name,
description,
args,
astNode: [
// for non-scalar types
]
}, ...],
} = astNode;