Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Await field level hooks and access control in parallel #6932

Merged
merged 6 commits into from
Nov 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/neat-melons-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@keystone-next/keystone': patch
---

Field-level hooks and field-level create and update access control functions are now awaited in parallel. Note this means all field-level hooks and access control are now awaited in parallel because field-level read access control was already awaited in parallel.
97 changes: 50 additions & 47 deletions packages/keystone/src/lib/core/mutations/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,29 +167,31 @@ export async function getAccessControlledItemForUpdate(
}

// Field level 'item' access control
const nonBooleans = [];
const fieldsDenied = [];
const accessErrors = [];
for (const fieldKey of Object.keys(inputData)) {
let result;
try {
result =
typeof list.fields[fieldKey].access[operation] === 'function'
? await list.fields[fieldKey].access[operation]({ ...args, fieldKey })
: access;
} catch (error: any) {
accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` });
continue;
}
if (typeof result !== 'boolean') {
nonBooleans.push({
tag: `${args.listKey}.${fieldKey}.access.${args.operation}`,
returned: typeof result,
});
} else if (!result) {
fieldsDenied.push(fieldKey);
}
}
const nonBooleans: { tag: string; returned: string }[] = [];
const fieldsDenied: string[] = [];
const accessErrors: { error: Error; tag: string }[] = [];
await Promise.all(
Object.keys(inputData).map(async fieldKey => {
let result;
try {
result =
typeof list.fields[fieldKey].access[operation] === 'function'
? await list.fields[fieldKey].access[operation]({ ...args, fieldKey })
: access;
} catch (error: any) {
accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` });
return;
}
if (typeof result !== 'boolean') {
nonBooleans.push({
tag: `${args.listKey}.${fieldKey}.access.${args.operation}`,
returned: typeof result,
});
} else if (!result) {
fieldsDenied.push(fieldKey);
}
})
);

if (accessErrors.length) {
throw extensionError('Access control', accessErrors);
Expand Down Expand Up @@ -257,30 +259,31 @@ export async function applyAccessControlForCreate(
}

// Field level 'item' access control
// Field level 'item' access control
const nonBooleans = [];
const fieldsDenied = [];
const accessErrors = [];
for (const fieldKey of Object.keys(inputData)) {
let result;
try {
result =
typeof list.fields[fieldKey].access[operation] === 'function'
? await list.fields[fieldKey].access[operation]({ ...args, fieldKey })
: access;
} catch (error: any) {
accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` });
continue;
}
if (typeof result !== 'boolean') {
nonBooleans.push({
tag: `${args.listKey}.${fieldKey}.access.${args.operation}`,
returned: typeof result,
});
} else if (!result) {
fieldsDenied.push(fieldKey);
}
}
const nonBooleans: { tag: string; returned: string }[] = [];
const fieldsDenied: string[] = [];
const accessErrors: { error: Error; tag: string }[] = [];
await Promise.all(
Object.keys(inputData).map(async fieldKey => {
let result;
try {
result =
typeof list.fields[fieldKey].access[operation] === 'function'
? await list.fields[fieldKey].access[operation]({ ...args, fieldKey })
: access;
} catch (error: any) {
accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` });
return;
}
if (typeof result !== 'boolean') {
nonBooleans.push({
tag: `${args.listKey}.${fieldKey}.access.${args.operation}`,
returned: typeof result,
});
} else if (!result) {
fieldsDenied.push(fieldKey);
}
})
);

if (accessErrors.length) {
throw extensionError('Access control', accessErrors);
Expand Down
40 changes: 23 additions & 17 deletions packages/keystone/src/lib/core/mutations/create-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,27 +321,33 @@ async function getResolvedData(
// Resolve input hooks
const hookName = 'resolveInput';
// Field hooks
let _resolvedData: Record<string, any> = {};
const fieldsErrors: { error: Error; tag: string }[] = [];
for (const [fieldKey, field] of Object.entries(list.fields)) {
if (field.hooks.resolveInput === undefined) {
_resolvedData[fieldKey] = resolvedData[fieldKey];
} else {
try {
_resolvedData[fieldKey] = await field.hooks.resolveInput({
...hookArgs,
resolvedData,
fieldKey,
});
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` });
}
}
}
resolvedData = Object.fromEntries(
await Promise.all(
Object.entries(list.fields).map(async ([fieldKey, field]) => {
if (field.hooks.resolveInput === undefined) {
return [fieldKey, resolvedData[fieldKey]];
} else {
try {
return [
fieldKey,
await field.hooks.resolveInput({
...hookArgs,
resolvedData,
fieldKey,
}),
];
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` });
return [fieldKey, undefined];
}
}
})
)
);
if (fieldsErrors.length) {
throw extensionError(hookName, fieldsErrors);
}
resolvedData = _resolvedData;

// List hooks
if (list.hooks.resolveInput) {
Expand Down
19 changes: 11 additions & 8 deletions packages/keystone/src/lib/core/mutations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@ export async function runSideEffectOnlyHook<

// Field hooks
const fieldsErrors: { error: Error; tag: string }[] = [];
for (const [fieldKey, field] of Object.entries(list.fields)) {
if (shouldRunFieldLevelHook(fieldKey)) {
try {
await field.hooks[hookName]?.({ fieldKey, ...args });
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` });
await Promise.all(
Object.entries(list.fields).map(async ([fieldKey, field]) => {
if (shouldRunFieldLevelHook(fieldKey)) {
try {
await field.hooks[hookName]?.({ fieldKey, ...args });
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` });
}
}
}
}
})
);

if (fieldsErrors.length) {
throw extensionError(hookName, fieldsErrors);
}
Expand Down
47 changes: 26 additions & 21 deletions packages/keystone/src/lib/core/mutations/validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { extensionError, validationFailureError } from '../graphql-errors';
import { InitialisedList } from '../types-for-lists';

type DistributiveOmit<T, K extends keyof T> = T extends any ? Omit<T, K> : never;

type UpdateCreateHookArgs = Parameters<
Exclude<InitialisedList['hooks']['validateInput'], undefined>
>[0];
Expand All @@ -9,30 +11,31 @@ export async function validateUpdateCreate({
hookArgs,
}: {
list: InitialisedList;
hookArgs: Omit<UpdateCreateHookArgs, 'addValidationError'>;
hookArgs: DistributiveOmit<UpdateCreateHookArgs, 'addValidationError'>;
}) {
const messages: string[] = [];

const fieldsErrors: { error: Error; tag: string }[] = [];
// Field validation hooks
for (const [fieldKey, field] of Object.entries(list.fields)) {
const addValidationError = (msg: string) =>
messages.push(`${list.listKey}.${fieldKey}: ${msg}`);
try {
// @ts-ignore
await field.hooks.validateInput?.({ ...hookArgs, addValidationError, fieldKey });
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateInput` });
}
}
await Promise.all(
Object.entries(list.fields).map(async ([fieldKey, field]) => {
const addValidationError = (msg: string) =>
messages.push(`${list.listKey}.${fieldKey}: ${msg}`);
try {
await field.hooks.validateInput?.({ ...hookArgs, addValidationError, fieldKey });
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateInput` });
}
})
);

if (fieldsErrors.length) {
throw extensionError('validateInput', fieldsErrors);
}

// List validation hooks
const addValidationError = (msg: string) => messages.push(`${list.listKey}: ${msg}`);
try {
// @ts-ignore
await list.hooks.validateInput?.({ ...hookArgs, addValidationError });
} catch (error: any) {
throw extensionError('validateInput', [{ error, tag: `${list.listKey}.hooks.validateInput` }]);
Expand All @@ -54,15 +57,17 @@ export async function validateDelete({
const messages: string[] = [];
const fieldsErrors: { error: Error; tag: string }[] = [];
// Field validation
for (const [fieldKey, field] of Object.entries(list.fields)) {
const addValidationError = (msg: string) =>
messages.push(`${list.listKey}.${fieldKey}: ${msg}`);
try {
await field.hooks.validateDelete?.({ ...hookArgs, addValidationError, fieldKey });
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateDelete` });
}
}
await Promise.all(
Object.entries(list.fields).map(async ([fieldKey, field]) => {
const addValidationError = (msg: string) =>
messages.push(`${list.listKey}.${fieldKey}: ${msg}`);
try {
await field.hooks.validateDelete?.({ ...hookArgs, addValidationError, fieldKey });
} catch (error: any) {
fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateDelete` });
}
})
);
if (fieldsErrors.length) {
throw extensionError('validateDelete', fieldsErrors);
}
Expand Down