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.mutationNameOBJECT: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
QueryandMutationobjects - 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
createDirectiveorcreateSchemaDirectives - both tools make use of a
directiveConfigobject
const directiveConfig = {
hooks: { function, ... }, // optional, see signatures below
directiveKey: string, // required, see details below
replaceResolver: function, // required, see signature below
};- use for creating a single directive resolver
- add the resolver to the Apollo Server
config.schemaDirectivesobject- the key must match the
<directive name>from the corresponding directive definition in the schema
- the key 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 = {
directiveKey: "admin",
/*
assumes the following function has been implemented somewhere:
requireAdmin(originalResolver, { objectType, field }) ->
adminResolverWrapper(root, args, context, info)
*/
replaceResolver: requireAdmin,
hooks: { /* optional hooks */ }
};
const adminDirective = createDirective(adminDirectiveConfig);
const server = new ApolloServer({
// typeDefs, resolvers, context, etc.
...
schemaDirectives: {
admin: adminDirective, // the key must match the directive name in the type defs, @admin in this case
},
});- accepts an array of directive config objects
- assign the result to
serverConfig.schemaDirectivesin the Apollo Server constructor - creates each directive and provides them as the schemaDirectives object in
{ directiveKey: 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 = {
directiveKey: "admin",
/*
assumes the following function has been implemented somewhere:
requireAdmin(originalResolver, { objectType, field }) ->
adminResolverWrapper(root, args, context, info)
*/
replaceResolver: 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 { directiveKey: directiveConfig, ... } form
schemaDirectives: createSchemaDirectives([adminDirectiveConfig]),
});- the
replaceResolverandresolverWrapperfunctions are used in a higher order function chain that returns aresolvedValuereplaceResolver->resolverWrapper->resolvedValue
- this sounds complicated but as seen below the implementation is intuitive
- only the directive behavior logic needs to be written in
resolverWrapperwhich returns a validresolvedValuereplaceResolverhas a standard boilerplatereplaceResolvercurries (HoF term for carrying arguments through the chain) theoriginalResolveranddirectiveContextso they are in scope inresolverWrapper- the
resolverWrapperfunction receives the original field resolver's arguments(root, args, context, info)
- general example
// this is the replaceResolver 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 replaceResolver 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
originalResolvermust be done using theapplysyntax
// 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);directiveConfigis validated and will throw an Error for missing or invalid properties- shape
const directiveConfig = {
directiveKey: string, // required, see details below
replaceResolver: function, // required, see signature below
hooks: { function, ... }, // optional, see signatures below
};- a higher order function used to bridge information between
createDirectiveand the directive logic in theresolverWrapper - used in
createDirectiveconfigparameter - may not be
async - must return a function that implements the
resolverWrappersignature (the same as the standard Apollo resolver) - signature
// directiveContext: { objectType, field }
replaceResolver(originalResolver, directiveContext) ->
resolverWrapper(root, args, context, info)- boilerplate
const replaceResolver = (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
replaceResolver - 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
_<directiveKeyIsWrappedproperty on theobjectType - you can read more from this Apollo Docs: Schema Directives section
- added as
- when using the
createSchemaDirectivesutility- used as the directive identifier in the
schemaDirectivesobject - must use the same name as the directive in your type defs
- ex: directive type def
@adminthendirectiveKey = "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)objectTypecan be accessed fromdetails.objectType
- called as the directive is being applied to an object or field
- called once immediately after
onVisitObjectoronVisitFieldDefinitionis called
- called once immediately after
- technical note: using the directive key,
config.directiveKey, 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
onApplyToObjectTypewill 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 (
OBJECTtarget) and Object Field Types (FIELDtarget) - currently does not support directive arguments
- individual directive:
createDirective - build the directive then assign as an entry in Apollo Server
config.schemaDirectivesobject
const createDirective = (config) => {
const { directiveKey, replaceResolver, 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[`_${directiveKey}DirectiveApplied`]) return;
objectType[`_${directiveKey}DirectiveApplied`] = true; // otherwise set _<key>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 replaceResolver
field.resolve = replaceResolver(originalResolver, {
field,
objectType,
});
});
}
};
};- builds a
schemaDirectivesobject in{ directiveKey: 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.directiveKey]: createDirective(directiveConfig),
}),
{},
);const validateConfig = (config) => {
const { directiveKey, replaceResolver } = config;
let message;
if (!directiveKey || !replaceResolver) {
message = "config.directiveKey is required";
} else if (!replaceResolver) {
message = "config.replaceResolver is required";
} else if (typeof directiveKey !== "string") {
message = "config.directiveKey must be a string";
} else if (typeof replaceResolver !== "function") {
message = "config.replaceResolver must be a function";
} else {
return config;
}
const error = new Error(message);
error.name = "CreateDirectiveError";
throw error;
};- the
visitXmethods are executed on server startup to register the respective directive implementation - each
visitXmethod should utilize (at minimum) a function that wraps theobjectType- **
applyToObjectTypefunction ** - executes the function reassignment for
field.resolveresolverReplacerfunction
- captures the resolver wrapper function returned by the
resolverReplacerfunctionresolverWrapperfunction
- **
- 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
visitXmethod / directive target likeOBJECTandFIELDare used- apollo docs discussing this concept
- best practice to implement and utilize the
applyToObjectTypefunction 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
_<key>DirectiveAppliedproperty should be added directly to theobjectTypein theapplyToObjectTypefunction- each directive needs a unique
<key>because an Object Type can be tagged with multiple directives <key>must be unique across all directiveSchemaVisitorsubclass 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
SchemaVisitorbegan - 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
replaceResolverandresolverWrappersignatures - after the HoF is transition the consumer just has to implement the directive type defs and provide their corresponding
directiveKey
- 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
onVisitObjecthook- signature:
onVisitObject(objectType) - called before the
applyToObjectTypemethod 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
onvisitFieldDefinitionhook- signature:
onvisitFieldDefinition(field, details)details.objectTypeaccess
- called before the
applyToObjectTypemethod is executed
- signature:
- called during server startup directive registration chain
- the
replaceResolverandresolverWrapperfunctions are used in a higher order function chain which must return aresolvedValuethat is allowed by the schema's definitionsreplaceResolver->resolverWrapper->resolvedValue
- the library consumer only has to implement directive behavior logic in
resolverWrapperand return a validresolvedValue- the
resolverWrapperfunction receives the original field resolver's arguments(root, args, context, info) replaceResolvercurries theoriginalResolveranddirectiveContextso 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
createDirectiveand the consumer's directive resolver logic - provided by library consumer in
createDirectiveconfigparameter - may not be
async - must return a function that implements the
resolverWrappersignature (the same as the standard Apollo resolver) - signature
// directiveContext: { objectType, field }
replaceResolver(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
replaceResolver - 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
replaceResolver(originalResolver, directiveContext): second parameter- through
directiveContext.objectType
- through
- shape
- can be found in:
visitFieldDefinitionfirst parameter - shape