makeWrapPlansPlugin
PostGraphile generates plan resolvers automatically for the fields that it generates across your GraphQL API. But sometimes, you want to change what these plans do. Commonly you might want to apply additional filters to a collection, or perform additional actions (or validations) before performing a mutation. Rather than rewriting the field and it's plans from scratch, you can "wrap" the plan that PostGraphile has made with one of your own.
makeWrapPlansPlugin
helps you to easily generate a "schema
plugin" for 'wrapping' the plan resolvers generated by
PostGraphile. You can load the resulting schema
plugin via your graphile.config.mjs
(or
similar) preset.
Some of the fields in the GraphQL schema expect their parent value to be of a particular step class, so in these cases you must be careful to ensure that you don't break this expectation otherwise planning errors may occur. This is typically not a concern with modifying the result of "leaf" fields (e.g. masking a users email address) since generally these do not depend on the specific step type.
Signatures
There are two variants of makeWrapPlansPlugin
with slightly different
signatures (function overloading). These reflect the two methods of calling
makeWrapPlansPlugin
. 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 makeWrapPlansPlugin(
rulesOrGenerator: PlanWrapperRules | PlanWrapperRulesGenerator,
): GraphileConfig.Plugin;
interface PlanWrapperRules {
[typeName: string]: {
[fieldName: string]: PlanWrapperRule | PlanWrapperFn;
};
}
interface PlanWrapperRule {
plan?: PlanWrapperFn;
}
type PlanWrapperFn = (
plan: SmartFieldPlanResolver,
$source: ExecutableStep,
fieldArgs: FieldArgs,
info: FieldInfo,
) => any;
type PlanWrapperRulesGenerator = (
build: Partial<GraphileBuild.Build> & GraphileBuild.BuildBase,
) => PlanWrapperRules;
/****************************************/
// Method 2: wrap all resolvers that match a filter function
function makeWrapPlansPlugin<T>(
filter: (
context: GraphileBuild.ContextObjectFieldsField,
build: GraphileBuild.Build,
field: GrafastFieldConfig,
) => T | null,
rule: (match: T) => PlanWrapperRule | PlanWrapperFn,
): GraphileConfig.Plugin;
Method 1: wrapping individual resolvers of known fields
// Method 1: wrap individual resolvers of known fields
function makeWrapPlansPlugin(
rulesOrGenerator: PlanWrapperRules | PlanWrapperRulesGenerator,
): GraphileConfig.Plugin;
In this method, makeWrapPlansPlugin
takes either the resolver wrapper
rules object directly, or a generator for this rules object, and returns a
plugin.
Example: convert to lower case
import { makeWrapPlansPlugin } from "postgraphile/utils";
import { lambda } from "postgraphile/grafast";
export default makeWrapPlansPlugin({
User: {
email(plan) {
const $email = plan();
return lambda($email, (email) => email.toLowerCase());
},
},
});
Example: multiple fields
When there's an explicit list of fields that you want to wrap in the same way, this method can still be useful:
import { sideEffect } from "postgraphile/grafast";
function assertValidUserData(data) {
if (!data || data.username?.length === 0) {
throw new Error("Invalid data");
}
}
const validateUserData = (propName) => {
return (plan, $source, fieldArgs) => {
const $user = fieldArgs.getRaw(["input", propName]);
// Callback throws error if invalid
sideEffect($user, (user) => assertValidUserData(user));
return plan();
};
};
export default makeWrapPlansPlugin({
Mutation: {
createUser: validateUserData("user"),
updateUser: validateUserData("userPatch"),
updateUserById: validateUserData("userPatch"),
updateUserByEmail: validateUserData("userPatch"),
},
});
Rules object
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 the Build object which can sometimes be useful,
e.g. to get the preset's schema options, or to retrieve things from the registry.
interface PlanWrapperRules {
[typeName: string]: {
[fieldName: string]: PlanWrapperRule | PlanWrapperFn;
};
}
type PlanWrapperRulesGenerator = (
build: Partial<GraphileBuild.Build> & GraphileBuild.BuildBase,
) => PlanWrapperRules;
Read about plan resolver wrapper functions below.
Example: null email unless own
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.)
import { makeWrapPlansPlugin } from "postgraphile/utils";
import { context, lambda } from "postgraphile/grafast";
export default makeWrapPlansPlugin({
User: {
email(plan, $user) {
const $userId = $user.get("id");
const $currentUserId = context().get("jwtClaims").get("user_id");
const $email = plan();
return lambda(
[$userId, $currentUserId, $email],
([userId, currentUserId, email]) =>
userId === currentUserId ? email : null,
);
},
},
});
Example: mask email
This example uses the default resolver of the User.email
field to get the
actual value, then masks the value instead of omitting it.
import { makeWrapPlansPlugin } from "postgraphile/utils";
import { lambda } from "postgraphile/grafast";
export default makeWrapPlansPlugin({
User: {
email(plan) {
const $email = plan();
return lambda($email, (email) =>
// [email protected] -> so***@su***.com
email.replace(
/^(.{1,2})[^@]*@(.{,2})[^.]*\.([A-z]{2,})$/,
"$1***@$2***.$3",
),
);
},
},
});
Method 2: wrap all resolvers matching a filter
function makeWrapPlansPlugin<T>(
filter: (
context: GraphileBuild.ContextObjectFieldsField,
build: GraphileBuild.Build,
field: GrafastFieldConfig,
) => T | null,
rule: (match: T) => PlanWrapperRule | PlanWrapperFn,
): GraphileConfig.Plugin;
In this method, makeWrapPlansPlugin
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 plan 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 itself
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.
import { makeWrapPlansPlugin } from "postgraphile/utils";
import { sideEffect } from "postgraphile/grafast";
// Example: log before and after each mutation runs
export default makeWrapPlansPlugin(
(context) => {
if (context.scope.isRootMutation) {
return { scope: context.scope };
}
return null;
},
({ scope }) =>
(plan, _, fieldArgs) => {
sideEffect(fieldArgs.getRaw(), (args) => {
console.log(
`Mutation '${scope.fieldName}' starting with arguments:`,
args,
);
});
const $result = plan();
sideEffect($result, (result) => {
console.log(`Mutation '${scope.fieldName}' result:`, result);
});
return $result;
},
);
Plan resolver wrapper functions
A resolver wrapper function is similar to a Grafast plan resolver, except it
takes an additional argument (at the start) which allows delegating to the plan
resolver that is being wrapped. If and when you call the plan
function, you
may optionally pass one or more of the arguments $source, fieldArgs, info
;
these will then override the values that the resolver will be passed. Calling
plan()
with no arguments will just pass through the original values
unmodified.
type PlanWrapperFn = (
plan: SmartFieldPlanResolver,
$source: ExecutableStep,
fieldArgs: FieldArgs,
info: FieldInfo,
) => any;