@theguild/federation-composition
Version:
Open Source Composition library for Apollo Federation
359 lines (354 loc) • 15.4 kB
JavaScript
import { concatAST, GraphQLError, Kind, OperationTypeNode, parse, visit, visitInParallel, } from 'graphql';
import { TypeNodeInfo, visitWithTypeNodeInfo } from '../../graphql/type-node-info.js';
import { createSpecSchema } from '../../specifications/federation.js';
import { parseLinkDirective } from '../../specifications/link.js';
import { AuthenticatedRule } from './rules/elements/authenticated.js';
import { ComposeDirectiveRules } from './rules/elements/compose-directive.js';
import { ContextDirectiveRules } from './rules/elements/context.js';
import { CostRule } from './rules/elements/cost.js';
import { ExtendsRules } from './rules/elements/extends.js';
import { ExternalRules } from './rules/elements/external.js';
import { FieldSetRules } from './rules/elements/field-set.js';
import { FromContextDirectiveRules } from './rules/elements/from-context.js';
import { InaccessibleRules } from './rules/elements/inaccessible.js';
import { InterfaceObjectRules } from './rules/elements/interface-object.js';
import { KeyRules } from './rules/elements/key.js';
import { ListSizeRule } from './rules/elements/list-size.js';
import { OverrideRules } from './rules/elements/override.js';
import { PolicyRule } from './rules/elements/policy.js';
import { ProvidesRules } from './rules/elements/provides.js';
import { RequiresScopesRule } from './rules/elements/requires-scopes.js';
import { RequiresRules } from './rules/elements/requires.js';
import { ShareableRules } from './rules/elements/shareable.js';
import { TagRules } from './rules/elements/tag.js';
import { KnownArgumentNamesOnDirectivesRule } from './rules/known-argument-names-on-directives-rule.js';
import { KnownDirectivesRule } from './rules/known-directives-rule.js';
import { KnownFederationDirectivesRule } from './rules/known-federation-directive-rule.js';
import { KnownRootTypeRule } from './rules/known-root-type-rule.js';
import { KnownTypeNamesRule } from './rules/known-type-names-rule.js';
import { LoneSchemaDefinitionRule } from './rules/lone-schema-definition-rule.js';
import { OnlyInterfaceImplementationRule } from './rules/only-interface-implementation-rule.js';
import { ProvidedArgumentsOnDirectivesRule } from './rules/provided-arguments-on-directives-rule.js';
import { ProvidedRequiredArgumentsOnDirectivesRule } from './rules/provided-required-arguments-on-directives-rule.js';
import { QueryRootTypeInaccessibleRule } from './rules/query-root-type-inaccessible-rule.js';
import { ReservedSubgraphNameRule } from './rules/reserved-subgraph-name-rule.js';
import { RootTypeUsedRule } from './rules/root-type-used-rule.js';
import { UniqueArgumentDefinitionNamesRule } from './rules/unique-argument-definition-names-rule.js';
import { UniqueArgumentNamesRule } from './rules/unique-argument-names-rule.js';
import { UniqueDirectiveNamesRule } from './rules/unique-directive-names-rule.js';
import { UniqueDirectivesPerLocationRule } from './rules/unique-directives-per-location-rule.js';
import { UniqueEnumValueNamesRule } from './rules/unique-enum-value-names-rule.js';
import { UniqueFieldDefinitionNamesRule } from './rules/unique-field-definition-names-rule.js';
import { UniqueInputFieldNamesRule } from './rules/unique-input-field-names-rule.js';
import { UniqueOperationTypesRule } from './rules/unique-operation-types-rule.js';
import { UniqueTypeNamesRule } from './rules/unique-type-names-rule.js';
import { validateSubgraphState } from './validate-state.js';
import { createSimpleValidationContext, createSubgraphValidationContext, } from './validation-context.js';
export function assertUniqueSubgraphNames(subgraphs) {
const names = new Set();
for (const subgraph of subgraphs) {
if (names.has(subgraph.name)) {
throw new Error(`A subgraph named ${subgraph.name} already exists`);
}
names.add(subgraph.name);
}
}
export function validateSubgraphCore(subgraph) {
const extractedLinks = extractLinks(subgraph);
if (extractedLinks.errors) {
extractedLinks.errors.forEach(error => enrichErrorWithSubgraphName(error, subgraph.name));
}
return extractedLinks;
}
export function validateSubgraph(subgraph, stateBuilder, federation, __internal) {
subgraph.typeDefs = cleanSubgraphTypeDefsFromSubgraphSpec(subgraph.typeDefs);
const linkSpecDefinitions = parse(`
enum Purpose {
EXECUTION
SECURITY
}
directive @link(
url: String
as: String
for: link__Purpose
import: [link__Import]
) repeatable on SCHEMA
scalar link__Import
enum link__Purpose {
"""
\`SECURITY\` features provide metadata necessary to securely resolve fields.
"""
SECURITY
"""
\`EXECUTION\` features provide metadata necessary for operation execution.
"""
EXECUTION
}
`).definitions;
const rulesToSkip = __internal?.disableValidationRules ?? [];
const typeNodeInfo = new TypeNodeInfo();
const validationContext = createSubgraphValidationContext(subgraph, federation, typeNodeInfo, stateBuilder);
const federationRules = [
ReservedSubgraphNameRule,
KnownFederationDirectivesRule,
FieldSetRules,
InaccessibleRules,
InterfaceObjectRules,
AuthenticatedRule,
PolicyRule,
RequiresScopesRule,
CostRule,
ListSizeRule,
OverrideRules,
ContextDirectiveRules,
FromContextDirectiveRules,
ExtendsRules,
QueryRootTypeInaccessibleRule,
KnownTypeNamesRule,
KnownRootTypeRule,
RootTypeUsedRule,
ShareableRules,
KeyRules,
ProvidesRules,
RequiresRules,
ExternalRules,
TagRules,
ComposeDirectiveRules,
];
const graphqlRules = [
OnlyInterfaceImplementationRule,
LoneSchemaDefinitionRule,
UniqueOperationTypesRule,
UniqueTypeNamesRule,
UniqueEnumValueNamesRule,
UniqueFieldDefinitionNamesRule,
UniqueArgumentDefinitionNamesRule,
KnownDirectivesRule,
UniqueDirectivesPerLocationRule,
KnownArgumentNamesOnDirectivesRule,
UniqueArgumentNamesRule,
UniqueInputFieldNamesRule,
UniqueDirectiveNamesRule,
ProvidedRequiredArgumentsOnDirectivesRule,
ProvidedArgumentsOnDirectivesRule,
];
visit(subgraph.typeDefs, visitWithTypeNodeInfo(typeNodeInfo, visitInParallel([stateBuilder.visitor(typeNodeInfo)].concat(federationRules.map(rule => {
if (rulesToSkip.includes(rule.name)) {
return {};
}
return rule(validationContext);
})))));
const federationDefinitionReplacements = validationContext.collectFederationDefinitionReplacements();
const linkSpecDefinitionsToInclude = linkSpecDefinitions.filter(def => {
if ('name' in def && typeof def.name?.value === 'string') {
return !stateBuilder.state.types.has(def.name.value);
}
return true;
});
const fullTypeDefs = concatAST([
{
kind: Kind.DOCUMENT,
definitions: validationContext
.getAvailableFederationTypeAndDirectiveDefinitions()
.filter(def => !federationDefinitionReplacements.has(def.name.value)),
},
validationContext.satisfiesVersionRange('> v1.0') && !stateBuilder.state.specs.link
?
linkSpecDefinitionsToInclude.length > 0
? {
kind: Kind.DOCUMENT,
definitions: linkSpecDefinitionsToInclude,
}
: null
: null,
subgraph.typeDefs,
].filter(onlyDocumentNode));
const subgraphStateErrors = validateSubgraphState(stateBuilder.state, validationContext);
const simpleValidationContext = createSimpleValidationContext(fullTypeDefs, typeNodeInfo);
visit(fullTypeDefs, visitInParallel(graphqlRules.map(rule => {
if (rulesToSkip.includes(rule.name)) {
return {};
}
return rule(simpleValidationContext);
})));
return validationContext
.collectReportedErrors()
.concat(validationContext.collectUnusedExternal().map(coordinate => enrichErrorWithSubgraphName(new GraphQLError(`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).`, {
extensions: {
code: 'EXTERNAL_UNUSED',
},
}), subgraph.name)))
.concat(simpleValidationContext.collectReportedErrors())
.concat(subgraphStateErrors)
.map(error => enrichErrorWithSubgraphName(error, subgraph.name));
}
function enrichErrorWithSubgraphName(error, subgraphName) {
if (error.extensions.subgraphName) {
return error;
}
error.message = `[${subgraphName}] ${error.message}`;
error.extensions.subgraphName = subgraphName;
return error;
}
const availableFeatures = {
link: ['v1.0'],
tag: ['v0.1', 'v0.2'],
kotlin_labs: ['v0.1', 'v0.2'],
join: ['v0.1', 'v0.2', 'v0.3', 'v0.4', 'v0.5'],
inaccessible: ['v0.1', 'v0.2'],
core: ['v0.1', 'v0.2'],
};
function extractLinks(subgraph) {
const schemaNodes = subgraph.typeDefs.definitions.filter(isSchemaDefinitionOrExtensionNode);
if (schemaNodes.length === 0) {
return {
links: [],
};
}
const linkDirectives = [];
for (const schemaNode of schemaNodes) {
if (schemaNode.directives?.length) {
for (const directiveNode of schemaNode.directives) {
if (directiveNode.name.value === 'link') {
linkDirectives.push(directiveNode);
}
}
}
}
if (!linkDirectives) {
return {
links: [],
};
}
const errors = [];
const links = [];
const identities = new Set();
const reportedAsDuplicate = new Set();
for (let i = 0; i < linkDirectives.length; i++) {
const linkDirective = linkDirectives[i];
try {
const link = parseLinkDirective(linkDirective);
if (!link) {
continue;
}
if (identities.has(link.identity) && !reportedAsDuplicate.has(link.identity)) {
errors.push(new GraphQLError(`Duplicate inclusion of feature ${link.identity}`, {
extensions: {
code: 'INVALID_LINK_DIRECTIVE_USAGE',
},
}));
reportedAsDuplicate.add(link.identity);
}
identities.add(link.identity);
if (link.version && !/^v\d+\.\d+/.test(link.version)) {
errors.push(new GraphQLError(`Expected a version string (of the form v1.2), got ${link.version}`, {
extensions: {
code: 'INVALID_LINK_IDENTIFIER',
},
}));
continue;
}
if (!link.name) {
errors.push(new GraphQLError(`Missing path in feature url '${link.identity}'`, {
extensions: {
code: 'INVALID_LINK_IDENTIFIER',
},
}));
continue;
}
if (link.identity.startsWith('https://specs.apollo.dev/')) {
if (link.name === 'federation') {
if (!link.version) {
errors.push(new GraphQLError(`Missing version in feature url '${link.identity}'`, {
extensions: {
code: 'TODO',
},
}));
continue;
}
const spec = createSpecSchema(link.version);
const availableElements = new Set(spec.directives.map(d => d.name.value).concat(spec.types.map(t => t.name.value)));
let pushedError = false;
for (const im of link.imports) {
if (!availableElements.has(im.name.replace(/^@/, ''))) {
pushedError = true;
errors.push(new GraphQLError(`Cannot import unknown element "${im.name}".`, {
extensions: {
code: 'INVALID_LINK_DIRECTIVE_USAGE',
},
}));
}
}
if (pushedError) {
continue;
}
}
else if (link.version && availableFeatures[link.name]) {
if (!availableFeatures[link.name].includes(link.version)) {
errors.push(new GraphQLError(`Schema uses unknown version ${link.version} of the ${link.name} spec`, {
extensions: {
code: 'UNKNOWN_LINK_VERSION',
},
}));
continue;
}
}
}
links.push(link);
}
catch (error) {
errors.push(error instanceof GraphQLError ? error : new GraphQLError(String(error)));
}
}
if (errors.length > 0) {
return {
errors,
};
}
return {
links,
};
}
function isSchemaDefinitionOrExtensionNode(node) {
return (node.kind === Kind.SCHEMA_DEFINITION || node.kind === Kind.SCHEMA_EXTENSION);
}
function onlyDocumentNode(item) {
return item != null;
}
function cleanSubgraphTypeDefsFromSubgraphSpec(typeDefs) {
let queryTypes = [];
const schemaDef = typeDefs.definitions.find(node => (node.kind === Kind.SCHEMA_DEFINITION || node.kind === Kind.SCHEMA_EXTENSION) &&
node.operationTypes?.some(op => op.operation === OperationTypeNode.QUERY));
const queryTypeName = schemaDef?.operationTypes?.find(op => op.operation === OperationTypeNode.QUERY)?.type.name
.value ?? 'Query';
typeDefs.definitions = typeDefs.definitions.filter(def => {
if ((def.kind === Kind.SCALAR_TYPE_DEFINITION ||
def.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) &&
def.name.value === '_Any') {
return false;
}
if (def.kind === Kind.UNION_TYPE_DEFINITION && def.name.value === '_Entity') {
return false;
}
if (def.kind === Kind.OBJECT_TYPE_DEFINITION && def.name.value === '_Service') {
return false;
}
if ((def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.OBJECT_TYPE_EXTENSION) &&
def.name.value === queryTypeName) {
queryTypes.push(def);
}
return true;
});
if (queryTypes.length > 0) {
for (const queryType of queryTypes) {
queryType.fields =
queryType.fields?.filter(field => {
if (field.name.value === '_service' || field.name.value === '_entities') {
return false;
}
return true;
}) ?? [];
}
}
return typeDefs;
}