diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 000000000..ca3d47ce7 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,261 @@ +# 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(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 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(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 index 17d46ff63..3fe1306cf 100644 --- a/graph/hooks.js +++ b/graph/hooks.js @@ -99,14 +99,31 @@ const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeNa field.resolve = async (obj, args, context, info) => { // Issue all pre hooks before we resolve the field. - await Promise.all(pre.map(async (pre) => await pre(obj, args, context, info))); + await Promise.all(pre.map((pre) => pre(obj, args, context, info))); // Resolve the field. - let result = await resolve(obj, args, context, info); + 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) => await post(obj, args, context, info, result), result); + 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); }; });