diff --git a/.changeset/floppy-women-poke.md b/.changeset/floppy-women-poke.md new file mode 100644 index 00000000000..588e4559596 --- /dev/null +++ b/.changeset/floppy-women-poke.md @@ -0,0 +1,37 @@ +--- +'@graphql-tools/executor': minor +--- + +Add optional schema coordinate in error extensions. This extension allows to precisely identify the +source of the error by automated tools like tracing or monitoring. + +This new feature is opt-in, you have to enable it using `schemaCoordinateInErrors` executor option. + +To avoid leaking schema information to the client, the extension key is a `Symbol` (which is not serializable). +To forward it to the client, copy it to a custom extension with a serializable key. + +```ts +import { getSchemaCoordinate } from '@graphql-tools/utils' +import { normalizedExecutor } from '@graphql-tools/executor' +import { parse } from 'graphql' +import schema from './schema' + +// You can also use `Symbol.for('graphql.error.schemaCoordinate')` to get the symbol if you don't +// want to depend on `@graphql-tools/utils` + +const result = await normalizedExecutor({ + schema, + document: parse(gql`...`), + schemaCoordinateInErrors: true, // enable adding schema coordinate to graphql errors +}); + +if (result.errors) { + for (const error of result.errors) { + console.log( + 'Error in resolver ', + getSchemaCoordinate(error), ':', + error.message + ) + } +} +``` diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 154ec3ac119..ab4997e67a0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,6 +19,7 @@ jobs: permissions: contents: read id-token: write + pull-requests: write uses: the-guild-org/shared-config/.github/workflows/release-snapshot.yml@v1 if: ${{ github.event.pull_request.title != 'Upcoming Release Changes' }} with: diff --git a/package-lock.json b/package-lock.json index 632d06c75d1..2fb8d3d570a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,6 @@ "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.0.8.tgz", "integrity": "sha512-oRnIcQjg8q22Fj1GaBUp+udhJswUtTMPM10v/8qb6xccluUpnYr9hPMiZkd+rcJKfg56OAsRJpvGKr18jkXcuw==", "license": "MIT", - "peer": true, "workspaces": [ "dist", "codegen", @@ -190,8 +189,7 @@ "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", "integrity": "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -221,7 +219,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2591,7 +2588,6 @@ "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.3.2.tgz", "integrity": "sha512-06Mu7fmyKzk09P2i2kHpGfItqLLgCq7uO5/nX4fc/iHMplWPNuAx4iYR+WXUQoFHDnP6EUbceQNQ5iyeMz9f3g==", "license": "MIT", - "peer": true, "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", @@ -6666,7 +6662,6 @@ "resolved": "https://registry.npmjs.org/@theguild/tailwind-config/-/tailwind-config-0.6.4.tgz", "integrity": "sha512-7zscZk+L9x0Z8tMQGtVBC+1usAJ6Nz7hJYCmKzwXyMA4paXr/ghCeMArF9hkIkBp/GH+gKpR+ERaHwmqmvqfVg==", "license": "MIT", - "peer": true, "dependencies": { "@tailwindcss/container-queries": "^0.1.1" }, @@ -7101,7 +7096,6 @@ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -7323,7 +7317,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7488,7 +7481,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -8401,7 +8393,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9517,7 +9508,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -9823,7 +9813,6 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -10560,7 +10549,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -10982,7 +10970,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -11380,8 +11367,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -11898,7 +11884,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12106,7 +12091,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12175,7 +12159,6 @@ "integrity": "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", @@ -12239,7 +12222,6 @@ "integrity": "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, @@ -13601,7 +13583,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz", "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -13738,7 +13719,6 @@ "integrity": "sha512-/R2dJea7WgvNlXRU4F8iFwWd95Qn1mN+R+yC8XBs1wKjUzr0Pvv8cGYtt6UUcVHw5CiDEtu7iQY5oOe3sDAWCQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@envelop/core": "^5.3.0", "@envelop/instrumentation": "^1.0.0", @@ -15235,7 +15215,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -16010,7 +15989,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -19065,7 +19043,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", "integrity": "sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.6", "@swc/helpers": "0.5.15", @@ -19297,7 +19274,6 @@ "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.29.2.tgz", "integrity": "sha512-njXuliz/cP+67jU2hukkxCNuH1yUi4QfdZZY+sMr5PPrIyXSu5iTb/qYC4BiWWB0vZ+7TbdvYUCeL23zpwCfbg==", "license": "MIT", - "peer": true, "dependencies": { "@shikijs/core": "1.29.2", "@shikijs/engine-javascript": "1.29.2", @@ -20257,7 +20233,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20272,7 +20247,6 @@ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz", "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==", "license": "MIT", - "peer": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -20315,7 +20289,6 @@ "resolved": "https://registry.npmjs.org/postcss-lightningcss/-/postcss-lightningcss-1.0.2.tgz", "integrity": "sha512-jI9gBe/2/ZEDYGDAHEHKbGLA3Dfn2uUTUCVsP3mDxpvmX6ifDdFqYB00GNRdny676gTcfo7XUCQoc4OYz20/TA==", "license": "MIT", - "peer": true, "dependencies": { "browserslist": "^4.19.1", "lightningcss": "^1.22.0" @@ -20429,7 +20402,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -20852,7 +20824,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20871,7 +20842,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -21848,7 +21818,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -21967,7 +21936,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -23012,7 +22980,6 @@ "integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -23148,7 +23115,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -23758,7 +23724,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -23934,7 +23899,6 @@ "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "lunr": "^2.3.9", "marked": "^4.3.0", @@ -24008,7 +23972,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24104,7 +24067,6 @@ "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", @@ -24680,7 +24642,6 @@ "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -25002,7 +24963,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -25664,7 +25624,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -25830,7 +25789,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/executor/src/execution/execute.ts b/packages/executor/src/execution/execute.ts index b05d5e60900..2a10e0c1d28 100644 --- a/packages/executor/src/execution/execute.ts +++ b/packages/executor/src/execution/execute.ts @@ -21,7 +21,6 @@ import { isNonNullType, isObjectType, Kind, - locatedError, OperationDefinitionNode, SchemaMetaFieldDef, TypeMetaFieldDef, @@ -43,6 +42,7 @@ import { isIterableObject, isObjectLike, isPromise, + locatedError, mapAsyncIterator, Maybe, MaybePromise, @@ -127,6 +127,7 @@ export interface ExecutionContext { signal?: AbortSignal; onSignalAbort?(handler: () => void): void; signalPromise?: Promise; + schemaCoordinateInErrors?: boolean; } export interface FormattedExecutionResult< @@ -247,6 +248,7 @@ export interface ExecutionArgs { typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; signal?: AbortSignal; + schemaCoordinateInErrors?: boolean; } /** @@ -419,6 +421,7 @@ export function buildExecutionContext { rawError = coerceError(rawError); - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); const handledError = handleFieldError(error, itemType, errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); return handledError; @@ -1214,7 +1248,12 @@ function completeListItemValue( completedResults.push(completedItem); } catch (rawError) { const coercedError = coerceError(rawError); - const error = locatedError(coercedError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + coercedError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); const handledError = handleFieldError(error, itemType, errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); completedResults.push(handledError); @@ -1789,13 +1828,23 @@ function executeSubscription(exeContext: ExecutionContext): MaybePromise assertEventStream(result, exeContext.signal, exeContext.onSignalAbort)) .then(undefined, error => { - throw locatedError(error, fieldNodes, pathToArray(path)); + throw locatedError( + error, + fieldNodes, + pathToArray(path), + exeContext.schemaCoordinateInErrors && info, + ); }); } return assertEventStream(result, exeContext.signal, exeContext.onSignalAbort); } catch (error) { - throw locatedError(error, fieldNodes, pathToArray(path)); + throw locatedError( + error, + fieldNodes, + pathToArray(path), + exeContext.schemaCoordinateInErrors && info, + ); } } @@ -1921,7 +1970,12 @@ function executeStreamField( // to take a second callback for the error case. completedItem = completedItem.then(undefined, rawError => { rawError = coerceError(rawError); - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); const handledError = handleFieldError(error, itemType, asyncPayloadRecord.errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); return handledError; @@ -1929,7 +1983,12 @@ function executeStreamField( } } catch (rawError) { const coercedError = coerceError(rawError); - const error = locatedError(coercedError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + coercedError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); completedItem = handleFieldError(error, itemType, asyncPayloadRecord.errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); } @@ -1977,7 +2036,12 @@ async function executeStreamIteratorItem( item = value; } catch (rawError) { const coercedError = coerceError(rawError); - const error = locatedError(coercedError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + coercedError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); // don't continue if iterator throws return { done: true, value }; @@ -1996,7 +2060,12 @@ async function executeStreamIteratorItem( if (isPromise(completedItem)) { completedItem = completedItem.then(undefined, rawError => { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); const handledError = handleFieldError(error, itemType, asyncPayloadRecord.errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); return handledError; @@ -2004,7 +2073,12 @@ async function executeStreamIteratorItem( } return { done: false, value: completedItem }; } catch (rawError) { - const error = locatedError(rawError, fieldNodes, pathToArray(itemPath)); + const error = locatedError( + rawError, + fieldNodes, + pathToArray(itemPath), + exeContext.schemaCoordinateInErrors && info, + ); const value = handleFieldError(error, itemType, asyncPayloadRecord.errors); filterSubsequentPayloads(exeContext, itemPath, asyncPayloadRecord); return { done: false, value }; diff --git a/packages/executor/src/execution/normalizedExecutor.ts b/packages/executor/src/execution/normalizedExecutor.ts index 3658d2e5514..2cdda0e2a76 100644 --- a/packages/executor/src/execution/normalizedExecutor.ts +++ b/packages/executor/src/execution/normalizedExecutor.ts @@ -49,6 +49,7 @@ export const executorFromSchema = memoize1(function executorFromSchema( rootValue: request.rootValue, contextValue: request.context, signal: request.signal || request.info?.signal, + schemaCoordinateInErrors: request.schemaCoordinateInErrors, }); }; }); diff --git a/packages/utils/src/Interfaces.ts b/packages/utils/src/Interfaces.ts index 1b77834df33..8e77616795a 100644 --- a/packages/utils/src/Interfaces.ts +++ b/packages/utils/src/Interfaces.ts @@ -93,6 +93,13 @@ export interface ExecutionRequest< subgraphName?: string; info?: GraphQLResolveInfo; signal?: AbortSignal; + /** + * Enable/Disable the addition of field schema coordinate in GraphQL Errors extension + * + * Note: Schema Coordinate are exposed using Symbol.for('schemaCoordinate') so that it's not + * serialized. Exposing schema coordinate can ease the discovery of private schemas. + */ + schemaCoordinateInErrors?: boolean; } // graphql-js non-exported typings diff --git a/packages/utils/src/errors.ts b/packages/utils/src/errors.ts index 61b26a10baa..02b686accb5 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -1,4 +1,4 @@ -import { ASTNode, GraphQLError, Source, versionInfo } from 'graphql'; +import { locatedError as _locatedError, ASTNode, GraphQLError, Source, versionInfo } from 'graphql'; import { Maybe } from './types.js'; interface GraphQLErrorOptions { @@ -60,11 +60,39 @@ export function createGraphQLError(message: string, options?: GraphQLErrorOption ); } +type SchemaCoordinateInfo = { fieldName: string; parentType: { name: string } }; +export const ERROR_EXTENSION_SCHEMA_COORDINATE = Symbol.for('graphql.error.schemaCoordinate'); +function addSchemaCoordinateToError(error: GraphQLError, info: SchemaCoordinateInfo): void { + // @ts-expect-error extensions can't be Symbol in official GraphQL Error type + error.extensions[ERROR_EXTENSION_SCHEMA_COORDINATE] = `${info.parentType.name}.${info.fieldName}`; +} + +export function getSchemaCoordinate(error: GraphQLError): string | undefined { + // @ts-expect-error extensions can't be Symbol in official GraphQL Error type + return error.extensions[ERROR_EXTENSION_SCHEMA_COORDINATE]; +} + +export function locatedError( + rawError: unknown, + nodes: ASTNode | ReadonlyArray | undefined, + path: Maybe>, + info: SchemaCoordinateInfo | false | null | undefined, +) { + const error = _locatedError(rawError, nodes, path); + + if (info) { + addSchemaCoordinateToError(error, info); + } + + return error; +} + export function relocatedError( originalError: GraphQLError, path?: ReadonlyArray, + info?: SchemaCoordinateInfo | false | null | undefined, ): GraphQLError { - return createGraphQLError(originalError.message, { + const error = createGraphQLError(originalError.message, { nodes: originalError.nodes, source: originalError.source, positions: originalError.positions, @@ -72,4 +100,10 @@ export function relocatedError( originalError, extensions: originalError.extensions, }); + + if (info) { + addSchemaCoordinateToError(error, info); + } + + return error; } diff --git a/packages/utils/tests/createGraphQLError.test.ts b/packages/utils/tests/createGraphQLError.test.ts deleted file mode 100644 index e8581da03cf..00000000000 --- a/packages/utils/tests/createGraphQLError.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GraphQLError } from 'graphql'; -import { createGraphQLError } from '../src/errors'; - -it('should handle non Error originalError', () => { - const error = createGraphQLError('message', { - originalError: { - message: 'originalError', - extensions: { code: 'ORIGINAL_ERROR' }, - } as any, - }); - expect(error.originalError).toBeInstanceOf(GraphQLError); - expect(error).toMatchObject({ - message: 'message', - originalError: { - message: 'originalError', - extensions: { code: 'ORIGINAL_ERROR' }, - }, - }); -}); diff --git a/packages/utils/tests/errors.test.ts b/packages/utils/tests/errors.test.ts new file mode 100644 index 00000000000..b18ac05fd7e --- /dev/null +++ b/packages/utils/tests/errors.test.ts @@ -0,0 +1,72 @@ +import { ASTNode, GraphQLError, Kind } from 'graphql'; +import { + createGraphQLError, + ERROR_EXTENSION_SCHEMA_COORDINATE, + getSchemaCoordinate, + locatedError, + relocatedError, +} from '../src/errors'; + +describe('Errors', () => { + describe('relocatedError', () => { + it('should adjust the path of a GraphqlError', () => { + const originalError = createGraphQLError('test', { + path: ['test'], + extensions: { + [ERROR_EXTENSION_SCHEMA_COORDINATE]: 'Query.test', + }, + }); + const newError = relocatedError(originalError, ['test', 1, 'id'], { + fieldName: 'id', + parentType: { name: 'Test' }, + }); + expect(getSchemaCoordinate(newError)).toEqual('Test.id'); + }); + }); + + describe('locatedError', () => { + it('should add path, nodes and coordinate to error', () => { + const originalError = createGraphQLError('test'); + const nodes: ASTNode[] = [{ kind: Kind.DOCUMENT, definitions: [] }]; + const error = locatedError(originalError, nodes, ['test'], { + fieldName: 'test', + parentType: { name: 'Query' }, + }); + expect(error.nodes).toBe(nodes); + expect(error.path).toEqual(['test']); + expect(error.extensions).toEqual({ + [ERROR_EXTENSION_SCHEMA_COORDINATE]: 'Query.test', + }); + }); + }); + + describe('createGraphQLError', () => { + it('should handle non Error originalError', () => { + const error = createGraphQLError('message', { + originalError: { + message: 'originalError', + extensions: { code: 'ORIGINAL_ERROR' }, + } as any, + }); + expect(error.originalError).toBeInstanceOf(GraphQLError); + expect(error).toMatchObject({ + message: 'message', + originalError: { + message: 'originalError', + extensions: { code: 'ORIGINAL_ERROR' }, + }, + }); + }); + + it('should handle coordinate', () => { + const error = createGraphQLError('message', { + extensions: { + [ERROR_EXTENSION_SCHEMA_COORDINATE]: 'Query.test', + }, + }); + expect(error.extensions).toMatchObject({ + [ERROR_EXTENSION_SCHEMA_COORDINATE]: 'Query.test', + }); + }); + }); +}); diff --git a/packages/utils/tests/relocatedError.test.ts b/packages/utils/tests/relocatedError.test.ts deleted file mode 100644 index 383922a40f3..00000000000 --- a/packages/utils/tests/relocatedError.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createGraphQLError, relocatedError } from '../src/errors.js'; - -describe('Errors', () => { - describe('relocatedError', () => { - test('should adjust the path of a GraphqlError', () => { - const originalError = createGraphQLError('test', { - path: ['test'], - }); - const newError = relocatedError(originalError, ['test', 1]); - const expectedError = createGraphQLError('test', { - path: ['test', 1], - }); - expect(newError).toEqual(expectedError); - }); - }); -});