[SUPERMASSIVE] async before hooks added (#433)

* [SUPERMASSIVE] async "before" hooks added
This commit is contained in:
vejrj 2024-11-06 13:35:07 +01:00 коммит произвёл GitHub
Родитель f984940e6c
Коммит 8da16263d9
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
4 изменённых файлов: 653 добавлений и 56 удалений

Просмотреть файл

@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "async before hooks added",
"packageName": "@graphitation/supermassive",
"email": "77059398+vejrj@users.noreply.github.com",
"dependentChangeType": "patch"
}

Просмотреть файл

@ -108,28 +108,7 @@ describe.each([
// BOE: beforeOperationExecute
// BSE: beforeSubscriptionEventEmit
// ABR: afterBuildResponse
const hooks: ExecutionHooks = {
beforeOperationExecute: jest
.fn()
.mockImplementation(
({ operation }: BaseExecuteOperationHookArgs<unknown>) => {
hookCalls.push(`BOE|${operation.name?.value}`);
},
),
beforeSubscriptionEventEmit: jest
.fn()
.mockImplementation(
({
operation,
eventPayload,
}: BeforeSubscriptionEventEmitHookArgs<unknown>) => {
hookCalls.push(
`BSE|${operation.name?.value}|${
(eventPayload as any).emitPersons.name
}`,
);
},
),
const syncAfterHooks: ExecutionHooks = {
afterBuildResponse: jest
.fn()
.mockImplementation(
@ -137,13 +116,6 @@ describe.each([
hookCalls.push(`ABR|${operation.name?.value}`);
},
),
beforeFieldResolve: jest
.fn()
.mockImplementation(
({ resolveInfo }: BaseExecuteFieldHookArgs<unknown>) => {
hookCalls.push(`BFR|${pathToArray(resolveInfo.path).join(".")}`);
},
),
afterFieldResolve: jest
.fn()
.mockImplementation(
@ -186,6 +158,68 @@ describe.each([
),
};
const syncBeforeHooks: ExecutionHooks = {
beforeOperationExecute: jest
.fn()
.mockImplementation(
({ operation }: BaseExecuteOperationHookArgs<unknown>) => {
hookCalls.push(`BOE|${operation.name?.value}`);
},
),
beforeSubscriptionEventEmit: jest
.fn()
.mockImplementation(
({
operation,
eventPayload,
}: BeforeSubscriptionEventEmitHookArgs<unknown>) => {
hookCalls.push(
`BSE|${operation.name?.value}|${
(eventPayload as any).emitPersons.name
}`,
);
},
),
beforeFieldResolve: jest
.fn()
.mockImplementation(
({ resolveInfo }: BaseExecuteFieldHookArgs<unknown>) => {
hookCalls.push(`BFR|${pathToArray(resolveInfo.path).join(".")}`);
},
),
};
const asyncBeforeHooks: ExecutionHooks = {
beforeOperationExecute: jest
.fn()
.mockImplementation(
async ({ operation }: BaseExecuteOperationHookArgs<unknown>) => {
hookCalls.push(`BOE|${operation.name?.value}`);
},
),
beforeSubscriptionEventEmit: jest
.fn()
.mockImplementation(
async ({
operation,
eventPayload,
}: BeforeSubscriptionEventEmitHookArgs<unknown>) => {
hookCalls.push(
`BSE|${operation.name?.value}|${
(eventPayload as any).emitPersons.name
}`,
);
},
),
beforeFieldResolve: jest
.fn()
.mockImplementation(
async ({ resolveInfo }: BaseExecuteFieldHookArgs<unknown>) => {
hookCalls.push(`BFR|${pathToArray(resolveInfo.path).join(".")}`);
},
),
};
beforeEach(() => {
jest.clearAllMocks();
hookCalls = [];
@ -520,7 +554,336 @@ describe.each([
},
];
it.each(testCases)(
const asyncHooksTestCases: Array<TestCase> = [
{
name: "succeeded sync resolver with async hooks",
document: `query GetPerson
{
person(id: 1) {
name
}
}`,
resolvers: {
...resolvers,
Person: {
name: (parent: any, _args: unknown, _context: any) => {
return parent.name;
},
},
} as UserResolvers,
expectedHookCalls: [
"BOE|GetPerson",
"BFR|person",
"AFR|person|[object]|undefined",
"BFR|person.name",
"AFR|person.name|Luke Skywalker|undefined",
"AFC|person.name|Luke Skywalker|undefined",
"AFC|person|[object]|undefined",
"ABR|GetPerson",
],
resultHasErrors: false,
isStrictHookCallsOrder: true,
},
{
name: "succeeded async resolver with async hooks",
document: `query GetPerson
{
person(id: 1) {
name
}
}`,
resolvers: {
...resolvers,
Person: {
name: async (parent: any, _args: unknown, _context: any) => {
return Promise.resolve(parent.name);
},
},
} as UserResolvers,
expectedHookCalls: [
"BOE|GetPerson",
"BFR|person",
"AFR|person|[object]|undefined",
"BFR|person.name",
"AFR|person.name|Luke Skywalker|undefined",
"AFC|person.name|Luke Skywalker|undefined",
"AFC|person|[object]|undefined",
"ABR|GetPerson",
],
resultHasErrors: false,
isStrictHookCallsOrder: false,
},
{
name: "error in sync resolver for nullable field with async hooks",
document: `query GetFilm
{
film(id: 1) {
producer
}
}`,
resolvers: {
...resolvers,
Film: {
producer: (_parent: any, _args: unknown, _context: any) => {
throw new Error("Resolver error");
},
},
} as UserResolvers,
expectedHookCalls: [
"BOE|GetFilm",
"BFR|film",
"AFR|film|[object]|undefined",
"BFR|film.producer",
"AFR|film.producer|undefined|Resolver error",
"AFC|film.producer|undefined|Resolver error",
"AFC|film|[object]|undefined",
"ABR|GetFilm",
],
resultHasErrors: true,
isStrictHookCallsOrder: true,
},
{
name: "error in async resolver for nullable field with async hooks",
document: `query GetFilm
{
film(id: 1) {
producer
}
}`,
resolvers: {
...resolvers,
Film: {
producer: async (_parent: any, _args: unknown, _context: any) => {
return Promise.reject(new Error("Resolver error"));
},
},
} as UserResolvers,
expectedHookCalls: [
"BOE|GetFilm",
"BFR|film",
"AFR|film|[object]|undefined",
"BFR|film.producer",
"AFR|film.producer|undefined|Resolver error",
"AFC|film.producer|undefined|Resolver error",
"AFC|film|[object]|undefined",
"ABR|GetFilm",
],
resultHasErrors: true,
isStrictHookCallsOrder: false,
},
{
name: "error in sync resolver for non-nullable field with async hooks",
document: `query GetFilm
{
film(id: 1) {
title
}
}`,
resolvers: {
...resolvers,
Film: {
title: (_parent: any, _args: unknown, _context: any) => {
throw new Error("Resolver error");
},
},
} as UserResolvers,
expectedHookCalls: [
"BOE|GetFilm",
"BFR|film",
"AFR|film|[object]|undefined",
"BFR|film.title",
"AFR|film.title|undefined|Resolver error",
"AFC|film.title|undefined|Resolver error",
"AFC|film|undefined|Resolver error",
"ABR|GetFilm",
],
resultHasErrors: true,
isStrictHookCallsOrder: true,
},
{
name: "error in async resolver for non-nullable field with async hooks",
document: `query GetFilm
{
film(id: 1) {
title
}
}`,
resolvers: {
...resolvers,
Film: {
title: async (_parent: any, _args: unknown, _context: any) => {
return Promise.reject(new Error("Resolver error"));
},
},
} as UserResolvers,
expectedHookCalls: [
"BOE|GetFilm",
"BFR|film",
"AFR|film|[object]|undefined",
"BFR|film.title",
"AFR|film.title|undefined|Resolver error",
"AFC|film.title|undefined|Resolver error",
"AFC|film|undefined|Resolver error",
"ABR|GetFilm",
],
resultHasErrors: true,
isStrictHookCallsOrder: false,
},
{
name: "do not invoke hooks for the field with default resolver with async hooks",
document: `query GetFilm
{
film(id: 1) {
title
}
}`,
resolvers: resolvers as UserResolvers,
expectedHookCalls: [
"BOE|GetFilm",
"BFR|film",
"AFR|film|[object]|undefined",
"AFC|film|[object]|undefined",
"ABR|GetFilm",
],
resultHasErrors: false,
isStrictHookCallsOrder: true,
},
{
name: "do not invoke hooks for the __typename with async hooks",
document: `query GetFilm
{
film(id: 1) {
__typename
title
}
}`,
resolvers: resolvers as UserResolvers,
expectedHookCalls: [
"BOE|GetFilm",
"BFR|film",
"AFR|film|[object]|undefined",
"AFC|film|[object]|undefined",
"ABR|GetFilm",
],
resultHasErrors: false,
isStrictHookCallsOrder: true,
},
{
name: "multiple root fields in selection set with async hooks",
document: `query GetFilmAndPerson
{
film(id: 1) {
title
}
person(id: 1) {
name
}
}`,
resolvers: resolvers as UserResolvers,
expectedHookCalls: [
"BOE|GetFilmAndPerson",
"BFR|film",
"BFR|person",
"AFR|film|[object]|undefined",
"AFR|person|[object]|undefined",
"AFC|film|[object]|undefined",
"AFC|person|[object]|undefined",
"ABR|GetFilmAndPerson",
],
resultHasErrors: false,
isStrictHookCallsOrder: true,
},
{
name: "subscription hooks",
document: `subscription EmitPersons($limit: Int!)
{
emitPersons(limit: $limit) {
name
}
}`,
variables: {
limit: 3,
},
resolvers: resolvers as UserResolvers,
expectedHookCalls: [
"BOE|EmitPersons",
"BFR|emitPersons",
"AFR|emitPersons|[object]|undefined",
"BSE|EmitPersons|Luke Skywalker",
"ABR|EmitPersons",
"BSE|EmitPersons|C-3PO",
"ABR|EmitPersons",
"BSE|EmitPersons|R2-D2",
"ABR|EmitPersons",
],
resultHasErrors: false,
isStrictHookCallsOrder: true,
},
{
name: "error in sync subscribe() resolver with async hooks",
document: `subscription EmitPersons($limit: Int!)
{
emitPersons(limit: $limit) {
name
}
}`,
resolvers: {
...resolvers,
Subscription: {
emitPersons: {
subscribe: (_parent: any, _args: unknown, _context: any) => {
throw new Error("Subscribe error");
},
},
},
} as UserResolvers,
variables: {
limit: 1,
},
expectedHookCalls: [
"BOE|EmitPersons",
"BFR|emitPersons",
"AFR|emitPersons|undefined|Subscribe error",
],
resultHasErrors: true,
isStrictHookCallsOrder: true,
},
{
name: "error in async subscribe() resolver with async hooks",
document: `subscription EmitPersons($limit: Int!)
{
emitPersons(limit: $limit) {
name
}
}`,
resolvers: {
...resolvers,
Subscription: {
emitPersons: {
subscribe: async (
_parent: any,
_args: unknown,
_context: any,
) => {
return Promise.reject(new Error("Subscribe error"));
},
},
},
} as UserResolvers,
variables: {
limit: 1,
},
expectedHookCalls: [
"BOE|EmitPersons",
"BFR|emitPersons",
"AFR|emitPersons|undefined|Subscribe error",
],
resultHasErrors: true,
isStrictHookCallsOrder: true,
},
];
it.each(asyncHooksTestCases)(
"$name",
async ({
document,
@ -534,7 +897,12 @@ describe.each([
const parsedDocument = parse(document);
const result = await drainExecution(
await execute(parsedDocument, resolvers, hooks, variables),
await execute(
parsedDocument,
resolvers,
{ ...asyncBeforeHooks, ...syncAfterHooks },
variables,
),
);
if (isStrictHookCallsOrder) {
@ -553,6 +921,99 @@ describe.each([
);
},
);
it.each(testCases)(
"$name",
async ({
document,
resolvers,
expectedHookCalls,
resultHasErrors,
isStrictHookCallsOrder,
variables,
}) => {
expect.assertions(4);
const parsedDocument = parse(document);
const result = await drainExecution(
await execute(
parsedDocument,
resolvers,
{ ...syncAfterHooks, ...syncBeforeHooks },
variables,
),
);
if (isStrictHookCallsOrder) {
expect(hookCalls).toEqual(expectedHookCalls);
} else {
// for async resolvers order of resolving isn't strict,
// so just verify whether corresponding hook calls happened
expect(hookCalls).toEqual(expect.arrayContaining(expectedHookCalls));
}
expect(hookCalls).toHaveLength(expectedHookCalls.length);
expect(isTotalExecutionResult(result as TotalExecutionResult)).toBe(
true,
);
expect(((result as TotalExecutionResult).errors?.length ?? 0) > 0).toBe(
resultHasErrors,
);
},
);
test("BFR returns promise conditionally", async () => {
const result = await drainExecution(
await execute(
parse(`query GetFilmAndPerson
{
film(id: 1) {
title
}
person(id: 1) {
name
}
}`),
resolvers as UserResolvers,
{
...asyncBeforeHooks,
...syncAfterHooks,
beforeFieldResolve: jest
.fn()
.mockImplementation(
({ resolveInfo }: BaseExecuteFieldHookArgs<unknown>) => {
hookCalls.push(
`BFR|${pathToArray(resolveInfo.path).join(".")}`,
);
if (resolveInfo.fieldName === "film")
return Promise.resolve();
return;
},
),
},
{
limit: 1,
},
),
);
const expectedHookCalls = [
"BOE|GetFilmAndPerson",
"BFR|film",
"BFR|person",
"AFR|person|[object]|undefined",
"AFC|person|[object]|undefined",
"AFR|film|[object]|undefined",
"AFC|film|[object]|undefined",
"ABR|GetFilmAndPerson",
];
expect(hookCalls).toEqual(expectedHookCalls);
expect(hookCalls).toHaveLength(expectedHookCalls.length);
expect(isTotalExecutionResult(result as TotalExecutionResult)).toBe(true);
expect(((result as TotalExecutionResult).errors?.length ?? 0) > 0).toBe(
false,
);
});
});
describe("Error thrown in the hook doesn't break execution and is returned in response 'errors'", () => {
@ -759,6 +1220,48 @@ describe.each([
expectedErrorMessage:
'Unexpected error in beforeSubscriptionEventEmit hook: "Hook error"',
},
{
name: "async beforeSubscriptionEventEmit (Error is thrown)",
document: `subscription EmitPersons($limit: Int!)
{
emitPersons(limit: $limit) {
name
}
}`,
variables: {
limit: 1,
},
hooks: {
beforeSubscriptionEventEmit: jest
.fn()
.mockImplementation(async () => {
throw new Error("Hook error");
}),
},
expectedErrorMessage:
"Unexpected error in beforeSubscriptionEventEmit hook: Hook error",
},
{
name: "async beforeSubscriptionEventEmit (string is thrown)",
document: `subscription EmitPersons($limit: Int!)
{
emitPersons(limit: $limit) {
name
}
}`,
variables: {
limit: 1,
},
hooks: {
beforeSubscriptionEventEmit: jest
.fn()
.mockImplementation(async () => {
throw "Hook error";
}),
},
expectedErrorMessage:
'Unexpected error in beforeSubscriptionEventEmit hook: "Hook error"',
},
];
it.each(testCases)(
@ -817,4 +1320,35 @@ describe.each([
expect.objectContaining({ hookContext: afterHookContext }),
);
});
it('passes async "before" hook context but "after" hook should already receive resolved promise', async () => {
expect.assertions(2);
const query = `
{
film(id: 1) {
title
}
}`;
const beforeHookContext = {
foo: "foo",
};
const afterHookContext = {
bar: "bar",
};
const hooks: ExecutionHooks = {
beforeFieldResolve: jest.fn(async () => beforeHookContext),
afterFieldResolve: jest.fn(() => afterHookContext),
afterFieldComplete: jest.fn(),
};
await execute(parse(query), resolvers as UserResolvers, hooks);
expect(hooks.afterFieldResolve).toHaveBeenCalledWith(
expect.objectContaining({ hookContext: beforeHookContext }),
);
expect(hooks.afterFieldComplete).toHaveBeenCalledWith(
expect.objectContaining({ hookContext: afterHookContext }),
);
});
});

Просмотреть файл

@ -144,7 +144,7 @@ export function executeWithoutSchema(
if (!("schemaFragment" in exeContext)) {
return { errors: exeContext };
} else {
return executeOperation(exeContext);
return executeOperationWithBeforeHook(exeContext);
}
}
@ -277,23 +277,35 @@ function buildPerEventExecutionContext(
};
}
function executeOperationWithBeforeHook(
exeContext: ExecutionContext,
): PromiseOrValue<ExecutionResult> {
const hooks = exeContext.fieldExecutionHooks;
let hook: Promise<void> | void | undefined;
if (hooks?.beforeOperationExecute) {
hook = invokeBeforeOperationExecuteHook(exeContext);
}
if (isPromise(hook)) {
return hook.then(() => executeOperation(exeContext));
}
return executeOperation(exeContext);
}
function executeOperation(
exeContext: ExecutionContext,
): PromiseOrValue<ExecutionResult> {
try {
const { operation, rootValue } = exeContext;
const rootTypeName = getOperationRootTypeName(operation);
const { groupedFieldSet, patches } = collectFields(
exeContext,
rootTypeName,
);
const path = undefined;
let result;
const hooks = exeContext.fieldExecutionHooks;
if (hooks?.beforeOperationExecute) {
invokeBeforeOperationExecuteHook(exeContext);
}
// Note: cannot use OperationTypeNode from graphql-js as it doesn't exist in 15.x
switch (operation.operation) {
case "query":
@ -686,7 +698,12 @@ function executeSubscriptionImpl(
// Call the `subscribe()` resolver or the default resolver to produce an
// AsyncIterable yielding raw payloads.
const result = resolveFn(rootValue, args, contextValue, info);
const result = isPromise(hookContext)
? hookContext.then((context) => {
hookContext = context;
return resolveFn(rootValue, args, contextValue, info);
})
: resolveFn(rootValue, args, contextValue, info);
if (isPromise(result)) {
return result.then(assertEventStream).then(
@ -796,18 +813,33 @@ function mapResultOrEventStreamOrPromise(
payload,
);
const hooks = exeContext?.fieldExecutionHooks;
let beforeExecuteFieldsHook: void | Promise<void> | undefined;
if (hooks?.beforeSubscriptionEventEmit) {
invokeBeforeSubscriptionEventEmitHook(perEventContext, payload);
beforeExecuteFieldsHook = invokeBeforeSubscriptionEventEmitHook(
perEventContext,
payload,
);
}
try {
const data = executeFields(
exeContext,
parentTypeName,
payload,
path,
groupedFieldSet,
undefined,
);
const data = isPromise(beforeExecuteFieldsHook)
? beforeExecuteFieldsHook.then(() =>
executeFields(
exeContext,
parentTypeName,
payload,
path,
groupedFieldSet,
undefined,
),
)
: executeFields(
exeContext,
parentTypeName,
payload,
path,
groupedFieldSet,
undefined,
);
// This is typechecked in collect values
return buildResponse(perEventContext, data) as TotalExecutionResult;
} catch (error) {
@ -919,7 +951,12 @@ function resolveAndCompleteField(
hookContext = invokeBeforeFieldResolveHook(info, exeContext);
}
const result = resolveFn(source, args, contextValue, info);
const result = isPromise(hookContext)
? hookContext.then((context) => {
hookContext = context;
return resolveFn(source, args, contextValue, info);
})
: resolveFn(source, args, contextValue, info);
let completed;
if (isPromise(result)) {
@ -1944,19 +1981,34 @@ function invokeAfterBuildResponseHook(
}
function executeSafe<T>(
execute: () => T,
execute: () => T | Promise<T>,
onComplete: (result: T | undefined, error: unknown) => void,
): T {
): T | Promise<T> {
let error: unknown;
let result: T | undefined;
let result: T | Promise<T> | undefined;
try {
result = execute();
} catch (e) {
error = e;
} finally {
onComplete(result, error);
if (!isPromise(result)) {
onComplete(result, error);
}
}
return result as T;
if (!isPromise(result)) {
return result as T;
}
return result
.then((hookResult) => {
onComplete(hookResult, error);
return hookResult;
})
.catch((e) => {
onComplete(undefined, e);
return undefined;
}) as Promise<T>;
}
function toGraphQLError(

Просмотреть файл

@ -46,7 +46,9 @@ export interface BeforeFieldResolveHook<
ResolveContext = unknown,
BeforeHookContext = unknown,
> {
(args: BaseExecuteFieldHookArgs<ResolveContext>): BeforeHookContext;
(args: BaseExecuteFieldHookArgs<ResolveContext>):
| Promise<BeforeHookContext>
| BeforeHookContext;
}
export interface AfterFieldResolveHook<
@ -71,11 +73,13 @@ export interface AfterBuildResponseHook<ResolveContext = unknown> {
}
export interface BeforeOperationExecuteHook<ResolveContext = unknown> {
(args: BaseExecuteOperationHookArgs<ResolveContext>): void;
(args: BaseExecuteOperationHookArgs<ResolveContext>): void | Promise<void>;
}
export interface BeforeSubscriptionEventEmitHook<ResolveContext = unknown> {
(args: BeforeSubscriptionEventEmitHookArgs<ResolveContext>): void;
(
args: BeforeSubscriptionEventEmitHookArgs<ResolveContext>,
): void | Promise<void>;
}
export interface ExecutionHooks<