UNPKG

@apollo/federation-internals

Version:
1,259 lines (1,170 loc) 117 kB
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,