Finally an easy way to create custom GraphQL directives! With this package creating a custom schema directive is as easy as writing any other Apollo resolver.
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.
concept
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?
- your directives are officially documented as part of the schema itself
- write its resolver once and use it any number of times by simply
@directive
tagging Types and Type Fields in your schema that you want to apply it to - no more concerns of forgetting to wrap a resolver leading to unexpected behavior
- there is no "hidden" magic that requires digging throughout the resolvers to understand
This 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.
current support
- directive targets (covers the vast majority of use cases):
OBJECT
: directives applied toType
definitions- the directive is applied to all the fields of the Object Type it is tagged on
FIELD_DEFINITION
: directives applied toType.field
definitions- the directive is applied only to the specific Object Type Field it is tagged on
- note this includes
Query.queryName
andMutation.mutationName
becauseQuery
andMutation
are considered Object Types
- directive arguments
- unit and integration tests are available in the
tests/
directory. the integration tests also serve as example implementations and can be run with
# all tests $ npm test # integration tests $ npm run test:integration
usage
$ npm install apollo-directive
- once you have written the directive type def you can implement its resolver using one of the two package utilities:
createDirective
orcreateSchemaDirectives
- both tools make use of a
directiveConfig
object
const directiveConfig = name: string // required, the directive name
resolverReplacer and directiveResolver
const resolverReplacer = { // 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; // 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; // process the result as dictated by your directive // return a resolved value (this is what is sent back in the API response) return resolvedValue; };
- the
resolverReplacer
anddirectiveResolver
functions are used in a higher order function chain that returns aresolvedValue
resolverReplacer
->directiveResolver
->resolvedValue
- this sounds complicated but as seen above the implementation on your end is as intuitive as writing any other resolver
resolverReplacer
is used internally to replace the original resolver with yourdirectiveResolver
- used as a bridge between
apollo-directive
and yourdirectiveResolver
- brings the
originalResolver
anddirectiveContext
parameters into the scope of yourdirectiveResolver
- used as a bridge between
- the
directiveResolver
function receives the original field resolver's arguments(root, args, context, info)
- these can be abbreviated into an array as
(...resolverArgs)
to make using theapply()
syntax easier (see below)
- the
directiveResolver
must be a function declaration not an arrow function - executing the
originalResolver
must be done using theapply
syntax
// resolverArgs: [root, args, context, info]result = originalResolver; // you can await if the original resolver is async / returns a promiseresult = await originalResolver; // 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() callresult = originalResolver;
- boilerplates to get going quickly
// export the directiveConfig for use in createSchemaDirectivesmoduleexports = name { // implement directive logic // return the resolved value }; // export the created directive ready to be put into serverConfig.schemaDirectives objectmoduleexports = ;
using createDirective
- use for creating a single directive resolver
- add the resolver to the Apollo Server
serverConfig.schemaDirectives
object- the name must match the
<directive name>
from the corresponding directive type definition in the schema
- the name must match the
const ApolloServer = ;const createDirective = ; // assumes @admin directive type def has been added to schema const adminDirectiveConfig = name: "admin" resolverReplacer: requireAdminReplacer hooks: /* optional hooks */ ; const adminDirective = ; const server = // typeDefs, resolvers, context, etc. ... schemaDirectives: // the name key must match the directive name in the type defs, @admin in this case admin: adminDirective ;
using createSchemaDirectives
- accepts an array of directive config objects in
config.directiveConfigs
- assign the result to
serverConfig.schemaDirectives
in the Apollo Server constructor - creates each directive and provides them as the schemaDirectives object in
{ name: directiveResolver, ... }
form
const ApolloServer = ;const createSchemaDirectives = ; // 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 = // typeDefs, resolvers, context, etc. ... // pass an array of directive config objects to create the schemaDirectives object schemaDirectives: // returns { name: directiveResolver, ... };
directive config
directiveConfig
is validated and will throw an Error for missing or invalid properties- shape
const directiveConfig = name: string // required, see details below
resolverReplacer
- a higher order function used to bridge information between
createDirective
and the directive logic in thedirectiveResolver
- used in
createDirective
config
parameter - may not be
async
- must return a function that implements the
directiveResolver
signature (the same as the standard Apollo resolver) - signature
-> -> resolved value
directiveContext
- the
directiveContext
object provides access to information about the directive itself - you can use this information in the
directiveResolver
as needed - see the [objectType] and [field] shapes
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;
directiveResolver
- a higher order function used to transform the result or behavior of the
originalResolver
- must be a function declaration not an arrow function
- may be
async
if you need to work with promises - must return a valid resolved value (valid according to the schema)
- for example if your schema dictates that the resolved value may not be
null
then you must support this rule by not returningundefined
ornull
from thedirectiveResolver
- for example if your schema dictates that the resolved value may not be
- signature:
-> resolved value -> resolved value
name
- the name of the directive (same as the name in the directive type definition in the schema)
- used for improving performance when directives are registered on server startup
- added as
_<name>DirectiveApplied
property on theobjectType
- you can read more from this Apollo Docs: Schema Directives section
- added as
- when using the
createSchemaDirectives
utility- used as the directive identifier in the
schemaDirectives
object - ex: directive type def
@admin
thenname = "admin"
- used as the directive identifier in the
hooks
- provide access to each step of the process as the directive resolver is applied during server startup
- purely observational, nothing returned from these functions is used
- can be used for logging or debugging
onVisitObject
- called once for each Object Type definition that the directive has been applied to
- called before the directive is applied to the Object Type
- receives the directiveContext object
- note that
directiveContext.field
will beundefined
for this hook
- note that
- signature
-> void
onVisitFieldDefinition
- called once for each Object Type field definition that the directive has been applied to
- called before the directive is applied to the field
- receives the directiveContext object
- signature
-> void
onApplyDirective
- called immediately before the directive is applied
- directive applied to an Object Type (
on OBJECT
): called once for each field in the Object - directive applied to a field (
on FIELD_DEFINITION
): called once for the field - called after
onVisitObject
oronVisitFieldDefinition
is executed
- directive applied to an Object Type (
- receives the directiveContext object
- technical note: using the directive name,
directiveConfig.name
, the internal method applying the directive will exit early for the following case:- directives that are applied to both an object and its individual field(s) will exit early to prevent duplicate application of the directive
onApplyDirective
will not be called a second time for this case due to exiting early- this is a performance measure that you can read more about from this Apollo Docs: Schema Directives section
- signature
-> void;
schema directive type definitions and usage
- learn more about writing directive type defs or see the examples below
creating schema directive type defs
# only able to tag Object Type Fieldsdirective @<directive name> on FIELD_DEFINITION # only able to tag Object Typesdirective @<directive name> on OBJECT # able to tag Object Types and Object Type Fieldsdirective @<directive name> on FIELD_DEFINITION | OBJECT # alternate accepted syntaxdirective @<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
using directives in your schema type defs
- applying directives is as simple as "tagging" them onto an Object Type or one of its fields
# tagging an Object Type Fieldtype 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 Typetype 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-separatedtype SomeType @firstDirective @secondDirective { # applying a directive to a list type must be to the right of the closing bracket aTaggedField: [TypeName] @<directive name>}
example of defining and using a schema directive
- a basic example
"""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 appliedtype User @upperCase { id: ID! username: String! friends: [User!]!} type Dog { id: ID! # only Dog.streetAddress will have the directive applied streetAddress: String! @upperCase}
- a more complex example of an authentication / authorization directive
- this directive can receive a
requires
argument with an array ofRole
enum elements- directives argument(s) are available in the
directiveResolver
throughdirectiveContext.args
- directives argument(s) are available in the
- the
requires
argument has a default value set as[ADMIN]
- if no argument is provided (just
@auth
) then this default argument will be provided as["ADMIN"]
- if no argument is provided (just
# example of a directive to enforce authentication / authorization# you can provide a default value just like arguments to any other definitiondirective @auth(requires: [Role] = [ADMIN]) on FIELD_DEFINITION | OBJECT # assumes a ROLE enum has been definedenum 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 usedtype 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])}
what targets should the directive be applied to?
- note that queries and resolver type definitions are considered fields of the
Query
andMutation
Object Types - directive needs to transform the result of a resolver
- tag the directive on a field
- any access to the field will execute the directive
- examples
- upper case a value
- translate a value
- format a date string
- directive needs to do some auxiliary behavior in a resolver
- tag the directive on a field, object, or both
- any queries that request values (directly or through nesting) from the tagged object and / or field will execute the directive
- examples
- enforcing authentication / authorization
- logging
examples
- annotated example from Apollo Docs: Schema Directives - Uppercase String
- corresponds to the following directive type def
directive @upperCase on FIELD_DEFINITION | OBJECT
// the resolverReplacer functionconst upperCaseReplacer = // the directiveResolver function { // execute the original resolver to store its output for directive processing below const result = await originalResolver; // return the a valid resolved value after directive processing if typeof result === "string" return result; return result; }; moduleexports = upperCaseReplacer;
the objectType and field shapes
- these two objects can be found in the
directiveContext
object - provide access to information about the Object Type or Object Type Field the directive is being applied to
- use the following shapes as a guide or use the hooks to log these in more detail as needed
- to expand the objects (incluidng AST nodes) in your log use
JSON.stringify(objectType | field, null, 2)
- to expand the objects (incluidng AST nodes) in your log use
objectType
- Object Type information
const name type description isDeprecated deprecationReason astNode // AST object _fields // the Object Type's fields { fieldName: fieldObject }} = objectType;
field
- Object Type Field information
const name type args: name type description defaultValue astNode ... description isDeprecated deprecationReason astNode // AST object } = field;
astNode
- it is unlikely you will need to access this property
- this is a parsed object of the AST for the Object Type or Object Type 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;