diff --git a/.gitignore b/.gitignore index 29f5961b2..8775ac487 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ dump.rdb *.cfg .idea/ coverage/ + +# 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/PLUGINS.md b/PLUGINS.md new file mode 100644 index 000000000..20ba789be --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,265 @@ +# Talk Plugins + +Plugins for Talk can take various forms, currently we are only supporting server +side plugins. + +## Plugin Registration: `plugins.json` + +All plugins must be registered in the root file `plugins.json`. + +The format for this file is thus: + +```js +{ + "server": [ + "people" + ] +} +``` + +Where we have a `server` key with an array of plugins that match the folder +name in the `plugins/` folder. For example, the above `plugins.json` would +require a plugin from `plugins/people`, which must provide a `index.js` file +that returns an object that matches the Plugin Specification. + +## Server Plugins + +### Specification + +Each plugin should export a single object with all hooks available on it. + +_**Note: You will have access to the whole core and other plugin's typeDefs, +context, loaders, mutators, resolvers, hooks. This is intentional, as it +encourages composing plugins to merge functionality, like a Slack plugin which +provides a Slack notify context function as well as having the loader for +comments.**_ + +The following are the hooks available: + +#### Field: `typeDefs` + +```graphql +enum COLOUR { + RED + BLUE +} + +type Person { + name: String! + colour: COLOUR! +} + +type RootMutation { + createPerson(name: String!): Person +} + +type RootQuery { + people: [Person!] +} +``` + +Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of +`typeDefs` should be a string that will be _merged_ with the existing type +definitions. `enum`'s will be appended to, types will be appended, and new types +will be added. + +#### Field: `context` + +```js +{ + Slack: (context) => ({ + notify: (message) => { + // return a promise after we're done sending notifications. + } + }) +} +``` + +Any property provided here will be added to the context parameter available +inside all resolvers, loaders, mutators, and of course, other context based +plugins. + +The top level item must accept a context for the request which it should use to +configure the context plugin before it would be mounted at `context.plugins`. +This plugin above would mount at: `context.plugins.Slack`, or, if you're using +[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`. + +#### Field: `loaders` + +```js +(context) => ({ + People: { + load: () => db.people.find({user: context.user}) + } +}) +``` + +Loaders should be provided as a function which returns a map which is used in +the resolvers function. These must return a promise or a value. + +#### Field: `mutators` + +```js +(context) => ({ + People: { + create: (name) => { + return db.people.insert({user: context.user, name}); + } + } +}) +``` + +Mutators should be provided as a function which returns a map which is used in +the resolvers function. These must return a promise or a value. + +#### Field: `resolvers` + +```js +{ + Person: { + name(obj, args, context) { + return obj.name; + }, + colour(obj, args, context) { + // Bill likes the colour red, everyone else likes blue. + return obj.name === 'bill' ? 'RED' : 'BLUE'; + } + }, + RootQuery: { + people(obj, args, {loaders: {People}}) { + return People.load(); + } + }, + RootMutation: { + createPerson(obj, {name}, {mutators: {People}}) { + return People.create(name); + } + } +} +``` + +Should return a resolver map as described in the +[Apollo Docs](http://dev.apollodata.com/tools/graphql-tools/resolvers.html#Resolver-map). + +This will merge with the existing resolvers in core and from previous plugins. + +#### Field: `hooks` + +```js +{ + RootMutation: { + createPerson: { + post: async (obj, args, {plugins: {Slack}}, person) { + if (!person) { + return person; + } + + await Slack.notify(`A new person just was created with name ${person.name}`); + + return person; + } + } + } +} +``` + +Hooks here are pretty special, for each resolver field, you can specify a +pre/post hook that will execute pre and post field resolution. + +If your post function accepts four parameters, then it can modify the field +result. It is *required* that the function resolves a promise (or returns) with +the modified value or simply the original if you didn't modify it. + +### Full Example + +Contents of `plugins.json`: + +```json +{ + "server": [ + "people" + ] +} +``` + +Located in `plugins/people/index.js`: + +```js +module.exports = { + typeDefs: ` + enum COLOUR { + RED + BLUE + } + + type Person { + name: String! + colour: COLOUR! + } + + type RootMutation { + createPerson(name: String!): Person + } + + type RootQuery { + people: [Person!] + } + `, + context: { + Slack: () => ({ + notify: (message) => { + // return a promise after we're done sending notifications. + } + }) + }, + loaders: ({user}) => ({ + People: { + load: () => db.people.find({user}) + } + }), + mutators: ({user}) => ({ + People: { + create: (name) => { + return db.people.insert({user, name}); + } + } + }), + resolvers: { + Person: { + name(obj, args, context) { + return obj.name; + }, + colour(obj, args, context) { + // Bill likes the colour red, everyone else likes blue. + return obj.name === 'bill' ? 'RED' : 'BLUE'; + } + }, + RootQuery: { + people(obj, args, {loaders: {People}}) { + return People.load(); + } + }, + RootMutation: { + createPerson(obj, {name}, {mutators: {People}}) { + return People.create(name); + } + } + }, + hooks: { + RootMutation: { + createPerson: { + post: async (obj, args, {plugins: {Slack}}, person) => { + if (!person) { + return person; + } + + await Slack.notify(`A new person just was created with name ${person.name}`); + + return person; + } + } + } + } +}; + +``` diff --git a/graph/context.js b/graph/context.js index ce8f5eb83..432ab2081 100644 --- a/graph/context.js +++ b/graph/context.js @@ -1,6 +1,32 @@ const loaders = require('./loaders'); const mutators = require('./mutators'); +const plugins = require('../plugins'); +const debug = require('debug')('talk:graph:context'); + +/** + * Contains the array of plugins that provide context to the server, these top + * level functions all need the context reference. + * @type {Array} + */ +const contextPlugins = plugins.get('server', 'context').map(({plugin, context}) => { + debug(`added plugin '${plugin.name}'`); + return {context}; +}); + +/** + * This should itterate over the passed in plugins and load them all with the + * current graph context. + * @return {Object} the saturated plugins object + */ +const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduce((acc, plugin) => { + Object.keys(plugin.context).forEach((service) => { + acc[service] = plugin.context[service](context); + }); + + return acc; +}, {}); + /** * Stores the request context. */ @@ -17,6 +43,9 @@ class Context { // Create the mutators. this.mutators = mutators(this); + + // Decorate the plugin context. + this.plugins = decorateContextPlugins(this, contextPlugins); } } diff --git a/graph/hooks.js b/graph/hooks.js new file mode 100644 index 000000000..3fe1306cf --- /dev/null +++ b/graph/hooks.js @@ -0,0 +1,132 @@ +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 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) => { + + // 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': + 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': + 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 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 = 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); + }; +}); + +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 36899a04b..909452231 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", "form-data": "^2.1.2", "graphql": "^0.8.2", "graphql-errors": "^2.1.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 b5f96464e..25171f74d 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"