Server Plugin API

The Server Folder

The server functionality of our plugin lives inside the server folder. The server folder must have an index.js file that exports the configuration of our plugin.

my-plugin/
  ├── client/
  │   └── ... <-- client side plugin files
  ├── server/
  │   └── index.js <-- index for server side functionality
  │   └── ... <-- other server side plugin files
  └── index.js <-- base plugin index

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:

GraphQL hooks

Field: typeDefs

enum COLOUR {
  RED
  BLUE
}

type Person {
  name: String!
  colour: COLOUR!
}

type RootMutation {
  createPerson(name: String!): Person
}

type RootQuery {
  people: [Person!]
}

type Subscription {
  leader: Person
}

Thanks to 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

{
  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, {plugins: {Slack}}.

Field: Sort

A special context hook, Sort will allow plugin authors to provide new methods to sort data. An example is as follows:

{
  Sort: () => ({
    Comments: { // <-- (1)
      likes: { // <-- (2)
        startCursor(ctx, nodes, {cursor}) { // <-- (3)
          return cursor != null ? cursor : 0;
        },
        endCursor(ctx, nodes, {cursor}) { // <-- (4)
          return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null;
        },
        sort(ctx, query, {cursor, sort}) { // <-- (5)
          if (cursor) {
            query = query.skip(cursor);
          }

          return query.sort({
            'action_counts.like': sort === 'DESC' ? -1 : 1,
            created_at: sort === 'DESC' ? -1 : 1,
          });
        },
      },
    },
  }),
}

This has a bunch of special features:

  1. Comments is the name of the type being sorted, this is pluralized and capitalized.
  2. likes is the sortBy field in lowercase.
  3. startCursor will retrieve the start cursor based on the current set of nodes and the current cursor.
  4. endCursor will retrieve the end cursor based on the current set of nodes and the current cursor.
  5. sort will mutate the query to apply the sort operations.

All the startCursor, endCursor, and sort functions must be provided in order for the sorting to apply properly.

Field: loaders

(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

(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

{
  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.

This will merge with the existing resolvers in core and from previous plugins.

Field: hooks

{
  RootMutation: {
    createPerson: {
      post: async (obj, args, {plugins: {Slack}}, info, 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.

Field: setupFunctions

setupFunctions: {
  leader: (options, args) => ({
    leader: {
      filter: (person) => person.place === 1
    },
  }),
}

Setup functions allow you to create filters that control which pubsub.publish() events send data to the client. If the type in question contains args, clients may subscribe using those arguments to further filter their subscription.

For more information, see the Apollo Docs.

Field: tokenUserNotFound

tokenUserNotFound: async ({jwt, token}) => {
  let profile = await someExternalService(token);
  if (!profile) {
    return null;
  }

  let user = await UserModel.findOneAndUpdate({
    id: profile.id
  }, {
    id: profile.id,
    username: profile.username,
    lowercaseUsername: profile.username.toLowerCase(),
    roles: [],
    profiles: []
  }, {
    setDefaultsOnInsert: true,
    new: true,
    upsert: true
  });

  return user;
}

The tokenUserNotFound hook allows auth integrations to hook into the event when a valid token is provided but a user can’t be found in the database that matches the provided id.

The function is async, and should return the user object that was created in the database, or null if the user wasn’t found. The jwt parameter of the object is the unpacked token, while token is the original jwt token string.

Field: tags

The tags hook allows a plugin to define tags that are code controlled (added or enabled by code). Below is an example pulled from the core off topic plugin on how to create a hook for the OFF_TOPIC name:

[
  {
    name: 'OFF_TOPIC',
    permissions: {
      public: true,
      self: true,
      roles: []
    },
    models: ['COMMENTS'],
    created_at: new Date()
  }
]

You can refer to models/schema/tag.js for the available schema to match when creating models to enable/disable specific features.

Routes

Field: router

(router) => {
  router.get('/api/v1/people', (req, res) => {
    res.json({people: [{name: 'Bob'}]});
  });
}

The Router hook allows you to create a function that accepts the base express router where you can mount any amount of middleware/routes to do any form of action needed by external applications.

Authorization middleware

The following example creates the requisite callback route and passport strategy needed to enable Facebook Authorization:

const authorization = require('middleware/authorization');

module.exports = {
  router(router) {
    router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => {
      res.json({people: [{name: 'SECRET PEOPLE'}]});
    });
  }
}

Field: passport

const FacebookStrategy = require('passport-facebook').Strategy;
const UsersService = require('services/users');
const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport');

module.exports = {
  passport(passport) {
    passport.use(new FacebookStrategy({
      clientID: process.env.TALK_FACEBOOK_APP_ID,
      clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
      callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
      passReqToCallback: true,
      profileFields: ['id', 'displayName', 'picture.type(large)']
    }, async (req, accessToken, refreshToken, profile, done) => {

      let user;
      try {
        user = await UsersService.findOrCreateExternalUser(profile);
      } catch (err) {
        return done(err);
      }

      return ValidateUserLogin(profile, user, done);
    }));
  },
  router(router) {

    // Note that we have to import the passport instance here, it is
    // instantiated after all the strategies have been mounted.
    const {passport} = require('services/passport');

    /**
     * Facebook auth endpoint, this will redirect the user immediately to facebook
     * for authorization.
     */
    router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']}));

    /**
     * Facebook callback endpoint, this will send the user a html page designed to
     * send back the user credentials upon successful login.
     */
    router.get('/facebook/callback', (req, res, next) => {

      // Perform the facebook login flow and pass the data back through the opener.
      passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next);
    });
  }
};

Full Example

Contents of plugins.json:

{
  "server": [
    "people"
  ]
}

Located in plugins/people/index.js:

module.exports = {
  typeDefs: `
  enum COLOUR {
    RED
    BLUE
  }

  type Person {
    name: String!
    colour: COLOUR!
  }

  type RootMutation {
    createPerson(name: String!): Person
  }

  type RootQuery {
    people: [Person!]
  }

  type Subscription {
    leader: 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}}, info, person) => {
          if (!person) {
            return person;
          }

          await Slack.notify(`A new person just was created with name ${person.name}`);

          return person;
        }
      }
    }
  },
  setupFunctions: {
    leader: (options, args) => ({
      leader: {
        filter: (person) => person.place === 1
      }
    }
  }
};

API

You can access any API available inside the talk directory in a plugin by simply importing the file relative to the talk project root. An example would be if you wanted to import the MetadataService, you would simply write:

const MetadataService = require('services/metadata');