UNPKG

@graphql-mesh/fusion-composition

Version:

Basic composition utility for Fusion spec

606 lines (605 loc) • 29.5 kB
import { concatAST, getNamedType, GraphQLSchema, isNonNullType, isObjectType, isSpecifiedScalarType, parse, } from 'graphql'; import pluralize from 'pluralize'; import { snakeCase } from 'snake-case'; import { stitchingDirectives } from '@graphql-tools/stitching-directives'; import { getDirectiveExtensions, getDocumentNodeFromSchema, MapperKind, mapSchema, } from '@graphql-tools/utils'; import { composeServices } from '@theguild/federation-composition'; import { convertSubgraphToFederationv2, detectAndAddMeshDirectives, importFederationDirectives, importMeshDirectives, normalizeDirectiveExtensions, } from './federation-utils.js'; export function getUnifiedGraphGracefully(subgraphs) { const result = composeSubgraphs(subgraphs); if (result.errors?.length) { throw new AggregateError(result.errors, `Failed to compose subgraphs; \n${result.errors.map(e => `- ${e.message}`).join('\n')}`); } return result.supergraphSdl; } function isEmptyObject(obj) { if (!obj) { return true; } for (const key in obj) { if (obj[key] != null) { return false; } } return true; } export function getAnnotatedSubgraphs(subgraphs, options = {}) { const annotatedSubgraphs = []; for (const subgraphConfig of subgraphs) { const { name: subgraphName, schema, transforms } = subgraphConfig; let url = subgraphConfig.url; const subgraphSchemaExtensions = getDirectiveExtensions(schema); const transportDirectives = subgraphSchemaExtensions?.transport; const transportDirective = transportDirectives?.[0]; let removeTransportDirective = false; if (transportDirective) { url = transportDirective.location; if (!options.alwaysAddTransportDirective && transportDirective.kind === 'http' && !transportDirective.headers && isEmptyObject(transportDirective.options)) { removeTransportDirective = true; } } let mergeDirectiveUsed = false; const sourceDirectiveUsed = transforms?.length > 0; const normalizedSchema = normalizeDirectiveExtensions(schema); if (removeTransportDirective && normalizedSchema.extensions?.directives?.transport) { delete (normalizedSchema.extensions?.directives).transport; } const annotatedSubgraph = mapSchema(normalizedSchema, { [MapperKind.TYPE]: type => { if (!sourceDirectiveUsed || isSpecifiedScalarType(type)) { return type; } const existingDirectives = getDirectiveExtensions(type); const existingSourceDirectives = existingDirectives?.source || []; if (existingSourceDirectives.length > 1) { throw new Error(`Type ${type.name} already has source directives from multiple subgraphs: ${existingSourceDirectives .map((source) => source.subgraph) .join(', ')}`); } const existingSourceDirective = existingSourceDirectives[0] || {}; const directives = { ...existingDirectives, source: { name: type.name, ...existingSourceDirective, subgraph: subgraphName, }, }; return new (Object.getPrototypeOf(type).constructor)({ ...type.toConfig(), extensions: { ...type.extensions, directives, }, astNode: undefined, }); }, [MapperKind.FIELD]: (fieldConfig, fieldName) => { if (!sourceDirectiveUsed) { return fieldConfig; } const newArgs = {}; if ('args' in fieldConfig && fieldConfig.args) { for (const argName in fieldConfig.args) { const arg = fieldConfig.args[argName]; const directives = getDirectiveExtensions(arg); const existingSourceDirectives = directives.source || []; if (existingSourceDirectives.length > 1) { throw new Error(`Argument ${argName} of field ${fieldName} already has source directives from multiple subgraphs: ${existingSourceDirectives .map((source) => source.subgraph) .join(', ')}`); } const existingSourceDirective = existingSourceDirectives[0] || {}; newArgs[argName] = { ...arg, extensions: { ...arg.extensions, directives: { ...directives, source: { name: argName, type: arg.type.toString(), ...existingSourceDirective, subgraph: subgraphName, }, }, }, astNode: undefined, }; } } const existingDirectives = getDirectiveExtensions(fieldConfig); const existingSourceDirectives = existingDirectives.source || []; if (existingSourceDirectives.length > 1) { throw new Error(`Field ${fieldName} already has source directives from multiple subgraphs: ${existingSourceDirectives .map((source) => source.subgraph) .join(', ')}`); } const existingSourceDirective = existingSourceDirectives[0] || {}; return { ...fieldConfig, args: newArgs, extensions: { ...fieldConfig.extensions, directives: { ...existingDirectives, source: { name: fieldName, type: fieldConfig.type.toString(), ...existingSourceDirective, subgraph: subgraphName, }, }, }, astNode: undefined, }; }, [MapperKind.ENUM_VALUE]: (valueConfig, _typeName, _schema, externalValue) => { if (!sourceDirectiveUsed) { return valueConfig; } const existingDirectives = getDirectiveExtensions(valueConfig); const existingSourceDirectives = existingDirectives.source || []; if (existingSourceDirectives.length > 1) { throw new Error(`Enum value ${externalValue} already has source directives from multiple subgraphs: ${existingSourceDirectives .map((source) => source.subgraph) .join(', ')}`); } const existingSourceDirective = existingSourceDirectives[0] || {}; return { ...valueConfig, extensions: { ...valueConfig.extensions, directives: { ...existingDirectives, source: { name: externalValue, ...existingSourceDirective, subgraph: subgraphName, }, }, }, astNode: undefined, }; }, [MapperKind.ROOT_FIELD]: (fieldConfig, fieldName) => { const directiveExtensions = getDirectiveExtensions(fieldConfig); if (!transforms?.length && !options.ignoreSemanticConventions) { addAnnotationsForSemanticConventions({ queryFieldName: fieldName, queryFieldConfig: fieldConfig, directiveExtensions, subgraphName, }); if (directiveExtensions.merge) { mergeDirectiveUsed = true; } return { ...fieldConfig, extensions: { ...fieldConfig.extensions, directives: directiveExtensions, }, astNode: undefined, }; } const newArgs = {}; if (fieldConfig.args) { for (const argName in fieldConfig.args) { const arg = fieldConfig.args[argName]; const directives = getDirectiveExtensions(arg); const existingSourceDirectives = directives.source || []; if (existingSourceDirectives.length > 1) { throw new Error(`Argument ${argName} of field ${fieldName} already has source directives from multiple subgraphs: ${existingSourceDirectives .map((source) => source.subgraph) .join(', ')}`); } const existingSourceDirective = existingSourceDirectives[0] || {}; newArgs[argName] = { ...arg, extensions: { ...arg.extensions, directives: { ...directives, source: { name: argName, type: arg.type.toString(), ...existingSourceDirective, subgraph: subgraphName, }, }, }, astNode: undefined, }; } } const existingSourceDirectives = directiveExtensions.source || []; if (existingSourceDirectives.length > 1) { throw new Error(`Field ${fieldName} already has source directives from multiple subgraphs: ${existingSourceDirectives .map((source) => source.subgraph) .join(', ')}`); } const existingSourceDirective = existingSourceDirectives[0] || {}; return { ...fieldConfig, extensions: { ...fieldConfig.extensions, directives: { ...directiveExtensions, source: { name: fieldName, type: fieldConfig.type.toString(), ...existingSourceDirective, subgraph: subgraphName, }, }, }, args: newArgs, astNode: undefined, }; }, [MapperKind.DIRECTIVE]: directive => { if (directive.name === 'transport' && removeTransportDirective) { return null; } if (!sourceDirectiveUsed && directive.name === 'source') { return null; } }, }); let transformedSubgraph = annotatedSubgraph; if (transforms?.length) { for (const transform of transforms) { transformedSubgraph = transform(transformedSubgraph, subgraphConfig); } // Semantic conventions transformedSubgraph = mapSchema(transformedSubgraph, { [MapperKind.ROOT_FIELD]: (fieldConfig, fieldName) => { // Automatic type merging configuration based on ById and ByIds naming conventions after transforms const directiveExtensions = getDirectiveExtensions(fieldConfig); if (!options.ignoreSemanticConventions) { addAnnotationsForSemanticConventions({ queryFieldName: fieldName, queryFieldConfig: fieldConfig, directiveExtensions, subgraphName, }); if (directiveExtensions.merge) { mergeDirectiveUsed = true; } return { ...fieldConfig, extensions: { ...fieldConfig.extensions, directives: directiveExtensions, }, astNode: undefined, }; } }, }); } transformedSubgraph = convertSubgraphToFederationv2(transformedSubgraph); transformedSubgraph = detectAndAddMeshDirectives(transformedSubgraph); const importedDirectives = new Set(); if (mergeDirectiveUsed) { importedDirectives.add('@merge'); } if (sourceDirectiveUsed) { importedDirectives.add('@source'); } const fieldMapper = (fieldConfig, fieldName) => { const fieldDirectives = getDirectiveExtensions(fieldConfig); const sourceDirectives = fieldDirectives?.source; if (sourceDirectives?.length) { const filteredSourceDirectives = sourceDirectives.filter(sourceDirective => sourceDirective.name !== fieldName || sourceDirective.type !== fieldConfig.type.toString()); fieldDirectives.source = filteredSourceDirectives; } if ('args' in fieldConfig) { const newArgs = {}; for (const argName in fieldConfig.args) { const arg = fieldConfig.args[argName]; const argDirectives = getDirectiveExtensions(arg); const sourceDirectives = argDirectives?.source; if (sourceDirectives?.length) { const filteredSourceDirectives = sourceDirectives.filter(sourceDirective => sourceDirective.name !== argName || sourceDirective.type !== arg.type.toString()); newArgs[argName] = { ...arg, extensions: { ...arg.extensions, directives: { ...argDirectives, source: filteredSourceDirectives, }, }, }; } else { newArgs[argName] = arg; } } return { ...fieldConfig, args: newArgs, extensions: { ...fieldConfig.extensions, directives: fieldDirectives, }, astNode: undefined, }; } return { ...fieldConfig, extensions: { ...fieldConfig.extensions, directives: fieldDirectives, }, astNode: undefined, }; }; // Remove unnecessary @source directives transformedSubgraph = mapSchema(transformedSubgraph, { [MapperKind.TYPE]: type => { const typeDirectives = getDirectiveExtensions(type); const sourceDirectives = typeDirectives?.source; if (sourceDirectives?.length) { const filteredSourceDirectives = sourceDirectives.filter(sourceDirective => sourceDirective.name !== type.name); const typeExtensions = (type.extensions ||= {}); typeExtensions.directives = { ...typeDirectives, source: filteredSourceDirectives, }; } return type; }, [MapperKind.ROOT_FIELD]: fieldMapper, [MapperKind.FIELD]: fieldMapper, [MapperKind.ENUM_VALUE]: (valueConfig, _typeName, _schema, externalValue) => { const valueDirectives = getDirectiveExtensions(valueConfig); const sourceDirectives = valueDirectives?.source; if (sourceDirectives?.length) { const filteredSourceDirectives = sourceDirectives.filter(sourceDirective => sourceDirective.name !== externalValue); const valueExtensions = (valueConfig.extensions ||= {}); valueExtensions.directives = { ...valueDirectives, source: filteredSourceDirectives, }; } return valueConfig; }, }); // Workaround to keep directives on unsupported nodes since not all of them are supported by the composition library let extraSchemaDefinitionDirectives; const schemaDirectiveExtensions = getDirectiveExtensions(transformedSubgraph); if (schemaDirectiveExtensions) { const schemaDirectiveExtensionsEntries = Object.entries(schemaDirectiveExtensions); const schemaDirectiveExtraEntries = schemaDirectiveExtensionsEntries.filter(([dirName]) => dirName !== 'link' && dirName !== 'composeDirective' && (removeTransportDirective ? dirName !== 'transport' : true)); if (schemaDirectiveExtraEntries.length) { transformedSubgraph = new GraphQLSchema({ ...transformedSubgraph.toConfig(), extensions: { ...transformedSubgraph.extensions, directives: { link: schemaDirectiveExtensions.link || [], composeDirective: schemaDirectiveExtensions.composeDirective || [], }, }, // Cleanup AST Node to avoid conflicts with extensions astNode: undefined, extensionASTNodes: [], }); extraSchemaDefinitionDirectives = Object.fromEntries(schemaDirectiveExtraEntries); } } const importedDirectivesAST = new Set(); if (mergeDirectiveUsed) { if (!transformedSubgraph.getDirective('merge')) { const { mergeDirectiveTypeDefs } = stitchingDirectives(); // Add subgraph argument to @merge directive importedDirectivesAST.add(mergeDirectiveTypeDefs .replace('@merge(', '@merge(subgraph: String, ') .replace('on ', 'repeatable on ')); transformedSubgraph = importFederationDirectives(transformedSubgraph, ['@key']); } } if (sourceDirectiveUsed) { importedDirectivesAST.add(/* GraphQL */ ` directive @source( name: String! type: String subgraph: String! ) repeatable on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION `); } const queryType = transformedSubgraph.getQueryType(); const queryTypeDirectives = getDirectiveExtensions(queryType) || {}; if (extraSchemaDefinitionDirectives) { importedDirectives.add('@extraSchemaDefinitionDirective'); importedDirectivesAST.add(/* GraphQL */ ` scalar _DirectiveExtensions `); importedDirectivesAST.add(/* GraphQL */ ` directive @extraSchemaDefinitionDirective( directives: _DirectiveExtensions ) repeatable on OBJECT `); queryTypeDirectives.extraSchemaDefinitionDirective ||= []; queryTypeDirectives.extraSchemaDefinitionDirective.push({ directives: extraSchemaDefinitionDirectives, }); } const queryTypeExtensions = (queryType.extensions ||= {}); queryTypeExtensions.directives = queryTypeDirectives; if (importedDirectives.size) { transformedSubgraph = importMeshDirectives(transformedSubgraph, [...importedDirectives]); } let subgraphAST = getDocumentNodeFromSchema(transformedSubgraph); if (importedDirectivesAST.size) { subgraphAST = concatAST([ subgraphAST, parse([...importedDirectivesAST].join('\n'), { noLocation: true }), ]); } annotatedSubgraphs.push({ name: subgraphName, typeDefs: subgraphAST, url, }); } return annotatedSubgraphs; } export function composeSubgraphs(subgraphs, options = {}) { const annotatedSubgraphs = getAnnotatedSubgraphs(subgraphs, options); const composedSupergraphWithAnnotatedSubgraphs = composeAnnotatedSubgraphs(annotatedSubgraphs); return { ...composedSupergraphWithAnnotatedSubgraphs, subgraphs, }; } export function composeAnnotatedSubgraphs(annotatedSubgraphs) { const composedSupergraphSdl = composeServices(annotatedSubgraphs); return { ...composedSupergraphSdl, supergraphSdl: composedSupergraphSdl.supergraphSdl?.trim(), annotatedSubgraphs, }; } function addAnnotationsForSemanticConventions({ queryFieldName, queryFieldConfig, directiveExtensions, subgraphName, }) { if (directiveExtensions.merge?.length) { return; } const type = getNamedType(queryFieldConfig.type); if (type.astNode?.directives?.some((directive) => directive.name.value === 'key')) { return; } if (isObjectType(type)) { const fieldMap = type.getFields(); let fieldNames = Object.keys(fieldMap); if (fieldNames.includes('id')) { fieldNames = ['id']; } else { const nonNullOnes = fieldNames.filter(fieldName => isNonNullType(fieldMap[fieldName].type)); if (nonNullOnes.length) { fieldNames = nonNullOnes; } } for (const fieldName of fieldNames) { const objectField = fieldMap[fieldName]; const objectFieldType = getNamedType(objectField.type); const argEntries = Object.entries(queryFieldConfig.args); let argName; let arg; if (argEntries.length === 1) { const argType = getNamedType(argEntries[0][1].type); if (argType.name === objectFieldType.name) { [argName, arg] = argEntries[0]; } } else { for (const [currentArgName, currentArg] of argEntries) { if (currentArgName === fieldName || pluralize(fieldName) === currentArgName) { argName = currentArgName; arg = currentArg; break; } } } const queryFieldNameSnakeCase = snakeCase(queryFieldName); const pluralTypeName = pluralize(type.name); if (arg) { const typeDirectives = getDirectiveExtensions(type); switch (queryFieldNameSnakeCase) { case snakeCase(type.name): case snakeCase(`get_${type.name}_by_${fieldName}`): case snakeCase(`${type.name}_by_${fieldName}`): { if (isNonNullType(arg.type)) { directiveExtensions.merge ||= []; directiveExtensions.merge.push({ subgraph: subgraphName, keyField: fieldName, keyArg: argName, }); typeDirectives.key ||= []; if (!typeDirectives.key.some((key) => key.fields === fieldName)) { typeDirectives.key.push({ fields: fieldName, }); const typeExtensions = (type.extensions ||= {}); typeExtensions.directives = typeDirectives; } } break; } case snakeCase(pluralTypeName): case snakeCase(`get_${pluralTypeName}_by_${fieldName}`): case snakeCase(`${pluralTypeName}_by_${fieldName}`): case snakeCase(`get_${pluralTypeName}_by_${fieldName}s`): case snakeCase(`${pluralTypeName}_by_${fieldName}s`): { directiveExtensions.merge ||= []; directiveExtensions.merge.push({ subgraph: subgraphName, keyField: fieldName, keyArg: argName, }); typeDirectives.key ||= []; if (!typeDirectives.key.some((key) => key.fields === fieldName)) { typeDirectives.key.push({ fields: fieldName, }); const typeExtensions = (type.extensions ||= {}); typeExtensions.directives = typeDirectives; } break; } } } /** For the schemas with filter in `where` argument */ /** Todo: if (fieldName === 'id') { const [, whereArg] = Object.entries(queryFieldConfig.args).find(([argName]) => argName === 'where') || []; const whereArgType = whereArg && getNamedType(whereArg.type); const whereArgTypeFields = isInputObjectType(whereArgType) && whereArgType.getFields(); const regularFieldInWhereArg = whereArgTypeFields?.[fieldName]; const regularFieldTypeName = regularFieldInWhereArg && getNamedType(regularFieldInWhereArg.type)?.name; const batchFieldInWhereArg = whereArgTypeFields?.[`${fieldName}_in`]; const batchFieldTypeName = batchFieldInWhereArg && getNamedType(batchFieldInWhereArg.type)?.name; const objectFieldTypeName = objectFieldType.name; if (regularFieldTypeName === objectFieldTypeName) { const operationName = pascalCase(`get_${type.name}_by_${fieldName}`); const originalFieldName = getOriginalFieldNameForSubgraph(queryField, subgraphName); const resolverAnnotation: ResolverAnnotation = { subgraph: subgraphName, operation: `query ${operationName}($${varName}: ${objectFieldTypeName}!) { ${originalFieldName}(where: { ${fieldName}: $${varName}) } }`, kind: 'FETCH', }; directiveExtensions.resolver ||= []; directiveExtensions.resolver.push(resolverAnnotation); directiveExtensions.variable ||= []; } if (batchFieldTypeName === objectFieldTypeName) { const pluralFieldName = pluralize(fieldName); const operationName = pascalCase(`get_${pluralTypeName}_by_${pluralFieldName}`); const originalFieldName = getOriginalFieldNameForSubgraph(queryField, subgraphName); const resolverAnnotation: ResolverAnnotation = { subgraph: subgraphName, operation: `query ${operationName}($${varName}: [${objectFieldTypeName}!]!) { ${originalFieldName}(where: { ${fieldName}_in: $${varName} }) }`, kind: 'BATCH', }; directiveExtensions.resolver ||= []; directiveExtensions.resolver.push(resolverAnnotation); directiveExtensions.variable ||= []; } } */ } } }