makeWrapResolversPlugin (graphile-utils)

NOTE: this documentation applies to PostGraphile v4.1.0+

Resolver wrapping enables you to easily take actions before or after an existing GraphQL field resolver, or even to prevent the resolver being called.

makeWrapResolversPlugin helps you to easily generate a "schema plugin" for 'wrapping' the resolvers generated by PostGraphile. You can load the resulting schema plugin with --append-plugins via the PostGraphile CLI, or with appendPlugins via the PostGraphile library.

IMPORTANT: Because PostGraphile uses the Graphile Engine look-ahead features, overriding a resolver may not effect the SQL that will be generated. If you want to influence how the system executes, only use this for modifying root-level resolvers (as these are responsible for generating the SQL and fetching from the database); however it's safe to use resolver wrapping for augmenting the returned values (for example masking private data, performing normalisation, etc) on any field.

Let's look at the makeWrapResolvers definition in the graphile-utils source code to understand how it works. There are two variants of makeWrapResolversPlugin with slightly different signatures (function overloading). These reflect the two methods of calling makeWrapResolversPlugin. If you want to wrap one or two specific resolvers (where you know the type name and field name) then method 1 is a handy shortcut. If, however, you want to wrap a number of resolvers in the same way then the more flexible method 2 is what you want.

// Method 1: wrap individual resolvers of known fields
function makeWrapResolversPlugin(
  rulesOrGenerator: ResolverWrapperRules | ResolverWrapperRulesGenerator
): Plugin;

// Method 2: wrap all resolvers that match a filter function
function makeWrapResolversPlugin<T>(
  filter: (
    context: Context,
    build: Build,
    field: GraphQLFieldConfig,
    options: Options
  ) => T,
  rule: (match: T) => ResolverWrapperRule | ResolverWrapperFn
);

/****************************************/

interface ResolverWrapperRules {
  [typeName: string]: {
    [fieldName: string]: ResolverWrapperRule | ResolverWrapperFn;
  };
}
type ResolverWrapperRulesGenerator = (options: Options) => ResolverWrapperRules;

Method 1: wrapping individual resolvers of known fields

function makeWrapResolversPlugin(
  rulesOrGenerator: ResolverWrapperRules | ResolverWrapperRulesGenerator
): Plugin;

In this method, makeWrapResolversPlugin takes either the resolver wrapper rules object directly, or a generator for this rules object, and returns a plugin. e.g.:

module.exports = makeWrapResolversPlugin({
  User: {
    async email(resolve, source, args, context, resolveInfo) {
      const result = await resolve();
      return result.toLowerCase();
    },
  },
});

Also when plugin is only for one type, then still better to use first method. e.g.:

const validateUserData = propName => {
  return async (resolve, source, args, context, resolveInfo) => {
    const user = args.input[propName];

    await isValidUserData(user); // throws error if invalid

    return resolve();
  };
};

module.exports = makeWrapResolversPlugin({
  Mutation: {
    createUser: validateUserData("user"),
    updateUser: validateUserData("userPatch"),
    updateUserById: validateUserData("userPatch"),
    updateUserByEmail: validateUserData("userPatch"),
  },
});

The rules object is a two-level map of typeName (the name of a GraphQLObjectType) and fieldName (the name of one of the fields of this type) to either a rule for that field, or a resolver wrapper function for that field. The generator function accepts an Options object (which contains everything you may have added to graphileBuildOptions and more).

interface ResolverWrapperRules {
  [typeName: string]: {
    [fieldName: string]: ResolverWrapperRule | ResolverWrapperFn;
  };
}
type ResolverWrapperRulesGenerator = (options: Options) => ResolverWrapperRules;

Read about resolver wrapper functions below.

For example, this plugin wraps the User.email field, returning null if the user requesting the field is not the same as the user for which the email was requested. (Note that the email is still retrieved from the database, it is just not returned to the user.)

const { makeWrapResolversPlugin } = require("graphile-utils");

module.exports = makeWrapResolversPlugin({
  User: {
    email: {
      requires: {
        siblingColumns: [{ column: "id", alias: "$user_id" }],
      },
      resolve(resolver, user, args, context, _resolveInfo) {
        if (context.jwtClaims.user_id !== user.$user_id) return null;
        return resolver();
      },
    },
  },
});

This example shows a different order of operation. It uses the default resolver of the User.email field to get the actual value, but then masks the value instead of omitting it.

const { makeWrapResolversPlugin } = require("graphile-utils");

module.exports = makeWrapResolversPlugin({
  User: {
    email: {
      async resolve(resolver, user, args, context, _resolveInfo) {
        const unmaskedValue = await resolver();
        // [email protected] -> so***@su***.com
        return unmaskedValue.replace(
          /^(.{1,2})[^@]*@(.{,2})[^.]*\.([A-z]{2,})$/,
          "$1***@$2***.$3"
        );
      },
    },
  },
});

Method 2: wrap all resolvers matching a filter

function makeWrapResolversPlugin<T>(
  filter: (
    context: Context,
    build: Build,
    field: GraphQLFieldConfig,
    options: Options
  ) => T | null,
  rule: (match: T) => ResolverWrapperRule | ResolverWrapperFn
);

In this method, makeWrapResolversPlugin takes two function arguments. The first function is a filter that is called for each field; it should return a truthy value if the field is to be wrapped (or null otherwise). The second function is called for each field that passes the filter, it will be passed the return value of the filter and must return a resolve wrapper function or rule (see Resolver wrapper functions below).

The filter is called with the following arguments:

  • context: the Context value of the field, the context.scope property is the most likely to be used
  • build: the Build objects which contains a lot of helpers
  • field: the field specification itself
  • options: object which contains everything you may have added to graphileBuildOptions and more

The value you return can be any arbitrary truthy value, it should contain anything from the above arguments that you need to create your resolver wrapper.

// Example: log before and after each mutation runs
module.exports = makeWrapResolversPlugin(
  context => {
    if (context.scope.isRootMutation) {
      return { scope: context.scope };
    }
    return null;
  },
  ({ scope }) => async (resolver, user, args, context, _resolveInfo) => {
    console.log(`Mutation '${scope.fieldName}' starting with arguments:`, args);
    const result = await resolver();
    console.log(`Mutation '${scope.fieldName}' result:`, result);
    return result;
  }
);

Usage

As mentioned above, you can load the resulting schema plugin with --append-plugins via the PostGraphile CLI, or with appendPlugins via the PostGraphile library.

The above examples defined a single plugin function generated by calling makeWrapResolversPlugin and exported via CommonJS as the only element in the JavaScript file (module).

If you are using ES6 modules (import/export) rather than Common JS (require/exports), then the syntax should be adjusted slightly:

import { makeWrapResolversPlugin } from 'graphile-utils';

export default makeWrapResolversPlugin(
...
);

Leveraging PostgreSQL transaction

If you want a mutation to succeed only if some custom code succeeds, you can do that plugging into the current PostgreSQL transaction. This allows you to 'rollback' the SQL transaction if the custom code fails.

export const CreatePostPlugin = makeWrapResolversPlugin({
  Mutation: {
    createPost: {
      requires: {
        childColumns: [{ column: "id", alias: "$post_id" }],
      },
      async resolve(resolve: any, _source, _args, context: any, _resolveInfo) {
        // The pgClient on context is already in a transaction configured for the user:
        const { pgClient } = context;
        // Create a savepoint we can roll back to
        await pgClient.query("SAVEPOINT mutation_wrapper");
        try {
          // Run the original resolver
          const result = await resolve();
          // Do the custom thing
          await doCustomThing(result.data.$post_id);
          // Finally return the result of our original mutation
          return result;
        } catch (e) {
          // Something went wrong - rollback!
          // NOTE: Do NOT rollback entire transaction as a transaction may be
          // shared across multiple mutations. Rolling back to the above defined
          // SAVEPOINT allows other mutations to succeed.
          await pgClient.query("ROLLBACK TO SAVEPOINT mutation_wrapper");
          // Re-throw the error so the GraphQL client knows about it
          throw e;
        } finally {
          await pgClient.query("RELEASE SAVEPOINT mutation_wrapper");
        }
      },
    },
  },
});

async function doCustomThing(postId: number) {
  throw new Error("to be implemented");
}

Resolver wrapper functions

A resolver wrapper function is similar to a GraphQL resolver, except it takes an additional argument (at the start) which allows delegating to the resolver that is being wrapped. If and when you call the resolve function, you may optionally pass one or more of the arguments source, args, context, resolveInfo; these will then override the values that the resolver will be passed. Calling resolve() with no arguments will just pass through the original values unmodified.

type ResolverWrapperFn = (
  resolve: GraphQLFieldResolver, // Delegates to the resolver we're wrapping
  source: TSource,
  args: TArgs,
  context: TContext,
  resolveInfo: GraphQLResolveInfo
) => any;

Should your wrapper require additional data, for example data about it's sibling or child columns, then instead of specifying the wrapper directly you can pass a rule object. The rule object should include the resolve method (your wrapper) and can also include a list of requirements. It's advised that your alias should begin with a dollar $ symbol to prevent it conflicting with any aliases generated by the system.

interface ResolverWrapperRequirements {
  childColumns?: Array<{ column: string; alias: string }>;
  siblingColumns?: Array<{ column: string; alias: string }>;
}
interface ResolverWrapperRule {
  requires?: ResolverWrapperRequirements;
  resolve?: ResolverWrapperFn;
  // subscribe?: ResolverWrapperFn;
}