diff --git a/src/index.ts b/src/index.ts index 219478ad42..0aa7ee3b64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -172,6 +172,8 @@ export { // Validate GraphQL schema. validateSchema, assertValidSchema, + experimentalValidateFullSchema, + experimentalAssertValidFullSchema, // Upholds the spec rules about naming. assertName, assertEnumValueName, diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index 8973013fa1..8b4db34f45 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -40,7 +40,12 @@ import { import { assertDirective, GraphQLDirective } from '../directives.ts'; import { GraphQLInt, GraphQLString } from '../scalars.ts'; import { GraphQLSchema } from '../schema.ts'; -import { assertValidSchema, validateSchema } from '../validate.ts'; +import { + assertValidSchema, + experimentalAssertValidFullSchema, + experimentalValidateFullSchema, + validateSchema, +} from '../validate.ts'; const SomeSchema = buildSchema(` scalar SomeScalar @@ -3401,3 +3406,123 @@ describe('assertValidSchema', () => { Type SomeType must define one or more fields.`); }); }); + +describe('Type System: A full schema may contain reserved names', () => { + it('accepts reserved names from a newer version of GraphQL', () => { + // Valid SDL returned from a hypothetical newer version of GraphQL. + const schema = buildSchema(` + type Query { + hello: String + } + + enum __ErrorBehavior { + NULL + PROPAGATE + HALT + } + + directive @behavior(onError: __ErrorBehavior) on SCHEMA + `); + + // validateSchema rejects the reserved name. + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Name "__ErrorBehavior" must not begin with "__", which is reserved by GraphQL introspection.', + locations: [{ line: 6, column: 7 }], + }, + ]); + + // experimentalValidateFullSchema permits it. + expectJSON(experimentalValidateFullSchema(schema)).toDeepEqual([]); + }); + + it('accepts reserved names on types, fields, args, enum values and input fields', () => { + const schema = schemaWithFieldType( + new GraphQLObjectType({ + name: '__SomeObject', + fields: { + __badField: { + type: GraphQLString, + args: { + __badArg: { type: GraphQLString }, + }, + }, + }, + }), + ); + + expect(validateSchema(schema)).to.not.deep.equal([]); + expectJSON(experimentalValidateFullSchema(schema)).toDeepEqual([]); + }); + + it('still reports non-name errors when validating a full schema', () => { + const schema = buildSchema(` + type SomeType + + enum __ErrorBehavior { + NULL + PROPAGATE + HALT + } + `); + + expectJSON(experimentalValidateFullSchema(schema)).toDeepEqual([ + { + message: 'Query root type must be provided.', + }, + { + message: 'Type SomeType must define one or more fields.', + locations: [{ line: 2, column: 7 }], + }, + ]); + }); + + it('caches full schema validation results independently', () => { + const schema = buildSchema(` + type Query { + hello: String + } + + enum __ErrorBehavior { + NULL + } + `); + + // Populate the regular cache first, then the full schema cache. + const regularErrors = validateSchema(schema); + const fullErrors = experimentalValidateFullSchema(schema); + + expect(regularErrors).to.have.lengthOf(1); + expect(fullErrors).to.deep.equal([]); + + // Repeated calls return the cached results for each mode. + expect(validateSchema(schema)).to.equal(regularErrors); + expect(experimentalValidateFullSchema(schema)).to.equal(fullErrors); + }); +}); + +describe('experimentalAssertValidFullSchema', () => { + it('does not throw on a full schema containing reserved names', () => { + const schema = buildSchema(` + type Query { + hello: String + } + + enum __ErrorBehavior { + NULL + PROPAGATE + HALT + } + `); + expect(() => experimentalAssertValidFullSchema(schema)).to.not.throw(); + }); + + it('combines multiple errors', () => { + const schema = buildSchema('type SomeType'); + expect(() => experimentalAssertValidFullSchema(schema)).to.throw(dedent` + Query root type must be provided. + + Type SomeType must define one or more fields.`); + }); +}); diff --git a/src/type/index.ts b/src/type/index.ts index 0f7f6206a9..ec0adef6ed 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -207,7 +207,12 @@ export { } from './introspection.ts'; // Validate GraphQL schema. -export { validateSchema, assertValidSchema } from './validate.ts'; +export { + validateSchema, + assertValidSchema, + experimentalValidateFullSchema, + experimentalAssertValidFullSchema, +} from './validate.ts'; // Upholds the spec rules about naming. export { assertName, assertEnumValueName } from './assertName.ts'; diff --git a/src/type/schema.ts b/src/type/schema.ts index 93bff3c792..84728d2536 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -208,6 +208,11 @@ export class GraphQLSchema { * @private */ __validationErrors: Maybe>; + /** + * Cached full schema validation errors, if validation has already run. + * @private + */ + __fullSchemaValidationErrors: Maybe>; private _queryType: Maybe; private _mutationType: Maybe; @@ -323,6 +328,9 @@ export class GraphQLSchema { this.assumeValid = config.assumeValid ?? false; // Used as a cache for validateSchema(). this.__validationErrors = config.assumeValid === true ? [] : undefined; + // Used as a cache for validateFullSchema(). + this.__fullSchemaValidationErrors = + config.assumeValid === true ? [] : undefined; this.description = config.description; this.extensions = toObjMapWithSymbols(config.extensions); diff --git a/src/type/validate.ts b/src/type/validate.ts index 4579901e2e..3fe6546736 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -103,18 +103,85 @@ export function validateSchema( } // Validate the schema, producing a list of errors. - const context = new SchemaValidationContext(schema); - validateRootTypes(context); - validateDirectives(context); - validateTypes(context); + const errors = validateSchemaImpl(schema, false); // Persist the results of validation before returning to ensure validation // does not run multiple times for this schema. - const errors = context.getErrors(); schema.__validationErrors = errors; return errors; } +/** + * Implements the "Type Validation" sub-sections of the specification's + * "Type System" section, with the exception that names reserved by GraphQL + * introspection (those beginning with `__`) are permitted. + * + * Unlike `validateSchema`, this validates a "full schema": one that includes + * the built-in definitions and service capabilities added by an implementation, + * such as a schema reconstructed from an introspection result. Such a schema may + * legitimately contain reserved names, including ones added by a newer version + * of GraphQL than this library implements, so those names are not rejected. + * + * Note: This API is experimental and may change or be removed in a future + * version while the behavior and test coverage for full schemas converge. + * + * Validation runs synchronously, returning an array of encountered errors, or + * an empty array if no errors were encountered and the Schema is valid. + * @param schema - GraphQL full schema to use. + * @returns Schema validation errors, or an empty array when the schema is valid. + * @experimental + * @example + * ```ts + * import { experimentalValidateFullSchema } from 'graphql/type'; + * import { buildSchema } from 'graphql/utilities'; + * + * const schema = buildSchema(` + * type Query { + * name: String + * } + * + * enum __ErrorBehavior { + * NULL + * PROPAGATE + * HALT + * } + * `); + * const errors = experimentalValidateFullSchema(schema); + * + * errors; // => [] + * ``` + */ +export function experimentalValidateFullSchema( + schema: GraphQLSchema, +): ReadonlyArray { + // First check to ensure the provided value is in fact a GraphQLSchema. + assertSchema(schema); + + // If this Schema has already been validated, return the previous results. + if (schema.__fullSchemaValidationErrors) { + return schema.__fullSchemaValidationErrors; + } + + // Validate the schema, producing a list of errors. + const errors = validateSchemaImpl(schema, true); + + // Persist the results of validation before returning to ensure validation + // does not run multiple times for this schema. + schema.__fullSchemaValidationErrors = errors; + return errors; +} + +function validateSchemaImpl( + schema: GraphQLSchema, + allowReservedNames: boolean, +): ReadonlyArray { + const context = new SchemaValidationContext(schema, allowReservedNames); + validateRootTypes(context); + validateDirectives(context); + validateTypes(context); + return context.getErrors(); +} + /** * Utility function which asserts a schema is valid by throwing an error if * it is invalid. @@ -140,13 +207,50 @@ export function assertValidSchema(schema: GraphQLSchema): void { } } +/** + * Utility function which asserts a full schema is valid by throwing an error if + * it is invalid. + * + * Note: This API is experimental and may change or be removed in a future + * version while the behavior and test coverage for full schemas converge. + * @param schema - GraphQL full schema to use. + * @experimental + * @example + * ```ts + * import { experimentalAssertValidFullSchema } from 'graphql/type'; + * import { buildSchema } from 'graphql/utilities'; + * + * const schema = buildSchema(` + * type Query { + * name: String + * } + * + * enum __ErrorBehavior { + * NULL + * PROPAGATE + * HALT + * } + * `); + * + * experimentalAssertValidFullSchema(schema); // does not throw + * ``` + */ +export function experimentalAssertValidFullSchema(schema: GraphQLSchema): void { + const errors = experimentalValidateFullSchema(schema); + if (errors.length !== 0) { + throw new Error(errors.map((error) => error.message).join('\n\n')); + } +} + class SchemaValidationContext { readonly _errors: Array; readonly schema: GraphQLSchema; + readonly allowReservedNames: boolean; - constructor(schema: GraphQLSchema) { + constructor(schema: GraphQLSchema, allowReservedNames: boolean) { this._errors = []; this.schema = schema; + this.allowReservedNames = allowReservedNames; } reportError( @@ -395,8 +499,9 @@ function validateName( context: SchemaValidationContext, node: { readonly name: string; readonly astNode: Maybe }, ): void { - // Ensure names are valid, however introspection types opt out. - if (node.name.startsWith('__')) { + // Ensure names are valid, however introspection types opt out, as do all + // reserved names when validating a full schema. + if (!context.allowReservedNames && node.name.startsWith('__')) { context.reportError( `Name "${node.name}" must not begin with "__", which is reserved by GraphQL introspection.`, node.astNode,