diff --git a/packages/apollo-federation/CHANGELOG.md b/packages/apollo-federation/CHANGELOG.md index 3adc90dea38..e0f9809b5aa 100644 --- a/packages/apollo-federation/CHANGELOG.md +++ b/packages/apollo-federation/CHANGELOG.md @@ -6,6 +6,7 @@ - __FIX__: CSDL complex `@key`s shouldn't result in an unparseable document [PR #4490](https://github.com/apollographql/apollo-server/pull/4490) - __FIX__: Value type validations - restrict unions, scalars, enums [PR #4496](https://github.com/apollographql/apollo-server/pull/4496) +- __FIX__: Composition - aggregate interfaces for types and interfaces in composed schema [PR #4497](https://github.com/apollographql/apollo-server/pull/4497) ## v0.19.1 diff --git a/packages/apollo-federation/src/composition/__tests__/composeAndValidate.test.ts b/packages/apollo-federation/src/composition/__tests__/composeAndValidate.test.ts index 4c31e197eb5..a585a56c6c4 100644 --- a/packages/apollo-federation/src/composition/__tests__/composeAndValidate.test.ts +++ b/packages/apollo-federation/src/composition/__tests__/composeAndValidate.test.ts @@ -5,6 +5,7 @@ import { DocumentNode, GraphQLScalarType, specifiedDirectives, + printSchema, } from 'graphql'; import { astSerializer, @@ -555,6 +556,83 @@ describe('composition of value types', () => { `); }); }); + + it('composed type implements ALL interfaces that value types implement', () => { + const serviceA = { + typeDefs: gql` + interface Node { + id: ID! + } + + interface Named { + name: String + } + + type Product implements Named & Node { + id: ID! + name: String + } + + type Query { + node(id: ID!): Node + } + `, + name: 'serviceA', + }; + + const serviceB = { + typeDefs: gql` + interface Node { + id: ID! + } + + type Product implements Node { + id: ID! + name: String + } + `, + name: 'serviceB', + }; + + const serviceC = { + typeDefs: gql` + interface Named { + name: String + } + + type Product implements Named { + id: ID! + name: String + } + `, + name: 'serviceC', + }; + + const serviceD = { + typeDefs: gql` + type Product { + id: ID! + name: String + } + `, + name: 'serviceD', + }; + + const { schema, errors, composedSdl } = composeAndValidate([ + serviceA, + serviceB, + serviceC, + serviceD, + ]); + + expect(errors).toHaveLength(0); + expect((schema.getType('Product') as GraphQLObjectType).getInterfaces()) + .toHaveLength(2); + + expect(printSchema(schema)).toContain('type Product implements Named & Node'); + expect(composedSdl).toContain('type Product implements Named & Node'); + + }); }); describe('composition of schemas with directives', () => { @@ -630,7 +708,7 @@ describe('composition of schemas with directives', () => { }); it(`doesn't strip the special case @deprecated and @specifiedBy type-system directives`, () => { - const specUrl = "http://my-spec-url.com"; + const specUrl = 'http://my-spec-url.com'; const deprecationReason = "Don't remove me please"; // Detecting >15.1.0 by the new addition of the `specifiedBy` directive @@ -641,9 +719,11 @@ describe('composition of schemas with directives', () => { typeDefs: gql` # This directive needs to be conditionally added depending on the testing # environment's version of graphql (>= 15.1.0 includes this new directive) - ${isAtLeastGraphqlVersionFifteenPointOne - ? `scalar MyScalar @specifiedBy(url: "${specUrl}")` - : ''} + ${ + isAtLeastGraphqlVersionFifteenPointOne + ? `scalar MyScalar @specifiedBy(url: "${specUrl}")` + : '' + } type EarthConcern { environmental: String! @@ -673,7 +753,9 @@ describe('composition of schemas with directives', () => { const specifiedBy = schema.getDirective('specifiedBy'); expect(specifiedBy).toMatchInlineSnapshot(`"@specifiedBy"`); const customScalar = schema.getType('MyScalar'); - expect((customScalar as GraphQLScalarType).specifiedByUrl).toEqual(specUrl); + expect((customScalar as GraphQLScalarType).specifiedByUrl).toEqual( + specUrl, + ); } }); }); diff --git a/packages/apollo-federation/src/composition/compose.ts b/packages/apollo-federation/src/composition/compose.ts index fe485ef814b..1f7461d2877 100644 --- a/packages/apollo-federation/src/composition/compose.ts +++ b/packages/apollo-federation/src/composition/compose.ts @@ -16,6 +16,8 @@ import { TypeDefinitionNode, DirectiveDefinitionNode, TypeExtensionNode, + ObjectTypeDefinitionNode, + NamedTypeNode, } from 'graphql'; import { transformSchema } from 'apollo-graphql'; import federationDirectives from '../directives'; @@ -348,11 +350,55 @@ export function buildSchemaFromDefinitionsAndExtensions({ directives: [...specifiedDirectives, ...federationDirectives], }); + // 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).flat(), + ...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], ),