resources used to learn:
the proposed 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.
- currently supported directive targets:
FIELD
:Type.field
,Query.queryName
,Mutation.mutationName
OBJECT
:Type
,Query
,Mutation
- no current support for directive arguments
- each directive resolver must have a corresponding type definition in the schema
- learn more about writing directive type defs
# only able to tag Object Type Fields
directive @<directive name> on FIELD
# only able to tag Object Types
directive @<directive name> on OBJECT
# able to tag Object Types and Type Fields
directive @<directive name> on FIELD | OBJECT
# alternate accepted syntax
directive @<directive name> on
| FIELD
| OBJECT
# adding a description to a directive
"""
directive description
(can be multi-line)
"""
directive @<directive name> on FIELD | OBJECT
# tagging an Object Type Field
# directive is executed when access to the tagged field(s) is made
type SomeType {
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
# directive is executed when any access to this Type (through queries / mutations / nesting) is made
}
- note that queries and resolver definitions are considered fields of the
Query
andMutation
objects - 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
- once you have written the directive type def you can implement its resolver using
createDirective
orcreateSchemaDirectives
- both tools make use of a
directiveConfig
object
const directiveConfig = {
hooks: { function, ... }, // optional, see signatures below
name: string, // required, see details below
resolverReplacer: function, // required, see signature below
};
- use for creating a single directive resolver
- add the resolver to the Apollo Server
config.schemaDirectives
object- the name must match the
<directive name>
from the corresponding directive definition in the schema
- the name must match the
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",
/*
assumes the following function has been implemented somewhere:
requireAdmin(originalResolver, { objectType, field }) ->
adminResolverWrapper(root, args, context, info)
*/
resolverReplacer: requireAdmin,
hooks: { /* optional hooks */ }
};
const adminDirective = createDirective(adminDirectiveConfig);
const server = new ApolloServer({
// typeDefs, resolvers, context, etc.
...
schemaDirectives: {
// the name must match the directive name in the type defs, @admin in this case
admin: adminDirective,
},
});
- accepts an array of directive config objects
- assign the result to
serverConfig.schemaDirectives
in the Apollo Server constructor - creates each directive and provides them as the schemaDirectives object in
{ name: directiveConfig, ... }
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",
/*
assumes the following function has been implemented somewhere:
requireAdmin(originalResolver, { objectType, field }) ->
adminResolverWrapper(root, args, context, info)
*/
resolverReplacer: requireAdmin,
hooks: { /* optional hooks */ }
};
const server = new ApolloServer({
// typeDefs, resolvers, context, etc.
...
// pass an array of directive config objects
// creates each directive and provides them as the schemaDirectives object in { name: directiveConfig, ... } form
schemaDirectives: createSchemaDirectives([adminDirectiveConfig]),
});
- the
resolverReplacer
andresolverWrapper
functions are used in a higher order function chain that returns aresolvedValue
resolverReplacer
->resolverWrapper
->resolvedValue
- this sounds complicated but as seen below the implementation is intuitive
- only the directive behavior logic needs to be written in
resolverWrapper
which returns a validresolvedValue
resolverReplacer
has a standard boilerplateresolverReplacer
curries (HoF term for carrying arguments through the chain) theoriginalResolver
anddirectiveContext
so they are in scope inresolverWrapper
- the
resolverWrapper
function receives the original field resolver's arguments(root, args, context, info)
- general example
// this is the resolverReplacer function boilerplate
module.exports = (originalResolver, directiveContext) =>
// this is the resolverWrapper function that you implement
function resolverWrapper(...args) { // put all the args into an array (makes it easier to use the .apply() syntax)
// use any of the original resolver arguments as needed
const [root, args, context, info] = args;
// use the directive context as needed
// access to information about the object or field that is being resolved
const { objectType, field } = directiveContext;
// implement directive logic
// you can execute the original resolver (to get its return value):
const result = originalResolver.apply(this, args);
// or if the original resolver is async / returns a promise
// if you use await dont forget to make the resolverWrapper 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;
}
- annotated example from Apollo Docs: Schema Directives - Uppercase String
// the resolverReplacer function
const upperCaseReplacer = (originalResolver, { objectType, field }) =>
// the resolverWrapper function
async function upperCaseResolver(...args) {
// execute the original resolver to store its output
const result = await originalResolver.apply(this, args);
// return the a valid resolved value after directive processing
if (typeof result === "string") {
return result.toUpperCase();
}
return result;
};
module.exports = upperCaseReplacer;
- executing the
originalResolver
must be done using theapply
syntax
// args: [root, args, context, info]
result = originalResolver.apply(this, args);
// you can await if the original resolver is async / returns a promise
result = await originalResolver.apply(this, args);
directiveConfig
is validated and will throw an Error for missing or invalid properties- shape
const directiveConfig = {
name: string, // required, see details below
resolverReplacer: function, // required, see signature below
hooks: { function, ... }, // optional, see signatures below
};
- a higher order function used to bridge information between
createDirective
and the directive logic in theresolverWrapper
- used in
createDirective
config
parameter - may not be
async
- must return a function that implements the
resolverWrapper
signature (the same as the standard Apollo resolver) - signature
// directiveContext: { objectType, field }
resolverReplacer(originalResolver, directiveContext) ->
resolverWrapper(root, args, context, info)
- boilerplate
const resolverReplacer = (originalResolver, { objectType, field }) =>
function resolverWrapper(root, args, context, info) {};
- a higher order function used to transform the result or behavior of the
originalResolver
- must be returned from
resolverReplacer
- must be a function declaration not an arrow function
- may be
async
- signature:
resolverWrapper(root, args, context, info) -> resolved value
- unique identifier for the directive
- must be unique across all directives registered on the schema
- used for improving performance when directives are registered on server startup
- added as
_<nameIsWrapped
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 - must use the same name as the directive in your type defs
- ex: directive type def
@admin
thenname = "admin"
- used as the directive identifier in the
- provide access to each step of the process as the directive resolver is applied during server startup
- called once for each Object Type definition that the directive has been applied to
- called before the directive is applied to the Object Type
- signature
onVisitObject(objectType);
- called once for each Object Type field definition that the directive has been applied to
- called before the directive is applied to the field
- signature
onvisitFieldDefinition(field, details);
objectType
can be accessed fromdetails.objectType
- called as the directive is being applied to an object or field
- called once immediately after
onVisitObject
oronVisitFieldDefinition
is called
- called once immediately after
- technical note: using the directive name,
config.name
, the internal method applying the directive will exit early instead of reapplying the directive- directives that are applied to both an object and its field(s) will trigger this behavior
onApplyToObjectType
will still be called even if it exits early- this is a performance measure that you can read more about from this Apollo Docs: Schema Directives section
- signature
onApplyToObjectType(objectType);
- these two objects can be found in the
reaplceResolver(originalResolver, directiveContext)
parameterdirectiveContext: { objectType, field }
- provide access to information about the object type or field as the directive is being executed on it
objectType {
}
field {
}
- currently covers Object Types (
OBJECT
target) and Object Field Types (FIELD
target) - currently does not support directive arguments
- individual directive:
createDirective
- build the directive then assign as an entry in Apollo Server
config.schemaDirectives
object
const createDirective = config => {
const { name, resolverReplacer, hooks = {} } = validateConfig(config);
const { onVisitObject, onVisitFieldDefinition, onApplyToObjectType } = hooks;
return class Directive extends SchemaDirectiveVisitor {
visitObject(objectType) {
if (onVisitObject) onVisitObject(objectType);
this.applyToObjectType(objectType);
}
visitFieldDefinition(field, details) {
if (onVisitFieldDefinition) onVisitFieldDefinition(field, details);
this.applyToObjectType(details.objectType);
}
applyToObjectType(objectType) {
if (onApplyToObjectType) onApplyToObjectType(objectType);
// exit early if the directive has already been applied to the object type
if (objectType[`_${name}DirectiveApplied`]) return;
objectType[`_${name}DirectiveApplied`] = true; // otherwise set _<name>DirectiveApplied flag
const fields = objectType.getFields();
Object.values(fields).forEach(field => {
// mapped scalar fields (without custom resolvers) will use the defaultFieldResolver
const originalResolver = field.resolve || defaultFieldResolver;
// replace the original resolver with the resolverWrapper returned from resolverReplacer
field.resolve = resolverReplacer(originalResolver, {
field,
objectType,
});
});
}
};
};
- builds a
schemaDirectives
object in{ name: directiveConfig, ... ]
form - accepts an array of directive config objects
- assign its output to Apollo Server
serverConfig.schemaDirectives
const createSchemaDirectives = directiveConfigs =>
directiveConfigs.reduce(
(schemaDirectives, directiveConfig) => ({
...schemaDirectives,
[directiveConfig.name]: createDirective(directiveConfig),
}),
{},
);
const validateConfig = config => {
const { name, resolverReplacer } = config;
let message;
if (!name || !resolverReplacer) {
message = "config.name is required";
} else if (!resolverReplacer) {
message = "config.resolverReplacer is required";
} else if (typeof name !== "string") {
message = "config.name must be a string";
} else if (typeof resolverReplacer !== "function") {
message = "config.resolverReplacer must be a function";
} else {
return config;
}
const error = new Error(message);
error.name = "CreateDirectiveError";
throw error;
};
- the
visitX
methods are executed on server startup to register the respective directive implementation - each
visitX
method should utilize (at minimum) a function that wraps theobjectType
- **
applyToObjectType
function ** - executes the function reassignment for
field.resolve
resolverReplacer
function
- captures the resolver wrapper function returned by the
resolverReplacer
functionresolverWrapper
function
- **
- adding a marker flag property to the Object prevents redundant application of a directive that has already been applied
- for cases where more than one
visitX
method / directive target likeOBJECT
andFIELD
are used- apollo docs discussing this concept
- best practice to implement and utilize the
applyToObjectType
function even if only a single visitor method / directive target is used- consistency of usage pattern
- makes extending the directive to multiple locations less error-prone
_<name>DirectiveApplied
property should be added directly to theobjectType
in theapplyToObjectType
function- each directive needs a unique
<name>
because an Object Type can be tagged with multiple directives <name>
must be unique across all directiveSchemaVisitor
subclass implementations to avoid naming collisions
- each directive needs a unique
- HoF have traditionally been much easier to write
- directives are known to be complicated to implement and even moreso to explain / understand
- but directives have the benefit of being documented and visible across the team's stack by being written directly in the schema, the contract of your API
- AED extends the abstraction that
SchemaVisitor
began - finally makes the process of designing and implementing directives painless and with easy to follow code
- AED makes it easy to transition existing HoF wrappers into directives
- most HoF implementations can be easily transition into the
resolverReplacer
andresolverWrapper
signatures - after the HoF is transition the consumer just has to implement the directive type defs and provide their corresponding
name
- most HoF implementations can be easily transition into the
- called during server startup directive registration chain
- once for each Object Type definition that the directive has been tagged on
- exposed through
onVisitObject
hook- signature:
onVisitObject(objectType)
- called before the
applyToObjectType
method is executed
- signature:
- called during server startup directive registration chain
- once for each Object Type field definition that the directive has been tagged on
- exposed through
onvisitFieldDefinition
hook- signature:
onvisitFieldDefinition(field, details)
details.objectType
access
- called before the
applyToObjectType
method is executed
- signature:
- called during server startup directive registration chain
- the
resolverReplacer
andresolverWrapper
functions are used in a higher order function chain which must return aresolvedValue
that is allowed by the schema's definitionsresolverReplacer
->resolverWrapper
->resolvedValue
- the library consumer only has to implement directive behavior logic in
resolverWrapper
and return a validresolvedValue
- the
resolverWrapper
function receives the original field resolver's arguments(root, args, context, info)
resolverReplacer
curries theoriginalResolver
anddirectiveContext
so they are in scope inresolverWrapper
- they can be used as needed in when implementing the directive logic
- the
- implemented by library consumer
- a higher order function used to bridge information between
createDirective
and the consumer's directive resolver logic - provided by library consumer in
createDirective
config
parameter - may not be
async
- must return a function that implements the
resolverWrapper
signature (the same as the standard Apollo resolver) - signature
// directiveContext: { objectType, field }
resolverReplacer(originalResolver, directiveContext) ->
resolverWrapper(root, args, context, info)
- example
module.exports = (originalResolver, { objectType, field }) =>
function resolverWrapper(...args) {
// implement directive logic
return resolvedValue;
};
- a higher order function used to transform the result or behavior of the
originalResolver
- must be returned from
resolverReplacer
- must be a function declaration not an arrow function
- may be
async
- signature:
resolverWrapper(root, args, context, info) -> resolved value
- annotated example from Apollo Docs: Schema Directives - Uppercase String
async function (...args) {
// use any of the original resolver arguments as needed
// args: [root, args, context, info]
// execute the original resolver to store its output
const result = await originalResolver.apply(this, args);
// implement other directive logic as needed
// return the resolved value after directive processing
if (typeof result === "string") {
return result.toUpperCase();
}
return result;
};
- can be found in:
visitObject(objectType)
: first parametervisitFieldDefinition(field, details)
: second parameter- through
details.objectType
- through
resolverReplacer(originalResolver, directiveContext)
: second parameter- through
directiveContext.objectType
- through
- shape
- can be found in:
visitFieldDefinition
first parameter - shape