diff --git a/.changeset/@graphql-tools_apollo-engine-loader-7675-dependencies.md b/.changeset/@graphql-tools_apollo-engine-loader-7675-dependencies.md new file mode 100644 index 00000000000..8d966d9f8cb --- /dev/null +++ b/.changeset/@graphql-tools_apollo-engine-loader-7675-dependencies.md @@ -0,0 +1,5 @@ +--- +"@graphql-tools/apollo-engine-loader": patch +--- +dependencies updates: + - Updated dependency [`@whatwg-node/fetch@^0.10.13` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/0.10.13) (from `^0.10.11`, in `dependencies`) diff --git a/.changeset/@graphql-tools_github-loader-7675-dependencies.md b/.changeset/@graphql-tools_github-loader-7675-dependencies.md new file mode 100644 index 00000000000..99dba9c3c48 --- /dev/null +++ b/.changeset/@graphql-tools_github-loader-7675-dependencies.md @@ -0,0 +1,5 @@ +--- +"@graphql-tools/github-loader": patch +--- +dependencies updates: + - Updated dependency [`@whatwg-node/fetch@^0.10.13` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/0.10.13) (from `^0.10.11`, in `dependencies`) diff --git a/.changeset/@graphql-tools_url-loader-7675-dependencies.md b/.changeset/@graphql-tools_url-loader-7675-dependencies.md new file mode 100644 index 00000000000..32ee890c129 --- /dev/null +++ b/.changeset/@graphql-tools_url-loader-7675-dependencies.md @@ -0,0 +1,5 @@ +--- +"@graphql-tools/url-loader": patch +--- +dependencies updates: + - Updated dependency [`@whatwg-node/fetch@^0.10.13` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/0.10.13) (from `^0.10.11`, in `dependencies`) diff --git a/.changeset/floppy-women-poke.md b/.changeset/floppy-women-poke.md new file mode 100644 index 00000000000..65eb2d61dea --- /dev/null +++ b/.changeset/floppy-women-poke.md @@ -0,0 +1,33 @@ +--- +'@graphql-tools/executor': minor +'@graphql-tools/utils': 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 { parse } from 'graphql' +import { normalizedExecutor } from '@graphql-tools/executor' +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 ', error.coordinate, ':', 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 54cbeead642..208840ebd2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,7 +123,6 @@ "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.0.9.tgz", "integrity": "sha512-Lh2drMzFE9x5jVS8RKmlGL5SORkvpyUJIT+wTErxDUR2HpWePiBfhhcHHRSlZFiCR866ewCv4euTc4IDF0GWxw==", "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.4.0.tgz", "integrity": "sha512-/1fat63pySE8rw/dZZArEVytLD90JApY85deDJ0/34gm+yhQ3k70CloSUevxoOE4YCGveG3s9SJJfQeeB4NAtQ==", "license": "MIT", - "peer": true, "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", @@ -6703,7 +6699,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" }, @@ -7138,7 +7133,6 @@ "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -7360,7 +7354,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7525,7 +7518,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -8438,7 +8430,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" }, @@ -9554,7 +9545,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -9860,7 +9850,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", @@ -10597,7 +10586,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" } @@ -11019,7 +11007,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" } @@ -11410,8 +11397,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", @@ -11919,7 +11905,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12127,7 +12112,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12196,7 +12180,6 @@ "integrity": "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.5.0", "enhanced-resolve": "^5.17.1", @@ -12260,7 +12243,6 @@ "integrity": "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0" }, @@ -13622,7 +13604,6 @@ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -13759,7 +13740,6 @@ "integrity": "sha512-heaD8ejapeEZ8+8CxB6DbYzkvMfC4gHEXr1Gc2CQCXEb5PVaDcEnQfiThBNic1KLPpuZixqQdJJ0pjcEVc9H7g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@envelop/core": "^5.3.0", "@envelop/instrumentation": "^1.0.0", @@ -15255,7 +15235,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -16030,7 +16009,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" } @@ -19085,7 +19063,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", @@ -19317,7 +19294,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", @@ -20270,7 +20246,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -20285,7 +20260,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", @@ -20328,7 +20302,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" @@ -20442,7 +20415,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -20865,7 +20837,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20884,7 +20855,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" }, @@ -21861,7 +21831,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" } @@ -21980,7 +21949,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", @@ -23027,7 +22995,6 @@ "integrity": "sha512-HQoZArIewxQVNedseDsgMgnRSC4XOXczxXLF9rOJaPIJkg58INOPUiL8aEtzqZIXNSZJyw8NmqObwg/voajiHQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -23163,7 +23130,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", @@ -23776,7 +23742,6 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -23952,7 +23917,6 @@ "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "lunr": "^2.3.9", "marked": "^4.3.0", @@ -24026,7 +23990,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" @@ -24122,7 +24085,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -24695,7 +24657,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", @@ -25212,7 +25173,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" }, @@ -25378,7 +25338,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..aefe64f047e 100644 --- a/packages/utils/src/errors.ts +++ b/packages/utils/src/errors.ts @@ -1,4 +1,11 @@ -import { ASTNode, GraphQLError, Source, versionInfo } from 'graphql'; +import { + GraphQLError as _GraphQLError, + locatedError as _locatedError, + ASTNode, + GraphQLError, + Source, + versionInfo, +} from 'graphql'; import { Maybe } from './types.js'; interface GraphQLErrorOptions { @@ -12,6 +19,7 @@ interface GraphQLErrorOptions { } >; extensions?: any; + coordinate?: string; } const possibleGraphQLErrorProperties = [ @@ -27,7 +35,7 @@ const possibleGraphQLErrorProperties = [ 'extensions', ]; -function isGraphQLErrorLike(error: any) { +export function isGraphQLErrorLike(error: any) { return ( error != null && typeof error === 'object' && @@ -35,6 +43,15 @@ function isGraphQLErrorLike(error: any) { ); } +declare module 'graphql' { + interface GraphQLError { + /** + * An optional schema coordinate (e.g. "MyType.myField") associated with this error. + */ + readonly coordinate?: string; + } +} + export function createGraphQLError(message: string, options?: GraphQLErrorOptions): GraphQLError { if ( options?.originalError && @@ -46,23 +63,57 @@ export function createGraphQLError(message: string, options?: GraphQLErrorOption options.originalError, ); } - if (versionInfo.major >= 17) { - return new (GraphQLError as any)(message, options); + let error: GraphQLError; + if (versionInfo.major >= 16) { + error = new (GraphQLError as any)(message, options); + } else { + error = new (GraphQLError as any)( + message, + options?.nodes, + options?.source, + options?.positions, + options?.path, + options?.originalError, + options?.extensions, + ); + } + if (options?.coordinate != null && !error.coordinate) { + Object.defineProperty(error, 'coordinate', { + value: options.coordinate, + enumerable: true, + configurable: true, + }); } - return new (GraphQLError as any)( - message, - options?.nodes, - options?.source, - options?.positions, - options?.path, - options?.originalError, - options?.extensions, - ); + return error; +} + +type SchemaCoordinateInfo = { fieldName: string; parentType: { name: string } }; + +export function getSchemaCoordinate(error: GraphQLError): string | undefined { + return error.coordinate; +} + +export function locatedError( + rawError: unknown, + nodes: ASTNode | ReadonlyArray | undefined, + path: Maybe>, + info: SchemaCoordinateInfo | false | null | undefined, +): GraphQLError { + const error = _locatedError(rawError, nodes, path) as GraphQLError; + + // `graphql` locatedError is only changing path and nodes if it is not already defined + if (!error.coordinate && info) { + // @ts-expect-error coordinate is readonly, but we don't want to recreate it just to add coordinate + error.coordinate = `${info.parentType.name}.${info.fieldName}`; + } + + return error; } export function relocatedError( originalError: GraphQLError, path?: ReadonlyArray, + info?: SchemaCoordinateInfo | false | null | undefined, ): GraphQLError { return createGraphQLError(originalError.message, { nodes: originalError.nodes, @@ -71,5 +122,6 @@ export function relocatedError( path: path == null ? originalError.path : path, originalError, extensions: originalError.extensions, + coordinate: info ? `${info.parentType.name}.${info.fieldName}` : undefined, }); } 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..56b6e80486d --- /dev/null +++ b/packages/utils/tests/errors.test.ts @@ -0,0 +1,85 @@ +import { ASTNode, GraphQLError, Kind, versionInfo } from 'graphql'; +import { describeIf, testIf } from '../../../packages/testing/utils'; +import { + createGraphQLError, + getSchemaCoordinate, + locatedError, + relocatedError, +} from '../src/errors'; + +describe('Errors', () => { + describe('relocatedError', () => { + it('should adjust the path of a GraphqlError', () => { + const originalError = createGraphQLError('test', { + path: ['test'], + coordinate: 'Query.test', + }); + const newError = relocatedError(originalError, ['test', 1, 'id'], { + fieldName: 'id', + parentType: { name: 'Test' }, + }); + if (versionInfo.major >= 16) { + 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']); + if (versionInfo.major >= 16) { + expect(error.coordinate).toEqual('Query.test'); + } + }); + }); + + describeIf(versionInfo.major >= 16)('getSchemaCoordinate', () => { + it('should always return the schema coordinate, even when typed as original graphql error', () => { + const error = new GraphQLError('test'); + expect(getSchemaCoordinate(error)).toBe(undefined); + // @ts-expect-error coordinate doesn't exists in `graphql` yet. + error.coordinate = 'Query.test'; + expect(getSchemaCoordinate(error)).toBe('Query.test'); + expect(createGraphQLError('test', { coordinate: 'Query.test' }).coordinate).toBe( + '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' }, + }, + }); + }); + + testIf(versionInfo.major >= 16)('should handle coordinate', () => { + const error = createGraphQLError('message', { + extensions: { + coordinate: 'Query.test', + }, + }); + expect(error.extensions).toMatchObject({ + 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); - }); - }); -});