diff --git a/.gitignore b/.gitignore index fbc3c54d3..d91635c94 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ dump.rdb coverage/ .tags .tags1 + +# remove plugin folders +plugins +plugins.json diff --git a/Dockerfile b/Dockerfile index 810d2a71f..eb3cc9c60 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,12 @@ EXPOSE 5000 # Install app dependencies COPY package.json yarn.lock /usr/src/app/ -RUN yarn install --production +RUN yarn install # Bundle app source COPY . /usr/src/app -CMD [ "yarn", "start" ] +# Build static assets +RUN yarn build + +CMD ["yarn", "start"] diff --git a/graph/hooks.js b/graph/hooks.js new file mode 100644 index 000000000..152e02b9b --- /dev/null +++ b/graph/hooks.js @@ -0,0 +1,115 @@ +const {forEachField} = require('graphql-tools'); +const debug = require('debug')('talk:graph:schema'); + +/** + * 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 schema with before and after 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) => { + + // Pull out the before/after hooks from the available hooks. + const { + before, + after + } = 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 before/after 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 'before': + debug(`adding before hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`); + + if (typeof hooks.before !== '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.before.push(hooks.before); + break; + case 'after': + debug(`adding after hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`); + + if (typeof hooks.after !== '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.after.unshift(hooks.after); + break; + default: + throw new Error(`invalid hook '${hook}' on resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`); + } + }); + + return acc; + }, { + before: [], + after: [] + }); + + // If we have no hooks to add here, don't try to modify anything. + if (before.length === 0 && after.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 before functions (and + // wait until they resolve) followed by waiting for the response and then + // firing their after hooks. Lastly, we respond with the result of the + // original resolver. + field.resolve = async (obj, args, context, info) => { + + // Issue all before hooks before we resolve the field. + await Promise.all(before.map(async (before) => await before(obj, args, context, info))); + + // Resolve the field. + let result = await resolve(obj, args, context, info); + + // Insure all after hooks after we've resolved the field with the result + // passed in as the fifth argument. + return await after.reduce(async (result, after) => await after(obj, args, context, info, result), result); + }; +}); + +module.exports = { + decorateWithHooks +}; diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 5b1894b65..40e723918 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -1,4 +1,5 @@ const _ = require('lodash'); +const debug = require('debug')('talk:graph:loaders'); const Actions = require('./actions'); const Assets = require('./assets'); @@ -7,6 +8,27 @@ const Metrics = require('./metrics'); const Settings = require('./settings'); const Users = require('./users'); +const plugins = require('../../plugins'); + +let loaders = [ + + // Load the core loaders. + Actions, + Assets, + Comments, + Metrics, + Settings, + Users, + + // Load the plugin loaders from the manager. + ...plugins + .get('server', 'loaders').map(({plugin, loaders}) => { + debug(`added plugin '${plugin.name}'`); + + return loaders; + }) +]; + /** * Creates a set of loaders based on a GraphQL context. * @param {Object} context the context of the GraphQL request @@ -15,14 +37,7 @@ const Users = require('./users'); module.exports = (context) => { // We need to return an object to be accessed. - return _.merge(...[ - Actions, - Assets, - Comments, - Metrics, - Settings, - Users - ].map((loaders) => { + return _.merge(...loaders.map((loaders) => { // Each loader is a function which takes the context. return loaders(context); diff --git a/graph/mutators/index.js b/graph/mutators/index.js index b799cf83d..e3df4aefb 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -1,17 +1,37 @@ const _ = require('lodash'); +const debug = require('debug')('talk:graph:mutators'); const Comment = require('./comment'); const Action = require('./action'); const User = require('./user'); +const plugins = require('../../plugins'); + +let mutators = [ + + // Load in the core mutators. + Comment, + Action, + User, + + // Load the plugin mutators from the manager. + ...plugins + .get('server', 'mutators').map(({plugin, mutators}) => { + debug(`added plugin '${plugin.name}'`); + + return mutators; + }) +]; + +/** + * Creates a set of mutators based on a GraphQL context. + * @param {Object} context the context of the GraphQL request + * @return {Object} object of mutators + */ module.exports = (context) => { // We need to return an object to be accessed. - return _.merge(...[ - Comment, - Action, - User, - ].map((mutators) => { + return _.merge(...mutators.map((mutators) => { // Each set of mutators is a function which takes the context. return mutators(context); diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 3bd8c571d..7e165f2db 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -1,3 +1,6 @@ +const _ = require('lodash'); +const debug = require('debug')('talk:graph:resolvers'); + const ActionSummary = require('./action_summary'); const Action = require('./action'); const AssetActionSummary = require('./asset_action_summary'); @@ -17,7 +20,10 @@ const UserError = require('./user_error'); const User = require('./user'); const ValidationUserError = require('./validation_user_error'); -module.exports = { +const plugins = require('../../plugins'); + +// Provide the core resolvers. +let resolvers = { ActionSummary, Action, AssetActionSummary, @@ -37,3 +43,16 @@ module.exports = { User, ValidationUserError, }; + +/** + * Plugin support requires that we merge in existing resolvers with our new + * plugin based ones. This allows plugins to extend existing resolvers as well + * as provide new ones. + */ +resolvers = plugins.get('server', 'resolvers').reduce((resolvers, {plugin}) => { + debug(`added plugin '${plugin.name}'`); + + return _.merge(resolvers, plugin.resolvers); +}, resolvers); + +module.exports = resolvers; diff --git a/graph/schema.js b/graph/schema.js index 2fca1a87f..c927d8c32 100644 --- a/graph/schema.js +++ b/graph/schema.js @@ -1,11 +1,17 @@ -const tools = require('graphql-tools'); -const maskErrors = require('graphql-errors').maskErrors; +const {makeExecutableSchema} = require('graphql-tools'); +const {maskErrors} = require('graphql-errors'); +const {decorateWithHooks} = require('./hooks'); +const plugins = require('../plugins'); const resolvers = require('./resolvers'); const typeDefs = require('./typeDefs'); -const schema = tools.makeExecutableSchema({typeDefs, resolvers}); +const schema = makeExecutableSchema({typeDefs, resolvers}); +// Plugin to the schema level resolvers to provide an before/after hook. +decorateWithHooks(schema, plugins.get('server', 'hooks')); + +// If we are in production mode, don't show server errors to the front end. if (process.env.NODE_ENV === 'production') { // Mask errors that are thrown if we are in a production environment. diff --git a/graph/typeDefs.js b/graph/typeDefs.js index 7ac982269..5b07d0f2d 100644 --- a/graph/typeDefs.js +++ b/graph/typeDefs.js @@ -4,8 +4,26 @@ const fs = require('fs'); const path = require('path'); +const {mergeStrings} = require('gql-merge'); +const debug = require('debug')('talk:graph:typeDefs'); +const plugins = require('../plugins'); -// Load the typeDefs from the graphql file. -const typeDefs = fs.readFileSync(path.join(__dirname, 'typeDefs.graphql'), 'utf8'); +/** + * Plugin support requires us to merge the type definitions from the loaded + * graphql tags, this gives us the ability to extend any portion of the + * available graph. + */ +const typeDefs = mergeStrings([ + + // Load the core graph definitions from the filesystem. + fs.readFileSync(path.join(__dirname, 'typeDefs.graphql'), 'utf8'), + + // Load the plugin definitions from the manager. + ...plugins.get('server', 'typeDefs').map(({plugin, typeDefs}) => { + debug(`added plugin '${plugin.name}'`); + + return typeDefs; + }) +]); module.exports = typeDefs; diff --git a/package.json b/package.json index 72b2e9b19..ec94e1db3 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "env-rewrite": "^1.0.2", "express": "^4.14.0", "express-session": "^1.14.2", + "gql-merge": "^0.0.4", "graphql": "^0.8.2", "graphql-errors": "^2.1.0", "graphql-server-express": "^0.5.0", diff --git a/plugins.js b/plugins.js new file mode 100644 index 000000000..7713e0b8f --- /dev/null +++ b/plugins.js @@ -0,0 +1,81 @@ +const fs = require('fs'); +const path = require('path'); + +let plugins = {}; + +// Try to parse the plugins.json file, logging out an error if the plugins.json +// file isn't loaded, but continuing. Else, like a parsing error, throw it and +// crash the program. +try { + plugins = JSON.parse(fs.readFileSync(path.join(__dirname, 'plugins.json'), 'utf8')); +} catch (err) { + if (err.code === 'ENOENT') { + console.error('plugins.json not found, plugins will not be active'); + } else { + throw err; + } +} + +/** + * Stores a reference to a section for a section of Plugins. + */ +class PluginSection { + constructor(plugin_names) { + this.plugins = plugin_names.map((plugin_name) => { + let plugin = require(`./plugins/${plugin_name}`); + + // Ensure we have a default plugin name, but allow the name to be + // overrided by the plugin. + plugin.name = plugin.name || plugin_name; + + return plugin; + }); + } + + /** + * This itterates over the section to provide all plugin hooks that are + * available. + */ + hook(hook) { + return this.plugins + .filter((plugin) => hook in plugin) + .map((plugin) => ({plugin, [hook]: plugin[hook]})); + } +} + +const NullPluginSection = new PluginSection([]); + +/** + * Stores references to all the plugins available on the application. + */ +class PluginManager { + constructor(plugins) { + this.sections = {}; + + for (let section in plugins) { + this.sections[section] = new PluginSection(plugins[section]); + } + } + + /** + * Utility function which combines the Plugins.section and PluginSection.hook + * calls. + */ + get(section, hook) { + return this.section(section).hook(hook); + } + + /** + * Returns the named section if it exists, otherwise it returns an empty + * plugin section. + */ + section(section) { + if (section in this.sections) { + return this.sections[section]; + } + + return NullPluginSection; + } +} + +module.exports = new PluginManager(plugins); diff --git a/yarn.lock b/yarn.lock index 32f0a4c2f..fdf36c0e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1133,7 +1133,7 @@ bluebird@3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.3.4.tgz#f780fe43e1a7a6510f67abd7d0d79533a40ddde6" -bluebird@3.4.6: +bluebird@3.4.6, bluebird@^3.4.6: version "3.4.6" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.6.tgz#01da8d821d87813d158967e743d5fe6c62cf8c0f" @@ -3230,7 +3230,7 @@ glob@7.0.5, glob@7.0.x: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.1, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5: +glob@7.1.1, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8" dependencies: @@ -3302,6 +3302,29 @@ got@^3.2.0: read-all-stream "^3.0.0" timed-out "^2.0.0" +gql-format@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/gql-format/-/gql-format-0.0.4.tgz#8237de7647de37f00aba2d0073abf6087e2da119" + dependencies: + commander "^2.9.0" + graphql "^0.7.2" + +gql-merge@^0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/gql-merge/-/gql-merge-0.0.4.tgz#1cb1d4cc8bb8768172cf08a45c5a4fbd0ecedc9f" + dependencies: + commander "^2.9.0" + gql-format "^0.0.4" + gql-utils "^0.0.2" + graphql "^0.7.2" + +gql-utils@^0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/gql-utils/-/gql-utils-0.0.2.tgz#962b3c1b34bf965a45d2564a93d3072921f61e86" + dependencies: + bluebird "^3.4.6" + glob "^7.1.1" + graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9: version "4.1.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" @@ -3369,6 +3392,12 @@ graphql-tools@^0.9.0: optionalDependencies: "@types/graphql" "^0.8.5" +graphql@^0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.7.2.tgz#cc894a32823399b8a0cb012b9e9ecad35cd00f72" + dependencies: + iterall "1.0.2" + graphql@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.8.2.tgz#eb1bb524b38104bbf2c9157f9abc67db2feba7d2"