@apollo/federation
Version:
Apollo Federation Utilities
724 lines (649 loc) • 23.8 kB
text/typescript
import {
GraphQLSchema,
extendSchema,
Kind,
isTypeDefinitionNode,
isTypeExtensionNode,
GraphQLError,
GraphQLNamedType,
isObjectType,
FieldDefinitionNode,
InputValueDefinitionNode,
DocumentNode,
GraphQLObjectType,
specifiedDirectives,
TypeDefinitionNode,
DirectiveDefinitionNode,
TypeExtensionNode,
ObjectTypeDefinitionNode,
NamedTypeNode,
lexicographicSortSchema,
} from 'graphql';
// Importing from 'dist' is not actually supported as part of the public API,
// but this allows us not to duplicate things in the meantime while the
// @apollo/federation package still exists.
import { transformSchema } from '@apollo/subgraph/dist/schema-helper';
import {
directivesWithNoDefinitionNeeded,
isDirectiveWithNoDefinitionNeeded,
directivesWithAutoIncludedDefinitions,
} from '@apollo/subgraph/dist/directives';
import {
findDirectivesOnNode,
isStringValueNode,
mapFieldNamesToServiceName,
stripExternalFieldsFromTypeDefs,
typeNodesAreEquivalent,
executableDirectiveLocations,
stripTypeSystemDirectivesFromTypeDefs,
defaultRootOperationNameLookup,
getFederationMetadata,
CompositionResult,
isDirectiveDefinitionNode,
parseFieldSet,
} from './utils';
import {
ServiceDefinition,
ExternalFieldDefinition,
FederationDirective,
} from './types';
import type {
FederationField,
FederationType,
ServiceNameToKeyDirectivesMap,
} from '../composition/types';
import { validateSDL } from 'graphql/validation/validate';
import { compositionRules } from './rules';
import { printSupergraphSdl } from '../service/printSupergraphSdl';
import { mapValues } from '../utilities';
import { DirectiveMetadata } from './DirectiveMetadata';
import { getJoinDefinitions } from '../joinSpec';
import { CoreDirective } from '../coreSpec';
const EmptyQueryDefinition: TypeDefinitionNode = {
kind: Kind.OBJECT_TYPE_DEFINITION,
name: { kind: Kind.NAME, value: defaultRootOperationNameLookup.query },
fields: [],
serviceName: null,
};
const EmptyMutationDefinition: TypeDefinitionNode = {
kind: Kind.OBJECT_TYPE_DEFINITION,
name: { kind: Kind.NAME, value: defaultRootOperationNameLookup.mutation },
fields: [],
serviceName: null,
};
// Map of all type definitions to eventually be passed to extendSchema
interface TypeDefinitionsMap {
[name: string]: TypeDefinitionNode[];
}
// Map of all type extensions to eventually be passed to extendSchema
interface TypeExtensionsMap {
[name: string]: TypeExtensionNode[];
}
// Map of all directive definitions to eventually be passed to extendSchema
interface DirectiveDefinitionsMap {
[name: string]: { [serviceName: string]: DirectiveDefinitionNode };
}
/**
* A map of base types to their owning service. Used by query planner to direct traffic.
* This contains the base type's "owner". Any fields that extend this type in another service
* are listed under "extensionFieldsToOwningServiceMap". extensionFieldsToOwningServiceMap are in the format { myField: my-service-name }
*
* Example resulting typeToServiceMap shape:
*
* const typeToServiceMap = {
* Product: {
* serviceName: "ProductService",
* extensionFieldsToOwningServiceMap: {
* reviews: "ReviewService", // Product.reviews comes from the ReviewService
* dimensions: "ShippingService",
* weight: "ShippingService"
* }
* }
* }
*/
interface TypeToServiceMap {
[typeName: string]: {
owningService?: string;
extensionFieldsToOwningServiceMap: { [fieldName: string]: string };
};
}
/*
* Map of types to their key directives (maintains association to their services)
*
* Example resulting KeyDirectivesMap shape:
*
* const keyDirectives = {
* Product: {
* serviceA: ["sku", "upc"]
* serviceB: ["color {id value}"] // Selection node simplified for readability
* }
* }
*/
export interface KeyDirectivesMap {
[typeName: string]: ServiceNameToKeyDirectivesMap;
}
/**
* A set of type names that have been determined to be a value type, a type
* shared across at least 2 services.
*/
type ValueTypes = Set<string>;
/**
* Loop over each service and process its typeDefs (`definitions`)
* - build up typeToServiceMap
* - push individual definitions onto either typeDefinitionsMap or typeExtensionsMap
*/
export function buildMapsFromServiceList(serviceList: ServiceDefinition[]) {
const typeDefinitionsMap: TypeDefinitionsMap = Object.create(null);
const typeExtensionsMap: TypeExtensionsMap = Object.create(null);
const directiveDefinitionsMap: DirectiveDefinitionsMap = Object.create(null);
const typeToServiceMap: TypeToServiceMap = Object.create(null);
const externalFields: ExternalFieldDefinition[] = [];
const keyDirectivesMap: KeyDirectivesMap = Object.create(null);
const valueTypes: ValueTypes = new Set();
const directiveMetadata = new DirectiveMetadata(serviceList);
for (const { typeDefs, name: serviceName } of serviceList) {
// Build a new SDL with @external fields removed, as well as information about
// the fields that were removed.
const {
typeDefsWithoutExternalFields,
strippedFields,
} = stripExternalFieldsFromTypeDefs(typeDefs, serviceName);
externalFields.push(...strippedFields);
// Type system directives from downstream services are not a concern of the
// gateway, but rather the services on which the fields live which serve
// those types. In other words, its up to an implementing service to
// act on such directives, not the gateway.
const typeDefsWithoutTypeSystemDirectives =
stripTypeSystemDirectivesFromTypeDefs(typeDefsWithoutExternalFields);
for (const definition of typeDefsWithoutTypeSystemDirectives.definitions) {
if (
definition.kind === Kind.OBJECT_TYPE_DEFINITION ||
definition.kind === Kind.OBJECT_TYPE_EXTENSION
// || definition.kind === Kind.INTERFACE_TYPE_DEFINITION
) {
const typeName = definition.name.value;
for (const keyDirective of findDirectivesOnNode(definition, 'key')) {
if (
keyDirective.arguments &&
isStringValueNode(keyDirective.arguments[0].value)
) {
// Initialize the entry for this type if necessary
keyDirectivesMap[typeName] = keyDirectivesMap[typeName] || {};
// Initialize the entry for this service if necessary
keyDirectivesMap[typeName][serviceName] =
keyDirectivesMap[typeName][serviceName] || [];
// Add @key metadata to the array
keyDirectivesMap[typeName][serviceName]!.push(
parseFieldSet(keyDirective.arguments[0].value.value),
);
}
}
}
if (isTypeDefinitionNode(definition)) {
const typeName = definition.name.value;
/**
* This type is a base definition (not an extension). If this type is already in the typeToServiceMap, then
* 1. It was declared by a previous service, but this newer one takes precedence, or...
* 2. It was extended by a service before declared
*/
if (!typeToServiceMap[typeName]) {
typeToServiceMap[typeName] = {
extensionFieldsToOwningServiceMap: Object.create(null),
};
}
typeToServiceMap[typeName].owningService = serviceName;
/**
* If this type already exists in the definitions map, push this definition to the array (newer defs
* take precedence). If the types are determined to be identical, add the type name
* to the valueTypes Set.
*
* If not, create the definitions array and add it to the typeDefinitionsMap.
*/
if (typeDefinitionsMap[typeName]) {
const isValueType = typeNodesAreEquivalent(
typeDefinitionsMap[typeName][
typeDefinitionsMap[typeName].length - 1
],
definition,
);
if (isValueType) {
valueTypes.add(typeName);
}
typeDefinitionsMap[typeName].push({ ...definition, serviceName });
} else {
typeDefinitionsMap[typeName] = [{ ...definition, serviceName }];
}
} else if (isTypeExtensionNode(definition)) {
const typeName = definition.name.value;
/**
* This definition is an extension of an OBJECT type defined in another service.
* TODO: handle extensions of non-object types?
*/
if (
definition.kind === Kind.OBJECT_TYPE_EXTENSION ||
definition.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION
) {
if (!definition.fields) break;
const fields = mapFieldNamesToServiceName<
FieldDefinitionNode | InputValueDefinitionNode
>(definition.fields, serviceName);
/**
* If the type already exists in the typeToServiceMap, add the extended fields. If not, create the object
* and add the extensionFieldsToOwningServiceMap, but don't add a serviceName. That will be added once that service
* definition is processed.
*/
if (typeToServiceMap[typeName]) {
typeToServiceMap[typeName].extensionFieldsToOwningServiceMap = {
...typeToServiceMap[typeName].extensionFieldsToOwningServiceMap,
...fields,
};
} else {
typeToServiceMap[typeName] = {
extensionFieldsToOwningServiceMap: fields,
};
}
}
if (definition.kind === Kind.ENUM_TYPE_EXTENSION) {
if (!definition.values) break;
const values = mapFieldNamesToServiceName(
definition.values,
serviceName,
);
if (typeToServiceMap[typeName]) {
typeToServiceMap[typeName].extensionFieldsToOwningServiceMap = {
...typeToServiceMap[typeName].extensionFieldsToOwningServiceMap,
...values,
};
} else {
typeToServiceMap[typeName] = {
extensionFieldsToOwningServiceMap: values,
};
}
}
/**
* If an extension for this type already exists in the extensions map, push this extension to the
* array (since a type can be extended by multiple services). If not, create the extensions array
* and add it to the typeExtensionsMap.
*/
if (typeExtensionsMap[typeName]) {
typeExtensionsMap[typeName].push({ ...definition, serviceName });
} else {
typeExtensionsMap[typeName] = [{ ...definition, serviceName }];
}
} else if (isDirectiveDefinitionNode(definition)) {
const directiveName = definition.name.value;
// The composed schema should only contain directives and their
// ExecutableDirectiveLocations. This filters out any TypeSystemDirectiveLocations.
// A new DirectiveDefinitionNode with this filtered list will be what is
// added to the schema.
const executableLocations = definition.locations.filter(location =>
executableDirectiveLocations.includes(location.value),
);
// If none of the directive's locations are executable, we don't need to
// include it in the composed schema at all.
if (executableLocations.length === 0) continue;
const definitionWithExecutableLocations: DirectiveDefinitionNode = {
...definition,
locations: executableLocations,
};
if (directiveDefinitionsMap[directiveName]) {
directiveDefinitionsMap[directiveName][
serviceName
] = definitionWithExecutableLocations;
} else {
directiveDefinitionsMap[directiveName] = {
[serviceName]: definitionWithExecutableLocations,
};
}
}
}
}
// Since all Query/Mutation definitions in service schemas are treated as
// extensions, we don't have a Query or Mutation DEFINITION in the definitions
// list. Without a Query/Mutation definition, we can't _extend_ the type.
// extendSchema will complain about this. We can't add an empty
// GraphQLObjectType to the schema constructor, so we add an empty definition
// here. We only add mutation if there is a mutation extension though.
if (!typeDefinitionsMap.Query)
typeDefinitionsMap.Query = [EmptyQueryDefinition];
if (typeExtensionsMap.Mutation && !typeDefinitionsMap.Mutation)
typeDefinitionsMap.Mutation = [EmptyMutationDefinition];
return {
typeToServiceMap,
typeDefinitionsMap,
typeExtensionsMap,
directiveDefinitionsMap,
externalFields,
keyDirectivesMap,
valueTypes,
directiveMetadata
};
}
export function buildSchemaFromDefinitionsAndExtensions({
typeDefinitionsMap,
typeExtensionsMap,
directiveDefinitionsMap,
directiveMetadata,
serviceList,
}: {
typeDefinitionsMap: TypeDefinitionsMap;
typeExtensionsMap: TypeExtensionsMap;
directiveDefinitionsMap: DirectiveDefinitionsMap;
directiveMetadata: DirectiveMetadata;
serviceList: ServiceDefinition[];
}) {
let errors: GraphQLError[] | undefined = undefined;
// We only want to include the definitions of auto-included Apollo directives
// (just @tag) if there are usages.
const autoIncludedDirectiveDefinitions =
directivesWithAutoIncludedDefinitions.filter((directive) =>
directiveMetadata.hasUsages(directive.name),
);
const {
FieldSetScalar,
JoinFieldDirective,
JoinTypeDirective,
JoinOwnerDirective,
JoinGraphEnum,
JoinGraphDirective,
} = getJoinDefinitions(serviceList);
let schema = new GraphQLSchema({
query: undefined,
directives: [
CoreDirective,
JoinFieldDirective,
JoinTypeDirective,
JoinOwnerDirective,
JoinGraphDirective,
...specifiedDirectives,
...directivesWithNoDefinitionNeeded,
...autoIncludedDirectiveDefinitions,
],
types: [FieldSetScalar, JoinGraphEnum],
});
// This interface and predicate is a TS / graphql-js workaround for now while
// we're using a local graphql version < v15. This predicate _could_ be:
// `node is ObjectTypeDefinitionNode | InterfaceTypeDefinitionNode` in the
// future to be more semantic. However this gives us type safety and flexibility
// for now.
interface HasInterfaces {
interfaces?: ObjectTypeDefinitionNode['interfaces'];
}
function nodeHasInterfaces(node: any): node is HasInterfaces {
return 'interfaces' in node;
}
// Extend the blank schema with the base type definitions (as an AST node)
const definitionsDocument: DocumentNode = {
kind: Kind.DOCUMENT,
definitions: [
...Object.values(typeDefinitionsMap).flatMap((typeDefinitions) => {
// See if any of our Objects or Interfaces implement any interfaces at all.
// If not, we can return early.
if (!typeDefinitions.some(nodeHasInterfaces)) return typeDefinitions;
const uniqueInterfaces: Map<string, NamedTypeNode> = (
typeDefinitions as HasInterfaces[]
).reduce((map, objectTypeDef) => {
objectTypeDef.interfaces?.forEach((iface) =>
map.set(iface.name.value, iface),
);
return map;
}, new Map());
// No interfaces, no aggregation - just return what we got.
if (uniqueInterfaces.size === 0) return typeDefinitions;
const [first, ...rest] = typeDefinitions;
return [
...rest,
{
...first,
interfaces: Array.from(uniqueInterfaces.values()),
},
];
}),
...Object.values(directiveDefinitionsMap).map(
(definitions) => Object.values(definitions)[0],
),
],
};
errors = validateSDL(definitionsDocument, schema, compositionRules) as GraphQLError[];
try {
schema = extendSchema(schema, definitionsDocument, {
assumeValidSDL: true,
});
} catch (e) {}
// Extend the schema with the extension definitions (as an AST node)
const extensionsDocument: DocumentNode = {
kind: Kind.DOCUMENT,
definitions: Object.values(typeExtensionsMap).flat(),
};
errors.push(...validateSDL(extensionsDocument, schema, compositionRules));
try {
schema = extendSchema(schema, extensionsDocument, {
assumeValidSDL: true,
});
} catch {}
// Remove apollo type system directives from the final schema
schema = new GraphQLSchema({
...schema.toConfig(),
directives: [
...schema
.getDirectives()
.filter((x) => !isDirectiveWithNoDefinitionNeeded(x)),
],
});
return { schema, errors };
}
/**
* Using the various information we've collected about the schema, augment the
* `schema` itself with `federation` metadata to the types and fields
*/
export function addFederationMetadataToSchemaNodes({
schema,
typeToServiceMap,
externalFields,
keyDirectivesMap,
valueTypes,
directiveDefinitionsMap,
directiveMetadata,
}: {
schema: GraphQLSchema;
typeToServiceMap: TypeToServiceMap;
externalFields: ExternalFieldDefinition[];
keyDirectivesMap: KeyDirectivesMap;
valueTypes: ValueTypes;
directiveDefinitionsMap: DirectiveDefinitionsMap;
directiveMetadata: DirectiveMetadata;
}) {
for (const [
typeName,
{ owningService, extensionFieldsToOwningServiceMap },
] of Object.entries(typeToServiceMap)) {
const namedType = schema.getType(typeName) as GraphQLNamedType;
if (!namedType) continue;
// Extend each type in the GraphQLSchema with the serviceName that owns it
// and the key directives that belong to it
const isValueType = valueTypes.has(typeName);
const serviceName = isValueType ? null : owningService;
const federationMetadata: FederationType = {
...getFederationMetadata(namedType),
serviceName,
isValueType,
...(keyDirectivesMap[typeName] && {
keys: keyDirectivesMap[typeName],
}),
};
namedType.extensions = {
...namedType.extensions,
federation: federationMetadata,
};
// For object types, add metadata for all the @provides directives from its fields
if (isObjectType(namedType)) {
for (const field of Object.values(namedType.getFields())) {
const [providesDirective] = findDirectivesOnNode(
field.astNode,
'provides',
);
if (
providesDirective &&
providesDirective.arguments &&
isStringValueNode(providesDirective.arguments[0].value)
) {
const fieldFederationMetadata: FederationField = {
...getFederationMetadata(field),
serviceName,
provides: parseFieldSet(
providesDirective.arguments[0].value.value,
),
belongsToValueType: isValueType,
};
field.extensions = {
...field.extensions,
federation: fieldFederationMetadata,
};
}
}
}
/**
* For extension fields, do 2 things:
* 1. Add serviceName metadata to all fields that belong to a type extension
* 2. add metadata from the @requires directive for each field extension
*/
for (const [fieldName, extendingServiceName] of Object.entries(
extensionFieldsToOwningServiceMap,
)) {
// TODO: Why don't we need to check for non-object types here
if (isObjectType(namedType)) {
const field = namedType.getFields()[fieldName];
if (!field) continue;
const fieldFederationMetadata: FederationField = {
...getFederationMetadata(field),
serviceName: extendingServiceName,
};
field.extensions = {
...field.extensions,
federation: fieldFederationMetadata,
};
const [requiresDirective] = findDirectivesOnNode(
field.astNode,
'requires',
);
if (
requiresDirective &&
requiresDirective.arguments &&
isStringValueNode(requiresDirective.arguments[0].value)
) {
const fieldFederationMetadata: FederationField = {
...getFederationMetadata(field),
requires: parseFieldSet(
requiresDirective.arguments[0].value.value,
),
};
field.extensions = {
...field.extensions,
federation: fieldFederationMetadata,
};
}
}
}
}
// add externals metadata
for (const field of externalFields) {
const namedType = schema.getType(field.parentTypeName);
if (!namedType) continue;
const existingMetadata = getFederationMetadata(namedType);
const typeFederationMetadata: FederationType = {
...existingMetadata,
externals: {
...existingMetadata?.externals,
[field.serviceName]: [
...(existingMetadata?.externals?.[field.serviceName] || []),
field,
],
},
};
namedType.extensions = {
...namedType.extensions,
federation: typeFederationMetadata,
};
}
// add all definitions of a specific directive for validation later
for (const directiveName of Object.keys(directiveDefinitionsMap)) {
const directive = schema.getDirective(directiveName);
if (!directive) continue;
const directiveFederationMetadata: FederationDirective = {
...getFederationMetadata(directive),
directiveDefinitions: directiveDefinitionsMap[directiveName],
};
directive.extensions = {
...directive.extensions,
federation: directiveFederationMetadata,
};
}
// currently this is only used to capture @tag metadata but could be used
// for others directives in the future
directiveMetadata.applyMetadataToSupergraphSchema(schema);
}
export function composeServices(services: ServiceDefinition[]): CompositionResult {
const {
typeToServiceMap,
typeDefinitionsMap,
typeExtensionsMap,
directiveDefinitionsMap,
externalFields,
keyDirectivesMap,
valueTypes,
directiveMetadata,
} = buildMapsFromServiceList(services);
let { schema, errors } = buildSchemaFromDefinitionsAndExtensions({
typeDefinitionsMap,
typeExtensionsMap,
directiveDefinitionsMap,
directiveMetadata,
serviceList: services,
});
// TODO: We should fix this to take non-default operation root types in
// implementing services into account.
schema = new GraphQLSchema({
...schema.toConfig(),
...mapValues(defaultRootOperationNameLookup, typeName =>
typeName
? (schema.getType(typeName) as GraphQLObjectType<any, any>)
: undefined,
),
extensions: {
serviceList: services
}
});
// If multiple type definitions and extensions for the same type implement the
// same interface, it will get added to the constructed object multiple times,
// resulting in a schema validation error. We therefore need to remove
// duplicate interfaces from object types manually.
schema = transformSchema(schema, type => {
if (isObjectType(type)) {
const config = type.toConfig();
return new GraphQLObjectType({
...config,
interfaces: Array.from(new Set(config.interfaces)),
});
}
return undefined;
});
schema = lexicographicSortSchema(schema);
addFederationMetadataToSchemaNodes({
schema,
typeToServiceMap,
externalFields,
keyDirectivesMap,
valueTypes,
directiveDefinitionsMap,
directiveMetadata,
});
const { graphNameToEnumValueName } = getJoinDefinitions(services);
if (errors.length > 0) {
return { schema, errors };
} else {
return {
schema,
supergraphSdl: printSupergraphSdl(schema, graphNameToEnumValueName),
};
}
}