Skip to content

Instantly share code, notes, and snippets.

@the-vampiire
Created July 28, 2019 16:56
Show Gist options
  • Save the-vampiire/dec334cb0400a6e63f3e43a465cee7a4 to your computer and use it in GitHub Desktop.
Save the-vampiire/dec334cb0400a6e63f3e43a465cee7a4 to your computer and use it in GitHub Desktop.
apollo-directives library proposal

apollo-directives library proposal

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.

documentation draft

  • 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

writing a directive type def

# 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

using a directive type def

# 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
}

what should the directive be applied to?

  • note that queries and resolver definitions are considered fields of the Query and Mutation 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

using apollo-directives

  • once you have written the directive type def you can implement its resolver using createDirective or createSchemaDirectives
  • both tools make use of a directiveConfig object
const directiveConfig = {
  hooks: { function, ... }, // optional, see signatures below
  directiveKey: string, // required, see details below
  replaceResolver: function, // required, see signature below
};

using createDirective

  • use for creating a single directive resolver
  • add the resolver to the Apollo Server config.schemaDirectives object
    • the key must match the <directive name> from the corresponding directive 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 = {
  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
  },
});

using createSchemaDirectives

  • 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 { 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]),
});

replaceResolver and resolverWrapper

  • the replaceResolver and resolverWrapper functions are used in a higher order function chain that returns a resolvedValue
    • replaceResolver -> 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 valid resolvedValue
    • replaceResolver has a standard boilerplate
    • replaceResolver curries (HoF term for carrying arguments through the chain) the originalResolver and directiveContext so they are in scope in resolverWrapper
    • the resolverWrapper function 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;
  }  
// 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 originalResolver must be done using the apply 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);

directive config

  • directiveConfig is 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
};

replaceResolver

  • a higher order function used to bridge information between createDirective and the directive logic in the resolverWrapper
  • 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 }
replaceResolver(originalResolver, directiveContext) ->
    resolverWrapper(root, args, context, info)
  • boilerplate
const replaceResolver = (originalResolver, { objectType, field }) =>
  function resolverWrapper(root, args, context, info) {}

resolverWrapper

  • 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

directiveKey

  • 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
  • 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 then directiveKey = "admin"

hooks

  • provide access to each step of the process as the directive resolver is applied during server startup

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
  • signature
onVisitObject(objectType)

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
  • signature
onvisitFieldDefinition(field, details)
  • objectType can be accessed from details.objectType

onApplyToObjectType

  • called as the directive is being applied to an object or field
    • called once immediately after onVisitObject or onVisitFieldDefinition is called
  • 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
    • 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)

the objectType and field shapes

  • these two objects can be found in the reaplceResolver(originalResolver, directiveContext) parameter
    • directiveContext: { objectType, field }
  • provide access to information about the object type or field as the directive is being executed on it

objectType

objectType {

}

field

field {

}

implementations draft

  • currently covers Object Types (OBJECT target) and Object Field Types (FIELD target)
  • currently does not support directive arguments

createDirective

  • individual directive: createDirective
  • build the directive then assign as an entry in Apollo Server config.schemaDirectives object
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,
        });
      });
    }
  };
};

createSchemaDirectives

  • builds a schemaDirectives object 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),
    }),
    {},
  );

validateConfig

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;
};

notes on deriving the pattern

  • 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 the objectType
    • **applyToObjectType function **
    • executes the function reassignment for field.resolve
      • resolverReplacer function
    • captures the resolver wrapper function returned by the resolverReplacer function
      • resolverWrapper 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 like OBJECT and FIELD 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
    • _<key>DirectiveApplied property should be added directly to the objectType in the applyToObjectType function
      • each directive needs a unique <key> because an Object Type can be tagged with multiple directives
      • <key> must be unique across all directive SchemaVisitor subclass implementations to avoid naming collisions

directives vs higher order resolver wrappers

  • 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 replaceResolver and resolverWrapper signatures
    • after the HoF is transition the consumer just has to implement the directive type defs and provide their corresponding directiveKey

[internal] visitObject method

  • 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

[internal] visitFieldDefinition method

  • 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

[internal] applyToObjectType function

  • called during server startup directive registration chain

replaceResolver and resolverWrapper

  • the replaceResolver and resolverWrapper functions are used in a higher order function chain which must return a resolvedValue that is allowed by the schema's definitions
    • replaceResolver -> resolverWrapper -> resolvedValue
  • the library consumer only has to implement directive behavior logic in resolverWrapper and return a valid resolvedValue
    • the resolverWrapper function receives the original field resolver's arguments (root, args, context, info)
    • replaceResolver curries the originalResolver and directiveContext so they are in scope in resolverWrapper
    • they can be used as needed in when implementing the directive logic

[library] replaceResolver function

  • 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 }
replaceResolver(originalResolver, directiveContext) ->
    resolverWrapper(root, args, context, info)
  • example
module.exports = (originalResolver, { objectType, field }) => function resolverWrapper(...args) {
  // implement directive logic
  
  return resolvedValue
}
    

[library] resolverWrapper function

  • 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
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;
};

the objectType and field shapes

objectType

  • can be found in:
    • visitObject(objectType): first parameter
    • visitFieldDefinition(field, details): second parameter
      • through details.objectType
    • replaceResolver(originalResolver, directiveContext): second parameter
      • through directiveContext.objectType
  • shape

field

  • can be found in: visitFieldDefinition first parameter
  • shape
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment