diff --git a/src/__tests__/starWarsIntrospection-test.ts b/src/__tests__/starWarsIntrospection-test.ts index d637787c4a..662361b038 100644 --- a/src/__tests__/starWarsIntrospection-test.ts +++ b/src/__tests__/starWarsIntrospection-test.ts @@ -37,6 +37,7 @@ describe('Star Wars Introspection Tests', () => { { name: 'Droid' }, { name: 'Query' }, { name: 'Boolean' }, + { name: 'ErrorBehavior' }, { name: '__Schema' }, { name: '__Type' }, { name: '__TypeKind' }, diff --git a/src/error/ErrorBehavior.ts b/src/error/ErrorBehavior.ts new file mode 100644 index 0000000000..973dccf7d1 --- /dev/null +++ b/src/error/ErrorBehavior.ts @@ -0,0 +1,7 @@ +export type ErrorBehavior = 'NO_PROPAGATE' | 'PROPAGATE' | 'ABORT'; + +export function isErrorBehavior(onError: unknown): onError is ErrorBehavior { + return ( + onError === 'NO_PROPAGATE' || onError === 'PROPAGATE' || onError === 'ABORT' + ); +} diff --git a/src/error/index.ts b/src/error/index.ts index 7e5d267f50..f0d55c34ed 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 { ErrorBehavior } from './ErrorBehavior'; diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 0f0c5b2861..86b4be0d9d 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -264,6 +264,7 @@ describe('Execute: Handles basic execution tasks', () => { 'rootValue', 'operation', 'variableValues', + 'errorBehavior', ); const operation = document.definitions[0]; @@ -276,6 +277,7 @@ describe('Execute: Handles basic execution tasks', () => { schema, rootValue, operation, + errorBehavior: 'PROPAGATE', }); const field = operation.selectionSet.selections[0]; @@ -286,6 +288,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({ @@ -740,6 +806,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 `NO_PROPAGATE`, `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 5cd64d40f9..477510f33a 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 { ErrorBehavior } 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: ErrorBehavior; } /** @@ -152,6 +155,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?: ErrorBehavior; /** Additional execution options. */ options?: { /** Set the maximum number of errors allowed for coercing (defaults to 50). */ @@ -291,9 +303,18 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + onError, options, } = args; + if (onError != null && !isErrorBehavior(onError)) { + return [ + new GraphQLError( + 'Unsupported `onError` value; supported values are `NO_PROPAGATE`, `PROPAGATE` and `ABORT`.', + ), + ]; + } + let operation: OperationDefinitionNode | undefined; const fragments: ObjMap = Object.create(null); for (const definition of document.definitions) { @@ -353,6 +374,7 @@ export function buildExecutionContext( typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, errors: [], + errorBehavior: onError ?? schema.defaultErrorBehavior, }; } @@ -591,6 +613,7 @@ export function buildResolveInfo( rootValue: exeContext.rootValue, operation: exeContext.operation, variableValues: exeContext.variableValues, + errorBehavior: exeContext.errorBehavior, }; } @@ -599,10 +622,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..cbd09a9b44 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 { ErrorBehavior } 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?: ErrorBehavior; } 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..66d3f5267c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,9 @@ export { GraphQLString, GraphQLBoolean, GraphQLID, + // Standard GraphQL Enums + specifiedEnumTypes, + GraphQLErrorBehavior, // Int boundaries constants GRAPHQL_MAX_INT, GRAPHQL_MIN_INT, @@ -106,6 +109,7 @@ export { isRequiredArgument, isRequiredInputField, isSpecifiedScalarType, + isSpecifiedEnumType, isIntrospectionType, isSpecifiedDirective, // Assertions @@ -395,6 +399,7 @@ export { } from './error/index'; export type { + ErrorBehavior, GraphQLErrorOptions, GraphQLFormattedError, GraphQLErrorExtensions, diff --git a/src/type/__tests__/introspection-test.ts b/src/type/__tests__/introspection-test.ts index 8c5cacba0d..67f0c2cf2b 100644 --- a/src/type/__tests__/introspection-test.ts +++ b/src/type/__tests__/introspection-test.ts @@ -26,6 +26,7 @@ describe('Introspection', () => { descriptions: false, specifiedByUrl: true, directiveIsRepeatable: true, + errorBehavior: true, }); const result = graphqlSync({ schema, source }); @@ -35,6 +36,7 @@ describe('Introspection', () => { queryType: { name: 'SomeObject', kind: 'OBJECT' }, mutationType: null, subscriptionType: null, + defaultErrorBehavior: 'PROPAGATE', types: [ { kind: 'OBJECT', @@ -78,6 +80,32 @@ describe('Introspection', () => { enumValues: null, possibleTypes: null, }, + { + enumValues: [ + { + deprecationReason: null, + isDeprecated: false, + name: 'NO_PROPAGATE', + }, + { + deprecationReason: null, + isDeprecated: false, + name: 'PROPAGATE', + }, + { + deprecationReason: null, + isDeprecated: false, + name: 'ABORT', + }, + ], + fields: null, + inputFields: null, + interfaces: null, + kind: 'ENUM', + name: 'ErrorBehavior', + possibleTypes: null, + specifiedByURL: null, + }, { kind: 'OBJECT', name: '__Schema', @@ -177,6 +205,21 @@ describe('Introspection', () => { isDeprecated: false, deprecationReason: null, }, + { + name: 'defaultErrorBehavior', + args: [], + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'ENUM', + name: 'ErrorBehavior', + ofType: null, + }, + }, + isDeprecated: false, + deprecationReason: null, + }, ], inputFields: null, interfaces: [], @@ -1006,6 +1049,26 @@ describe('Introspection', () => { locations: ['INPUT_OBJECT'], args: [], }, + { + args: [ + { + defaultValue: 'PROPAGATE', + name: 'onError', + type: { + kind: 'NON_NULL', + name: null, + ofType: { + kind: 'ENUM', + name: 'ErrorBehavior', + ofType: null, + }, + }, + }, + ], + isRepeatable: false, + locations: ['SCHEMA'], + name: 'behavior', + }, ], }, }, @@ -1754,4 +1817,62 @@ describe('Introspection', () => { }); expect(result).to.not.have.property('errors'); }); + + it('reflects the default error behavior (default)', () => { + const schema = buildSchema(` + type SomeObject { + someField: String + } + + schema { + query: SomeObject + } + `); + + const source = /* GraphQL */ ` + { + __schema { + defaultErrorBehavior + } + } + `; + + const result = graphqlSync({ schema, source }); + expect(result).to.deep.equal({ + data: { + __schema: { + defaultErrorBehavior: 'PROPAGATE', + }, + }, + }); + }); + + it('reflects the default error behavior (NO_PROPAGATE)', () => { + const schema = buildSchema(` + type SomeObject { + someField: String + } + + schema @behavior(onError: NO_PROPAGATE) { + query: SomeObject + } + `); + + const source = /* GraphQL */ ` + { + __schema { + defaultErrorBehavior + } + } + `; + + const result = graphqlSync({ schema, source }); + expect(result).to.deep.equal({ + data: { + __schema: { + defaultErrorBehavior: 'NO_PROPAGATE', + }, + }, + }); + }); }); diff --git a/src/type/__tests__/predicate-test.ts b/src/type/__tests__/predicate-test.ts index 81e721e7df..9f77449869 100644 --- a/src/type/__tests__/predicate-test.ts +++ b/src/type/__tests__/predicate-test.ts @@ -65,6 +65,7 @@ import { isDirective, isSpecifiedDirective, } from '../directives'; +import { GraphQLErrorBehavior, isSpecifiedEnumType } from '../enums'; import { GraphQLBoolean, GraphQLFloat, @@ -166,6 +167,16 @@ describe('Type predicates', () => { }); }); + describe('isSpecifiedEnumType', () => { + it('returns true for specified enums', () => { + expect(isSpecifiedEnumType(GraphQLErrorBehavior)).to.equal(true); + }); + + it('returns false for custom scalar', () => { + expect(isSpecifiedEnumType(EnumType)).to.equal(false); + }); + }); + describe('isObjectType', () => { it('returns true for object type', () => { expect(isObjectType(ObjectType)).to.equal(true); diff --git a/src/type/__tests__/schema-test.ts b/src/type/__tests__/schema-test.ts index 8a31b50ada..4e4f34b5ba 100644 --- a/src/type/__tests__/schema-test.ts +++ b/src/type/__tests__/schema-test.ts @@ -296,6 +296,7 @@ describe('Type System: Schema', () => { 'ASub', 'Boolean', 'String', + 'ErrorBehavior', '__Schema', '__Type', '__TypeKind', diff --git a/src/type/definition.ts b/src/type/definition.ts index 7eaac560dc..ddd3b5e18a 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 { ErrorBehavior } 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: ErrorBehavior; } /** diff --git a/src/type/directives.ts b/src/type/directives.ts index 6881f20532..c19e7d36d7 100644 --- a/src/type/directives.ts +++ b/src/type/directives.ts @@ -18,6 +18,7 @@ import { defineArguments, GraphQLNonNull, } from './definition'; +import { GraphQLErrorBehavior } from './enums'; import { GraphQLBoolean, GraphQLString } from './scalars'; /** @@ -220,6 +221,21 @@ export const GraphQLOneOfDirective: GraphQLDirective = new GraphQLDirective({ args: {}, }); +/** + * Used to indicate the default error behavior. + */ +export const GraphQLBehaviorDirective: GraphQLDirective = new GraphQLDirective({ + name: 'behavior', + description: 'Indicates the default error behavior of the schema.', + locations: [DirectiveLocation.SCHEMA], + args: { + onError: { + type: new GraphQLNonNull(GraphQLErrorBehavior), + defaultValue: 'PROPAGATE', + }, + }, +}); + /** * The full list of specified directives. */ @@ -230,6 +246,7 @@ export const specifiedDirectives: ReadonlyArray = GraphQLDeprecatedDirective, GraphQLSpecifiedByDirective, GraphQLOneOfDirective, + GraphQLBehaviorDirective, ]); export function isSpecifiedDirective(directive: GraphQLDirective): boolean { diff --git a/src/type/enums.ts b/src/type/enums.ts new file mode 100644 index 0000000000..c047830474 --- /dev/null +++ b/src/type/enums.ts @@ -0,0 +1,33 @@ +import type { GraphQLNamedType } from './definition'; +import { GraphQLEnumType } from './definition'; + +export const GraphQLErrorBehavior: GraphQLEnumType = new GraphQLEnumType({ + name: 'ErrorBehavior', + description: + 'An enum detailing the error behavior a GraphQL request should use.', + values: { + NO_PROPAGATE: { + value: 'NO_PROPAGATE', + description: + 'Indicates that an error should result in the response position becoming null, even if it is marked as non-null.', + }, + PROPAGATE: { + value: 'PROPAGATE', + description: + 'Indicates that an error that occurs in a non-null position should propagate to the nearest nullable response position.', + }, + ABORT: { + value: 'ABORT', + description: + 'Indicates execution should cease when the first error occurs, and that the response data should be null.', + }, + }, +}); + +export const specifiedEnumTypes: ReadonlyArray = Object.freeze( + [GraphQLErrorBehavior], +); + +export function isSpecifiedEnumType(type: GraphQLNamedType): boolean { + return specifiedEnumTypes.some(({ name }) => type.name === name); +} diff --git a/src/type/index.ts b/src/type/index.ts index cf276d1e02..585cf1d6fe 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -158,6 +158,13 @@ export { GRAPHQL_MAX_INT, GRAPHQL_MIN_INT, } from './scalars'; +export { + // Predicate + isSpecifiedEnumType, + // Standard GraphQL Enums + specifiedEnumTypes, + GraphQLErrorBehavior, +} from './enums'; export { // Predicate diff --git a/src/type/introspection.ts b/src/type/introspection.ts index 2c66ca5098..e2ce198d9d 100644 --- a/src/type/introspection.ts +++ b/src/type/introspection.ts @@ -30,6 +30,7 @@ import { isUnionType, } from './definition'; import type { GraphQLDirective } from './directives'; +import { GraphQLErrorBehavior } from './enums'; import { GraphQLBoolean, GraphQLString } from './scalars'; import type { GraphQLSchema } from './schema'; @@ -74,6 +75,12 @@ export const __Schema: GraphQLObjectType = new GraphQLObjectType({ ), resolve: (schema) => schema.getDirectives(), }, + defaultErrorBehavior: { + description: + 'The default error behavior that will be used for requests which do not specify `onError`.', + type: new GraphQLNonNull(GraphQLErrorBehavior), + resolve: (schema) => schema.defaultErrorBehavior, + }, } as GraphQLFieldConfigMap), }); @@ -558,6 +565,7 @@ export const introspectionTypes: ReadonlyArray = __InputValue, __EnumValue, __TypeKind, + // ErrorBehavior, ]); export function isIntrospectionType(type: GraphQLNamedType): boolean { diff --git a/src/type/schema.ts b/src/type/schema.ts index 97c2782145..c4d590ca0a 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -6,6 +6,8 @@ import type { Maybe } from '../jsutils/Maybe'; import type { ObjMap } from '../jsutils/ObjMap'; import { toObjMap } from '../jsutils/toObjMap'; +import type { ErrorBehavior } from '../error/ErrorBehavior'; +import { isErrorBehavior } from '../error/ErrorBehavior'; import type { GraphQLError } from '../error/GraphQLError'; import type { @@ -129,6 +131,8 @@ export interface GraphQLSchemaExtensions { */ export class GraphQLSchema { description: Maybe; + /** @experimental */ + readonly defaultErrorBehavior: ErrorBehavior; extensions: Readonly; astNode: Maybe; extensionASTNodes: ReadonlyArray; @@ -163,8 +167,15 @@ export class GraphQLSchema { '"directives" must be Array if provided but got: ' + `${inspect(config.directives)}.`, ); + devAssert( + !config.defaultErrorBehavior || + isErrorBehavior(config.defaultErrorBehavior), + '"defaultErrorBehavior" must be one of "NO_PROPAGATE", "PROPAGATE" or "ABORT", but got: ' + + `${inspect(config.defaultErrorBehavior)}.`, + ); this.description = config.description; + this.defaultErrorBehavior = config.defaultErrorBehavior ?? 'PROPAGATE'; this.extensions = toObjMap(config.extensions); this.astNode = config.astNode; this.extensionASTNodes = config.extensionASTNodes ?? []; @@ -386,6 +397,20 @@ export interface GraphQLSchemaConfig extends GraphQLSchemaValidationOptions { subscription?: Maybe; types?: Maybe>; directives?: Maybe>; + /** + * Experimental. Defines the default GraphQL error behavior when the + * GraphQLArgs does not include an `onError` property. + * + * Set to NO_PROPAGATE if your schema only needs to support modern + * "error-handling" clients. + * + * It is not recommended to set this to ABORT. + * + * Default: PROPAGATE + * + * @experimental + */ + defaultErrorBehavior?: Maybe; extensions?: Maybe>; astNode?: Maybe; extensionASTNodes?: Maybe>; diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 29280474ec..06cded5816 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -21,6 +21,7 @@ import { } from '../../type/definition'; import { assertDirective, + GraphQLBehaviorDirective, GraphQLDeprecatedDirective, GraphQLIncludeDirective, GraphQLOneOfDirective, @@ -223,7 +224,7 @@ describe('Schema Builder', () => { it('Maintains @include, @skip & @specifiedBy', () => { const schema = buildSchema('type Query'); - expect(schema.getDirectives()).to.have.lengthOf(5); + expect(schema.getDirectives()).to.have.lengthOf(6); expect(schema.getDirective('skip')).to.equal(GraphQLSkipDirective); expect(schema.getDirective('include')).to.equal(GraphQLIncludeDirective); expect(schema.getDirective('deprecated')).to.equal( @@ -232,6 +233,7 @@ describe('Schema Builder', () => { expect(schema.getDirective('specifiedBy')).to.equal( GraphQLSpecifiedByDirective, ); + expect(schema.getDirective('behavior')).to.equal(GraphQLBehaviorDirective); expect(schema.getDirective('oneOf')).to.equal(GraphQLOneOfDirective); }); @@ -241,10 +243,11 @@ describe('Schema Builder', () => { directive @include on FIELD directive @deprecated on FIELD_DEFINITION directive @specifiedBy on FIELD_DEFINITION + directive @behavior on SCHEMA directive @oneOf on OBJECT `); - expect(schema.getDirectives()).to.have.lengthOf(5); + expect(schema.getDirectives()).to.have.lengthOf(6); expect(schema.getDirective('skip')).to.not.equal(GraphQLSkipDirective); expect(schema.getDirective('include')).to.not.equal( GraphQLIncludeDirective, @@ -255,19 +258,23 @@ describe('Schema Builder', () => { expect(schema.getDirective('specifiedBy')).to.not.equal( GraphQLSpecifiedByDirective, ); + expect(schema.getDirective('behavior')).to.not.equal( + GraphQLBehaviorDirective, + ); expect(schema.getDirective('oneOf')).to.not.equal(GraphQLOneOfDirective); }); - it('Adding directives maintains @include, @skip, @deprecated, @specifiedBy, and @oneOf', () => { + it('Adding directives maintains @include, @skip, @deprecated, @specifiedBy, @behavior and @oneOf', () => { const schema = buildSchema(` directive @foo(arg: Int) on FIELD `); - expect(schema.getDirectives()).to.have.lengthOf(6); + expect(schema.getDirectives()).to.have.lengthOf(7); expect(schema.getDirective('skip')).to.not.equal(undefined); expect(schema.getDirective('include')).to.not.equal(undefined); expect(schema.getDirective('deprecated')).to.not.equal(undefined); expect(schema.getDirective('specifiedBy')).to.not.equal(undefined); + expect(schema.getDirective('behavior')).to.not.equal(undefined); expect(schema.getDirective('oneOf')).to.not.equal(undefined); }); diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index e8cf046921..bb2bfa7e22 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -18,6 +18,7 @@ import { GraphQLString, } from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; +import { validateSchema } from '../../type/validate'; import { graphqlSync } from '../../graphql'; @@ -158,6 +159,26 @@ describe('Type System: build schema from introspection', () => { expect(clientSchema.getType('ID')).to.equal(undefined); }); + it('reflects defaultErrorBehavior', () => { + const schema = buildSchema(` + schema @behavior(onError: NO_PROPAGATE) { + query: Query + } + type Query { + foo: String + } + `); + const introspection = introspectionFromSchema(schema, { + errorBehavior: true, + }); + const clientSchema = buildClientSchema(introspection); + + expect(clientSchema.defaultErrorBehavior).to.equal('NO_PROPAGATE'); + + const errors = validateSchema(clientSchema); + expect(errors).to.have.length(0); + }); + it('builds a schema with a recursive type reference', () => { const sdl = dedent` schema { diff --git a/src/utilities/__tests__/findBreakingChanges-test.ts b/src/utilities/__tests__/findBreakingChanges-test.ts index ba526deb48..135b50b411 100644 --- a/src/utilities/__tests__/findBreakingChanges-test.ts +++ b/src/utilities/__tests__/findBreakingChanges-test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { describe, it } from 'mocha'; import { + GraphQLBehaviorDirective, GraphQLDeprecatedDirective, GraphQLIncludeDirective, GraphQLOneOfDirective, @@ -803,6 +804,7 @@ describe('findBreakingChanges', () => { GraphQLSkipDirective, GraphQLIncludeDirective, GraphQLSpecifiedByDirective, + GraphQLBehaviorDirective, GraphQLOneOfDirective, ], }); diff --git a/src/utilities/__tests__/printSchema-test.ts b/src/utilities/__tests__/printSchema-test.ts index 37af4a60f7..6f1a04823d 100644 --- a/src/utilities/__tests__/printSchema-test.ts +++ b/src/utilities/__tests__/printSchema-test.ts @@ -276,6 +276,21 @@ describe('Type System Printer', () => { `); }); + it('Prints schema with NO_PROPAGATE error behavior', () => { + const schema = new GraphQLSchema({ + defaultErrorBehavior: 'NO_PROPAGATE', + query: new GraphQLObjectType({ name: 'Query', fields: {} }), + }); + + expectPrintedSchema(schema).to.equal(dedent` + schema @behavior(onError: NO_PROPAGATE) { + query: Query + } + + type Query + `); + }); + it('Omits schema of common names', () => { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', fields: {} }), @@ -694,6 +709,27 @@ describe('Type System Printer', () => { """ directive @oneOf on INPUT_OBJECT + """Indicates the default error behavior of the schema.""" + directive @behavior(onError: ErrorBehavior! = PROPAGATE) on SCHEMA + + """An enum detailing the error behavior a GraphQL request should use.""" + enum ErrorBehavior { + """ + Indicates that an error should result in the response position becoming null, even if it is marked as non-null. + """ + NO_PROPAGATE + + """ + Indicates that an error that occurs in a non-null position should propagate to the nearest nullable response position. + """ + PROPAGATE + + """ + Indicates execution should cease when the first error occurs, and that the response data should be null. + """ + ABORT + } + """ A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. """ @@ -718,6 +754,11 @@ describe('Type System Printer', () => { """A list of all directives supported by this server.""" directives: [__Directive!]! + + """ + The default error behavior that will be used for requests which do not specify \`onError\`. + """ + defaultErrorBehavior: ErrorBehavior! } """ diff --git a/src/utilities/buildClientSchema.ts b/src/utilities/buildClientSchema.ts index 83f6abada8..463235b0a5 100644 --- a/src/utilities/buildClientSchema.ts +++ b/src/utilities/buildClientSchema.ts @@ -27,6 +27,7 @@ import { isOutputType, } from '../type/definition'; import { GraphQLDirective } from '../type/directives'; +import { specifiedEnumTypes } from '../type/enums'; import { introspectionTypes, TypeKind } from '../type/introspection'; import { specifiedScalarTypes } from '../type/scalars'; import type { GraphQLSchemaValidationOptions } from '../type/schema'; @@ -83,7 +84,11 @@ export function buildClientSchema( ); // Include standard types only if they are used. - for (const stdType of [...specifiedScalarTypes, ...introspectionTypes]) { + for (const stdType of [ + ...specifiedScalarTypes, + ...specifiedEnumTypes, + ...introspectionTypes, + ]) { if (typeMap[stdType.name]) { typeMap[stdType.name] = stdType; } @@ -108,6 +113,8 @@ export function buildClientSchema( ? schemaIntrospection.directives.map(buildDirective) : []; + const defaultErrorBehavior = schemaIntrospection.defaultErrorBehavior; + // Then produce and return a Schema with these types. return new GraphQLSchema({ description: schemaIntrospection.description, @@ -116,6 +123,7 @@ export function buildClientSchema( subscription: subscriptionType, types: Object.values(typeMap), directives, + defaultErrorBehavior, assumeValid: options?.assumeValid, }); diff --git a/src/utilities/extendSchema.ts b/src/utilities/extendSchema.ts index d53752d919..463d70b63d 100644 --- a/src/utilities/extendSchema.ts +++ b/src/utilities/extendSchema.ts @@ -5,6 +5,8 @@ import { keyMap } from '../jsutils/keyMap'; import { mapValue } from '../jsutils/mapValue'; import type { Maybe } from '../jsutils/Maybe'; +import type { ErrorBehavior } from '../error/ErrorBehavior'; + import type { DirectiveDefinitionNode, DocumentNode, @@ -64,11 +66,13 @@ import { isUnionType, } from '../type/definition'; import { + GraphQLBehaviorDirective, GraphQLDeprecatedDirective, GraphQLDirective, GraphQLOneOfDirective, GraphQLSpecifiedByDirective, } from '../type/directives'; +import { isSpecifiedEnumType, specifiedEnumTypes } from '../type/enums'; import { introspectionTypes, isIntrospectionType } from '../type/introspection'; import { isSpecifiedScalarType, specifiedScalarTypes } from '../type/scalars'; import type { @@ -165,6 +169,14 @@ export function extendSchemaImpl( } } + let defaultErrorBehavior: Maybe = schemaDef + ? getDefaultErrorBehavior(schemaDef) + : null; + for (const extensionNode of schemaExtensions) { + defaultErrorBehavior = + getDefaultErrorBehavior(extensionNode) ?? defaultErrorBehavior; + } + // If this document contains no new types, extensions, or directives then // return the same unmodified GraphQLSchema instance. if ( @@ -201,6 +213,7 @@ export function extendSchemaImpl( // Then produce and return a Schema config with these types. return { description: schemaDef?.description?.value, + defaultErrorBehavior, ...operationTypes, types: Object.values(typeMap), directives: [ @@ -245,7 +258,11 @@ export function extendSchemaImpl( } function extendNamedType(type: GraphQLNamedType): GraphQLNamedType { - if (isIntrospectionType(type) || isSpecifiedScalarType(type)) { + if ( + isIntrospectionType(type) || + isSpecifiedScalarType(type) || + isSpecifiedEnumType(type) + ) { // Builtin types are not extended. return type; } @@ -655,7 +672,7 @@ export function extendSchemaImpl( } const stdTypeMap = keyMap( - [...specifiedScalarTypes, ...introspectionTypes], + [...specifiedScalarTypes, ...specifiedEnumTypes, ...introspectionTypes], (type) => type.name, ); @@ -691,3 +708,14 @@ function getSpecifiedByURL( function isOneOf(node: InputObjectTypeDefinitionNode): boolean { return Boolean(getDirectiveValues(GraphQLOneOfDirective, node)); } + +/** + * Given a schema node, returns the GraphQL error behavior from the `@behavior(onError:)` argument. + */ +function getDefaultErrorBehavior( + node: SchemaDefinitionNode | SchemaExtensionNode, +): Maybe { + const behavior = getDirectiveValues(GraphQLBehaviorDirective, node); + // @ts-expect-error validated by `getDirectiveValues` + return behavior?.onError; +} diff --git a/src/utilities/findBreakingChanges.ts b/src/utilities/findBreakingChanges.ts index 2489af9d62..3916036b74 100644 --- a/src/utilities/findBreakingChanges.ts +++ b/src/utilities/findBreakingChanges.ts @@ -28,6 +28,7 @@ import { isScalarType, isUnionType, } from '../type/definition'; +import { isSpecifiedEnumType } from '../type/enums'; import { isSpecifiedScalarType } from '../type/scalars'; import type { GraphQLSchema } from '../type/schema'; @@ -183,8 +184,11 @@ function findTypeChanges( for (const oldType of typesDiff.removed) { schemaChanges.push({ type: BreakingChangeType.TYPE_REMOVED, + /* c8 ignore next 5 */ description: isSpecifiedScalarType(oldType) ? `Standard scalar ${oldType.name} was removed because it is not referenced anymore.` + : isSpecifiedEnumType(oldType) + ? `Standard enum ${oldType.name} was removed because it is not referenced anymore.` : `${oldType.name} was removed.`, }); } diff --git a/src/utilities/getIntrospectionQuery.ts b/src/utilities/getIntrospectionQuery.ts index 373b474ed5..28250f8f4d 100644 --- a/src/utilities/getIntrospectionQuery.ts +++ b/src/utilities/getIntrospectionQuery.ts @@ -1,5 +1,7 @@ import type { Maybe } from '../jsutils/Maybe'; +import type { ErrorBehavior } from '../error/ErrorBehavior'; + import type { DirectiveLocation } from '../language/directiveLocation'; export interface IntrospectionOptions { @@ -38,6 +40,12 @@ export interface IntrospectionOptions { * Default: false */ oneOf?: boolean; + + /** + * Whether target GraphQL server supports changing error behaviors. + * Default: false + */ + errorBehavior?: boolean; } /** @@ -52,6 +60,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { schemaDescription: false, inputValueDeprecation: false, oneOf: false, + errorBehavior: false, ...options, }; @@ -65,6 +74,9 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { const schemaDescription = optionsWithDefault.schemaDescription ? descriptions : ''; + const defaultErrorBehavior = optionsWithDefault.errorBehavior + ? 'defaultErrorBehavior' + : ''; function inputDeprecation(str: string) { return optionsWithDefault.inputValueDeprecation ? str : ''; @@ -78,6 +90,7 @@ export function getIntrospectionQuery(options?: IntrospectionOptions): string { queryType { name kind } mutationType { name kind } subscriptionType { name kind } + ${defaultErrorBehavior} types { ...FullType } @@ -195,6 +208,7 @@ export interface IntrospectionSchema { >; readonly types: ReadonlyArray; readonly directives: ReadonlyArray; + readonly defaultErrorBehavior?: Maybe; } export type IntrospectionType = diff --git a/src/utilities/lexicographicSortSchema.ts b/src/utilities/lexicographicSortSchema.ts index 26b6908c9f..269c151ba1 100644 --- a/src/utilities/lexicographicSortSchema.ts +++ b/src/utilities/lexicographicSortSchema.ts @@ -30,6 +30,7 @@ import { isUnionType, } from '../type/definition'; import { GraphQLDirective } from '../type/directives'; +import { isSpecifiedEnumType } from '../type/enums'; import { isIntrospectionType } from '../type/introspection'; import { GraphQLSchema } from '../type/schema'; @@ -115,7 +116,12 @@ export function lexicographicSortSchema(schema: GraphQLSchema): GraphQLSchema { } function sortNamedType(type: GraphQLNamedType): GraphQLNamedType { - if (isScalarType(type) || isIntrospectionType(type)) { + if ( + isScalarType(type) || + isIntrospectionType(type) || + isIntrospectionType(type) || + isSpecifiedEnumType(type) + ) { return type; } if (isObjectType(type)) { diff --git a/src/utilities/printSchema.ts b/src/utilities/printSchema.ts index edac6262c5..3c81636ba9 100644 --- a/src/utilities/printSchema.ts +++ b/src/utilities/printSchema.ts @@ -30,6 +30,7 @@ import { DEFAULT_DEPRECATION_REASON, isSpecifiedDirective, } from '../type/directives'; +import { isSpecifiedEnumType } from '../type/enums'; import { isIntrospectionType } from '../type/introspection'; import { isSpecifiedScalarType } from '../type/scalars'; import type { GraphQLSchema } from '../type/schema'; @@ -45,11 +46,23 @@ export function printSchema(schema: GraphQLSchema): string { } export function printIntrospectionSchema(schema: GraphQLSchema): string { - return printFilteredSchema(schema, isSpecifiedDirective, isIntrospectionType); + return printFilteredSchema( + schema, + isSpecifiedDirective, + isIntrospectionSchemaType, + ); } function isDefinedType(type: GraphQLNamedType): boolean { - return !isSpecifiedScalarType(type) && !isIntrospectionType(type); + return ( + !isSpecifiedScalarType(type) && + !isSpecifiedEnumType(type) && + !isIntrospectionType(type) + ); +} + +function isIntrospectionSchemaType(type: GraphQLNamedType): boolean { + return isIntrospectionType(type) || isSpecifiedEnumType(type); } function printFilteredSchema( @@ -70,7 +83,11 @@ function printFilteredSchema( } function printSchemaDefinition(schema: GraphQLSchema): Maybe { - if (schema.description == null && isSchemaOfCommonNames(schema)) { + if ( + schema.description == null && + schema.defaultErrorBehavior === 'PROPAGATE' && + isSchemaOfCommonNames(schema) + ) { return; } @@ -90,8 +107,15 @@ function printSchemaDefinition(schema: GraphQLSchema): Maybe { if (subscriptionType) { operationTypes.push(` subscription: ${subscriptionType.name}`); } + const directives = + schema.defaultErrorBehavior !== 'PROPAGATE' + ? `@behavior(onError: ${schema.defaultErrorBehavior}) ` + : ''; - return printDescription(schema) + `schema {\n${operationTypes.join('\n')}\n}`; + return ( + printDescription(schema) + + `schema ${directives}{\n${operationTypes.join('\n')}\n}` + ); } /** diff --git a/src/validation/rules/KnownTypeNamesRule.ts b/src/validation/rules/KnownTypeNamesRule.ts index fadc080c35..4219c3c9e3 100644 --- a/src/validation/rules/KnownTypeNamesRule.ts +++ b/src/validation/rules/KnownTypeNamesRule.ts @@ -11,6 +11,7 @@ import { } from '../../language/predicates'; import type { ASTVisitor } from '../../language/visitor'; +import { specifiedEnumTypes } from '../../type/enums'; import { introspectionTypes } from '../../type/introspection'; import { specifiedScalarTypes } from '../../type/scalars'; @@ -70,9 +71,11 @@ export function KnownTypeNamesRule( }; } -const standardTypeNames = [...specifiedScalarTypes, ...introspectionTypes].map( - (type) => type.name, -); +const standardTypeNames = [ + ...specifiedScalarTypes, + ...specifiedEnumTypes, + ...introspectionTypes, +].map((type) => type.name); function isSDLNode(value: ASTNode | ReadonlyArray): boolean { return (