Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ export {
// Validate GraphQL schema.
validateSchema,
assertValidSchema,
experimentalValidateFullSchema,
experimentalAssertValidFullSchema,
// Upholds the spec rules about naming.
assertName,
assertEnumValueName,
Expand Down
127 changes: 126 additions & 1 deletion src/type/__tests__/validation-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.`);
});
});
7 changes: 6 additions & 1 deletion src/type/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 8 additions & 0 deletions src/type/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ export class GraphQLSchema {
* @private
*/
__validationErrors: Maybe<ReadonlyArray<GraphQLError>>;
/**
* Cached full schema validation errors, if validation has already run.
* @private
*/
__fullSchemaValidationErrors: Maybe<ReadonlyArray<GraphQLError>>;

private _queryType: Maybe<GraphQLObjectType>;
private _mutationType: Maybe<GraphQLObjectType>;
Expand Down Expand Up @@ -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);
Expand Down
121 changes: 113 additions & 8 deletions src/type/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GraphQLError> {
// 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<GraphQLError> {
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.
Expand All @@ -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<GraphQLError>;
readonly schema: GraphQLSchema;
readonly allowReservedNames: boolean;

constructor(schema: GraphQLSchema) {
constructor(schema: GraphQLSchema, allowReservedNames: boolean) {
this._errors = [];
this.schema = schema;
this.allowReservedNames = allowReservedNames;
}

reportError(
Expand Down Expand Up @@ -395,8 +499,9 @@ function validateName(
context: SchemaValidationContext,
node: { readonly name: string; readonly astNode: Maybe<ASTNode> },
): 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,
Expand Down
Loading