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
: theContext
value of the field, thecontext.scope
property is the most likely to be usedbuild
: theBuild
objects which contains a lot of helpersfield
: the field specification itselfoptions
: object which contains everything you may have added tographileBuildOptions
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;
}