diff --git a/.changeset/quick-masks-hang.md b/.changeset/quick-masks-hang.md new file mode 100644 index 00000000000..e39fc70a2bb --- /dev/null +++ b/.changeset/quick-masks-hang.md @@ -0,0 +1,9 @@ +--- +"@graphql-tools/delegate": patch +"@graphql-tools/merge": patch +"@graphql-tools/stitch": minor +"@graphql-tools/stitching-directives": minor +"@graphql-tools/website": patch +--- + +enhance(stitch) canonical merged type and field definitions. Use the @canonical directive to promote preferred type and field descriptions into the combined gateway schema. diff --git a/packages/delegate/src/types.ts b/packages/delegate/src/types.ts index edc1ca3f238..2aa71649fcf 100644 --- a/packages/delegate/src/types.ts +++ b/packages/delegate/src/types.ts @@ -172,9 +172,10 @@ export interface SubschemaConfig { export interface MergedTypeConfig extends MergedTypeResolverOptions { selectionSet?: string; - fields?: Record; + fields?: Record; computedFields?: Record; key?: (originalResult: any) => K; + canonical?: boolean; resolve?: MergedTypeResolver; } diff --git a/packages/merge/src/typedefs-mergers/enum.ts b/packages/merge/src/typedefs-mergers/enum.ts index 8ea93c9eca6..1670a3c202c 100644 --- a/packages/merge/src/typedefs-mergers/enum.ts +++ b/packages/merge/src/typedefs-mergers/enum.ts @@ -18,7 +18,7 @@ export function mergeEnum( : 'EnumTypeExtension', loc: e1.loc, directives: mergeDirectives(e1.directives, e2.directives, config), - values: mergeEnumValues(e1.values, e2.values, config), + values: mergeEnumValues(e2.values, e1.values, config), } as any; } diff --git a/packages/stitch/src/mergeCandidates.ts b/packages/stitch/src/mergeCandidates.ts index 5810725bd87..72051fd4f09 100644 --- a/packages/stitch/src/mergeCandidates.ts +++ b/packages/stitch/src/mergeCandidates.ts @@ -10,8 +10,10 @@ import { isUnionType, isEnumType, isInputObjectType, + GraphQLFieldConfig, GraphQLFieldConfigMap, GraphQLInputObjectType, + GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, ObjectTypeDefinitionNode, InputObjectTypeDefinitionNode, @@ -33,8 +35,10 @@ import { TypeMergingOptions, MergeFieldConfigCandidate, MergeInputFieldConfigCandidate, + MergeEnumValueConfigCandidate, } from './types'; import { fieldToFieldConfig, inputFieldToFieldConfig } from '@graphql-tools/utils'; +import { isSubschemaConfig } from '@graphql-tools/delegate'; export function mergeCandidates( typeName: string, @@ -68,6 +72,8 @@ function mergeObjectTypeCandidates( candidates: Array, typeMergingOptions: TypeMergingOptions ): GraphQLObjectType { + candidates = orderedTypeCandidates(candidates, typeMergingOptions); + const description = mergeTypeDescriptions(candidates, typeMergingOptions); const fields = fieldConfigMapFromTypeCandidates(candidates, typeMergingOptions); const typeConfigs = candidates.map(candidate => (candidate.type as GraphQLObjectType).toConfig()); @@ -84,6 +90,17 @@ function mergeObjectTypeCandidates( const interfaces = Object.keys(interfaceMap).map(interfaceName => interfaceMap[interfaceName]); const astNodes = pluck('astNode', candidates); + const fieldAstNodes = canonicalFieldNamesForType(candidates) + .map(fieldName => fields[fieldName]?.astNode) + .filter(n => n != null); + + if (astNodes.length > 1 && fieldAstNodes.length) { + astNodes.push({ + ...astNodes[astNodes.length - 1], + fields: JSON.parse(JSON.stringify(fieldAstNodes)), + }); + } + const astNode = astNodes .slice(1) .reduce( @@ -113,10 +130,23 @@ function mergeInputObjectTypeCandidates( candidates: Array, typeMergingOptions: TypeMergingOptions ): GraphQLInputObjectType { + candidates = orderedTypeCandidates(candidates, typeMergingOptions); + const description = mergeTypeDescriptions(candidates, typeMergingOptions); const fields = inputFieldConfigMapFromTypeCandidates(candidates, typeMergingOptions); const astNodes = pluck('astNode', candidates); + const fieldAstNodes = canonicalFieldNamesForType(candidates) + .map(fieldName => fields[fieldName]?.astNode) + .filter(n => n != null); + + if (astNodes.length > 1 && fieldAstNodes.length) { + astNodes.push({ + ...astNodes[astNodes.length - 1], + fields: JSON.parse(JSON.stringify(fieldAstNodes)), + }); + } + const astNode = astNodes .slice(1) .reduce( @@ -149,6 +179,8 @@ function mergeInterfaceTypeCandidates( candidates: Array, typeMergingOptions: TypeMergingOptions ): GraphQLInterfaceType { + candidates = orderedTypeCandidates(candidates, typeMergingOptions); + const description = mergeTypeDescriptions(candidates, typeMergingOptions); const fields = fieldConfigMapFromTypeCandidates(candidates, typeMergingOptions); const typeConfigs = candidates.map(candidate => (candidate.type as GraphQLInterfaceType).toConfig()); @@ -165,6 +197,17 @@ function mergeInterfaceTypeCandidates( const interfaces = Object.keys(interfaceMap).map(interfaceName => interfaceMap[interfaceName]); const astNodes = pluck('astNode', candidates); + const fieldAstNodes = canonicalFieldNamesForType(candidates) + .map(fieldName => fields[fieldName]?.astNode) + .filter(n => n != null); + + if (astNodes.length > 1 && fieldAstNodes.length) { + astNodes.push({ + ...astNodes[astNodes.length - 1], + fields: JSON.parse(JSON.stringify(fieldAstNodes)), + }); + } + const astNode = astNodes .slice(1) .reduce( @@ -194,6 +237,7 @@ function mergeUnionTypeCandidates( candidates: Array, typeMergingOptions: TypeMergingOptions ): GraphQLUnionType { + candidates = orderedTypeCandidates(candidates, typeMergingOptions); const description = mergeTypeDescriptions(candidates, typeMergingOptions); const typeConfigs = candidates.map(candidate => (candidate.type as GraphQLUnionType).toConfig()); @@ -234,16 +278,10 @@ function mergeEnumTypeCandidates( candidates: Array, typeMergingOptions: TypeMergingOptions ): GraphQLEnumType { - const description = mergeTypeDescriptions(candidates, typeMergingOptions); + candidates = orderedTypeCandidates(candidates, typeMergingOptions); - const typeConfigs = candidates.map(candidate => (candidate.type as GraphQLEnumType).toConfig()); - const values = typeConfigs.reduce( - (acc, typeConfig) => ({ - ...acc, - ...typeConfig.values, - }), - {} - ); + const description = mergeTypeDescriptions(candidates, typeMergingOptions); + const values = enumValueConfigMapFromTypeCandidates(candidates, typeMergingOptions); const astNodes = pluck('astNode', candidates); const astNode = astNodes @@ -266,13 +304,56 @@ function mergeEnumTypeCandidates( return new GraphQLEnumType(typeConfig); } +function enumValueConfigMapFromTypeCandidates( + candidates: Array, + typeMergingOptions: TypeMergingOptions +): GraphQLEnumValueConfigMap { + const enumValueConfigCandidatesMap: Record> = Object.create(null); + + candidates.forEach(candidate => { + const valueMap = (candidate.type as GraphQLEnumType).toConfig().values; + Object.keys(valueMap).forEach(enumValue => { + const enumValueConfigCandidate = { + enumValueConfig: valueMap[enumValue], + enumValue, + type: candidate.type as GraphQLEnumType, + subschema: candidate.subschema, + transformedSubschema: candidate.transformedSubschema, + }; + + if (enumValue in enumValueConfigCandidatesMap) { + enumValueConfigCandidatesMap[enumValue].push(enumValueConfigCandidate); + } else { + enumValueConfigCandidatesMap[enumValue] = [enumValueConfigCandidate]; + } + }); + }); + + const enumValueConfigMap = Object.create(null); + + Object.keys(enumValueConfigCandidatesMap).forEach(enumValue => { + const enumValueConfigMerger = typeMergingOptions?.enumValueConfigMerger ?? defaultEnumValueConfigMerger; + enumValueConfigMap[enumValue] = enumValueConfigMerger(enumValueConfigCandidatesMap[enumValue]); + }); + + return enumValueConfigMap; +} + +function defaultEnumValueConfigMerger(candidates: Array) { + const preferred = candidates.find( + ({ type, subschema }) => isSubschemaConfig(subschema) && subschema.merge?.[type.name]?.canonical + ); + return (preferred || candidates[candidates.length - 1]).enumValueConfig; +} + function mergeScalarTypeCandidates( typeName: string, candidates: Array, typeMergingOptions: TypeMergingOptions ): GraphQLScalarType { - const description = mergeTypeDescriptions(candidates, typeMergingOptions); + candidates = orderedTypeCandidates(candidates, typeMergingOptions); + const description = mergeTypeDescriptions(candidates, typeMergingOptions); const serializeFns = pluck>('serialize', candidates); const serialize = serializeFns[serializeFns.length - 1]; @@ -285,7 +366,10 @@ function mergeScalarTypeCandidates( const astNodes = pluck('astNode', candidates); const astNode = astNodes .slice(1) - .reduce((acc, astNode) => mergeScalar(acc, astNode), astNodes[0]) as ScalarTypeDefinitionNode; + .reduce( + (acc, astNode) => mergeScalar(astNode, acc as ScalarTypeDefinitionNode) as ScalarTypeDefinitionNode, + astNodes[0] + ); const extensionASTNodes = [].concat(pluck>('extensionASTNodes', candidates)); @@ -305,6 +389,29 @@ function mergeScalarTypeCandidates( return new GraphQLScalarType(typeConfig); } +function orderedTypeCandidates( + candidates: Array, + typeMergingOptions: TypeMergingOptions +): Array { + const typeCandidateMerger = typeMergingOptions?.typeCandidateMerger ?? defaultTypeCandidateMerger; + const candidate = typeCandidateMerger(candidates); + return candidates.filter(c => c !== candidate).concat([candidate]); +} + +function defaultTypeCandidateMerger(candidates: Array): MergeTypeCandidate { + const canonical: Array = candidates.filter(({ type, subschema }) => + isSubschemaConfig(subschema) ? subschema.merge?.[type.name]?.canonical : false + ); + + if (canonical.length > 1) { + throw new Error(`Multiple canonical definitions for "${canonical[0].type.name}"`); + } else if (canonical.length) { + return canonical[0]; + } + + return candidates[candidates.length - 1]; +} + function mergeTypeDescriptions(candidates: Array, typeMergingOptions: TypeMergingOptions): string { const typeDescriptionsMerger = typeMergingOptions?.typeDescriptionsMerger ?? defaultTypeDescriptionMerger; return typeDescriptionsMerger(candidates); @@ -354,6 +461,26 @@ function mergeFieldConfigs(candidates: Array, typeMer } function defaultFieldConfigMerger(candidates: Array) { + const canonicalByField: Array> = []; + const canonicalByType: Array> = []; + + candidates.forEach(({ type, fieldName, fieldConfig, subschema }) => { + if (!isSubschemaConfig(subschema)) return; + if (subschema.merge?.[type.name]?.fields?.[fieldName]?.canonical) { + canonicalByField.push(fieldConfig); + } else if (subschema.merge?.[type.name]?.canonical) { + canonicalByType.push(fieldConfig); + } + }); + + if (canonicalByField.length > 1) { + throw new Error(`Multiple canonical definitions for "${candidates[0].type.name}.${candidates[0].fieldName}"`); + } else if (canonicalByField.length) { + return canonicalByField[0]; + } else if (canonicalByType.length) { + return canonicalByType[0]; + } + return candidates[candidates.length - 1].fieldConfig; } @@ -385,23 +512,49 @@ function inputFieldConfigMapFromTypeCandidates( const inputFieldConfigMap = Object.create(null); Object.keys(inputFieldConfigCandidatesMap).forEach(fieldName => { - inputFieldConfigMap[fieldName] = mergeInputFieldConfigs( - inputFieldConfigCandidatesMap[fieldName], - typeMergingOptions - ); + const inputFieldConfigMerger = typeMergingOptions?.inputFieldConfigMerger ?? defaultInputFieldConfigMerger; + inputFieldConfigMap[fieldName] = inputFieldConfigMerger(inputFieldConfigCandidatesMap[fieldName]); }); return inputFieldConfigMap; } -function mergeInputFieldConfigs( - candidates: Array, - typeMergingOptions: TypeMergingOptions -) { - const inputFieldConfigMerger = typeMergingOptions?.inputFieldConfigMerger ?? defaultInputFieldConfigMerger; - return inputFieldConfigMerger(candidates); -} - function defaultInputFieldConfigMerger(candidates: Array) { + const canonicalByField: Array = []; + const canonicalByType: Array = []; + + candidates.forEach(({ type, fieldName, inputFieldConfig, subschema }) => { + if (!isSubschemaConfig(subschema)) return; + if (subschema.merge?.[type.name]?.fields?.[fieldName]?.canonical) { + canonicalByField.push(inputFieldConfig); + } else if (subschema.merge?.[type.name]?.canonical) { + canonicalByType.push(inputFieldConfig); + } + }); + + if (canonicalByField.length > 1) { + throw new Error(`Multiple canonical definitions for "${candidates[0].type.name}.${candidates[0].fieldName}"`); + } else if (canonicalByField.length) { + return canonicalByField[0]; + } else if (canonicalByType.length) { + return canonicalByType[0]; + } + return candidates[candidates.length - 1].inputFieldConfig; } + +function canonicalFieldNamesForType(candidates: Array): Array { + const canonicalFieldNames: Record = Object.create(null); + + candidates.forEach(({ type, subschema }) => { + if (isSubschemaConfig(subschema) && subschema.merge?.[type.name]?.fields && !subschema.merge[type.name].canonical) { + Object.entries(subschema.merge[type.name].fields).forEach(([fieldName, mergedFieldConfig]) => { + if (mergedFieldConfig.canonical) { + canonicalFieldNames[fieldName] = true; + } + }); + } + }); + + return Object.keys(canonicalFieldNames); +} diff --git a/packages/stitch/src/types.ts b/packages/stitch/src/types.ts index 2a520af6d76..6532b7ffb24 100644 --- a/packages/stitch/src/types.ts +++ b/packages/stitch/src/types.ts @@ -8,6 +8,8 @@ import { GraphQLInterfaceType, GraphQLInputFieldConfig, GraphQLInputObjectType, + GraphQLEnumValueConfig, + GraphQLEnumType, } from 'graphql'; import { ITypeDefinitions, TypeMap } from '@graphql-tools/utils'; import { MergedTypeResolver, Subschema, SubschemaConfig } from '@graphql-tools/delegate'; @@ -35,6 +37,14 @@ export interface MergeInputFieldConfigCandidate { transformedSubschema?: Subschema; } +export interface MergeEnumValueConfigCandidate { + enumValueConfig: GraphQLEnumValueConfig; + enumValue: string; + type: GraphQLEnumType; + subschema?: GraphQLSchema | SubschemaConfig; + transformedSubschema?: Subschema; +} + export type MergeTypeFilter = (mergeTypeCandidates: Array, typeName: string) => boolean; export interface MergedTypeInfo { @@ -70,9 +80,11 @@ export interface IStitchSchemasOptions extends Omit SubschemaConfig; export interface TypeMergingOptions { + typeCandidateMerger?: (candidates: Array) => MergeTypeCandidate; typeDescriptionsMerger?: (candidates: Array) => string; fieldConfigMerger?: (candidates: Array) => GraphQLFieldConfig; inputFieldConfigMerger?: (candidates: Array) => GraphQLInputFieldConfig; + enumValueConfigMerger?: (candidates: Array) => GraphQLEnumValueConfig; } export type OnTypeConflict = ( diff --git a/packages/stitch/tests/mergeDefinitions.test.ts b/packages/stitch/tests/mergeDefinitions.test.ts new file mode 100644 index 00000000000..76356a2fb30 --- /dev/null +++ b/packages/stitch/tests/mergeDefinitions.test.ts @@ -0,0 +1,314 @@ +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { stitchSchemas } from '@graphql-tools/stitch'; +import { getDirectives } from '@graphql-tools/utils'; +import { + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLInputObjectType, + GraphQLEnumType, + GraphQLUnionType, + GraphQLScalarType, + graphql +} from 'graphql'; + +describe('merge canonical types', () => { + const firstSchema = makeExecutableSchema({ + typeDefs: ` + directive @mydir(value: String) on OBJECT | INTERFACE | INPUT_OBJECT | UNION | ENUM | SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + "first" + type Product implements IProduct @mydir(value: "first") { + "first" + id: ID! @mydir(value: "first") @deprecated(reason: "first") + "first" + url: String @mydir(value: "first") @deprecated(reason: "first") + } + + "first" + interface IProduct @mydir(value: "first") { + "first" + id: ID! @mydir(value: "first") + "first" + url: String @mydir(value: "first") + } + + "first" + input ProductInput @mydir(value: "first") { + "first" + id: ID @mydir(value: "first") + "first" + url: String @mydir(value: "first") + } + + "first" + enum ProductEnum @mydir(value: "first") { + "first" + YES + "first" + NO + } + + "first" + union ProductUnion @mydir(value: "first") = Product + + "first" + scalar ProductScalar @mydir(value: "first") + + "first" + type Query @mydir(value: "first") { + "first" + field1: String @mydir(value: "first") + "first" + field2: String @mydir(value: "first") + } + `, + resolvers: { + Query: { + field1: () => 'first', + field2: () => 'first', + } + } + }); + + const secondSchema = makeExecutableSchema({ + typeDefs: ` + directive @mydir(value: String) on OBJECT | INTERFACE | INPUT_OBJECT | UNION | ENUM | SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + "second" + type Product implements IProduct @mydir(value: "second") { + "second" + id: ID! @mydir(value: "second") @deprecated(reason: "second") + "second" + url: String @mydir(value: "second") @deprecated(reason: "second") + } + + "second" + interface IProduct @mydir(value: "second") { + "second" + id: ID! @mydir(value: "second") + "second" + url: String @mydir(value: "second") + } + + "second" + input ProductInput @mydir(value: "second") { + "second" + id: ID @mydir(value: "second") + "second" + url: String @mydir(value: "second") + } + + "second" + enum ProductEnum @mydir(value: "second") { + "second" + YES + "second" + NO + "second" + MAYBE + } + + "second" + union ProductUnion @mydir(value: "second") = Product + + "second" + scalar ProductScalar @mydir(value: "second") + + "second" + type Query @mydir(value: "second") { + "second" + field1: String @mydir(value: "second") + "second" + field2: String @mydir(value: "second") + } + `, + resolvers: { + Query: { + field1: () => 'second', + field2: () => 'second', + } + } + }); + + const gatewaySchema = stitchSchemas({ + subschemas: [ + { + schema: firstSchema, + merge: { + Product: { + selectionSet: '{ id }', + fieldName: 'product', + args: ({ id }) => ({ id }), + canonical: true, + }, + IProduct: { + canonical: true, + }, + ProductInput: { + canonical: true, + }, + ProductEnum: { + canonical: true, + }, + ProductUnion: { + canonical: true, + }, + ProductScalar: { + canonical: true, + }, + Query: { + canonical: true, + }, + } + }, + { + schema: secondSchema, + merge: { + Product: { + selectionSet: '{ id }', + fieldName: 'product', + args: ({ id }) => ({ id }), + fields: { + url: { canonical: true }, + } + }, + IProduct: { + fields: { + url: { canonical: true }, + } + }, + ProductInput: { + fields: { + url: { canonical: true }, + } + }, + Query: { + fields: { + field2: { canonical: true }, + } + }, + } + }, + ], + typeDefs: ` + directive @mydir(value: String) on OBJECT | INTERFACE | INPUT_OBJECT | UNION | ENUM | SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION + + "third" + type Product implements IProduct @mydir(value: "third") { + "third" + id: ID! @mydir(value: "third") + "third" + url: String @mydir(value: "third") + } + + "third" + interface IProduct @mydir(value: "third") { + "third" + id: ID! @mydir(value: "third") + "third" + url: String @mydir(value: "third") + } + + "third" + input ProductInput @mydir(value: "third") { + "third" + id: ID @mydir(value: "third") + "third" + url: String @mydir(value: "third") + } + + "third" + enum ProductEnum @mydir(value: "third") { + "third" + YES + "third" + NO + } + + "third" + union ProductUnion @mydir(value: "third") = Product + + "third" + scalar ProductScalar @mydir(value: "third") + ` + }); + + it('merges prioritized descriptions', () => { + expect(gatewaySchema.getQueryType().description).toEqual('first'); + expect(gatewaySchema.getType('Product').description).toEqual('first'); + expect(gatewaySchema.getType('IProduct').description).toEqual('first'); + expect(gatewaySchema.getType('ProductInput').description).toEqual('first'); + expect(gatewaySchema.getType('ProductEnum').description).toEqual('first'); + expect(gatewaySchema.getType('ProductUnion').description).toEqual('first'); + expect(gatewaySchema.getType('ProductScalar').description).toEqual('first'); + + const queryType = gatewaySchema.getQueryType(); + const objectType = gatewaySchema.getType('Product') as GraphQLObjectType; + const interfaceType = gatewaySchema.getType('IProduct') as GraphQLInterfaceType; + const inputType = gatewaySchema.getType('ProductInput') as GraphQLInputObjectType; + const enumType = gatewaySchema.getType('ProductEnum') as GraphQLEnumType; + + expect(queryType.getFields().field1.description).toEqual('first'); + expect(queryType.getFields().field2.description).toEqual('second'); + + expect(objectType.getFields().id.description).toEqual('first'); + expect(interfaceType.getFields().id.description).toEqual('first'); + expect(inputType.getFields().id.description).toEqual('first'); + + expect(objectType.getFields().url.description).toEqual('second'); + expect(interfaceType.getFields().url.description).toEqual('second'); + expect(inputType.getFields().url.description).toEqual('second'); + + expect(enumType.toConfig().values.YES.description).toEqual('first'); + expect(enumType.toConfig().values.NO.description).toEqual('first'); + expect(enumType.toConfig().values.MAYBE.description).toEqual('second'); + }); + + it('merges prioritized ASTs', () => { + const queryType = gatewaySchema.getQueryType(); + const objectType = gatewaySchema.getType('Product') as GraphQLObjectType; + const interfaceType = gatewaySchema.getType('IProduct') as GraphQLInterfaceType; + const inputType = gatewaySchema.getType('ProductInput') as GraphQLInputObjectType; + const enumType = gatewaySchema.getType('ProductEnum') as GraphQLEnumType; + const unionType = gatewaySchema.getType('ProductUnion') as GraphQLUnionType; + const scalarType = gatewaySchema.getType('ProductScalar') as GraphQLScalarType; + + expect(getDirectives(firstSchema, queryType.toConfig()).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, objectType.toConfig()).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, interfaceType.toConfig()).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, inputType.toConfig()).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, enumType.toConfig()).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, unionType.toConfig()).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, scalarType.toConfig()).mydir.value).toEqual('first'); + + expect(getDirectives(firstSchema, queryType.getFields().field1).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, queryType.getFields().field2).mydir.value).toEqual('second'); + expect(getDirectives(firstSchema, objectType.getFields().id).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, objectType.getFields().url).mydir.value).toEqual('second'); + expect(getDirectives(firstSchema, interfaceType.getFields().id).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, interfaceType.getFields().url).mydir.value).toEqual('second'); + expect(getDirectives(firstSchema, inputType.getFields().id).mydir.value).toEqual('first'); + expect(getDirectives(firstSchema, inputType.getFields().url).mydir.value).toEqual('second'); + + expect(enumType.toConfig().astNode.values.map(v => v.description.value)).toEqual(['first', 'first', 'second']); + expect(enumType.toConfig().values.YES.astNode.description.value).toEqual('first'); + expect(enumType.toConfig().values.NO.astNode.description.value).toEqual('first'); + expect(enumType.toConfig().values.MAYBE.astNode.description.value).toEqual('second'); + }); + + it('merges prioritized deprecations', () => { + const objectType = gatewaySchema.getType('Product') as GraphQLObjectType; + expect(objectType.getFields().id.deprecationReason).toEqual('first'); + expect(objectType.getFields().url.deprecationReason).toEqual('second'); + expect(getDirectives(firstSchema, objectType.getFields().id).deprecated.reason).toEqual('first'); + expect(getDirectives(firstSchema, objectType.getFields().url).deprecated.reason).toEqual('second'); + }); + + it('promotes canonical root field definitions', async () => { + const { data } = await graphql(gatewaySchema, '{ field1 field2 }'); + expect(data).toEqual({ + field1: 'first', + field2: 'second', + }); + }); +}); diff --git a/packages/stitching-directives/src/defaultStitchingDirectiveOptions.ts b/packages/stitching-directives/src/defaultStitchingDirectiveOptions.ts index 88427758f51..59dbe7376b2 100644 --- a/packages/stitching-directives/src/defaultStitchingDirectiveOptions.ts +++ b/packages/stitching-directives/src/defaultStitchingDirectiveOptions.ts @@ -3,6 +3,7 @@ import { StitchingDirectivesOptions } from './types'; export const defaultStitchingDirectiveOptions: StitchingDirectivesOptions = { keyDirectiveName: 'key', computedDirectiveName: 'computed', + canonicalDirectiveName: 'canonical', mergeDirectiveName: 'merge', pathToDirectivesInExtensions: ['directives'], }; diff --git a/packages/stitching-directives/src/stitchingDirectives.ts b/packages/stitching-directives/src/stitchingDirectives.ts index 5474a5a2e5d..19f72d9586b 100644 --- a/packages/stitching-directives/src/stitchingDirectives.ts +++ b/packages/stitching-directives/src/stitchingDirectives.ts @@ -14,6 +14,7 @@ export function stitchingDirectives( keyDirectiveTypeDefs: string; computedDirectiveTypeDefs: string; mergeDirectiveTypeDefs: string; + canonicalDirectiveTypeDefs: string; stitchingDirectivesTypeDefs: string; // for backwards compatibility allStitchingDirectivesTypeDefs: string; stitchingDirectivesValidator: (schema: GraphQLSchema) => GraphQLSchema; @@ -21,6 +22,7 @@ export function stitchingDirectives( keyDirective: GraphQLDirective; computedDirective: GraphQLDirective; mergeDirective: GraphQLDirective; + canonicalDirective: GraphQLDirective; allStitchingDirectives: Array; } { const finalOptions = { @@ -28,11 +30,12 @@ export function stitchingDirectives( ...options, }; - const { keyDirectiveName, computedDirectiveName, mergeDirectiveName } = finalOptions; + const { keyDirectiveName, computedDirectiveName, mergeDirectiveName, canonicalDirectiveName } = finalOptions; const keyDirectiveTypeDefs = `directive @${keyDirectiveName}(selectionSet: String!) on OBJECT`; const computedDirectiveTypeDefs = `directive @${computedDirectiveName}(selectionSet: String!) on FIELD_DEFINITION`; const mergeDirectiveTypeDefs = `directive @${mergeDirectiveName}(argsExpr: String, keyArg: String, keyField: String, key: [String!], additionalArgs: String) on FIELD_DEFINITION`; + const canonicalDirectiveTypeDefs = `directive @${canonicalDirectiveName} on OBJECT | INTERFACE | INPUT_OBJECT | UNION | ENUM | SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION`; const keyDirective = new GraphQLDirective({ name: keyDirectiveName, @@ -62,22 +65,39 @@ export function stitchingDirectives( }, }); - const allStitchingDirectivesTypeDefs = ` - ${keyDirectiveTypeDefs} - ${computedDirectiveTypeDefs} - ${mergeDirectiveTypeDefs} - `; + const canonicalDirective = new GraphQLDirective({ + name: canonicalDirectiveName, + locations: [ + 'OBJECT', + 'INTERFACE', + 'INPUT_OBJECT', + 'UNION', + 'ENUM', + 'SCALAR', + 'FIELD_DEFINITION', + 'INPUT_FIELD_DEFINITION', + ], + }); + + const allStitchingDirectivesTypeDefs = [ + keyDirectiveTypeDefs, + computedDirectiveTypeDefs, + mergeDirectiveTypeDefs, + canonicalDirectiveTypeDefs, + ].join('\n'); return { keyDirectiveTypeDefs, computedDirectiveTypeDefs, mergeDirectiveTypeDefs, + canonicalDirectiveTypeDefs, stitchingDirectivesTypeDefs: allStitchingDirectivesTypeDefs, // for backwards compatibility allStitchingDirectivesTypeDefs, keyDirective, computedDirective, mergeDirective, - allStitchingDirectives: [keyDirective, computedDirective, mergeDirective], + canonicalDirective, + allStitchingDirectives: [keyDirective, computedDirective, mergeDirective, canonicalDirective], stitchingDirectivesValidator: stitchingDirectivesValidator(finalOptions), stitchingDirectivesTransformer: stitchingDirectivesTransformer(finalOptions), }; diff --git a/packages/stitching-directives/src/stitchingDirectivesTransformer.ts b/packages/stitching-directives/src/stitchingDirectivesTransformer.ts index 0b4bdf95ea3..d8043094e20 100644 --- a/packages/stitching-directives/src/stitchingDirectivesTransformer.ts +++ b/packages/stitching-directives/src/stitchingDirectivesTransformer.ts @@ -35,7 +35,13 @@ import { stitchingDirectivesValidator } from './stitchingDirectivesValidator'; export function stitchingDirectivesTransformer( options: StitchingDirectivesOptions = {} ): (subschemaConfig: SubschemaConfig) => SubschemaConfig { - const { keyDirectiveName, computedDirectiveName, mergeDirectiveName, pathToDirectivesInExtensions } = { + const { + keyDirectiveName, + computedDirectiveName, + mergeDirectiveName, + canonicalDirectiveName, + pathToDirectivesInExtensions, + } = { ...defaultStitchingDirectiveOptions, ...options, }; @@ -46,12 +52,25 @@ export function stitchingDirectivesTransformer( const selectionSetsByType: Record = Object.create(null); const computedFieldSelectionSets: Record> = Object.create(null); const mergedTypesResolversInfo: Record = Object.create(null); + const canonicalTypesInfo: Record }> = Object.create( + null + ); const schema = subschemaConfig.schema; // gateway should also run validation stitchingDirectivesValidator(options)(schema); + function setCanonicalDefinition(typeName: string, fieldName?: string): void { + canonicalTypesInfo[typeName] = canonicalTypesInfo[typeName] || Object.create(null); + if (fieldName) { + canonicalTypesInfo[typeName].fields = canonicalTypesInfo[typeName].fields || Object.create(null); + canonicalTypesInfo[typeName].fields[fieldName] = true; + } else { + canonicalTypesInfo[typeName].canonical = true; + } + } + mapSchema(schema, { [MapperKind.OBJECT_TYPE]: type => { const directives = getDirectives(schema, type, pathToDirectivesInExtensions); @@ -62,6 +81,10 @@ export function stitchingDirectivesTransformer( selectionSetsByType[type.name] = selectionSet; } + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(type.name); + } + return undefined; }, [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { @@ -94,6 +117,73 @@ export function stitchingDirectivesTransformer( }); } + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(typeName, fieldName); + } + + return undefined; + }, + [MapperKind.INTERFACE_TYPE]: type => { + const directives = getDirectives(schema, type, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(type.name); + } + + return undefined; + }, + [MapperKind.INTERFACE_FIELD]: (fieldConfig, fieldName, typeName) => { + const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(typeName, fieldName); + } + + return undefined; + }, + [MapperKind.INPUT_OBJECT_TYPE]: type => { + const directives = getDirectives(schema, type, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(type.name); + } + + return undefined; + }, + [MapperKind.INPUT_OBJECT_FIELD]: (inputFieldConfig, fieldName, typeName) => { + const directives = getDirectives(schema, inputFieldConfig, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(typeName, fieldName); + } + + return undefined; + }, + [MapperKind.UNION_TYPE]: type => { + const directives = getDirectives(schema, type, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(type.name); + } + + return undefined; + }, + [MapperKind.ENUM_TYPE]: type => { + const directives = getDirectives(schema, type, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(type.name); + } + + return undefined; + }, + [MapperKind.SCALAR_TYPE]: type => { + const directives = getDirectives(schema, type, pathToDirectivesInExtensions); + + if (directives[canonicalDirectiveName]) { + setCanonicalDefinition(type.name); + } + return undefined; }, }); @@ -264,6 +354,34 @@ export function stitchingDirectivesTransformer( } }); + Object.entries(canonicalTypesInfo).forEach(([typeName, canonicalTypeInfo]) => { + if (newSubschemaConfig.merge == null) { + newSubschemaConfig.merge = Object.create(null); + } + + if (newSubschemaConfig.merge[typeName] == null) { + newSubschemaConfig.merge[typeName] = Object.create(null); + } + + const mergeTypeConfig = newSubschemaConfig.merge[typeName]; + + if (canonicalTypeInfo.canonical) { + mergeTypeConfig.canonical = true; + } + + if (canonicalTypeInfo.fields) { + if (mergeTypeConfig.fields == null) { + mergeTypeConfig.fields = Object.create(null); + } + Object.keys(canonicalTypeInfo.fields).forEach(fieldName => { + if (mergeTypeConfig.fields[fieldName] == null) { + mergeTypeConfig.fields[fieldName] = Object.create(null); + } + mergeTypeConfig.fields[fieldName].canonical = true; + }); + } + }); + return newSubschemaConfig; }; } diff --git a/packages/stitching-directives/src/types.ts b/packages/stitching-directives/src/types.ts index 84340c017f7..fcd99187e85 100644 --- a/packages/stitching-directives/src/types.ts +++ b/packages/stitching-directives/src/types.ts @@ -25,6 +25,7 @@ export interface StitchingDirectivesOptions { keyDirectiveName?: string; computedDirectiveName?: string; mergeDirectiveName?: string; + canonicalDirectiveName?: string; pathToDirectivesInExtensions?: Array; } diff --git a/packages/stitching-directives/tests/stitchingDirectivesTransformer.test.ts b/packages/stitching-directives/tests/stitchingDirectivesTransformer.test.ts index 296df23696b..b458056a90a 100644 --- a/packages/stitching-directives/tests/stitchingDirectivesTransformer.test.ts +++ b/packages/stitching-directives/tests/stitchingDirectivesTransformer.test.ts @@ -678,4 +678,67 @@ describe('type merging directives', () => { }], }); }); + + test('applies canonical merge attributions', () => { + const typeDefs = ` + ${allStitchingDirectivesTypeDefs} + + type User implements IUser @canonical { + id: ID + name: String @canonical + } + + interface IUser @canonical { + id: ID + name: String @canonical + } + + input UserInput @canonical { + id: ID + name: String @canonical + } + + enum UserEnum @canonical { + VALUE + } + + union UserUnion @canonical = User + + scalar Key @canonical + `; + + const schema = makeExecutableSchema({ typeDefs }); + const subschemaConfig = { schema }; + const transformedSubschemaConfig = stitchingDirectivesTransformer(subschemaConfig); + + expect(transformedSubschemaConfig.merge).toEqual({ + User: { + canonical: true, + fields: { + name: { canonical: true }, + } + }, + IUser: { + canonical: true, + fields: { + name: { canonical: true }, + } + }, + UserInput: { + canonical: true, + fields: { + name: { canonical: true }, + } + }, + UserEnum: { + canonical: true, + }, + UserUnion: { + canonical: true, + }, + Key: { + canonical: true, + } + }); + }); }); diff --git a/website/docs/schema-stitching.md b/website/docs/schema-stitching.md index a0ebdacb9a3..5f302f24847 100644 --- a/website/docs/schema-stitching.md +++ b/website/docs/schema-stitching.md @@ -4,6 +4,93 @@ title: Schema Stitching sidebar_label: Schema Stitching --- -Schema stitching (`@graphql-tools/stitch`) creates a single GraphQL gateway schema from multiple underlying GraphQL services. Unlike [schema merging](/docs/merge-schemas), which simply consolidates local schema instances, stitching builds a combined proxy layer that delegates requests through to many underlying service APIs. +Schema stitching (`@graphql-tools/stitch`) creates a single GraphQL gateway schema from multiple underlying GraphQL services. Unlike [schema merging](/docs/merge-schemas), which simply combines local schema instances, stitching builds a combined proxy layer that delegates requests through to underlying service APIs. As of GraphQL Tools v7, stitching is a comparable alternative to [Apollo Federation](https://www.apollographql.com/docs/federation/) with automated query planning, merged types, and declarative schema directives. -[Learn more about schema stitching](/docs/stitch-combining-schemas) +## Topics + +Browse the following documentation topics to learn about stitching libraries, or review the [Schema Stitching Handbook](https://github.com/gmac/schema-stitching-handbook) for working examples of major stitching features. + +- [Combining multiple schemas](/docs/stitch-combining-schemas) +- [Merging types across schemas](/docs/stitch-type-merging) +- [Schema extensions](/docs/stitch-type-merging) +- [Stitching directives SDL](/docs/stitch-directives-sdl) + +## Basic example + +Given two self-contained subschemas, a single "stitched" schema can be built that delegates (or, proxies) relevant portions of a request to each subservice: + +```js +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { stitchSchemas } from '@graphql-tools/stitch'; +import { stitchingDirectives } from '@graphql-tools/stitching-directives'; + +const postsService = makeExecutableSchema({ + typeDefs: ` + type Post { + id: ID! + message: String! + author: User + } + + type User { + id: ID! + posts: [Post] + } + + type Query { + post(id: ID!): Post + users(ids: [ID!]!): [User]! @merge(keyField: "id") + } + `, + resolvers: { + // ... + } +}); + +const usersService = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String! + email: String! + } + + type Query { + users(ids: [ID!]!): [User]! @merge(keyField: "id") @canonical + } + `, + resolvers: { + // ... + } +}); + +const { stitchingDirectivesTransformer } = stitchingDirectives({ + // options... +}); + +const gatewaySchema = stitchSchemas({ + subschemaConfigTransforms: [stitchingDirectivesTransformer], + subschemas: [ + { schema: postsSchema, batch: true }, + { schema: usersSchema, batch: true }, + ] +}); +``` + +Using the stitched proxy schema, data may be requested interchangeably from any service in the same request: + +```graphql +query { + users(ids: ["1", "2"]) { + username + email + posts { + message + author { + username + email + } + } + } +} +``` diff --git a/website/docs/stitch-combining-schemas.md b/website/docs/stitch-combining-schemas.md index a32f79a7f29..f8289fe347a 100644 --- a/website/docs/stitch-combining-schemas.md +++ b/website/docs/stitch-combining-schemas.md @@ -4,7 +4,7 @@ title: Combining schemas sidebar_label: Combining schemas --- -Schema stitching (`@graphql-tools/stitch`) creates a single GraphQL gateway schema from multiple underlying GraphQL services. Unlike [schema merging](/docs/merge-schemas), which simply combines local schema instances, stitching builds a combined proxy layer that delegates requests through to underlying service APIs. Stitching is a comperable alternative to [Apollo Federation](https://www.apollographql.com/docs/federation/). +Schema stitching (`@graphql-tools/stitch`) creates a single GraphQL gateway schema from multiple underlying GraphQL services. Unlike [schema merging](/docs/merge-schemas), which simply combines local schema instances, stitching builds a combined proxy layer that delegates requests through to underlying service APIs. As of GraphQL Tools v7, stitching is a comparable alternative to [Apollo Federation](https://www.apollographql.com/docs/federation/) with automated query planning, merged types, and declarative schema directives. ## Why stitching? @@ -102,7 +102,7 @@ Also note that these subschema config objects may need to be referenced again in ## Stitching remote schemas -To include a remote schema in the combined gateway, you must provide at least the `schema` and `executor` subschema config options: +To include a remote schema in the combined gateway, you must provide at least the `schema` and `executor` subschema config options, and an optional `subscriber` for subscriptions: ```js import { introspectSchema } from '@graphql-tools/wrap'; @@ -136,33 +136,26 @@ Stitching has two strategies for handling types duplicated across subschemas: an ### Automatic merge -Types with the same name are automatically merged by default in GraphQL Tools v7. That means objects, interfaces, and input objects with the same name will consolidate their fields consolidated from across subschemas, and unions/enums will consolidate all their members. The combined gateway schema will then smartly delegate portions of a request to the proper origin subschema(s). See [type merging guide](/docs/stitch-type-merging/) for a comprehensive overview. +Types with the same name are automatically merged by default in GraphQL Tools v7. That means objects, interfaces, and input objects with the same name will consolidate their fields across subschemas, and unions/enums will consolidate all their members. The combined gateway schema will then smartly delegate portions of a request to the proper origin subschema(s). See [type merging guide](/docs/stitch-type-merging/) for a comprehensive overview. -Automatic merging will only encounter conflicts on fields and type descriptions. By default, the final definition of a field or type description found in the subschemas array is used. You may customize this selection logic in `typeMergingOptions`: +Automatic merging will only encounter conflicts on type descriptions and fields. By default, the final definition of a type or field found in the subschemas array is used, or a specific definition may be [marked as canonical](/docs/stitch-type-merging#canonical-definitions). You may customize all selection logic using `typeMergingOptions`; the following prefers the _first_ definition of each conflicting element found in the subschemas array: ```js const gatewaySchema = stitchSchemas({ subschemas: [...], mergeTypes: true, // << default in v7 typeMergingOptions: { - typeDescriptionsMerger(candidates) { - const candidate = candidates.find(({ type }) => !!type.description) || candidates.pop(); - return candidate.type.description; - }, - fieldConfigMerger(candidates) { - const configs = candidates.map(c => c.fieldConfig); - return configs.find(({ description }) => !!description) || configs.pop(); - }, - inputFieldConfigMerger(candidates) { - const configs = candidates.map(c => c.inputFieldConfig); - return configs.find(({ description }) => !!description) || configs.pop(); - } + // select a preferred type candidate that provides definitions: + typeCandidateMerger: (candidates) => candidate[0], + // and/or itemize the selection of other specific definitions: + typeDescriptionsMerger: (candidates) => candidate[0].type.description, + fieldConfigMerger: (candidates) => candidate[0].fieldConfig, + inputFieldConfigMerger: (candidates) => candidate[0].inputFieldConfig, + enumValueConfigMerger: (candidates) => candidate[0].enumValueConfig, }, }); ``` -In the example above, the first non-blank description encountered for each type and field in the subschemas array will be used. - ### Manual resolution By setting `mergeTypes: false`, only the final description and fields for a type found in the subschemas array will be used. You may manually resolve differences between conflicting types with an `onTypeConflict` handler: diff --git a/website/docs/stitch-directives-sdl.md b/website/docs/stitch-directives-sdl.md index 1531ec50690..b2d983c5a2b 100644 --- a/website/docs/stitch-directives-sdl.md +++ b/website/docs/stitch-directives-sdl.md @@ -12,14 +12,14 @@ Using SDL directives, a subservice may express its complete schema _and type mer ```graphql # --- Users schema --- -type User @key(selectionSet: "{ id }") { +type User { id: ID! username: String! email: String! } type Query { - users(ids: [ID!]!): [User]! @merge(keyField: "id") + users(ids: [ID!]!): [User]! @merge(keyField: "id") @canonical } # --- Posts schema --- @@ -29,14 +29,14 @@ type Post { author: User } -type User @key(selectionSet: "{ id }") { +type User { id: ID! posts: [Post] } type Query { post(id: ID!): Post - _users(ids: [ID!]!): [User]! @merge(keyField: "id") + users(ids: [ID!]!): [User]! @merge(keyField: "id") } ``` @@ -44,20 +44,21 @@ In the above example, the Users and Posts schemas will be combined in the stitch ## Directives glossary -By default, stitching directives use the following definitions (though the names of these directives [may be customized](#customizing-names)): +By default, stitching directives use the following definitions (though the names of these directives [may be customized](#customizing-directive-names)): ```graphql directive @merge(keyField: String, keyArg: String, additionalArgs: String, key: [String!], argsExpr: String) on FIELD_DEFINITION directive @key(selectionSet: String!) on OBJECT directive @computed(selectionSet: String!) on FIELD_DEFINITION +directive @canonical on OBJECT | INTERFACE | INPUT_OBJECT | UNION | ENUM | SCALAR | FIELD_DEFINITION | INPUT_FIELD_DEFINITION ``` The function of these directives are: -* **`@merge`:** denotes a root field used to query a merged type across services. The marked field's name is analogous to the `fieldName` setting in [merged type configuration](/docs/stitch-type-merging#basic-example), while the field's arguments and return types automatically configure merging. Additional arguments may tune the merge behavior (see [example recipes](#recipes)): +* **`@merge`:** denotes a root field used to query a merged type across services. The marked field's name is analogous to the `fieldName` setting in [merged type configuration](/docs/stitch-type-merging#basic-example), while the field's arguments and return type are used to infer merge configuration. Directive arguments tune the merge behavior (see [example recipes](#recipes)): - * `keyField`: specifies the name of a field to pick off origin objects as the key value. Omitting this option requires specification of an [object key](#object-keys) using the `@key` directive. - * `keyArg`: specifies which field argument receives the merge key. This may be omitted for fields with only one argument where the key recipient can be inferred. + * `keyField`: specifies the name of a field to pick off origin objects as the key value. When omitted, a `@key` directive must be included on the return type's definition to be built into an [object key](#object-keys). + * `keyArg`: specifies which field argument receives the merge key. This may be omitted for fields with only one argument where the recipient can be inferred. * `additionalArgs`: specifies a string of additional keys and values to apply to other arguments, formatted as `""" arg1: "value", arg2: "value" """`. * _`key`: advanced use only; builds a custom key._ * _`argsExpr`: advanced use only; builds a custom args object._ @@ -66,7 +67,9 @@ The function of these directives are: * **`@computed`:** specifies a selection of fields required from other services to compute the value of this field. These additional fields are only selected when the computed field is requested. Analogous to [computed field](/docs/stitch-type-merging#computed-fields) in merged type configuration. Computed field dependencies must be sent into the subservice using an [object key](#object-keys). -#### Customizing names +* **`@canonical`:** specifies types and fields that provide a [canonical definition](/docs/stitch-type-merging#canonical-definitions) to be built into the gateway schema. Useful for selecting preferred characteristics among types and fields that overlap across subschemas. Root fields marked as canonical specify which subschema the field proxies for new queries entering the graph. + +#### Customizing directive names You may use the `stitchingDirectives` helper to build your own type definitions and validator with custom names. For example, the configuration below creates the resources for `@myKey`, `@myMerge`, and `@myComputed` directives: @@ -181,11 +184,11 @@ The simplest merge pattern picks a key field from origin objects: ```graphql type User { - # ... + id: ID! } type Product { - # ... + upc: ID! } type Query { @@ -194,7 +197,7 @@ type Query { } ``` -This SDL translates into the following merge config: +Here, the `@merge` directive marks each type's merge query, and its `keyField` argument specifies a field to be picked from each original object as the query argument value. The above SDL translates into the following merge config: ```js merge: { @@ -214,15 +217,13 @@ merge: { } ``` -Here, the `@merge` directive marks each type's merge query—then `keyField` specifies a field to be picked from each original object as the query argument value. - ### Multiple arguments This pattern configures a merge query that receives multiple arguments: ```graphql type User { - # ... + id: ID! } type Query { @@ -234,7 +235,7 @@ type Query { } ``` -This SDL translates into the following merge config: +Because the merger field receives multiple arguments, the `keyArg` parameter is required to specify which argument receives the key(s). The `additionalArgs` parameter may also be used to provide static values for other arguments. The above SDL translates into the following merge config: ```js merge: { @@ -247,15 +248,13 @@ merge: { } ``` -Because the merge field receives multiple arguments, the `keyArg` parameter is required to specify which argument receives the key(s). The `additionalArgs` parameter may then be used to provide static values for the other arguments. - ### Object keys -In the absence of a `keyField` to pick, keys will assume the shape of an object with a `__typename` and all fields collected for all selectionSets on the type. These object keys may be represented in your schema with a dedicated scalar type, or as an [input object](#typed-inputs): +In the absence of a `keyField` for the merge directive to pick, keys will assume the shape of an object with a `__typename` and all fields collected for utilized selectionSets on the type: ```graphql type Product @key(selectionSet: "{ upc }") { - # ... + upc: ID! shippingEstimate: Int @computed(selectionSet: "{ price weight }") } @@ -266,7 +265,7 @@ type Query { } ``` -You may use any name for the key scalar, here we're calling it `_Key`. This SDL translates into the following merge config: +The above SDL specifies a type-level selectionSet using the `@key` directive, and a field-level selectionSet using the `@computed` directive. The `@merge` directive takes no arguments here, and will build object keys with fields collected from all utilized selectionSets. These object keys are passed to the merger field as a custom scalar (here called `_Key`), or as an [input object](#typed-inputs). This SDL translates into the following merge config: ```js // assume "pick" works like the lodash method... @@ -307,7 +306,7 @@ Similar to the [object keys](#object-keys) discussed above, an input object type ```graphql type Product @key(selectionSet: "{ upc }") { - # ... + upc: ID! shippingEstimate: Int @computed(selectionSet: "{ price weight }") } @@ -354,7 +353,7 @@ More advanced cases may need to interface with complex inputs. In these cases, t ```graphql type Product @key(selectionSet: "{ upc }") { - # ... + upc: ID! } input ProductKey { @@ -374,9 +373,9 @@ type Query { Once subschemas and their merge configurations are defined as annotated SDLs, new versions of these documents can be pushed to the gateway to trigger a ["hot" reload](https://github.com/gmac/schema-stitching-handbook/tree/master/hot-schema-reloading)—or, a reload of the gateway schema without restarting its server. -However, pushing untested SDLs directly to the gateway is risky due to the potential for incompatible subschema versions to be mixed. Therefore, a formal versioning, testing, and release strategy is necessary for long-term stability. See the [versioning handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/versioning-schema-releases) that demonstrates using the GitHub API to turn a basic Git repo into a schema registry that manages versioning and release. +However, pushing untested SDLs directly to the gateway is risky due to the potential for incompatible subschema versions to be mixed. Therefore, a formal versioning, testing, and release strategy is necessary for long-term stability. See the [handbook's versioning example](https://github.com/gmac/schema-stitching-handbook/tree/master/versioning-schema-releases) that demonstrates turning a basic Git repo into a schema registry that manages versioning and release. -The general process for zero-downtime rollouts is: +**The general process for zero-downtime rollouts is:** 1. Compose and test all subschema head versions together to verify their combined stability prior to release. 1. Deploy all updated subservice applications while keeping their existing subschema features operational. diff --git a/website/docs/stitch-type-merging.md b/website/docs/stitch-type-merging.md index edb2e4c82e8..c5dd31fe4c9 100644 --- a/website/docs/stitch-type-merging.md +++ b/website/docs/stitch-type-merging.md @@ -92,10 +92,10 @@ That's it! Under the subschema config `merge` option, each merged type provides - `selectionSet` specifies one or more key fields required from other services to perform this query. Query planning will automatically resolve these fields from other subschemas in dependency order. - `args` formats the initial object representation into query arguments. -See related [handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/type-merging-single-records) for a working demonstration of this setup. This JavaScript-based syntax may also be written directly into schema type definitions using the [stitching directives SDL](/docs/stitch-directives-sdl): +See related [handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/type-merging-single-records) for a working demonstration of this setup. This JavaScript-based syntax may also be written directly into schema type definitions using the `@merge` directive of the [stitching SDL](/docs/stitch-directives-sdl): ```graphql -type User @key(selectionSet: "{ id }") { +type User { id: ID! email: String! } @@ -476,7 +476,116 @@ The main disadvantage of computed fields is that they cannot be resolved indepen ## Federation services -If you're familiar with [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/), then you may notice that the above pattern of computed fields looks similar to the `_entities` service design of the [Apollo Federation specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). Federation resources can be included in a stitched gateway when integrating with third-party services or in the process of a migration. See related [handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/federation-services) for specifics. +If you're familiar with [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/), then you may notice that the above pattern of computed fields looks similar to the `_entities` service design of the [Apollo Federation specification](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). Federation resources may be included in a stitched gateway; this comes in handy when integrating with third-party services or in the process of a migration. See related [handbook example](https://github.com/gmac/schema-stitching-handbook/tree/master/federation-services) for more information. + +## Canonical definitions + +Managing the gateway schema definition of each type and field becomes challenging as the same type names are introduced across subschemas. By default, the final definition of each named GraphQL element found in the stitched `subschemas` array provides its gateway definition. However, preferred definitions may be marked as `canonical` to recieve this final priority. Canonical definitions provide: + +- an element's description (doc string). +- an element's final directive values. +- a field's final nullability, arguments, and deprecation reason. +- a root field's default delegation target. + +The following example uses [stitching directives](/docs/stitch-directives-sdl) to mark preferred subschema elements as `@canonical`: + +```graphql +# --- Users schema --- + +"Represents an authenticated user" +type User @canonical { + "The primary key of this user record" + id: ID! @mydir(schema: "users") + "other description" + field: String! +} + +type Query { + "Users schema definition" + user(id: ID!): User @canonical +} + +# --- Posts schema --- + +type Post { + id: ID! +} + +"other description" +type User { + "other description" + id: ID! @mydir(schema: "posts") + "The canonical field description" + field: String @canonical + "Posts authored by this user" + posts: [Post!] +} + +type Query { + "Posts schema definition" + user(id: ID!): User +} +``` + +The above ASTs will merge into the following gateway schema definition, and the root `user` field will proxy the Users subschema by default: + +```graphql +# --- Gateway schema --- + +"Represents an authenticated user" +type User { + "The primary key of this user record" + id: ID! @mydir(schema: "users") + "The canonical field description" + field: String + "Posts authored by this user" + posts: [Post!] +} + +type Query { + "Users schema definition" + user(id: ID!): User +} +``` + +- **Types** marked as canonical will provide their definition _and that of all of their fields_ to the combined gateway schema. +- **Fields** marked as canonical will override those of a canonical type. +- **Root fields** marked as canonical will specify which subschema the field proxies by default for new queries entering the graph. + +Only one of any given type or field may be made canonical. Fields that are unique to one service (such as `User.posts` above) have no competing definition so are canonical by default. + +The above SDL directives can also be written as static configuration: + +```js +const gatewaySchema = stitchSchemas({ + subschemas: [{ + schema: usersSchema, + merge: { + User: { + // ... + canonical: true + }, + Query: { + fields: { + user: { canonical: true } + } + } + } + }, { + schema: postsSchema, + merge: { + User: { + // ... + fields: { + email: { canonical: true } + } + } + } + }] +}); +``` + +> **Implementation note:** canonical settings are _only_ used for building the combined gateway schema definition and defaulting root field targets; otherwise, they are given no special priority in runtime query planning (which always selects necessary fields from as few subschemas as possible). You may override the assembly of canonical definitions using [`typeMergingOptions`](/docs/stitch-combining-schemas#automatic-merge). ## Type resolvers