diff --git a/.azure-devops/graphitation-release.yml b/.azure-devops/graphitation-release.yml index e5ff2a284..db0dc815e 100644 --- a/.azure-devops/graphitation-release.yml +++ b/.azure-devops/graphitation-release.yml @@ -2,6 +2,7 @@ pr: none trigger: - main - alloy/relay-apollo-duct-tape + - jvejr/supermassive-hooks-error-handling-alpha-release variables: - group: InfoSec-SecurityResults diff --git a/examples/apollo-watch-fragments/package.json b/examples/apollo-watch-fragments/package.json index 1f49f6a88..1949643a6 100644 --- a/examples/apollo-watch-fragments/package.json +++ b/examples/apollo-watch-fragments/package.json @@ -38,7 +38,7 @@ "devDependencies": { "@graphitation/apollo-react-relay-duct-tape-compiler": "^1.6.11", "@graphitation/embedded-document-artefact-loader": "^0.8.5", - "@graphitation/supermassive": "^3.7.2", + "@graphitation/supermassive": "^3.8.0-alpha.4", "@graphql-codegen/cli": "2.2.0", "@graphql-codegen/typescript": "2.2.2", "@graphql-codegen/typescript-resolvers": "^2.2.1", diff --git a/examples/supermassive-todomvc/package.json b/examples/supermassive-todomvc/package.json index 8298220a4..7736ecdf9 100644 --- a/examples/supermassive-todomvc/package.json +++ b/examples/supermassive-todomvc/package.json @@ -18,7 +18,7 @@ "@graphitation/apollo-react-relay-duct-tape": "^1.3.13", "@graphitation/apollo-react-relay-duct-tape-compiler": "^1.6.11", "@graphitation/graphql-js-tag": "^0.9.4", - "@graphitation/supermassive": "^3.7.2", + "@graphitation/supermassive": "^3.8.0-alpha.4", "@graphitation/ts-transform-graphql-js-tag": "^1.4.4", "concurrently": "^6.5.1", "graphql": "^15.6.1", diff --git a/package.json b/package.json index 6d0c7990b..d23283271 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,10 @@ "lint": "lage lint --continue", "lage": "lage", "ci": "yarn lage build types test lint && yarn checkchange", - "beachball": "beachball -b origin/main", + "beachball": "beachball -b origin/jvejr/supermassive-hooks-error-handling-alpha-release", "change": "yarn beachball change", "checkchange": "yarn beachball check", - "release": "yarn beachball publish -t latest", + "release": "yarn beachball publish -t alpha", "postinstall": "patch-package" }, "devDependencies": { diff --git a/packages/apollo-react-relay-duct-tape-compiler/CHANGELOG.json b/packages/apollo-react-relay-duct-tape-compiler/CHANGELOG.json index d8a490135..29bbe9270 100644 --- a/packages/apollo-react-relay-duct-tape-compiler/CHANGELOG.json +++ b/packages/apollo-react-relay-duct-tape-compiler/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@graphitation/apollo-react-relay-duct-tape-compiler", "entries": [ + { + "date": "Wed, 25 Dec 2024 21:18:59 GMT", + "version": "1.6.11", + "tag": "@graphitation/apollo-react-relay-duct-tape-compiler_v1.6.11", + "comments": { + "none": [ + { + "author": "beachball", + "package": "@graphitation/apollo-react-relay-duct-tape-compiler", + "comment": "Bump @graphitation/supermassive to v3.8.0-alpha.4", + "commit": "not available" + } + ] + } + }, { "date": "Mon, 16 Dec 2024 15:39:40 GMT", "version": "1.6.11", diff --git a/packages/apollo-react-relay-duct-tape-compiler/package.json b/packages/apollo-react-relay-duct-tape-compiler/package.json index e2f7bb10c..d4bbc6514 100644 --- a/packages/apollo-react-relay-duct-tape-compiler/package.json +++ b/packages/apollo-react-relay-duct-tape-compiler/package.json @@ -42,7 +42,7 @@ }, "peerDependencies": { "graphql": "^15.0.0", - "@graphitation/supermassive": "^3.7.2", + "@graphitation/supermassive": "^3.8.0-alpha.4", "typescript": "^5.5.3" }, "publishConfig": { diff --git a/packages/graphql-codegen-supermassive-schema-extraction-plugin/CHANGELOG.json b/packages/graphql-codegen-supermassive-schema-extraction-plugin/CHANGELOG.json index 2d08a494c..7161aee79 100644 --- a/packages/graphql-codegen-supermassive-schema-extraction-plugin/CHANGELOG.json +++ b/packages/graphql-codegen-supermassive-schema-extraction-plugin/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@graphitation/graphql-codegen-supermassive-schema-extraction-plugin", "entries": [ + { + "date": "Wed, 25 Dec 2024 21:18:59 GMT", + "version": "2.0.19", + "tag": "@graphitation/graphql-codegen-supermassive-schema-extraction-plugin_v2.0.19", + "comments": { + "none": [ + { + "author": "beachball", + "package": "@graphitation/graphql-codegen-supermassive-schema-extraction-plugin", + "comment": "Bump @graphitation/supermassive to v3.8.0-alpha.4", + "commit": "not available" + } + ] + } + }, { "date": "Mon, 16 Dec 2024 15:39:40 GMT", "version": "2.0.19", diff --git a/packages/graphql-codegen-supermassive-schema-extraction-plugin/package.json b/packages/graphql-codegen-supermassive-schema-extraction-plugin/package.json index 76c0d4f48..1f2ef5a09 100644 --- a/packages/graphql-codegen-supermassive-schema-extraction-plugin/package.json +++ b/packages/graphql-codegen-supermassive-schema-extraction-plugin/package.json @@ -23,7 +23,7 @@ "@graphql-codegen/plugin-helpers": ">= 1.18.0 < 2" }, "dependencies": { - "@graphitation/supermassive": "^3.7.2", + "@graphitation/supermassive": "^3.8.0-alpha.4", "graphql": "^15.0.0" }, "sideEffects": false, diff --git a/packages/graphql-codegen-supermassive-typed-document-node-plugin/CHANGELOG.json b/packages/graphql-codegen-supermassive-typed-document-node-plugin/CHANGELOG.json index 12da08e13..bcfd3857e 100644 --- a/packages/graphql-codegen-supermassive-typed-document-node-plugin/CHANGELOG.json +++ b/packages/graphql-codegen-supermassive-typed-document-node-plugin/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@graphitation/graphql-codegen-supermassive-typed-document-node-plugin", "entries": [ + { + "date": "Wed, 25 Dec 2024 21:18:59 GMT", + "version": "1.0.12", + "tag": "@graphitation/graphql-codegen-supermassive-typed-document-node-plugin_v1.0.12", + "comments": { + "none": [ + { + "author": "beachball", + "package": "@graphitation/graphql-codegen-supermassive-typed-document-node-plugin", + "comment": "Bump @graphitation/supermassive to v3.8.0-alpha.4", + "commit": "not available" + } + ] + } + }, { "date": "Mon, 16 Dec 2024 15:39:40 GMT", "version": "1.0.12", diff --git a/packages/graphql-codegen-supermassive-typed-document-node-plugin/package.json b/packages/graphql-codegen-supermassive-typed-document-node-plugin/package.json index 3c3d5a23b..400036296 100644 --- a/packages/graphql-codegen-supermassive-typed-document-node-plugin/package.json +++ b/packages/graphql-codegen-supermassive-typed-document-node-plugin/package.json @@ -29,7 +29,7 @@ "@graphql-codegen/visitor-plugin-common": ">= ^1.17.0 < 2", "graphql-tag": ">= 2.11.0 < 3", "@graphql-tools/optimize": "^1.0.1", - "@graphitation/supermassive": "^3.7.2" + "@graphitation/supermassive": "^3.8.0-alpha.4" }, "sideEffects": false, "access": "public", diff --git a/packages/supermassive/CHANGELOG.json b/packages/supermassive/CHANGELOG.json index 66adcac88..8e6e864f0 100644 --- a/packages/supermassive/CHANGELOG.json +++ b/packages/supermassive/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@graphitation/supermassive", "entries": [ + { + "date": "Wed, 25 Dec 2024 21:18:59 GMT", + "version": "3.8.0-alpha.4", + "tag": "@graphitation/supermassive_v3.8.0-alpha.4", + "comments": { + "prerelease": [ + { + "author": "77059398+vejrj@users.noreply.github.com", + "package": "@graphitation/supermassive", + "commit": "24097ac60df4238a0e6c7fbd549f63384e196770", + "comment": "Supermassive PR comment fixes" + } + ] + } + }, { "date": "Mon, 16 Dec 2024 15:39:40 GMT", "version": "3.7.2", diff --git a/packages/supermassive/CHANGELOG.md b/packages/supermassive/CHANGELOG.md index 30d26947d..c9073e876 100644 --- a/packages/supermassive/CHANGELOG.md +++ b/packages/supermassive/CHANGELOG.md @@ -1,9 +1,17 @@ # Change Log - @graphitation/supermassive - + +## 3.8.0-alpha.4 + +Wed, 25 Dec 2024 21:18:59 GMT + +### Changes + +- Supermassive PR comment fixes (77059398+vejrj@users.noreply.github.com) + ## 3.7.2 Mon, 16 Dec 2024 15:39:40 GMT diff --git a/packages/supermassive/hooks.md b/packages/supermassive/hooks.md new file mode 100644 index 000000000..0298fa034 --- /dev/null +++ b/packages/supermassive/hooks.md @@ -0,0 +1,64 @@ +# Hooks Documentation + +## Overview + +This document describes the behaviour of hooks when they encounter errors. + +## General Rule + +- **Thrown Error**: Specific behaviour is applied based on the hook. +- **Returned Error**: The error is registered and execution continues. + +### Hooks and Their Behaviours + +#### `beforeOperationExecute` + +Called before every operation + +- **Thrown Error**: Stops execution and sets `data` to `null` and registers the error. +- **Returned Error**: The error is registered and execution continues. + +#### `beforeSubscriptionEventEmit` + +- **Thrown ErErrorror**: Sets `data` to `null` and registers the error. +- **Returned Error**: The error is registered and execution continues. + +#### `beforeFieldResolve` + +Called before every field resolution + +- **Thrown Error**: The field is not executed and is handled as if it has returned `null`. +- **Returned Error**: The error is registered and execution continues. + +#### `afterFieldResolve` + +Called after every field resolution. + +- **Thrown Error**: The field is set to `null` and the error is registered. +- **Returned Error**: The error is registered and execution continues. + +#### `afterFieldComplete` + +Called when field value is completed + +- **Thrown Error**: The field is set to `null` and the error is registered. +- **Returned Error**: The error is registered and execution continues. + +#### `afterBuildResponse` + +- **Thrown Error**: Returns no data property, only errors. +- **Returned Error**: The error is registered and execution continues. + +## Additional Hooks + +### `beforeFieldSubscribe` + +Called before subscription event stream creation + +- **Thrown or Returned Error**: Stops execution and sets `data` is `undefined` and error is returned in `errors` field. + +### `afterFieldSubscribe` + +Called after subscription event stream creation + +- **Thrown or Returned Error**: Stops execution and sets `data` is `undefined` and error is returned in `errors` field. diff --git a/packages/supermassive/package.json b/packages/supermassive/package.json index 936979128..d390a3e99 100644 --- a/packages/supermassive/package.json +++ b/packages/supermassive/package.json @@ -1,7 +1,7 @@ { "name": "@graphitation/supermassive", "license": "MIT", - "version": "3.7.2", + "version": "3.8.0-alpha.4", "main": "./src/index.ts", "repository": { "type": "git", diff --git a/packages/supermassive/src/__tests__/hooks.test.ts b/packages/supermassive/src/__tests__/hooks.test.ts index 155f43b47..7b40046cc 100644 --- a/packages/supermassive/src/__tests__/hooks.test.ts +++ b/packages/supermassive/src/__tests__/hooks.test.ts @@ -17,6 +17,7 @@ import type { UserResolvers, TotalExecutionResult } from "../types"; import type { AfterFieldCompleteHookArgs, AfterFieldResolveHookArgs, + AfterFieldSubscribeHookArgs, BaseExecuteFieldHookArgs, BaseExecuteOperationHookArgs, BeforeSubscriptionEventEmitHookArgs, @@ -136,6 +137,26 @@ describe.each([ ); }, ), + afterFieldSubscribe: jest + .fn() + .mockImplementation( + ({ + resolveInfo, + result, + error, + }: AfterFieldSubscribeHookArgs) => { + const resultValue = + typeof result === "object" && result !== null + ? "[object]" + : result; + const errorMessage = error instanceof Error ? error.message : error; + hookCalls.push( + `AFS|${pathToArray(resolveInfo.path).join( + ".", + )}|${resultValue}|${errorMessage}`, + ); + }, + ), afterFieldComplete: jest .fn() .mockImplementation( @@ -187,6 +208,13 @@ describe.each([ hookCalls.push(`BFR|${pathToArray(resolveInfo.path).join(".")}`); }, ), + beforeFieldSubscribe: jest + .fn() + .mockImplementation( + ({ resolveInfo }: BaseExecuteFieldHookArgs) => { + hookCalls.push(`BFS|${pathToArray(resolveInfo.path).join(".")}`); + }, + ), }; const asyncBeforeHooks: ExecutionHooks = { @@ -194,7 +222,7 @@ describe.each([ .fn() .mockImplementation( async ({ operation }: BaseExecuteOperationHookArgs) => { - hookCalls.push(`BOE|${operation.name?.value}`); + hookCalls.push(`ABOE|${operation.name?.value}`); }, ), beforeSubscriptionEventEmit: jest @@ -205,7 +233,7 @@ describe.each([ eventPayload, }: BeforeSubscriptionEventEmitHookArgs) => { hookCalls.push( - `BSE|${operation.name?.value}|${ + `ABSE|${operation.name?.value}|${ (eventPayload as any).emitPersons.name }`, ); @@ -215,7 +243,14 @@ describe.each([ .fn() .mockImplementation( async ({ resolveInfo }: BaseExecuteFieldHookArgs) => { - hookCalls.push(`BFR|${pathToArray(resolveInfo.path).join(".")}`); + hookCalls.push(`ABFR|${pathToArray(resolveInfo.path).join(".")}`); + }, + ), + beforeFieldSubscribe: jest + .fn() + .mockImplementation( + async ({ resolveInfo }: BaseExecuteFieldHookArgs) => { + hookCalls.push(`ABFR|${pathToArray(resolveInfo.path).join(".")}`); }, ), }; @@ -516,8 +551,8 @@ describe.each([ resolvers: resolvers as UserResolvers, expectedHookCalls: [ "BOE|EmitPersons", - "BFR|emitPersons", - "AFR|emitPersons|[object]|undefined", + "BFS|emitPersons", + "AFS|emitPersons|[object]|undefined", "BSE|EmitPersons|Luke Skywalker", "ABR|EmitPersons", "BSE|EmitPersons|C-3PO", @@ -551,8 +586,8 @@ describe.each([ }, expectedHookCalls: [ "BOE|EmitPersons", - "BFR|emitPersons", - "AFR|emitPersons|undefined|Subscribe error", + "BFS|emitPersons", + "AFS|emitPersons|undefined|Subscribe error", ], resultHasErrors: true, isStrictHookCallsOrder: true, @@ -584,8 +619,8 @@ describe.each([ }, expectedHookCalls: [ "BOE|EmitPersons", - "BFR|emitPersons", - "AFR|emitPersons|undefined|Subscribe error", + "BFS|emitPersons", + "AFS|emitPersons|undefined|Subscribe error", ], resultHasErrors: true, isStrictHookCallsOrder: true, @@ -610,10 +645,10 @@ describe.each([ }, } as UserResolvers, expectedHookCalls: [ - "BOE|GetPerson", - "BFR|person", + "ABOE|GetPerson", + "ABFR|person", "AFR|person|[object]|undefined", - "BFR|person.name", + "ABFR|person.name", "AFR|person.name|Luke Skywalker|undefined", "AFC|person.name|Luke Skywalker|undefined", "AFC|person|[object]|undefined", @@ -639,10 +674,10 @@ describe.each([ }, } as UserResolvers, expectedHookCalls: [ - "BOE|GetPerson", - "BFR|person", + "ABOE|GetPerson", + "ABFR|person", "AFR|person|[object]|undefined", - "BFR|person.name", + "ABFR|person.name", "AFR|person.name|Luke Skywalker|undefined", "AFC|person.name|Luke Skywalker|undefined", "AFC|person|[object]|undefined", @@ -668,10 +703,10 @@ describe.each([ }, } as UserResolvers, expectedHookCalls: [ - "BOE|GetFilm", - "BFR|film", + "ABOE|GetFilm", + "ABFR|film", "AFR|film|[object]|undefined", - "BFR|film.producer", + "ABFR|film.producer", "AFR|film.producer|undefined|Resolver error", "AFC|film.producer|undefined|Resolver error", "AFC|film|[object]|undefined", @@ -697,10 +732,10 @@ describe.each([ }, } as UserResolvers, expectedHookCalls: [ - "BOE|GetFilm", - "BFR|film", + "ABOE|GetFilm", + "ABFR|film", "AFR|film|[object]|undefined", - "BFR|film.producer", + "ABFR|film.producer", "AFR|film.producer|undefined|Resolver error", "AFC|film.producer|undefined|Resolver error", "AFC|film|[object]|undefined", @@ -726,10 +761,10 @@ describe.each([ }, } as UserResolvers, expectedHookCalls: [ - "BOE|GetFilm", - "BFR|film", + "ABOE|GetFilm", + "ABFR|film", "AFR|film|[object]|undefined", - "BFR|film.title", + "ABFR|film.title", "AFR|film.title|undefined|Resolver error", "AFC|film.title|undefined|Resolver error", "AFC|film|undefined|Resolver error", @@ -755,10 +790,10 @@ describe.each([ }, } as UserResolvers, expectedHookCalls: [ - "BOE|GetFilm", - "BFR|film", + "ABOE|GetFilm", + "ABFR|film", "AFR|film|[object]|undefined", - "BFR|film.title", + "ABFR|film.title", "AFR|film.title|undefined|Resolver error", "AFC|film.title|undefined|Resolver error", "AFC|film|undefined|Resolver error", @@ -777,8 +812,8 @@ describe.each([ }`, resolvers: resolvers as UserResolvers, expectedHookCalls: [ - "BOE|GetFilm", - "BFR|film", + "ABOE|GetFilm", + "ABFR|film", "AFR|film|[object]|undefined", "AFC|film|[object]|undefined", "ABR|GetFilm", @@ -797,8 +832,8 @@ describe.each([ }`, resolvers: resolvers as UserResolvers, expectedHookCalls: [ - "BOE|GetFilm", - "BFR|film", + "ABOE|GetFilm", + "ABFR|film", "AFR|film|[object]|undefined", "AFC|film|[object]|undefined", "ABR|GetFilm", @@ -819,9 +854,9 @@ describe.each([ }`, resolvers: resolvers as UserResolvers, expectedHookCalls: [ - "BOE|GetFilmAndPerson", - "BFR|film", - "BFR|person", + "ABOE|GetFilmAndPerson", + "ABFR|film", + "ABFR|person", "AFR|film|[object]|undefined", "AFR|person|[object]|undefined", "AFC|film|[object]|undefined", @@ -844,14 +879,14 @@ describe.each([ }, resolvers: resolvers as UserResolvers, expectedHookCalls: [ - "BOE|EmitPersons", - "BFR|emitPersons", - "AFR|emitPersons|[object]|undefined", - "BSE|EmitPersons|Luke Skywalker", + "ABOE|EmitPersons", + "ABFR|emitPersons", + "AFS|emitPersons|[object]|undefined", + "ABSE|EmitPersons|Luke Skywalker", "ABR|EmitPersons", - "BSE|EmitPersons|C-3PO", + "ABSE|EmitPersons|C-3PO", "ABR|EmitPersons", - "BSE|EmitPersons|R2-D2", + "ABSE|EmitPersons|R2-D2", "ABR|EmitPersons", ], resultHasErrors: false, @@ -879,9 +914,9 @@ describe.each([ limit: 1, }, expectedHookCalls: [ - "BOE|EmitPersons", - "BFR|emitPersons", - "AFR|emitPersons|undefined|Subscribe error", + "ABOE|EmitPersons", + "ABFR|emitPersons", + "AFS|emitPersons|undefined|Subscribe error", ], resultHasErrors: true, isStrictHookCallsOrder: true, @@ -912,9 +947,9 @@ describe.each([ limit: 1, }, expectedHookCalls: [ - "BOE|EmitPersons", - "BFR|emitPersons", - "AFR|emitPersons|undefined|Subscribe error", + "ABOE|EmitPersons", + "ABFR|emitPersons", + "AFS|emitPersons|undefined|Subscribe error", ], resultHasErrors: true, isStrictHookCallsOrder: true, @@ -1019,11 +1054,15 @@ describe.each([ .fn() .mockImplementation( ({ resolveInfo }: BaseExecuteFieldHookArgs) => { + if (resolveInfo.fieldName === "film") { + hookCalls.push( + `ABFR|${pathToArray(resolveInfo.path).join(".")}`, + ); + return Promise.resolve(); + } hookCalls.push( `BFR|${pathToArray(resolveInfo.path).join(".")}`, ); - if (resolveInfo.fieldName === "film") - return Promise.resolve(); return; }, ), @@ -1035,8 +1074,8 @@ describe.each([ ); const expectedHookCalls = [ - "BOE|GetFilmAndPerson", - "BFR|film", + "ABOE|GetFilmAndPerson", + "ABFR|film", "BFR|person", "AFR|person|[object]|undefined", "AFC|person|[object]|undefined", @@ -1054,7 +1093,229 @@ describe.each([ }); }); - describe("Error thrown in the hook doesn't break execution and is returned in response 'errors'", () => { + describe("error in beforeSubscriptionEventEmit", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases: Array = [ + { + name: "beforeSubscriptionEventEmit (Error is thrown)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + beforeSubscriptionEventEmit: jest.fn().mockImplementation(() => { + throw new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error thrown by beforeSubscriptionEventEmit hook (operation: EmitPersons): 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 thrown by beforeSubscriptionEventEmit hook (operation: EmitPersons): Hook error", + }, + ]; + + it.each(testCases)( + "$name", + async ({ document, hooks, expectedErrorMessage, variables }) => { + expect.assertions(5); + const parsedDocument = parse(document); + + const response = await drainExecution( + await execute( + parsedDocument, + resolvers as UserResolvers, + hooks, + variables, + ), + ); + const result = Array.isArray(response) ? response[0] : response; + + expect(isTotalExecutionResult(result)).toBe(true); + + const errors = result.errors; + expect(result.data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe(expectedErrorMessage); + }, + ); + }); + + describe("afterFieldSubscribe and beforeFieldSubscribe hook errors during creating event stream. It should throw if an error is thrown or returned", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases: Array = [ + { + name: "afterFieldSubscribe (Error is returned)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + afterFieldSubscribe: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error returned from afterFieldSubscribe hook: Hook error", + }, + { + name: "afterFieldSubscribe (Error is thrown)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + afterFieldSubscribe: jest.fn().mockImplementation(() => { + throw new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error thrown by afterFieldSubscribe hook: Hook error", + }, + { + name: "afterFieldSubscribe (string is thrown)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + afterFieldSubscribe: jest.fn().mockImplementation(() => { + throw "Hook error"; + }), + }, + expectedErrorMessage: + 'Unexpected error thrown by afterFieldSubscribe hook: "Hook error"', + }, + { + name: "beforeFieldSubscribe (Error is thrown)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + beforeFieldSubscribe: jest.fn().mockImplementation(() => { + throw new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error thrown by beforeFieldSubscribe hook: Hook error", + }, + { + name: "beforeFieldSubscribe (Error is returned)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + beforeFieldSubscribe: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error returned from beforeFieldSubscribe hook: Hook error", + }, + { + name: "beforeFieldSubscribe (string is thrown)", + document: `subscription EmitPersons($limit: Int!) + { + emitPersons(limit: $limit) { + name + } + }`, + variables: { + limit: 1, + }, + hooks: { + beforeFieldSubscribe: jest.fn().mockImplementation(() => { + throw "Hook error"; + }), + }, + expectedErrorMessage: + 'Unexpected error thrown by beforeFieldSubscribe hook: "Hook error"', + }, + ]; + + it.each(testCases)( + "$name", + async ({ document, hooks, expectedErrorMessage, variables }) => { + expect.assertions(5); + const parsedDocument = parse(document); + + const response = await drainExecution( + await execute( + parsedDocument, + resolvers as UserResolvers, + hooks, + variables, + ), + ); + const result = Array.isArray(response) ? response[0] : response; + expect(isTotalExecutionResult(result)).toBe(true); + const errors = result.errors; + expect(result.data).toBeUndefined(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe(expectedErrorMessage); + }, + ); + }); + + describe("Error is thrown by hooks during field resolution", () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -1074,7 +1335,7 @@ describe.each([ }), }, expectedErrorMessage: - "Unexpected error in beforeFieldResolve hook: Hook error", + "Unexpected error thrown by beforeFieldResolve hook: Hook error", }, { name: "beforeFieldResolve (string is thrown)", @@ -1090,7 +1351,7 @@ describe.each([ }), }, expectedErrorMessage: - 'Unexpected error in beforeFieldResolve hook: "Hook error"', + 'Unexpected error thrown by beforeFieldResolve hook: "Hook error"', }, { name: "afterFieldResolve (Error is thrown)", @@ -1106,7 +1367,7 @@ describe.each([ }), }, expectedErrorMessage: - "Unexpected error in afterFieldResolve hook: Hook error", + "Unexpected error thrown by afterFieldResolve hook: Hook error", }, { name: "afterFieldResolve (string is thrown)", @@ -1122,7 +1383,7 @@ describe.each([ }), }, expectedErrorMessage: - 'Unexpected error in afterFieldResolve hook: "Hook error"', + 'Unexpected error thrown by afterFieldResolve hook: "Hook error"', }, { name: "afterFieldComplete (Error is thrown)", @@ -1138,7 +1399,7 @@ describe.each([ }), }, expectedErrorMessage: - "Unexpected error in afterFieldComplete hook: Hook error", + "Unexpected error thrown by afterFieldComplete hook: Hook error", }, { name: "afterFieldComplete (string is thrown)", @@ -1154,8 +1415,188 @@ describe.each([ }), }, expectedErrorMessage: - 'Unexpected error in afterFieldComplete hook: "Hook error"', + 'Unexpected error thrown by afterFieldComplete hook: "Hook error"', + }, + ]; + + it.each(testCases)( + "$name", + async ({ document, hooks, expectedErrorMessage, variables }) => { + expect.assertions(5); + const parsedDocument = parse(document); + + const response = await drainExecution( + await execute( + parsedDocument, + resolvers as UserResolvers, + hooks, + variables, + ), + ); + const result = Array.isArray(response) ? response[0] : response; + + expect(isTotalExecutionResult(result)).toBe(true); + const errors = result.errors; + + expect(result.data.film).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe(expectedErrorMessage); }, + ); + }); + + describe("Error is returned by hooks during field resolution", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases: Array = [ + { + name: "beforeFieldResolve (Error is returned)", + document: ` + { + film(id: 1) { + title + } + }`, + hooks: { + beforeFieldResolve: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error returned from beforeFieldResolve hook: Hook error", + }, + { + name: "afterFieldResolve (Error is returned)", + document: ` + { + film(id: 1) { + title + } + }`, + hooks: { + afterFieldResolve: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error returned from afterFieldResolve hook: Hook error", + }, + { + name: "afterFieldComplete (Error is returned)", + document: ` + { + film(id: 1) { + title + } + }`, + hooks: { + afterFieldComplete: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), + }, + expectedErrorMessage: + "Unexpected error returned from afterFieldComplete hook: Hook error", + }, + ]; + + it.each(testCases)( + "$name", + async ({ document, hooks, expectedErrorMessage, variables }) => { + expect.assertions(5); + const parsedDocument = parse(document); + + const response = await drainExecution( + await execute( + parsedDocument, + resolvers as UserResolvers, + hooks, + variables, + ), + ); + const result = Array.isArray(response) ? response[0] : response; + expect(isTotalExecutionResult(result)).toBe(true); + const errors = result.errors; + expect(result.data.film).not.toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe(expectedErrorMessage); + }, + ); + }); + + describe("Error in afterBuildResponse", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("afterBuildResponse (Error is thrown)", async () => { + expect.assertions(5); + + const response = await drainExecution( + await execute( + parse(`query GetFilm{ + film(id: 1) { + title + } + }`), + resolvers as UserResolvers, + { + afterBuildResponse: jest.fn().mockImplementation(() => { + throw new Error("Hook error"); + }), + }, + ), + ); + const result = Array.isArray(response) ? response[0] : response; + expect(isTotalExecutionResult(result)).toBe(true); + const errors = result.errors; + expect(result.data).toBeUndefined(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe( + "Unexpected error thrown by afterBuildResponse hook (operation: GetFilm): Hook error", + ); + }); + + test("afterBuildResponse (Error is returned)", async () => { + expect.assertions(5); + + const response = await drainExecution( + await execute( + parse(`{ + film(id: 1) { + title + } + }`), + resolvers as UserResolvers, + { + afterBuildResponse: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), + }, + ), + ); + const result = Array.isArray(response) ? response[0] : response; + expect(isTotalExecutionResult(result)).toBe(true); + const errors = result.errors; + expect(result.data).toBeDefined(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe( + "Unexpected error returned from afterBuildResponse hook (operation: unknown): Hook error", + ); + }); + }); + + describe("Error thrown in the BEFORE OPERATION hook breaks execution", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases: Array = [ { name: "beforeOperationExecute (Error is thrown)", document: ` @@ -1170,7 +1611,7 @@ describe.each([ }), }, expectedErrorMessage: - "Unexpected error in beforeOperationExecute hook: Hook error", + "Unexpected error thrown by beforeOperationExecute hook (operation: unknown): Hook error", }, { name: "beforeOperationExecute (string is thrown)", @@ -1186,10 +1627,45 @@ describe.each([ }), }, expectedErrorMessage: - 'Unexpected error in beforeOperationExecute hook: "Hook error"', + 'Unexpected error thrown by beforeOperationExecute hook (operation: unknown): "Hook error"', + }, + ]; + + it.each(testCases)( + "$name", + async ({ document, hooks, expectedErrorMessage, variables }) => { + expect.assertions(5); + const parsedDocument = parse(document); + + const response = await drainExecution( + await execute( + parsedDocument, + resolvers as UserResolvers, + hooks, + variables, + ), + ); + + const result = Array.isArray(response) ? response[0] : response; + expect(isTotalExecutionResult(result)).toBe(true); + const errors = result.errors; + + expect(result.data).toBeNull(); + expect(errors).toBeDefined(); + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe(expectedErrorMessage); }, + ); + }); + + describe("Error returned in the hook doesn't break execution and is returned in response 'errors'", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases: Array = [ { - name: "afterBuildResponse (Error is thrown)", + name: "beforeFieldResolve (Error is thrown)", document: ` { film(id: 1) { @@ -1197,15 +1673,15 @@ describe.each([ } }`, hooks: { - afterBuildResponse: jest.fn().mockImplementation(() => { - throw new Error("Hook error"); + beforeFieldResolve: jest.fn().mockImplementation(() => { + return new Error("Hook error"); }), }, expectedErrorMessage: - "Unexpected error in afterBuildResponse hook: Hook error", + "Unexpected error returned from beforeFieldResolve hook: Hook error", }, { - name: "afterBuildResponse (string is thrown)", + name: "beforeOperationExecute (Error is thrown)", document: ` { film(id: 1) { @@ -1213,53 +1689,47 @@ describe.each([ } }`, hooks: { - afterBuildResponse: jest.fn().mockImplementation(() => { - throw "Hook error"; + beforeOperationExecute: jest.fn().mockImplementation(() => { + return new Error("Hook error"); }), }, expectedErrorMessage: - 'Unexpected error in afterBuildResponse hook: "Hook error"', + "Unexpected error returned from beforeOperationExecute hook (operation: unknown): Hook error", }, { - name: "beforeSubscriptionEventEmit (Error is thrown)", - document: `subscription EmitPersons($limit: Int!) + name: "afterFieldResolve (Error is thrown)", + document: ` { - emitPersons(limit: $limit) { - name + film(id: 1) { + title } }`, - variables: { - limit: 1, - }, hooks: { - beforeSubscriptionEventEmit: jest.fn().mockImplementation(() => { - throw new Error("Hook error"); + afterFieldResolve: jest.fn().mockImplementation(() => { + return new Error("Hook error"); }), }, expectedErrorMessage: - "Unexpected error in beforeSubscriptionEventEmit hook: Hook error", + "Unexpected error returned from afterFieldResolve hook: Hook error", }, { - name: "beforeSubscriptionEventEmit (string is thrown)", - document: `subscription EmitPersons($limit: Int!) + name: "afterFieldComplete (Error is thrown)", + document: ` { - emitPersons(limit: $limit) { - name + film(id: 1) { + title } }`, - variables: { - limit: 1, - }, hooks: { - beforeSubscriptionEventEmit: jest.fn().mockImplementation(() => { - throw "Hook error"; + afterFieldComplete: jest.fn().mockImplementation(() => { + return new Error("Hook error"); }), }, expectedErrorMessage: - 'Unexpected error in beforeSubscriptionEventEmit hook: "Hook error"', + "Unexpected error returned from afterFieldComplete hook: Hook error", }, { - name: "async beforeSubscriptionEventEmit (Error is thrown)", + name: "beforeSubscriptionEventEmit (Error is thrown)", document: `subscription EmitPersons($limit: Int!) { emitPersons(limit: $limit) { @@ -1270,17 +1740,15 @@ describe.each([ limit: 1, }, hooks: { - beforeSubscriptionEventEmit: jest - .fn() - .mockImplementation(async () => { - throw new Error("Hook error"); - }), + beforeSubscriptionEventEmit: jest.fn().mockImplementation(() => { + return new Error("Hook error"); + }), }, expectedErrorMessage: - "Unexpected error in beforeSubscriptionEventEmit hook: Hook error", + "Unexpected error returned from beforeSubscriptionEventEmit hook (operation: EmitPersons): Hook error", }, { - name: "async beforeSubscriptionEventEmit (string is thrown)", + name: "async beforeSubscriptionEventEmit (Error is thrown)", document: `subscription EmitPersons($limit: Int!) { emitPersons(limit: $limit) { @@ -1294,11 +1762,11 @@ describe.each([ beforeSubscriptionEventEmit: jest .fn() .mockImplementation(async () => { - throw "Hook error"; + return new Error("Hook error"); }), }, expectedErrorMessage: - 'Unexpected error in beforeSubscriptionEventEmit hook: "Hook error"', + "Unexpected error returned from beforeSubscriptionEventEmit hook (operation: EmitPersons): Hook error", }, ]; diff --git a/packages/supermassive/src/executeWithoutSchema.ts b/packages/supermassive/src/executeWithoutSchema.ts index c5807b3dd..e7a89c178 100644 --- a/packages/supermassive/src/executeWithoutSchema.ts +++ b/packages/supermassive/src/executeWithoutSchema.ts @@ -281,13 +281,22 @@ function executeOperationWithBeforeHook( exeContext: ExecutionContext, ): PromiseOrValue { const hooks = exeContext.fieldExecutionHooks; - let hook: Promise | void | undefined; + let hookResultPromise; if (hooks?.beforeOperationExecute) { - hook = invokeBeforeOperationExecuteHook(exeContext); + hookResultPromise = invokeBeforeOperationExecuteHook(exeContext); } - if (isPromise(hook)) { - return hook.then(() => executeOperation(exeContext)); + if (hookResultPromise instanceof GraphQLError) { + return buildResponse(exeContext, null); + } + + if (isPromise(hookResultPromise)) { + return hookResultPromise.then((hookResult) => { + if (hookResult instanceof GraphQLError) { + return buildResponse(exeContext, null); + } + return executeOperation(exeContext); + }); } return executeOperation(exeContext); @@ -401,10 +410,16 @@ function buildResponse( }; } else { if (hooks?.afterBuildResponse) { - invokeAfterBuildResponseHook(exeContext, initialResult); + const hookResult = invokeAfterBuildResponseHook( + exeContext, + initialResult, + ); if (exeContext.errors.length > (initialResult.errors?.length ?? 0)) { initialResult.errors = exeContext.errors; } + if (hookResult instanceof GraphQLError) { + return { errors: initialResult.errors }; + } } return initialResult; } @@ -640,6 +655,26 @@ function createSourceEventStream( } } +function afterFieldSubscribeHandle( + resolved: unknown, + isDefaultResolverUsed: boolean, + exeContext: ExecutionContext, + info: ResolveInfo, + hookContext: unknown | undefined, + afterFieldSubscribe: boolean, + error?: Error, +) { + if (!isDefaultResolverUsed && afterFieldSubscribe) { + hookContext = invokeAfterFieldSubscribeHook( + info, + exeContext, + hookContext, + resolved, + error, + ); + } +} + function executeSubscriptionImpl( exeContext: ExecutionContext, ): PromiseOrValue> { @@ -693,6 +728,12 @@ function executeSubscriptionImpl( // Implements the "ResolveFieldEventStream" algorithm from GraphQL specification. // It differs from "ResolveFieldValue" due to providing a different `resolveFn`. + let result: unknown; + + if (!isDefaultResolverUsed && hooks?.beforeFieldSubscribe) { + hookContext = invokeBeforeFieldSubscribeHook(info, exeContext); + } + // Build a JS object of arguments from the field.arguments AST, using the // variables scope to fulfill any variable references. const args = getArgumentValues(exeContext, fieldDef, fieldGroup[0]); @@ -702,60 +743,63 @@ function executeSubscriptionImpl( // used to represent an authenticated user, or request-specific caches. const contextValue = exeContext.contextValue; - if (!isDefaultResolverUsed && hooks?.beforeFieldResolve) { - hookContext = invokeBeforeFieldResolveHook(info, exeContext); + if (hookContext) { + if (isPromise(hookContext)) { + result = hookContext.then((context) => { + hookContext = context; + + return resolveFn(rootValue, args, contextValue, info); + }); + } } // Call the `subscribe()` resolver or the default resolver to produce an // AsyncIterable yielding raw payloads. - const result = isPromise(hookContext) - ? hookContext.then((context) => { - hookContext = context; - return resolveFn(rootValue, args, contextValue, info); - }) - : resolveFn(rootValue, args, contextValue, info); + if (result === undefined) { + result = resolveFn(rootValue, args, contextValue, info); + } if (isPromise(result)) { - return result.then(assertEventStream).then( - (resolved) => { - if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { - hookContext = invokeAfterFieldResolveHook( - info, - exeContext, - hookContext, - resolved, - ); - } - return resolved; - }, - (error) => { - if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { - hookContext = invokeAfterFieldResolveHook( - info, - exeContext, - hookContext, - undefined, - error, - ); - } + return result + .then(assertEventStream, (error) => { + afterFieldSubscribeHandle( + undefined, + isDefaultResolverUsed, + exeContext, + info, + hookContext, + !!hooks?.afterFieldSubscribe, + error, + ); throw locatedError(error, fieldGroup, pathToArray(path)); - }, - ); + }) + .then((resolved) => { + afterFieldSubscribeHandle( + resolved, + isDefaultResolverUsed, + exeContext, + info, + hookContext, + !!hooks?.afterFieldSubscribe, + ); + + return resolved; + }); } const stream = assertEventStream(result); - if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { - hookContext = invokeAfterFieldResolveHook( - info, - exeContext, - hookContext, - stream, - ); - } + afterFieldSubscribeHandle( + stream, + isDefaultResolverUsed, + exeContext, + info, + hookContext, + !!hooks?.afterFieldSubscribe, + ); return stream; } catch (error) { - if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { - hookContext = invokeAfterFieldResolveHook( + if (!isDefaultResolverUsed && hooks?.afterFieldSubscribe) { + invokeAfterFieldSubscribeHook( info, exeContext, hookContext, @@ -763,6 +807,7 @@ function executeSubscriptionImpl( error, ); } + throw locatedError(error, fieldGroup, pathToArray(path)); } } @@ -823,27 +868,34 @@ function mapResultOrEventStreamOrPromise( payload, ); const hooks = exeContext?.fieldExecutionHooks; - let beforeExecuteFieldsHook: void | Promise | undefined; + let beforeExecuteSubscriptionEvenEmitHook; + if (hooks?.beforeSubscriptionEventEmit) { - beforeExecuteFieldsHook = invokeBeforeSubscriptionEventEmitHook( - perEventContext, - payload, - ); + beforeExecuteSubscriptionEvenEmitHook = + invokeBeforeSubscriptionEventEmitHook(perEventContext, payload); + + if (beforeExecuteSubscriptionEvenEmitHook instanceof GraphQLError) { + return buildResponse(perEventContext, null) as TotalExecutionResult; + } } try { - const data = isPromise(beforeExecuteFieldsHook) - ? beforeExecuteFieldsHook.then(() => - executeFields( - exeContext, + const data = isPromise(beforeExecuteSubscriptionEvenEmitHook) + ? beforeExecuteSubscriptionEvenEmitHook.then((context) => { + if (context instanceof GraphQLError) { + return null; + } + + return executeFields( + perEventContext, parentTypeName, payload, path, groupedFieldSet, undefined, - ), - ) + ); + }) : executeFields( - exeContext, + perEventContext, parentTypeName, payload, path, @@ -961,17 +1013,29 @@ function resolveAndCompleteField( hookContext = invokeBeforeFieldResolveHook(info, exeContext); } - const result = isPromise(hookContext) - ? hookContext.then((context) => { - hookContext = context; - return resolveFn(source, args, contextValue, info); - }) - : resolveFn(source, args, contextValue, info); + let result: unknown; + + if (hookContext instanceof GraphQLError) { + result = null; + } else if (isPromise(hookContext)) { + result = hookContext.then((context) => { + hookContext = context; + + if (hookContext instanceof GraphQLError) { + return null; + } + + return resolveFn(source, args, contextValue, info); + }); + } else { + result = resolveFn(source, args, contextValue, info); + } + let completed; if (isPromise(result)) { - completed = result.then( - (resolved) => { + completed = result + .then((resolved) => { if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { hookContext = invokeAfterFieldResolveHook( info, @@ -979,33 +1043,39 @@ function resolveAndCompleteField( hookContext, resolved, ); + return hookContext instanceof GraphQLError ? null : resolved; } - return completeValue( - exeContext, - returnTypeRef, - fieldGroup, - info, - path, - resolved, - incrementalDataRecord, - ); - }, - (rawError) => { - // That's where afterResolve hook can only be called - // in the case of async resolver promise rejection. - if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { - hookContext = invokeAfterFieldResolveHook( - info, + + return resolved; + }) + .then( + (resolved) => { + return completeValue( exeContext, - hookContext, - undefined, - rawError, + returnTypeRef, + fieldGroup, + info, + path, + hookContext instanceof GraphQLError ? null : resolved, + incrementalDataRecord, ); - } - // Error will be handled on field completion - throw rawError; - }, - ); + }, + (rawError) => { + // That's where afterResolve hook can only be called + // in the case of async resolver promise rejection. + if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { + hookContext = invokeAfterFieldResolveHook( + info, + exeContext, + hookContext, + undefined, + rawError, + ); + } + // Error will be handled on field completion + throw rawError; + }, + ); } else { if (!isDefaultResolverUsed && hooks?.afterFieldResolve) { hookContext = invokeAfterFieldResolveHook( @@ -1014,7 +1084,9 @@ function resolveAndCompleteField( hookContext, result, ); + result = hookContext instanceof GraphQLError ? null : result; } + completed = completeValue( exeContext, returnTypeRef, @@ -1032,19 +1104,23 @@ function resolveAndCompleteField( return completed.then( (resolved) => { if (!isDefaultResolverUsed && hooks?.afterFieldComplete) { - invokeAfterFieldCompleteHook( + hookContext = invokeAfterFieldCompleteHook( info, exeContext, hookContext, resolved, ); + + return hookContext instanceof GraphQLError ? null : resolved; } + return resolved; }, (rawError) => { const error = locatedError(rawError, fieldGroup, pathToArray(path)); + if (!isDefaultResolverUsed && hooks?.afterFieldComplete) { - invokeAfterFieldCompleteHook( + hookContext = invokeAfterFieldCompleteHook( info, exeContext, hookContext, @@ -1052,6 +1128,7 @@ function resolveAndCompleteField( error, ); } + handleFieldError( rawError, exeContext, @@ -1064,8 +1141,16 @@ function resolveAndCompleteField( }, ); } + if (!isDefaultResolverUsed && hooks?.afterFieldComplete) { - invokeAfterFieldCompleteHook(info, exeContext, hookContext, completed); + hookContext = invokeAfterFieldCompleteHook( + info, + exeContext, + hookContext, + completed, + ); + + return hookContext instanceof GraphQLError ? null : completed; } return completed; } catch (rawError) { @@ -1098,6 +1183,7 @@ function resolveAndCompleteField( error, ); } + handleFieldError( rawError, exeContext, @@ -1659,6 +1745,7 @@ function ensureValidRuntimeType( fieldGroup, ); } + if (typeof runtimeTypeName !== "string") { throw locatedError( `Abstract type "${returnTypeName}" must resolve to an Object type at runtime for field "${info.returnTypeName}.${info.fieldName}" with ` + @@ -1817,6 +1904,47 @@ function collectAndExecuteSubfields( return subFields; } +function invokeBeforeFieldSubscribeHook( + resolveInfo: ResolveInfo, + exeContext: ExecutionContext, +) { + const hook = exeContext.fieldExecutionHooks?.beforeFieldSubscribe; + if (!hook) { + return; + } + + return executeSafe( + () => + hook({ + resolveInfo, + context: exeContext.contextValue, + }), + (result, rawError) => { + if (rawError) { + const error = toGraphQLError( + rawError, + resolveInfo.path, + "Unexpected error thrown by beforeFieldSubscribe hook", + ); + exeContext.errors.push(error); + + throw error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + resolveInfo.path, + "Unexpected error returned from beforeFieldSubscribe hook", + ); + exeContext.errors.push(error); + + throw error; + } + + return result; + }, + ); +} + function invokeBeforeFieldResolveHook( resolveInfo: ResolveInfo, exeContext: ExecutionContext, @@ -1825,21 +1953,33 @@ function invokeBeforeFieldResolveHook( if (!hook) { return; } + return executeSafe( () => hook({ resolveInfo, context: exeContext.contextValue, }), - (_, rawError) => { + (result, rawError) => { if (rawError) { const error = toGraphQLError( rawError, resolveInfo.path, - "Unexpected error in beforeFieldResolve hook", + "Unexpected error thrown by beforeFieldResolve hook", + ); + exeContext.errors.push(error); + + return error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + resolveInfo.path, + "Unexpected error returned from beforeFieldResolve hook", ); exeContext.errors.push(error); } + + return result; }, ); } @@ -1864,15 +2004,72 @@ function invokeAfterFieldResolveHook( result, error, }), - (_, rawError) => { + (result, rawError) => { + if (rawError) { + const error = toGraphQLError( + rawError, + resolveInfo.path, + "Unexpected error thrown by afterFieldResolve hook", + ); + exeContext.errors.push(error); + + return error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + resolveInfo.path, + "Unexpected error returned from afterFieldResolve hook", + ); + exeContext.errors.push(error); + } + + return result; + }, + ); +} + +function invokeAfterFieldSubscribeHook( + resolveInfo: ResolveInfo, + exeContext: ExecutionContext, + hookContext: unknown, + result?: unknown, + error?: unknown, +) { + const hook = exeContext.fieldExecutionHooks?.afterFieldSubscribe; + if (!hook) { + return; + } + return executeSafe( + () => + hook({ + resolveInfo, + context: exeContext.contextValue, + hookContext, + result, + error, + }), + (result, rawError) => { if (rawError) { const error = toGraphQLError( rawError, resolveInfo.path, - "Unexpected error in afterFieldResolve hook", + "Unexpected error thrown by afterFieldSubscribe hook", + ); + exeContext.errors.push(error); + + throw error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + resolveInfo.path, + "Unexpected error returned from afterFieldSubscribe hook", ); exeContext.errors.push(error); + + throw error; } + + return result; }, ); } @@ -1883,12 +2080,12 @@ function invokeAfterFieldCompleteHook( hookContext: unknown, result?: unknown, error?: unknown, -): void { +) { const hook = exeContext.fieldExecutionHooks?.afterFieldComplete; if (!hook) { return; } - executeSafe( + return executeSafe( () => hook({ resolveInfo, @@ -1897,15 +2094,26 @@ function invokeAfterFieldCompleteHook( result, error, }), - (_, rawError) => { + (result, rawError) => { if (rawError) { const error = toGraphQLError( rawError, resolveInfo.path, - "Unexpected error in afterFieldComplete hook", + "Unexpected error thrown by afterFieldComplete hook", + ); + exeContext.errors.push(error); + + return error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + resolveInfo.path, + "Unexpected error returned from afterFieldComplete hook", ); exeContext.errors.push(error); } + + return result; }, ); } @@ -1921,15 +2129,29 @@ function invokeBeforeOperationExecuteHook(exeContext: ExecutionContext) { context: exeContext.contextValue, operation: exeContext.operation, }), - (_, rawError) => { + (result, rawError) => { + const operationName = exeContext.operation.name?.value ?? "unknown"; if (rawError) { const error = toGraphQLError( rawError, undefined, - "Unexpected error in beforeOperationExecute hook", + `Unexpected error thrown by beforeOperationExecute hook (operation: ${operationName})`, + ); + exeContext.errors.push(error); + + return error; + } + + if (result instanceof Error) { + const error = toGraphQLError( + result, + undefined, + `Unexpected error returned from beforeOperationExecute hook (operation: ${operationName})`, ); exeContext.errors.push(error); } + + return result; }, ); } @@ -1949,15 +2171,27 @@ function invokeBeforeSubscriptionEventEmitHook( operation: exeContext.operation, eventPayload, }), - (_, rawError) => { + (result, rawError) => { + const operationName = exeContext.operation.name?.value ?? "unknown"; if (rawError) { const error = toGraphQLError( rawError, undefined, - "Unexpected error in beforeSubscriptionEventEmit hook", + `Unexpected error thrown by beforeSubscriptionEventEmit hook (operation: ${operationName})`, + ); + exeContext.errors.push(error); + + return error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + undefined, + `Unexpected error returned from beforeSubscriptionEventEmit hook (operation: ${operationName})`, ); exeContext.errors.push(error); } + + return result; }, ); } @@ -1977,14 +2211,25 @@ function invokeAfterBuildResponseHook( operation: exeContext.operation, result, }), - (_, rawError) => { + (result, rawError) => { + const operationName = exeContext.operation.name?.value ?? "unknown"; if (rawError) { const error = toGraphQLError( rawError, undefined, - "Unexpected error in afterBuildResponse hook", + `Unexpected error thrown by afterBuildResponse hook (operation: ${operationName})`, ); exeContext.errors.push(error); + + return error; + } else if (result instanceof Error) { + const error = toGraphQLError( + result, + undefined, + `Unexpected error returned from afterBuildResponse hook (operation: ${operationName})`, + ); + + exeContext.errors.push(error); } }, ); @@ -1992,7 +2237,7 @@ function invokeAfterBuildResponseHook( function executeSafe( execute: () => T | Promise, - onComplete: (result: T | undefined, error: unknown) => void, + onComplete: (result: T | undefined, error: unknown) => T | Promise, ): T | Promise { let error: unknown; let result: T | Promise | undefined; @@ -2000,24 +2245,18 @@ function executeSafe( result = execute(); } catch (e) { error = e; - } finally { - if (!isPromise(result)) { - onComplete(result, error); - } } if (!isPromise(result)) { - return result as T; + return onComplete(result, error); } return result .then((hookResult) => { - onComplete(hookResult, error); - return hookResult; + return onComplete(hookResult, error); }) .catch((e) => { - onComplete(undefined, e); - return undefined; + return onComplete(undefined, e); }) as Promise; } diff --git a/packages/supermassive/src/hooks/types.ts b/packages/supermassive/src/hooks/types.ts index b41165c23..aafdd7071 100644 --- a/packages/supermassive/src/hooks/types.ts +++ b/packages/supermassive/src/hooks/types.ts @@ -1,5 +1,6 @@ import type { OperationDefinitionNode } from "graphql"; import type { ResolveInfo, TotalExecutionResult } from "../types"; +import { PromiseOrValue } from "../jsutils/PromiseOrValue"; interface BaseExecuteHookArgs { context: ResolveContext; @@ -21,6 +22,12 @@ export interface AfterFieldResolveHookArgs error?: unknown; } +export interface AfterFieldSubscribeHookArgs + extends PostExecuteFieldHookArgs { + result?: unknown; + error?: unknown; +} + export interface AfterFieldCompleteHookArgs extends PostExecuteFieldHookArgs { result?: unknown; @@ -47,8 +54,20 @@ export interface BeforeFieldResolveHook< BeforeHookContext = unknown, > { (args: BaseExecuteFieldHookArgs): - | Promise - | BeforeHookContext; + | PromiseOrValue + | PromiseOrValue; +} + +/** + * Represents a user in the system. + */ +export interface BeforeFieldSubscribe< + ResolveContext = unknown, + BeforeHookContext = unknown, +> { + (args: BaseExecuteFieldHookArgs): + | PromiseOrValue + | PromiseOrValue; } export interface AfterFieldResolveHook< @@ -56,30 +75,44 @@ export interface AfterFieldResolveHook< BeforeHookContext = unknown, AfterHookContext = BeforeHookContext, > { - ( - args: AfterFieldResolveHookArgs, - ): AfterHookContext; + (args: AfterFieldResolveHookArgs): + | AfterHookContext + | Error; +} + +export interface AfterFieldSubscribe< + ResolveContext = unknown, + BeforeHookContext = unknown, + AfterHookContext = BeforeHookContext, +> { + (args: AfterFieldSubscribeHookArgs): + | AfterHookContext + | Error; } export interface AfterFieldCompleteHook< ResolveContext = unknown, AfterHookContext = unknown, > { - (args: AfterFieldCompleteHookArgs): void; + ( + args: AfterFieldCompleteHookArgs, + ): void | Error; } export interface AfterBuildResponseHook { - (args: AfterBuildResponseHookArgs): void; + (args: AfterBuildResponseHookArgs): void | Error; } export interface BeforeOperationExecuteHook { - (args: BaseExecuteOperationHookArgs): void | Promise; + (args: BaseExecuteOperationHookArgs): + | PromiseOrValue + | PromiseOrValue; } export interface BeforeSubscriptionEventEmitHook { - ( - args: BeforeSubscriptionEventEmitHookArgs, - ): void | Promise; + (args: BeforeSubscriptionEventEmitHookArgs): + | PromiseOrValue + | PromiseOrValue; } export interface ExecutionHooks< @@ -87,17 +120,83 @@ export interface ExecutionHooks< BeforeHookContext = unknown, AfterHookContext = BeforeHookContext, > { + /** + * Called before every operation. + * + * @hook + * @throws {Error} Stops execution and sets `data` to `null` and registers the error. + * @returns {Error} The error is registered and execution continues. + */ beforeOperationExecute?: BeforeOperationExecuteHook; + /** + * Called before every subscription event emit. + * + * @hook + * @throws {Error} Sets `data` to `null` and registers the error. + * @returns {Error} The error is registered and execution continues. + */ beforeSubscriptionEventEmit?: BeforeSubscriptionEventEmitHook; + /** + * Called before every field resolution. + * + * @hook + * @throws {Error} The field is not executed and is handled as if it has returned `null`. + * @returns {Error} The error is registered and execution continues. + */ beforeFieldResolve?: BeforeFieldResolveHook< ResolveContext, BeforeHookContext >; + /** + * Called before subscription event stream creation. + * + * @hook + * @throws {Error} Stops execution and sets `data` to `undefined` and error is returned in `errors` field. + * @returns {Error} Stops execution and sets `data` to `undefined` and error is returned in `errors` field. + */ + beforeFieldSubscribe?: BeforeFieldSubscribe< + ResolveContext, + BeforeHookContext + >; + /** + * Called after every field resolution. + * + * @hook + * @throws {Error} The field is set to `null` and the error is registered. + * @returns {Error} The error is registered and execution continues. + */ afterFieldResolve?: AfterFieldResolveHook< ResolveContext, BeforeHookContext, AfterHookContext >; + + /** + * Called after subscription event stream creation. + * + * @hook + * @throws {Error} Stops execution and sets `data` to `undefined` and error is returned in `errors` field. + * @returns {Error} Stops execution and sets `data` to `undefined` and error is returned in `errors` field. + */ + afterFieldSubscribe?: AfterFieldSubscribe< + ResolveContext, + BeforeHookContext, + AfterHookContext + >; + /** + * Called when field value is completed. + * + * @hook + * @throws {Error} The field is set to `null` and the error is registered. + * @returns {Error} The error is registered and execution continues. + */ afterFieldComplete?: AfterFieldCompleteHook; + /** + * Called after the response is built. + * + * @hook + * @throws {Error} Returns no data property, only errors. + * @returns {Error} The error is registered and execution continues. + */ afterBuildResponse?: AfterBuildResponseHook; } diff --git a/packages/webpack-loader/CHANGELOG.json b/packages/webpack-loader/CHANGELOG.json index ff264c761..3d8097515 100644 --- a/packages/webpack-loader/CHANGELOG.json +++ b/packages/webpack-loader/CHANGELOG.json @@ -1,6 +1,21 @@ { "name": "@graphitation/webpack-loader", "entries": [ + { + "date": "Wed, 25 Dec 2024 21:18:59 GMT", + "version": "1.0.17", + "tag": "@graphitation/webpack-loader_v1.0.17", + "comments": { + "none": [ + { + "author": "beachball", + "package": "@graphitation/webpack-loader", + "comment": "Bump @graphitation/supermassive to v3.8.0-alpha.4", + "commit": "not available" + } + ] + } + }, { "date": "Mon, 16 Dec 2024 15:39:40 GMT", "version": "1.0.17", diff --git a/packages/webpack-loader/package.json b/packages/webpack-loader/package.json index adde05a68..b6269233c 100644 --- a/packages/webpack-loader/package.json +++ b/packages/webpack-loader/package.json @@ -17,7 +17,7 @@ }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0", - "@graphitation/supermassive": "^3.7.2" + "@graphitation/supermassive": "^3.8.0-alpha.4" }, "dependencies": { "@graphql-tools/optimize": "^1.1.1",