@apollo/federation-internals
Version:
Apollo Federation internal utilities
1,259 lines (1,170 loc) • 117 kB
text/typescript
import {
allSchemaRootKinds,
baseType,
CompositeType,
CoreFeature,
defaultRootName,
Directive,
DirectiveDefinition,
ErrGraphQLValidationFailed,
Extension,
FieldDefinition,
InputFieldDefinition,
InterfaceType,
isCompositeType,
isInterfaceType,
isObjectType,
isUnionType,
ListType,
NamedType,
NonNullType,
ObjectType,
ScalarType,
Schema,
SchemaBlueprint,
SchemaConfig,
SchemaDefinition,
SchemaElement,
sourceASTs,
UnionType,
ArgumentDefinition,
InputType,
OutputType,
WrapperType,
isNonNullType,
isLeafType,
isListType,
isWrapperType,
possibleRuntimeTypes,
isIntType,
Type,
} from "./definitions";
import { assert, MultiMap, printHumanReadableList, OrderedMap, mapValues, assertUnreachable } from "./utils";
import { SDLValidationRule } from "graphql/validation/ValidationContext";
import { specifiedSDLRules } from "graphql/validation/specifiedRules";
import {
ASTNode,
DocumentNode,
GraphQLError,
Kind,
KnownTypeNamesRule,
PossibleTypeExtensionsRule,
print as printAST,
Source,
GraphQLErrorOptions,
SchemaDefinitionNode,
OperationTypeNode,
OperationTypeDefinitionNode,
ConstDirectiveNode,
} from "graphql";
import { KnownTypeNamesInFederationRule } from "./validation/KnownTypeNamesInFederationRule";
import { buildSchema, buildSchemaFromAST } from "./buildSchema";
import { FragmentSelection, hasSelectionWithPredicate, parseOperationAST, parseSelectionSet, Selection, SelectionSet } from './operations';
import { TAG_VERSIONS } from "./specs/tagSpec";
import {
errorCodeDef,
ErrorCodeDefinition,
ERROR_CATEGORIES,
ERRORS,
withModifiedErrorMessage,
extractGraphQLErrorOptions,
errorCauses,
} from "./error";
import { computeShareables } from "./precompute";
import {
CoreSpecDefinition,
FeatureVersion,
LINK_VERSIONS,
LinkDirectiveArgs,
linkDirectiveDefaultName,
linkIdentity,
FeatureUrl,
CoreImport,
extractCoreFeatureImports,
CoreOrLinkDirectiveArgs,
} from "./specs/coreSpec";
import {
FEDERATION_VERSIONS,
federationIdentity,
FederationDirectiveName,
FederationTypeName,
FEDERATION1_TYPES,
FEDERATION1_DIRECTIVES,
FederationSpecDefinition,
} from "./specs/federationSpec";
import { defaultPrintOptions, PrintOptions as PrintOptions, printSchema } from "./print";
import { createObjectTypeSpecification, createScalarTypeSpecification, createUnionTypeSpecification } from "./directiveAndTypeSpecification";
import { didYouMean, suggestionList } from "./suggestions";
import { coreFeatureDefinitionIfKnown } from "./knownCoreFeatures";
import { joinIdentity } from "./specs/joinSpec";
import { COST_VERSIONS, CostDirectiveArguments, ListSizeDirectiveArguments, costIdentity } from "./specs/costSpec";
const linkSpec = LINK_VERSIONS.latest();
const tagSpec = TAG_VERSIONS.latest();
const federationSpec = (version?: FeatureVersion): FederationSpecDefinition => {
if (!version) return FEDERATION_VERSIONS.latest();
const spec = FEDERATION_VERSIONS.find(version);
assert(spec, `Federation spec version ${version} is not known`);
return spec;
};
// Some users rely on auto-expanding fed v1 graphs with fed v2 directives. While technically we should only expand @tag
// directive from v2 definitions, we will continue expanding other directives (up to v2.4) to ensure backwards compatibility.
const autoExpandedFederationSpec = federationSpec(new FeatureVersion(2, 4));
const latestFederationSpec = federationSpec();
// We don't let user use this as a subgraph name. That allows us to use it in `query graphs` to name the source of roots
// in the "federated query graph" without worrying about conflict (see `FEDERATED_GRAPH_ROOT_SOURCE` in `querygraph.ts`).
// (note that we could deal with this in other ways, but having a graph named '_' feels like a terrible idea anyway, so
// disallowing it feels like more a good thing than a real restriction).
export const FEDERATION_RESERVED_SUBGRAPH_NAME = '_';
export const FEDERATION_UNNAMED_SUBGRAPH_NAME = '<unnamed>';
const FEDERATION_OMITTED_VALIDATION_RULES = [
// We allow subgraphs to declare an extension even if the subgraph itself doesn't have a corresponding definition.
// The implication being that the definition is in another subgraph.
PossibleTypeExtensionsRule,
// The `KnownTypeNamesRule` of graphQL-js only looks at type definitions, so this goes against our previous
// desire to let a subgraph only have an extension for a type. Below, we add a replacement rules that looks
// at both type definitions _and_ extensions.
KnownTypeNamesRule
];
const FEDERATION_SPECIFIC_VALIDATION_RULES = [
KnownTypeNamesInFederationRule
];
const FEDERATION_VALIDATION_RULES = specifiedSDLRules.filter(rule => !FEDERATION_OMITTED_VALIDATION_RULES.includes(rule)).concat(FEDERATION_SPECIFIC_VALIDATION_RULES);
const ALL_DEFAULT_FEDERATION_DIRECTIVE_NAMES: string[] = Object.values(FederationDirectiveName);
/**
* Federation 1 has that specificity that it wasn't using @link to name-space federation elements,
* and so to "distinguish" the few federation type names, it prefixed those with a `_`. That is,
* the `FieldSet` type was named `_FieldSet` in federation1. To handle this without too much effort,
* we use a fake `CoreFeature` with imports for all the fed1 types to use those specific "aliases"
* and we pass it when adding those types. This allows to reuse the same `TypeSpecification` objects
* for both fed1 and fed2. Note that in the object below, all that is used is the imports, the rest
* is just filling the blanks.
*/
const FAKE_FED1_CORE_FEATURE_TO_RENAME_TYPES: CoreFeature = new CoreFeature(
new FeatureUrl('<fed1>', 'fed1', new FeatureVersion(0, 1)),
'fed1',
new Directive('fed1'),
FEDERATION1_TYPES.map((spec) => ({ name: spec.name, as: '_' + spec.name})),
);
function validateFieldSetSelections({
directiveName,
selectionSet,
hasExternalInParents,
metadata,
onError,
allowOnNonExternalLeafFields,
allowFieldsWithArguments,
}: {
directiveName: string,
selectionSet: SelectionSet,
hasExternalInParents: boolean,
metadata: FederationMetadata,
onError: (error: GraphQLError) => void,
allowOnNonExternalLeafFields: boolean,
allowFieldsWithArguments: boolean,
}): void {
for (const selection of selectionSet.selections()) {
const appliedDirectives = selection.element.appliedDirectives;
if (appliedDirectives.length > 0) {
onError(ERROR_CATEGORIES.DIRECTIVE_IN_FIELDS_ARG.get(directiveName).err(
`cannot have directive applications in the @${directiveName}(fields:) argument but found ${appliedDirectives.join(', ')}.`,
));
}
if (selection.kind === 'FieldSelection') {
const field = selection.element.definition;
const isExternal = metadata.isFieldExternal(field);
if (!allowFieldsWithArguments && field.hasArguments()) {
onError(ERROR_CATEGORIES.FIELDS_HAS_ARGS.get(directiveName).err(
`field ${field.coordinate} cannot be included because it has arguments (fields with argument are not allowed in @${directiveName})`,
{ nodes: field.sourceAST },
));
}
// The field must be external if we don't allow non-external leaf fields, it's a leaf, and we haven't traversed an external field in parent chain leading here.
const mustBeExternal = !selection.selectionSet && !allowOnNonExternalLeafFields && !hasExternalInParents;
if (!isExternal && mustBeExternal) {
const errorCode = ERROR_CATEGORIES.DIRECTIVE_FIELDS_MISSING_EXTERNAL.get(directiveName);
if (metadata.isFieldFakeExternal(field)) {
onError(errorCode.err(
`field "${field.coordinate}" should not be part of a @${directiveName} since it is already "effectively" provided by this subgraph `
+ `(while it is marked @${FederationDirectiveName.EXTERNAL}, it is a @${FederationDirectiveName.KEY} field of an extension type, which are not internally considered external for historical/backward compatibility reasons)`,
{ nodes: field.sourceAST }
));
} else {
onError(errorCode.err(
`field "${field.coordinate}" should not be part of a @${directiveName} since it is already provided by this subgraph (it is not marked @${FederationDirectiveName.EXTERNAL})`,
{ nodes: field.sourceAST }
));
}
}
if (selection.selectionSet) {
// When passing the 'hasExternalInParents', the field might be external himself, but we may also have
// the case where the field parent is an interface and some implementation of the field are external, in
// which case we should say we have an external on the path, because we may have one.
let newHasExternalInParents = hasExternalInParents || isExternal;
const parentType = field.parent;
if (!newHasExternalInParents && isInterfaceType(parentType)) {
for (const implem of parentType.possibleRuntimeTypes()) {
const fieldInImplem = implem.field(field.name);
if (fieldInImplem && metadata.isFieldExternal(fieldInImplem)) {
newHasExternalInParents = true;
break;
}
}
}
validateFieldSetSelections({
directiveName,
selectionSet: selection.selectionSet,
hasExternalInParents: newHasExternalInParents,
metadata,
onError,
allowOnNonExternalLeafFields,
allowFieldsWithArguments,
});
}
} else {
validateFieldSetSelections({
directiveName,
selectionSet: selection.selectionSet,
hasExternalInParents,
metadata,
onError,
allowOnNonExternalLeafFields,
allowFieldsWithArguments,
});
}
}
}
function validateFieldSet({
type,
directive,
metadata,
errorCollector,
allowOnNonExternalLeafFields,
allowFieldsWithArguments,
onFields,
}: {
type: CompositeType,
directive: Directive<any, {fields: any}>,
metadata: FederationMetadata,
errorCollector: GraphQLError[],
allowOnNonExternalLeafFields: boolean,
allowFieldsWithArguments: boolean,
onFields?: (field: FieldDefinition<any>) => void,
}): void {
try {
// Note that `parseFieldSetArgument` already properly format the error, hence the separate try-catch.
// TODO: `parseFieldSetArgument` throws on the first issue found and never accumulate multiple
// errors. We could fix this, but this require changes that reaches beyond this single file, so
// we leave this for "later" (the `fields` value are rarely very big, so the benefit of accumulating
// multiple errors within one such value is not tremendous, so that this doesn't feel like a pressing
// issue).
const fieldAccessor = onFields
? (type: CompositeType, fieldName: string) => {
const field = type.field(fieldName);
if (field) {
onFields(field);
}
return field;
}
: undefined;
const selectionSet = parseFieldSetArgument({parentType: type, directive, fieldAccessor});
validateFieldSetSelections({
directiveName: directive.name,
selectionSet,
hasExternalInParents: false,
metadata,
onError: (error) => errorCollector.push(handleFieldSetValidationError(directive, error)),
allowOnNonExternalLeafFields,
allowFieldsWithArguments,
});
} catch (e) {
if (e instanceof GraphQLError) {
errorCollector.push(e);
} else {
throw e;
}
}
}
function handleFieldSetValidationError(
directive: Directive<any, {fields: any}>,
originalError: GraphQLError,
messageUpdater?: (msg: string) => string,
): GraphQLError {
const nodes = sourceASTs(directive);
if (originalError.nodes) {
nodes.push(...originalError.nodes);
}
let codeDef = errorCodeDef(originalError);
// "INVALID_GRAPHQL" errors happening during validation means that the selection set is invalid, and
// that's where we want to use a more precise code.
if (!codeDef || codeDef === ERRORS.INVALID_GRAPHQL) {
codeDef = ERROR_CATEGORIES.DIRECTIVE_INVALID_FIELDS.get(directive.name);
}
let msg = originalError.message.trim();
if (messageUpdater) {
msg = messageUpdater(msg);
}
return codeDef.err(
`${fieldSetErrorDescriptor(directive)}: ${msg}`,
{
nodes,
originalError,
}
);
}
function fieldSetErrorDescriptor(directive: Directive<any, {fields: any}>): string {
return `On ${fieldSetTargetDescription(directive)}, for ${directiveStrUsingASTIfPossible(directive)}`;
}
// This method is called to display @key, @provides or @requires directives in error message in place where the directive `fields`
// argument might be invalid because it was not a string in the underlying AST. If that's the case, we want to use the AST to
// print the directive or the message might be a bit confusing for the user.
function directiveStrUsingASTIfPossible(directive: Directive<any>): string {
return directive.sourceAST ? printAST(directive.sourceAST) : directive.toString();
}
function fieldSetTargetDescription(directive: Directive<any, {fields: any}>): string {
const targetKind = directive.parent instanceof FieldDefinition ? "field" : "type";
return `${targetKind} "${directive.parent?.coordinate}"`;
}
export function parseContext(input: string) {
const regex = /^(?:[\n\r\t ,]|#[^\n\r]*(?![^\n\r]))*\$(?:[\n\r\t ,]|#[^\n\r]*(?![^\n\r]))*([A-Za-z_]\w*(?!\w))([\s\S]*)$/;
const match = input.match(regex);
if (!match) {
return { context: undefined, selection: undefined };
}
const [, context, selection] = match;
return {
context,
selection,
};
}
const wrapResolvedType = ({
originalType,
resolvedType,
}: {
originalType: OutputType,
resolvedType: InputType,
}): InputType | undefined => {
const stack = [];
let unwrappedType: NamedType | WrapperType = originalType;
while(unwrappedType.kind === 'NonNullType' || unwrappedType.kind === 'ListType') {
stack.push(unwrappedType.kind);
unwrappedType = unwrappedType.ofType;
}
let type: NamedType | WrapperType = resolvedType;
while(stack.length > 0) {
const kind = stack.pop();
if (kind === 'ListType') {
type = new ListType(type);
}
}
return type;
};
const validateFieldValueType = ({
currentType,
selectionSet,
errorCollector,
metadata,
fromContextParent,
}: {
currentType: CompositeType,
selectionSet: SelectionSet,
errorCollector: GraphQLError[],
metadata: FederationMetadata,
fromContextParent: ArgumentDefinition<FieldDefinition<ObjectType | InterfaceType | UnionType>>,
}): { resolvedType: InputType | undefined } => {
const selections = selectionSet.selections();
// ensure that type is not an interfaceObject
const interfaceObjectDirective = metadata.interfaceObjectDirective();
if (currentType.kind === 'ObjectType' && isFederationDirectiveDefinedInSchema(interfaceObjectDirective) && (currentType.appliedDirectivesOf(interfaceObjectDirective).length > 0)) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "is used in "${fromContextParent.coordinate}" but the selection is invalid: One of the types in the selection is an interfaceObject: "${currentType.name}"`,
{ nodes: sourceASTs(fromContextParent) }
));
}
const typesArray = selections.map((selection): { resolvedType: InputType | undefined } => {
if (selection.kind !== 'FieldSelection') {
return { resolvedType: undefined };
}
const { element, selectionSet: childSelectionSet } = selection;
assert(element.definition.type, 'Element type definition should exist');
let type = element.definition.type;
if (childSelectionSet) {
assert(isCompositeType(baseType(type)), 'Child selection sets should only exist on composite types');
const { resolvedType } = validateFieldValueType({
currentType: baseType(type) as CompositeType,
selectionSet: childSelectionSet,
errorCollector,
metadata,
fromContextParent,
});
if (!resolvedType) {
return { resolvedType: undefined };
}
return { resolvedType: wrapResolvedType({ originalType: type, resolvedType}) };
}
assert(isLeafType(baseType(type)), 'Expected a leaf type');
return {
resolvedType: wrapResolvedType({
originalType: type,
resolvedType: baseType(type) as InputType
})
};
});
return typesArray.reduce((acc, { resolvedType }) => {
if (acc.resolvedType?.toString() === resolvedType?.toString()) {
return { resolvedType };
}
return { resolvedType: undefined };
});
};
const validateSelectionFormat = ({
context,
selection,
fromContextParent,
errorCollector,
} : {
context: string,
selection: string,
fromContextParent: ArgumentDefinition<FieldDefinition<ObjectType | InterfaceType | UnionType>>,
errorCollector: GraphQLError[],
}): {
selectionType: 'error' | 'field',
} | {
selectionType: 'inlineFragment',
typeConditions: Set<string>,
} => {
// we only need to parse the selection once, not do it for each location
try {
const node = parseOperationAST(selection.trim().startsWith('{') ? selection : `{${selection}}`);
const selections = node.selectionSet.selections;
if (selections.length === 0) {
// a selection must be made.
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no selection is made`,
{ nodes: sourceASTs(fromContextParent) }
));
return { selectionType: 'error' };
}
const firstSelectionKind = selections[0].kind;
if (firstSelectionKind === 'Field') {
// if the first selection is a field, there should be only one
if (selections.length !== 1) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple selections are made`,
{ nodes: sourceASTs(fromContextParent) }
));
return { selectionType: 'error' };
}
return { selectionType: 'field' };
} else if (firstSelectionKind === 'InlineFragment') {
const inlineFragmentTypeConditions: Set<string> = new Set();
if (!selections.every((s) => s.kind === 'InlineFragment')) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: multiple fields could be selected`,
{ nodes: sourceASTs(fromContextParent) }
));
return { selectionType: 'error' };
}
selections.forEach((s) => {
assert(s.kind === 'InlineFragment', 'Expected an inline fragment');
const { typeCondition }= s;
if (typeCondition) {
inlineFragmentTypeConditions.add(typeCondition.name.value);
}
});
if (inlineFragmentTypeConditions.size !== selections.length) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions have same name`,
{ nodes: sourceASTs(fromContextParent) }
));
return { selectionType: 'error' };
}
return {
selectionType: 'inlineFragment',
typeConditions: inlineFragmentTypeConditions,
};
} else if (firstSelectionKind === 'FragmentSpread') {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: fragment spread is not allowed`,
{ nodes: sourceASTs(fromContextParent) }
));
return { selectionType: 'error' };
} else {
assertUnreachable(firstSelectionKind);
}
} catch (err) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: ${err.message}`,
{ nodes: sourceASTs(fromContextParent) }
));
return { selectionType: 'error' };
}
}
// implementation of spec https://spec.graphql.org/draft/#IsValidImplementationFieldType()
function isValidImplementationFieldType(fieldType: InputType, implementedFieldType: InputType): boolean {
if (isNonNullType(fieldType)) {
if (isNonNullType(implementedFieldType)) {
return isValidImplementationFieldType(fieldType.ofType, implementedFieldType.ofType);
} else {
return isValidImplementationFieldType(fieldType.ofType, implementedFieldType);
}
}
if (isListType(fieldType) && isListType(implementedFieldType)) {
return isValidImplementationFieldType(fieldType.ofType, implementedFieldType.ofType);
}
return !isWrapperType(fieldType) &&
!isWrapperType(implementedFieldType) &&
fieldType.name === implementedFieldType.name;
}
function selectionSetHasDirectives(selectionSet: SelectionSet): boolean {
return hasSelectionWithPredicate(selectionSet, (s: Selection) => {
if (s.kind === 'FieldSelection') {
return s.element.appliedDirectives.length > 0;
}
else if (s.kind === 'FragmentSelection') {
return s.element.appliedDirectives.length > 0;
} else {
assertUnreachable(s);
}
});
}
function selectionSetHasAlias(selectionSet: SelectionSet): boolean {
return hasSelectionWithPredicate(selectionSet, (s: Selection) => {
if (s.kind === 'FieldSelection') {
return s.element.alias !== undefined;
}
return false;
});
}
function validateFieldValue({
context,
selection,
fromContextParent,
setContextLocations,
errorCollector,
metadata,
} : {
context: string,
selection: string,
fromContextParent: ArgumentDefinition<FieldDefinition<ObjectType | InterfaceType | UnionType>>,
setContextLocations: (ObjectType | InterfaceType | UnionType)[],
errorCollector: GraphQLError[],
metadata: FederationMetadata,
}): void {
const expectedType = fromContextParent.type;
assert(expectedType, 'Expected a type');
const validateSelectionFormatResults =
validateSelectionFormat({ context, selection, fromContextParent, errorCollector });
const selectionType = validateSelectionFormatResults.selectionType;
// if there was an error, just return, we've already added it to the errorCollector
if (selectionType === 'error') {
return;
}
const usedTypeConditions = new Set<string>;
for (const location of setContextLocations) {
// for each location, we need to validate that the selection will result in exactly one field being selected
// the number of selection sets created will be the same
let selectionSet: SelectionSet;
try {
selectionSet = parseSelectionSet({ parentType: location, source: selection});
} catch (e) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid for type ${location.name}. Error: ${e.message}`,
{ nodes: sourceASTs(fromContextParent) }
));
return;
}
if (selectionSetHasDirectives(selectionSet)) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: directives are not allowed in the selection`,
{ nodes: sourceASTs(fromContextParent) }
));
}
if (selectionSetHasAlias(selectionSet)) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: aliases are not allowed in the selection`,
{ nodes: sourceASTs(fromContextParent) }
));
}
if (selectionType === 'field') {
const { resolvedType } = validateFieldValueType({
currentType: location,
selectionSet,
errorCollector,
metadata,
fromContextParent,
});
if (resolvedType === undefined || !isValidImplementationFieldType(resolvedType, expectedType!)) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection "${resolvedType}" does not match the expected type "${expectedType?.toString()}"`,
{ nodes: sourceASTs(fromContextParent) }
));
return;
}
} else if (selectionType === 'inlineFragment') {
// ensure that each location maps to exactly one fragment
const selections: FragmentSelection[] = [];
for (const selection of selectionSet.selections()) {
if (selection.kind !== 'FragmentSelection') {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: selection should only contain a single field or at least one inline fragment}"`,
{ nodes: sourceASTs(fromContextParent) }
));
continue;
}
const { typeCondition } = selection.element;
if (!typeCondition) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: inline fragments must have type conditions"`,
{ nodes: sourceASTs(fromContextParent) }
));
continue;
}
if (typeCondition.kind === 'ObjectType') {
if (possibleRuntimeTypes(location).includes(typeCondition)) {
selections.push(selection);
usedTypeConditions.add(typeCondition.name);
}
} else {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type conditions must be an object type"`,
{ nodes: sourceASTs(fromContextParent) }
));
}
}
if (selections.length === 0) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: no type condition matches the location "${location.coordinate}"`,
{ nodes: sourceASTs(fromContextParent) }
));
return;
} else {
for (const selection of selections) {
let { resolvedType } = validateFieldValueType({
currentType: selection.element.typeCondition!,
selectionSet: selection.selectionSet,
errorCollector,
metadata,
fromContextParent,
});
if (resolvedType === undefined) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection does not match the expected type "${expectedType?.toString()}"`,
{ nodes: sourceASTs(fromContextParent) }
));
return;
}
// Because other subgraphs may define members of the location type,
// it's always possible that none of the type conditions map, so we
// must remove any surrounding non-null wrapper if present.
if (isNonNullType(resolvedType)) {
resolvedType = resolvedType.ofType;
}
if (!isValidImplementationFieldType(resolvedType!, expectedType!)) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: the type of the selection "${resolvedType?.toString()}" does not match the expected type "${expectedType?.toString()}"`,
{ nodes: sourceASTs(fromContextParent) }
));
return;
}
}
}
}
}
if (validateSelectionFormatResults.selectionType === 'inlineFragment') {
for (const typeCondition of validateSelectionFormatResults.typeConditions) {
if (!usedTypeConditions.has(typeCondition)) {
errorCollector.push(ERRORS.CONTEXT_INVALID_SELECTION.err(
`Context "${context}" is used in "${fromContextParent.coordinate}" but the selection is invalid: type condition "${typeCondition}" is never used.`,
{ nodes: sourceASTs(fromContextParent) }
));
}
}
}
}
function validateAllFieldSet<TParent extends SchemaElement<any, any>>({
definition,
targetTypeExtractor,
errorCollector,
metadata,
isOnParentType = false,
allowOnNonExternalLeafFields = false,
allowFieldsWithArguments = false,
allowOnInterface = false,
onFields,
}: {
definition: DirectiveDefinition<{fields: any}>,
targetTypeExtractor: (element: TParent) => CompositeType,
errorCollector: GraphQLError[],
metadata: FederationMetadata,
isOnParentType?: boolean,
allowOnNonExternalLeafFields?: boolean,
allowFieldsWithArguments?: boolean,
allowOnInterface?: boolean,
onFields?: (field: FieldDefinition<any>) => void,
}): void {
for (const application of definition.applications()) {
const elt = application.parent as TParent;
const type = targetTypeExtractor(elt);
const parentType = isOnParentType ? type : (elt.parent as NamedType);
if (isInterfaceType(parentType) && !allowOnInterface) {
const code = ERROR_CATEGORIES.DIRECTIVE_UNSUPPORTED_ON_INTERFACE.get(definition.name);
errorCollector.push(code.err(
isOnParentType
? `Cannot use ${definition.coordinate} on interface "${parentType.coordinate}": ${definition.coordinate} is not yet supported on interfaces`
: `Cannot use ${definition.coordinate} on ${fieldSetTargetDescription(application)} of parent type "${parentType}": ${definition.coordinate} is not yet supported within interfaces`,
{ nodes: sourceASTs(application).concat(isOnParentType ? [] : sourceASTs(type)) },
));
}
validateFieldSet({
type,
directive: application,
metadata,
errorCollector,
allowOnNonExternalLeafFields,
allowFieldsWithArguments,
onFields,
});
}
}
export function collectUsedFields(metadata: FederationMetadata): Set<FieldDefinition<CompositeType>> {
const usedFields = new Set<FieldDefinition<CompositeType>>();
// Collects all external fields used by a key, requires or provides
collectUsedFieldsForDirective<CompositeType>(
metadata.keyDirective(),
type => type,
usedFields,
);
collectUsedFieldsForDirective<FieldDefinition<CompositeType>>(
metadata.requiresDirective(),
field => field.parent!,
usedFields,
);
collectUsedFieldsForDirective<FieldDefinition<CompositeType>>(
metadata.providesDirective(),
field => {
const type = baseType(field.type!);
return isCompositeType(type) ? type : undefined;
},
usedFields,
);
// also for @fromContext
collectUsedFieldsForFromContext<CompositeType>(
metadata,
usedFields,
);
// Collects all fields used to satisfy an interface constraint
for (const itfType of metadata.schema.interfaceTypes()) {
const runtimeTypes = itfType.possibleRuntimeTypes();
for (const field of itfType.fields()) {
for (const runtimeType of runtimeTypes) {
const implemField = runtimeType.field(field.name);
if (implemField) {
usedFields.add(implemField);
}
}
}
}
return usedFields;
}
function collectUsedFieldsForFromContext<TParent extends SchemaElement<any, any>>(
metadata: FederationMetadata,
usedFieldDefs: Set<FieldDefinition<CompositeType>>
) {
const fromContextDirective = metadata.fromContextDirective();
const contextDirective = metadata.contextDirective();
// if one of the directives is not defined, there's nothing to validate
if (!isFederationDirectiveDefinedInSchema(fromContextDirective) || !isFederationDirectiveDefinedInSchema(contextDirective)) {
return;
}
// build the list of context entry points
const entryPoints = new Map<string, Set<CompositeType>>();
for (const application of contextDirective.applications()) {
const type = application.parent;
if (!type) {
// Means the application is wrong: we ignore it here as later validation will detect it
continue;
}
const context = application.arguments().name;
if (!entryPoints.has(context)) {
entryPoints.set(context, new Set());
}
entryPoints.get(context)!.add(type as CompositeType);
}
for (const application of fromContextDirective.applications()) {
const type = application.parent as TParent;
if (!type) {
// Means the application is wrong: we ignore it here as later validation will detect it
continue;
}
const fieldValue = application.arguments().field;
const { context, selection } = parseContext(fieldValue);
if (!context) {
continue;
}
// now we need to collect all the fields used for every type that they could be used for
const contextTypes = entryPoints.get(context);
if (!contextTypes) {
continue;
}
for (const contextType of contextTypes) {
try {
// helper function
const fieldAccessor = (t: CompositeType, f: string) => {
const field = t.field(f);
if (field) {
usedFieldDefs.add(field);
if (isInterfaceType(t)) {
for (const implType of t.possibleRuntimeTypes()) {
const implField = implType.field(f);
if (implField) {
usedFieldDefs.add(implField);
}
}
}
}
return field;
};
parseSelectionSet({ parentType: contextType, source: selection, fieldAccessor });
} catch (e) {
// ignore the error, it will be caught later
}
}
}
}
function collectUsedFieldsForDirective<TParent extends SchemaElement<any, any>>(
definition: DirectiveDefinition<{fields: any}>,
targetTypeExtractor: (element: TParent) => CompositeType | undefined,
usedFieldDefs: Set<FieldDefinition<CompositeType>>
) {
for (const application of definition.applications()) {
const type = targetTypeExtractor(application.parent! as TParent);
if (!type) {
// Means the application is wrong: we ignore it here as later validation will detect it
continue;
}
// Note that we don't want to 'validate', because even if a field set is invalid for some reason, we still want to consider
// its field as "used". This avoid, when a `fields` argument is invalid, to get one error for the `fields` itself, but also
// a bunch of other errors that says some external fields are unused that are just a consequence of not considering that
// particular `fields` argument. In other words, this avoid cascading errors that would be confusing to the user without
// being of any concrete use.
collectTargetFields({
parentType: type,
directive: application as Directive<any, {fields: any}>,
includeInterfaceFieldsImplementations: true,
validate: false,
}).forEach((field) => usedFieldDefs.add(field));
}
}
/**
* Checks that all fields marked @external is used in a federation directive (@key, @provides or @requires) _or_ to satisfy an
* interface implementation. Otherwise, the field declaration is somewhat useless.
*/
function validateAllExternalFieldsUsed(metadata: FederationMetadata, errorCollector: GraphQLError[]): void {
for (const type of metadata.schema.types()) {
if (!isObjectType(type) && !isInterfaceType(type)) {
continue;
}
for (const field of type.fields()) {
if (!metadata.isFieldExternal(field) || metadata.isFieldUsed(field)) {
continue;
}
errorCollector.push(ERRORS.EXTERNAL_UNUSED.err(
`Field "${field.coordinate}" is marked @external but is not used in any federation directive (@key, @provides, @requires) or to satisfy an interface;`
+ ' the field declaration has no use and should be removed (or the field should not be @external).',
{ nodes: field.sourceAST },
));
}
}
}
function validateNoExternalOnInterfaceFields(metadata: FederationMetadata, errorCollector: GraphQLError[]) {
for (const itf of metadata.schema.interfaceTypes()) {
for (const field of itf.fields()) {
if (metadata.isFieldExternal(field)) {
errorCollector.push(ERRORS.EXTERNAL_ON_INTERFACE.err(
`Interface type field "${field.coordinate}" is marked @external but @external is not allowed on interface fields (it is nonsensical).`,
{ nodes: field.sourceAST },
));
}
}
}
}
function validateKeyOnInterfacesAreAlsoOnAllImplementations(metadata: FederationMetadata, errorCollector: GraphQLError[]): void {
for (const itfType of metadata.schema.interfaceTypes()) {
const implementations = itfType.possibleRuntimeTypes();
for (const keyApplication of itfType.appliedDirectivesOf(metadata.keyDirective())) {
// Note that we will always have validated all @key fields at this point, so not bothering with extra validation
const fields = parseFieldSetArgument({parentType: itfType, directive: keyApplication, validate: false});
const isResolvable = !(keyApplication.arguments().resolvable === false);
const implementationsWithKeyButNotResolvable = new Array<ObjectType>();
const implementationsMissingKey = new Array<ObjectType>();
for (const type of implementations) {
const matchingApp = type.appliedDirectivesOf(metadata.keyDirective()).find((app) => {
const appFields = parseFieldSetArgument({parentType: type, directive: app, validate: false});
return fields.equals(appFields);
});
if (matchingApp) {
if (isResolvable && matchingApp.arguments().resolvable === false) {
implementationsWithKeyButNotResolvable.push(type);
}
} else {
implementationsMissingKey.push(type);
}
}
if (implementationsMissingKey.length > 0) {
const typesString = printHumanReadableList(
implementationsMissingKey.map((i) => `"${i.coordinate}"`),
{
prefix: 'type',
prefixPlural: 'types',
}
);
errorCollector.push(ERRORS.INTERFACE_KEY_NOT_ON_IMPLEMENTATION.err(
`Key ${keyApplication} on interface type "${itfType.coordinate}" is missing on implementation ${typesString}.`,
{ nodes: sourceASTs(...implementationsMissingKey) },
));
} else if (implementationsWithKeyButNotResolvable.length > 0) {
const typesString = printHumanReadableList(
implementationsWithKeyButNotResolvable.map((i) => `"${i.coordinate}"`),
{
prefix: 'type',
prefixPlural: 'types',
}
);
errorCollector.push(ERRORS.INTERFACE_KEY_NOT_ON_IMPLEMENTATION.err(
`Key ${keyApplication} on interface type "${itfType.coordinate}" should be resolvable on all implementation types, but is declared with argument "@key(resolvable:)" set to false in ${typesString}.`,
{ nodes: sourceASTs(...implementationsWithKeyButNotResolvable) },
));
}
}
}
}
function validateInterfaceObjectsAreOnEntities(metadata: FederationMetadata, errorCollector: GraphQLError[]): void {
for (const application of metadata.interfaceObjectDirective().applications()) {
if (!isEntityType(application.parent)) {
errorCollector.push(ERRORS.INTERFACE_OBJECT_USAGE_ERROR.err(
`The @interfaceObject directive can only be applied to entity types but type "${application.parent.coordinate}" has no @key in this subgraph.`,
{ nodes: application.parent.sourceAST }
));
}
}
}
function validateShareableNotRepeatedOnSameDeclaration(
element: ObjectType | FieldDefinition<ObjectType>,
metadata: FederationMetadata,
errorCollector: GraphQLError[],
) {
const shareableApplications: Directive[] = element.appliedDirectivesOf(metadata.shareableDirective());
if (shareableApplications.length <= 1) {
return;
}
type ByExtensions = {
without: Directive<any, {}>[],
with: MultiMap<Extension<any>, Directive<any, {}>>,
};
const byExtensions = shareableApplications.reduce<ByExtensions>(
(acc, v) => {
const ext = v.ofExtension();
if (ext) {
acc.with.add(ext, v);
} else {
acc.without.push(v);
}
return acc;
},
{ without: [], with: new MultiMap() }
);
const groups = [ byExtensions.without ].concat(mapValues(byExtensions.with));
for (const group of groups) {
if (group.length > 1) {
const eltStr = element.kind === 'ObjectType'
? `the same type declaration of "${element.coordinate}"`
: `field "${element.coordinate}"`;
errorCollector.push(ERRORS.INVALID_SHAREABLE_USAGE.err(
`Invalid duplicate application of @shareable on ${eltStr}: `
+ '@shareable is only repeatable on types so it can be used simultaneously on a type definition and its extensions, but it should not be duplicated on the same definition/extension declaration',
{ nodes: sourceASTs(...group) },
));
}
}
}
function validateCostNotAppliedToInterface(application: Directive<SchemaElement<any, any>, CostDirectiveArguments>, errorCollector: GraphQLError[]) {
const parent = application.parent;
// @cost cannot be used on interfaces https://ibm.github.io/graphql-specs/cost-spec.html#sec-No-Cost-on-Interface-Fields
if (parent instanceof FieldDefinition && parent.parent instanceof InterfaceType) {
errorCollector.push(ERRORS.COST_APPLIED_TO_INTERFACE_FIELD.err(
`@cost cannot be applied to interface "${parent.coordinate}"`,
{ nodes: sourceASTs(application, parent) }
));
}
}
function validateListSizeAppliedToList(
application: Directive<SchemaElement<any, any>, ListSizeDirectiveArguments>,
parent: FieldDefinition<CompositeType>,
errorCollector: GraphQLError[],
) {
const { sizedFields = [] } = application.arguments();
// @listSize must be applied to a list https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-List-Size-Target
if (!sizedFields.length && parent.type && !isListType(parent.type)) {
errorCollector.push(ERRORS.LIST_SIZE_APPLIED_TO_NON_LIST.err(
`"${parent.coordinate}" is not a list`,
{ nodes: sourceASTs(application, parent) },
));
}
}
function validateAssumedSizeNotNegative(
application: Directive<SchemaElement<any, any>, ListSizeDirectiveArguments>,
parent: FieldDefinition<CompositeType>,
errorCollector: GraphQLError[]
) {
const { assumedSize } = application.arguments();
// Validate assumed size, but we differ from https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Assumed-Size.
// Assumed size is used as a backup for slicing arguments in the event they are both specified.
// The spec aims to rule out cases when the assumed size will never be used because there is always
// a slicing argument. Two applications which are compliant with that validation rule can be merged
// into an application which is not compliant, thus we need to handle this case gracefully at runtime regardless.
// We omit this check to keep the validations to those that will otherwise cause runtime failures.
//
// With all that said, assumed size should not be negative.
if (assumedSize !== undefined && assumedSize !== null && assumedSize < 0) {
errorCollector.push(ERRORS.LIST_SIZE_INVALID_ASSUMED_SIZE.err(
`Assumed size of "${parent.coordinate}" cannot be negative`,
{ nodes: sourceASTs(application, parent) },
));
}
}
function isNonNullIntType(ty: Type): boolean {
return isNonNullType(ty) && isIntType(ty.ofType)
}
function validateSlicingArgumentsAreValidIntegers(
application: Directive<SchemaElement<any, any>, ListSizeDirectiveArguments>,
parent: FieldDefinition<CompositeType>,
errorCollector: GraphQLError[]
) {
const { slicingArguments = [] } = application.arguments();
// Validate slicingArguments https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Slicing-Arguments-Target
for (const slicingArgumentName of slicingArguments) {
const slicingArgument = parent.argument(slicingArgumentName);
if (!slicingArgument?.type) {
// Slicing arguments must be one of the field's arguments
errorCollector.push(ERRORS.LIST_SIZE_INVALID_SLICING_ARGUMENT.err(
`Slicing argument "${slicingArgumentName}" is not an argument of "${parent.coordinate}"`,
{ nodes: sourceASTs(application, parent) }
));
} else if (!isIntType(slicingArgument.type) && !isNonNullIntType(slicingArgument.type)) {
// Slicing arguments must be Int or Int!
errorCollector.push(ERRORS.LIST_SIZE_INVALID_SLICING_ARGUMENT.err(
`Slicing argument "${slicingArgument.coordinate}" must be Int or Int!`,
{ nodes: sourceASTs(application, parent) }
));
}
}
}
function isNonNullListType(ty: Type): boolean {
return isNonNullType(ty) && isListType(ty.ofType)
}
function validateSizedFieldsAreValidLists(
application: Directive<SchemaElement<any, any>, ListSizeDirectiveArguments>,
parent: FieldDefinition<CompositeType>,
errorCollector: GraphQLError[]
) {
const { sizedFields = [] } = application.arguments();
// Validate sizedFields https://ibm.github.io/graphql-specs/cost-spec.html#sec-Valid-Sized-Fields-Target
if (sizedFields.length) {
if (!parent.type || !isCompositeType(parent.type)) {
// The output type must have fields
errorCollector.push(ERRORS.LIST_SIZE_INVALID_SIZED_FIELD.err(
`Sized fields cannot be used because "${parent.type}" is not a composite type`,
{ nodes: sourceASTs(application, parent)}
));
} else {
for (const sizedFieldName of sizedFields) {
const sizedField = parent.type.field(sizedFieldName);
if (!sizedField) {
// Sized fields must be present on the output type
errorCollector.push(ERRORS.LIST_SIZE_INVALID_SIZED_FIELD.err(
`Sized field "${sizedFieldName}" is not a field on type "${parent.type.coordinate}"`,
{ nodes: sourceASTs(application, parent) }
));
} else if (!sizedField.type || !(isListType(sizedField.type) || isNonNullListType(sizedField.type))) {
// Sized fields must be lists
errorCollector.push(ERRORS.LIST_SIZE_APPLIED_TO_NON_LIST.err(
`Sized field "${sizedField.coordinate}" is not a list`,
{ nodes: sourceASTs(application, parent) },
));
}
}
}
}
}
export class FederationMetadata {
private _externalTester?: ExternalTester;
private _sharingPredicate?: (field: FieldDefinition<CompositeType>) => boolean;
private _fieldUsedPredicate?: (field: FieldDefinition<CompositeType>) => boolean;
private _isFed2Schema?: boolean;
constructor(readonly schema: Schema) {}
private onInvalidate() {
this._externalTester = undefined;
this._sharingPredicate = undefined;
this._isFed2Schema = undefined;
this._fieldUsedPredicate = undefined;
}
isFed2Schema(): boolean {
if (!this._isFed2Schema) {
const feature = this.federationFeature();
this._isFed2Schema = !!feature && feature.url.version.satisfies(new FeatureVersion(2, 0))
}
return this._isFed2Schema;
}
federationFeature(): CoreFeature | undefined {
return this.schema.coreFeatures?.getByIdentity(latestFederationSpec.identity);
}
private externalTester(): ExternalTester {
if (!this._externalTester) {
this._externalTester = new ExternalTester(this.schema, this.isFed2Schema());
}
return this._externalTester;
}
private sharingPredicate(): (field: FieldDefinition<CompositeType>) => boolean {
if (!this._sharingPredicate) {
this._sharingPredicate = computeShareables(this.schema);
}
return this._sharingPredicate;
}
private fieldUsedPredicate(): (field: FieldDefinition<CompositeType>) => boolean {
if (!this._fieldUsedPredicate) {
const usedFields = collectUsedFields(this);
this._fieldUsedPredicate = (field: FieldDefinition<CompositeType>) => !!usedFields.has(field);
}
return this._fieldUsedPredicate;
}
isFieldUsed(field: FieldDefinition<CompositeType>): boolean {
return this.fieldUsedPredicate()(field);
}
isFieldExternal(field: FieldDefinition<any> | InputFieldDefinition) {
return this.externalTester().isExternal(field);
}
isFieldPartiallyExternal(field: FieldDefinition<any> | InputFieldDefinition) {
return this.externalTester().isPartiallyExternal(field);
}
isFieldFullyExternal(field: FieldDefinition<any> | InputFieldDefinition) {
return this.externalTester().isFullyExternal(field);
}
isFieldFakeExternal(field: FieldDefinition<any> | InputFieldDefinition) {
return this.externalTester().isFakeExternal(field);
}
selectionSelectsAnyExternalField(selectionSet: SelectionSet): boolean {
return this.externalTester().selectsAnyExternalField(selectionSet);
}
isFieldShareable(field: FieldDefinition<any>): boolean {
return this.sharingPredicate()(field);
}
isInterfaceObjectType(type: NamedType): type is ObjectType {
return isObjectType(type)
&& hasAppliedDirective(type, this.interfaceObjectDirective());
}
federationDirectiveNameInSchema(name: string): string {
if (this.isFed2Schema()) {
const coreFeatures = this.schema.coreFeatures;
assert(coreFeatures, 'Schema should be a core schema');
const federationFeature = coreFeatures.getByIdentity(latestFederationSpec.identity);
assert(federationFeature,