diff --git a/src/error/ErrorBehavior.ts b/src/error/ErrorBehavior.ts new file mode 100644 index 0000000000..665f241905 --- /dev/null +++ b/src/error/ErrorBehavior.ts @@ -0,0 +1,9 @@ +export type GraphQLErrorBehavior = 'PROPAGATE' | 'NO_PROPAGATE' | 'ABORT'; + +export function isErrorBehavior( + onError: unknown, +): onError is GraphQLErrorBehavior { + return ( + onError === 'PROPAGATE' || onError === 'NO_PROPAGATE' || onError === 'ABORT' + ); +} diff --git a/src/error/index.ts b/src/error/index.ts index 7e5d267f50..b9da3e897e 100644 --- a/src/error/index.ts +++ b/src/error/index.ts @@ -9,3 +9,4 @@ export type { export { syntaxError } from './syntaxError'; export { locatedError } from './locatedError'; +export type { GraphQLErrorBehavior } from './ErrorBehavior'; diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index c758d3e426..e8cdc0b39a 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -263,6 +263,7 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'errorBehavior', ); const operation = document.definitions[0]; @@ -275,6 +276,7 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, + errorBehavior: 'PROPAGATE', }); const field = operation.selectionSet.selections[0]; @@ -285,6 +287,70 @@ describe('Execute: Handles basic execution tasks', () => { }); }); + it('reflects onError:NO_PROPAGATE via errorBehavior', () => { + let resolvedInfo; + const testType = new GraphQLObjectType({ + name: 'Test', + fields: { + test: { + type: GraphQLString, + resolve(_val, _args, _ctx, info) { + resolvedInfo = info; + }, + }, + }, + }); + const schema = new GraphQLSchema({ query: testType }); + + const document = parse('query ($var: String) { result: test }'); + const rootValue = { root: 'val' }; + const variableValues = { var: 'abc' }; + + executeSync({ + schema, + document, + rootValue, + variableValues, + onError: 'NO_PROPAGATE', + }); + + expect(resolvedInfo).to.include({ + errorBehavior: 'NO_PROPAGATE', + }); + }); + + it('reflects onError:ABORT via errorBehavior', () => { + let resolvedInfo; + const testType = new GraphQLObjectType({ + name: 'Test', + fields: { + test: { + type: GraphQLString, + resolve(_val, _args, _ctx, info) { + resolvedInfo = info; + }, + }, + }, + }); + const schema = new GraphQLSchema({ query: testType }); + + const document = parse('query ($var: String) { result: test }'); + const rootValue = { root: 'val' }; + const variableValues = { var: 'abc' }; + + executeSync({ + schema, + document, + rootValue, + variableValues, + onError: 'ABORT', + }); + + expect(resolvedInfo).to.include({ + errorBehavior: 'ABORT', + }); + }); + it('populates path correctly with complex types', () => { let path; const someObject = new GraphQLObjectType({ @@ -739,6 +805,163 @@ describe('Execute: Handles basic execution tasks', () => { }); }); + it('Full response path is included for non-nullable fields with onError:NO_PROPAGATE', () => { + const A: GraphQLObjectType = new GraphQLObjectType({ + name: 'A', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + nonNullA: { + type: new GraphQLNonNull(A), + resolve: () => ({}), + }, + throws: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('Catch me if you can'); + }, + }, + }), + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + }), + }), + }); + + const document = parse(` + query { + nullableA { + aliasedA: nullableA { + nonNullA { + anotherA: nonNullA { + throws + } + } + } + } + } + `); + + const result = executeSync({ schema, document, onError: 'NO_PROPAGATE' }); + expectJSON(result).toDeepEqual({ + data: { + nullableA: { + aliasedA: { + nonNullA: { + anotherA: { + throws: null, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Catch me if you can', + locations: [{ line: 7, column: 17 }], + path: ['nullableA', 'aliasedA', 'nonNullA', 'anotherA', 'throws'], + }, + ], + }); + }); + + it('Full response path is included for non-nullable fields with onError:ABORT', () => { + const A: GraphQLObjectType = new GraphQLObjectType({ + name: 'A', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + nonNullA: { + type: new GraphQLNonNull(A), + resolve: () => ({}), + }, + throws: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('Catch me if you can'); + }, + }, + }), + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + nullableA: { + type: A, + resolve: () => ({}), + }, + }), + }), + }); + + const document = parse(` + query { + nullableA { + aliasedA: nullableA { + nonNullA { + anotherA: nonNullA { + throws + } + } + } + } + } + `); + + const result = executeSync({ schema, document, onError: 'ABORT' }); + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Catch me if you can', + locations: [{ line: 7, column: 17 }], + path: ['nullableA', 'aliasedA', 'nonNullA', 'anotherA', 'throws'], + }, + ], + }); + }); + + it('raises request error with invalid onError', () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'query', + fields: () => ({ + a: { + type: GraphQLInt, + }, + }), + }), + }); + + const document = parse('{ a }'); + const result = executeSync({ + schema, + document, + // @ts-expect-error + onError: 'DANCE', + }); + expectJSON(result).toDeepEqual({ + errors: [ + { + message: + 'Unsupported `onError` value; supported values are `PROPAGATE`, `NO_PROPAGATE` and `ABORT`.', + }, + ], + }); + }); + it('uses the inline operation if no operation name is provided', () => { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 55c22ea9de..65953098c1 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -13,6 +13,8 @@ import { promiseForObject } from '../jsutils/promiseForObject'; import type { PromiseOrValue } from '../jsutils/PromiseOrValue'; import { promiseReduce } from '../jsutils/promiseReduce'; +import type { GraphQLErrorBehavior } from '../error/ErrorBehavior'; +import { isErrorBehavior } from '../error/ErrorBehavior'; import type { GraphQLFormattedError } from '../error/GraphQLError'; import { GraphQLError } from '../error/GraphQLError'; import { locatedError } from '../error/locatedError'; @@ -115,6 +117,7 @@ export interface ExecutionContext { typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; errors: Array; + errorBehavior: GraphQLErrorBehavior; } /** @@ -130,6 +133,7 @@ export interface ExecutionResult< > { errors?: ReadonlyArray; data?: TData | null; + onError?: GraphQLErrorBehavior; extensions?: TExtensions; } @@ -152,6 +156,15 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + /** + * Experimental. Set to NO_PROPAGATE to prevent error propagation. Set to ABORT to + * abort a request when any error occurs. + * + * Default: PROPAGATE + * + * @experimental + */ + onError?: GraphQLErrorBehavior; } /** @@ -286,8 +299,17 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + onError, } = args; + if (onError != null && !isErrorBehavior(onError)) { + return [ + new GraphQLError( + 'Unsupported `onError` value; supported values are `PROPAGATE`, `NO_PROPAGATE` and `ABORT`.', + ), + ]; + } + let operation: OperationDefinitionNode | undefined; const fragments: ObjMap = Object.create(null); for (const definition of document.definitions) { @@ -347,6 +369,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, errors: [], + errorBehavior: onError ?? 'PROPAGATE', }; } @@ -585,6 +608,7 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + errorBehavior: exeContext.errorBehavior, }; } @@ -593,10 +617,26 @@ function handleFieldError( returnType: GraphQLOutputType, exeContext: ExecutionContext, ): null { - // If the field type is non-nullable, then it is resolved without any - // protection from errors, however it still properly locates the error. - if (isNonNullType(returnType)) { + if (exeContext.errorBehavior === 'PROPAGATE') { + // If the field type is non-nullable, then it is resolved without any + // protection from errors, however it still properly locates the error. + // Note: semantic non-null types are treated as nullable for the purposes + // of error handling. + if (isNonNullType(returnType)) { + throw error; + } + } else if (exeContext.errorBehavior === 'ABORT') { + // In this mode, any error aborts the request throw error; + } else if (exeContext.errorBehavior === 'NO_PROPAGATE') { + // In this mode, the client takes responsibility for error handling, so we + // treat the field as if it were nullable. + /* c8 ignore next 6 */ + } else { + invariant( + false, + 'Unexpected errorBehavior setting: ' + inspect(exeContext.errorBehavior), + ); } // Otherwise, error protection is applied, logging the error and resolving diff --git a/src/graphql.ts b/src/graphql.ts index bc6fb9bb72..7edd260b83 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -3,6 +3,8 @@ import { isPromise } from './jsutils/isPromise'; import type { Maybe } from './jsutils/Maybe'; import type { PromiseOrValue } from './jsutils/PromiseOrValue'; +import type { GraphQLErrorBehavior } from './error/ErrorBehavior'; + import { parse } from './language/parser'; import type { Source } from './language/source'; @@ -66,6 +68,15 @@ export interface GraphQLArgs { operationName?: Maybe; fieldResolver?: Maybe>; typeResolver?: Maybe>; + /** + * Experimental. Set to NO_PROPAGATE to prevent error propagation. Set to ABORT to + * abort a request when any error occurs. + * + * Default: PROPAGATE + * + * @experimental + */ + onError?: GraphQLErrorBehavior; } export function graphql(args: GraphQLArgs): Promise { @@ -106,6 +117,7 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + onError, } = args; // Validate Schema @@ -138,5 +150,6 @@ function graphqlImpl(args: GraphQLArgs): PromiseOrValue { operationName, fieldResolver, typeResolver, + onError, }); } diff --git a/src/index.ts b/src/index.ts index 73c713a203..4df70d7d74 100644 --- a/src/index.ts +++ b/src/index.ts @@ -395,6 +395,7 @@ export { } from './error/index'; export type { + GraphQLErrorBehavior, GraphQLErrorOptions, GraphQLFormattedError, GraphQLErrorExtensions, diff --git a/src/type/definition.ts b/src/type/definition.ts index 7eaac560dc..61c57c4f38 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -14,6 +14,7 @@ import type { PromiseOrValue } from '../jsutils/PromiseOrValue'; import { suggestionList } from '../jsutils/suggestionList'; import { toObjMap } from '../jsutils/toObjMap'; +import type { GraphQLErrorBehavior } from '../error/ErrorBehavior'; import { GraphQLError } from '../error/GraphQLError'; import type { @@ -988,6 +989,8 @@ export interface GraphQLResolveInfo { readonly rootValue: unknown; readonly operation: OperationDefinitionNode; readonly variableValues: { [variable: string]: unknown }; + /** @experimental */ + readonly errorBehavior: GraphQLErrorBehavior; } /**