@apollo/federation-internals
Version:
Apollo Federation internal utilities
353 lines (324 loc) • 13.7 kB
text/typescript
import {
ArgumentDefinition,
Directive,
DirectiveDefinition,
EnumType,
InputFieldDefinition,
InputObjectType,
InterfaceType,
isInputObjectType,
isNonNullType,
isScalarType,
NamedSchemaElement,
ObjectType,
Schema,
sourceASTs,
Type,
UnionType,
VariableDefinitions
} from "./definitions";
import { assertName, ASTNode, GraphQLError, GraphQLErrorOptions } from "graphql";
import { isValidValue, valueToString, isValidValueApplication } from "./values";
import { introspectionTypeNames, isIntrospectionName } from "./introspection";
import { isSubtype, sameType } from "./types";
import { ERRORS } from "./error";
// Note really meant to be called manually as it is part of `Schema.validate`, but separated for core-organization reasons.
// This mostly apply the validations that graphQL-js does in `validateSchema` which we don't reuse because it applies to
// a `GraphQLSchema` (but note that the bulk of the validation is done by `validateSDL` which we _do_ reuse in `Schema.validate`).
export function validateSchema(schema: Schema): GraphQLError[] {
return new Validator(schema).validate();
}
class InputObjectCircularRefsValidator {
private readonly visitedTypes = new Set<string>();
// Array of types nodes used to produce meaningful errors
private readonly fieldPath: InputFieldDefinition[] = [];
// Position in the field path
private readonly fieldPathIndexByTypeName = new Map<string, number>();
constructor(private readonly onError: (message: string, options: GraphQLErrorOptions) => void) {
}
detectCycles(type: InputObjectType) {
if (this.visitedTypes.has(type.name)) {
return;
}
this.visitedTypes.add(type.name);
this.fieldPathIndexByTypeName.set(type.name, this.fieldPath.length);
for (const field of type.fields()) {
if (isNonNullType(field.type!) && isInputObjectType(field.type.ofType)) {
const fieldType = field.type.ofType;
const cycleIndex = this.fieldPathIndexByTypeName.get(fieldType.name);
this.fieldPath.push(field);
if (cycleIndex === undefined) {
this.detectCycles(fieldType);
} else {
const cyclePath = this.fieldPath.slice(cycleIndex);
const pathStr = cyclePath.map((fieldObj) => fieldObj.name).join('.');
this.onError(
`Cannot reference Input Object "${fieldType.name}" within itself through a series of non-null fields: "${pathStr}".`,
{ nodes: sourceASTs(...cyclePath) },
);
}
this.fieldPath.pop();
}
}
this.fieldPathIndexByTypeName.delete(type.name);
}
}
class Validator {
private readonly emptyVariables = new VariableDefinitions();
private hasMissingTypes: boolean = false;
private readonly errors: GraphQLError[] = [];
constructor(readonly schema: Schema) {}
validate(): GraphQLError[] {
for (const type of this.schema.types()) {
if (!introspectionTypeNames.includes(type.name)) {
this.validateName(type);
}
switch (type.kind) {
case 'ObjectType':
case 'InterfaceType':
this.validateObjectOrInterfaceType(type);
break;
case 'InputObjectType':
this.validateInputObjectType(type);
break;
case 'UnionType':
this.validateUnionType(type);
break;
case 'EnumType':
this.validateEnumType(type);
break;
}
}
for (const directive of this.schema.allDirectives()) {
this.validateName(directive);
for (const arg of directive.arguments()) {
this.validateArg(arg);
}
for (const application of directive.applications()) {
this.validateDirectiveApplication(directive, application)
}
}
// We do the interface implementation and input object cycles validation after we've validated
// all types, because both of those checks reach into other types than the one directly checked
// so we want to make sure all types are properly set. That is also why we skip those checks if
// we found any type missing (in which case, there will be some errors and users should fix those
// first).
if (!this.hasMissingTypes) {
const refsValidator = new InputObjectCircularRefsValidator((msg, opts) => this.addError(msg, opts));
for (const type of this.schema.types()) {
switch (type.kind) {
case 'ObjectType':
case 'InterfaceType':
this.validateImplementedInterfaces(type);
break;
case 'InputObjectType':
refsValidator.detectCycles(type);
break;
}
}
}
return this.errors;
}
private addError(message: string, options: GraphQLErrorOptions) {
this.errors.push(ERRORS.INVALID_GRAPHQL.err(message, options));
}
private validateHasType(elt: { type?: Type, coordinate: string, sourceAST?: ASTNode }): boolean {
// Note that this error can't happen if you parse the schema since it wouldn't be valid syntax, but it can happen for
// programmatically constructed schema.
if (!elt.type) {
this.addError(`Element ${elt.coordinate} does not have a type set`, { nodes: elt.sourceAST });
this.hasMissingTypes = false;
}
return !!elt.type;
}
private validateName(elt: { name: string, sourceAST?: ASTNode}) {
if (isIntrospectionName(elt.name)) {
this.addError(
`Name "${elt.name}" must not begin with "__", which is reserved by GraphQL introspection.`,
elt.sourceAST ? { nodes: elt.sourceAST } : {}
);
return;
}
try {
assertName(elt.name);
} catch (e) {
this.addError(e.message, elt.sourceAST ? { nodes: elt.sourceAST } : {});
}
}
private validateObjectOrInterfaceType(type: ObjectType | InterfaceType) {
if (!type.hasFields()) {
this.addError(`Type ${type.name} must define one or more fields.`, { nodes: type.sourceAST });
}
for (const field of type.fields()) {
this.validateName(field);
this.validateHasType(field);
for (const arg of field.arguments()) {
this.validateArg(arg);
}
}
}
private validateImplementedInterfaces(type: ObjectType | InterfaceType) {
if (type.implementsInterface(type.name)) {
this.addError(
`Type ${type} cannot implement itself because it would create a circular reference.`,
{ nodes: sourceASTs(type, type.interfaceImplementation(type.name)!) },
);
}
for (const itf of type.interfaces()) {
for (const itfField of itf.fields()) {
const field = type.field(itfField.name);
if (!field) {
this.addError(
`Interface field ${itfField.coordinate} expected but ${type} does not provide it.`,
{ nodes: sourceASTs(itfField, type) },
);
continue;
}
// Note that we may not have validated the interface yet, so making sure we have a meaningful error
// if the type is not set, even if that means a bit of cpu wasted since we'll re-check later (and
// as many type as the interface is implemented); it's a cheap check anyway.
if (this.validateHasType(itfField) && !isSubtype(itfField.type!, field.type!)) {
this.addError(
`Interface field ${itfField.coordinate} expects type ${itfField.type} but ${field.coordinate} of type ${field.type} is not a proper subtype.`,
{ nodes: sourceASTs(itfField, field) },
);
}
for (const itfArg of itfField.arguments()) {
const arg = field.argument(itfArg.name);
if (!arg) {
this.addError(
`Interface field argument ${itfArg.coordinate} expected but ${field.coordinate} does not provide it.`,
{ nodes: sourceASTs(itfArg, field) },
);
continue;
}
// Note that we could use contra-variance but as graphQL-js currently doesn't allow it, we mimic that.
if (this.validateHasType(itfArg) && !sameType(itfArg.type!, arg.type!)) {
this.addError(
`Interface field argument ${itfArg.coordinate} expects type ${itfArg.type} but ${arg.coordinate} is type ${arg.type}.`,
{ nodes: sourceASTs(itfArg, arg) },
);
}
}
for (const arg of field.arguments()) {
// Now check arguments on the type field that are not in the interface. They should not be required.
if (itfField.argument(arg.name)) {
continue;
}
if (arg.isRequired()) {
this.addError(
`Field ${field.coordinate} includes required argument ${arg.name} that is missing from the Interface field ${itfField.coordinate}.`,
{ nodes: sourceASTs(arg, itfField) },
);
}
}
}
// Now check that this type also declare implementations of all the interfaces of its interface.
for (const itfOfItf of itf.interfaces()) {
if (!type.implementsInterface(itfOfItf)) {
if (itfOfItf === type) {
this.addError(
`Type ${type} cannot implement ${itf} because it would create a circular reference.`,
{ nodes: sourceASTs(type, itf) },
);
} else {
this.addError(
`Type ${type} must implement ${itfOfItf} because it is implemented by ${itf}.`,
{ nodes: sourceASTs(type, itf, itfOfItf) },
);
}
}
}
}
}
private validateInputObjectType(type: InputObjectType) {
if (!type.hasFields()) {
this.addError(`Input Object type ${type.name} must define one or more fields.`, { nodes: type.sourceAST });
}
for (const field of type.fields()) {
this.validateName(field);
if (!this.validateHasType(field)) {
continue;
}
if (field.isRequired() && field.isDeprecated()) {
this.addError(
`Required input field ${field.coordinate} cannot be deprecated.`,
{ nodes: sourceASTs(field.appliedDirectivesOf('deprecated')[0], field) },
);
}
if (field.defaultValue !== undefined && !isValidValue(field.defaultValue, field, new VariableDefinitions())) {
this.addError(
`Invalid default value (got: ${valueToString(field.defaultValue)}) provided for input field ${field.coordinate} of type ${field.type}.`,
{ nodes: sourceASTs(field) },
);
}
}
}
private validateArg(arg: ArgumentDefinition<any>) {
this.validateName(arg);
if (!this.validateHasType(arg)) {
return;
}
if (arg.isRequired() && arg.isDeprecated()) {
this.addError(
`Required argument ${arg.coordinate} cannot be deprecated.`,
{ nodes: sourceASTs(arg.appliedDirectivesOf('deprecated')[0], arg) },
);
}
if (arg.defaultValue !== undefined && !isValidValue(arg.defaultValue, arg, new VariableDefinitions())) {
// don't error if custom scalar is shadowing a builtin scalar
const builtInScalar = this.schema.builtInScalarTypes().find((t) => arg.type && isScalarType(arg.type) && t.name === arg.type.name);
if (!builtInScalar || !isValidValueApplication(arg.defaultValue, builtInScalar, arg.defaultValue, new VariableDefinitions())) {
this.addError(
`Invalid default value (got: ${valueToString(arg.defaultValue)}) provided for argument ${arg.coordinate} of type ${arg.type}.`,
{ nodes: sourceASTs(arg) },
);
}
}
}
private validateUnionType(type: UnionType) {
if (type.membersCount() === 0) {
this.addError(`Union type ${type.coordinate} must define one or more member types.`, { nodes: type.sourceAST });
}
}
private validateEnumType(type: EnumType) {
if (type.values.length === 0) {
this.addError(`Enum type ${type.coordinate} must define one or more values.`, { nodes: type.sourceAST });
}
for (const value of type.values) {
this.validateName(value);
if (value.name === 'true' || value.name === 'false' || value.name === 'null') {
this.addError(
`Enum type ${type.coordinate} cannot include value: ${value}.`,
{ nodes: value.sourceAST },
);
}
}
}
private validateDirectiveApplication(definition: DirectiveDefinition, application: Directive) {
// Note that graphQL `validateSDL` method will already have validated that we only have
// known arguments and that that we don't miss a required argument. What remains is to
// ensure each provided value if valid for the argument type.
for (const argument of definition.arguments()) {
const value = application.arguments()[argument.name];
if (!value) {
// Again, that implies that value is not required.
continue;
}
// Note that we validate if the definition argument has a type set separatly
// and log an error if necesary, but we just want to avoid calling
// `isValidValue` if there is not type as it may throw.
if (argument.type && !isValidValue(value, argument, this.emptyVariables)) {
const parent = application.parent;
// The only non-named SchemaElement is the `schema` definition.
const parentDesc = parent instanceof NamedSchemaElement
? parent.coordinate
: 'schema';
this.addError(
`Invalid value for "${argument.coordinate}" of type "${argument.type}" in application of "${definition.coordinate}" to "${parentDesc}".`,
{ nodes: sourceASTs(application, argument) },
);
}
}
}
}