Files
talk/graph/hooks.js
2018-01-11 20:00:34 -07:00

251 lines
8.5 KiB
JavaScript

const { forEachField } = require('./utils');
const debug = require('debug')('talk:graph:schema');
const Joi = require('joi');
/**
* XXX taken from graphql-js: src/execution/execute.js, because that function
* is not exported
*
* If a resolve function is not given, then a default resolve behavior is used
* which takes the property of the source object of the same name as the field
* and returns it as the result, or if it's a function, returns the result
* of calling that function.
*/
const defaultResolveFn = (source, args, context, { fieldName }) => {
// ensure source is a value for which property access is acceptable.
if (typeof source === 'object' || typeof source === 'function') {
const property = source[fieldName];
if (typeof property === 'function') {
return source[fieldName](args, context);
}
return property;
}
};
/**
* Decorates the field with the post resolvers (if available) and attaches a
* default type in the form of `Default${typeName}`.
*/
const decorateResolveFunction = (field, typeName, fieldName, post) => {
// Cache the original resolverType function.
let resolveType = field.resolveType;
// defaultResolveType is the default type that is resolved on a resolver
// when the interface being looked up is not defined.
const defaultResolveType = `Default${typeName}`;
// Return the function to handle the resolveType hooks.
const defaultResolveFn = (obj, context, info) => {
let type = resolveType(obj, context, info);
// Only if a previous resolver was unable to resolve the field type do we
// progress to the hooks (in order!) to resolve the field name until we
// have resolved it.
if (typeof type !== 'undefined' && type != null) {
return type;
}
// All else fails, resort to the defaultResolveType.
return defaultResolveType;
};
// This only needs to do something if post hooks are defined.
if (post.length === 0) {
// Set the default on the resolveType function.
field.resolveType = defaultResolveFn;
return;
}
// Ensure it matches the format we expect.
Joi.assert(
post,
Joi.array().items(Joi.func().maxArity(3)),
`invalid post hooks were found for ${typeName}.${fieldName}`
);
// Return the function to handle the resolveType hooks.
field.resolveType = (obj, context, info) => {
let type = defaultResolveFn(obj, context, info);
// Only if a previous resolver was unable to resolve the field type do we
// progress to the hooks (in order!) to resolve the field name until we
// have resolved it.
if (
typeof type !== 'undefined' &&
type != null &&
type !== defaultResolveType
) {
return type;
}
// We will walk through the post hooks until we find the right one. This
// follows what redux does to combine existing reducers.
for (let i = 0; i < post.length; i++) {
let resolveType = post[i];
let resolvedType = resolveType(obj, context, info);
if (typeof resolvedType !== 'undefined' && resolvedType != null) {
return resolvedType;
}
}
return type;
};
};
/**
* Decorates the schema with pre and post hooks as provided by the Plugin
* Manager.
* @param {GraphQLSchema} schema the schema to decorate
* @param {Array} hooks hooks to apply to the schema
* @return {void}
*/
const decorateWithHooks = (schema, hooks) =>
forEachField(
schema,
(field, typeName, fieldName, isResolveType = false) => {
// Pull out the pre/post hooks from the available hooks.
const { pre, post } = hooks
// Only grab hooks that are associated with thie field and typeName.
.filter(
({ hooks }) => typeName in hooks && fieldName in hooks[typeName]
)
// Grab the hooks we need.
.map(({ plugin, hooks }) => ({
plugin,
hooks: hooks[typeName][fieldName],
}))
// Combine the pre/post hooks from each plugin into an array we can
// execute.
.reduce(
(acc, { plugin, hooks }) => {
// Itterate over the hooks on the fields and look at it with a switch
// block to check for misconfigured plugins.
Object.keys(hooks).forEach(hook => {
switch (hook) {
case 'pre':
Joi.assert(hooks.pre, Joi.func().maxArity(4));
debug(
`adding pre hook to resolver ${typeName}.${fieldName} from plugin '${
plugin.name
}'`
);
if (typeof hooks.pre !== 'function') {
throw new Error(
`expected ${hook} hook on resolver ${typeName}.${fieldName} from plugin '${
plugin.name
}' to be a function, it was a '${typeof hooks[hook]}'`
);
}
acc.pre.push(hooks.pre);
break;
case 'post':
Joi.assert(hooks.pre, Joi.func().maxArity(5));
debug(
`adding post hook to resolver ${typeName}.${fieldName} from plugin '${
plugin.name
}'`
);
if (typeof hooks.post !== 'function') {
throw new Error(
`expected ${hook} hook on resolver ${typeName}.${fieldName} from plugin '${
plugin.name
}' to be a function, it was a '${typeof hooks[hook]}'`
);
}
acc.post.unshift(hooks.post);
break;
default:
throw new Error(
`invalid hook '${hook}' on resolver ${typeName}.${fieldName} from plugin '${
plugin.name
}'`
);
}
});
return acc;
},
{
pre: [],
post: [],
}
);
// If this is a resolve type, we need to do some specific things to handle
// this type of field.
if (isResolveType) {
// Warn if we have any pre hooks.
if (pre.length !== 0) {
throw new Error(
`invalid pre hooks were found for ${typeName}.${fieldName}, only post hooks are supported on the __resolveType hook`
);
}
// Decorate the resolve function on the field with the new resolveType func.
decorateResolveFunction(field, typeName, fieldName, post);
return;
}
// If we have no hooks to add here, don't try to modify anything.
if (pre.length === 0 && post.length === 0) {
return;
}
// Cache the original resolve function, this emulates the beheviour found in
// graphql-tools: https://github.com/apollographql/graphql-tools/blob/6e9cc124b10d673448386041e6c3d058bc205a02/src/schemaGenerator.ts#L423-L425
let resolve = field.resolve;
if (typeof resolve === 'undefined') {
resolve = defaultResolveFn;
}
// Apply our async resolve function which will fire all pre functions (and
// wait until they resolve) followed by waiting for the response and then
// firing their post hooks. Lastly, we respond with the result of the
// original resolver.
field.resolve = async (obj, args, context, info) => {
// Issue all pre hooks before we resolve the field.
await Promise.all(pre.map(pre => pre(obj, args, context, info)));
// Resolve the field.
let result = await resolve(obj, args, context, info);
// Insure all post hooks after we've resolved the field with the result
// passed in as the fifth argument.
return await post.reduce(async (result, post) => {
// Wait for the accumulator to resolve before we continue.
result = await result;
// Check to see if this post function accepts a result, if it does, we
// expect that it modifies the result, otherwise, just fire the post hook,
// wait till it's done, then move onto the next hook.
if (post.length === 5) {
return await post(obj, args, context, info, result);
}
// Wait for the post hook to finish.
await post(obj, args, context, info);
// Return the result, which we already awaited for before.
return result;
}, result);
};
},
{
includeResolveType: true,
}
);
module.exports = {
decorateWithHooks,
};