UNPKG

@apollo/federation

Version:
1,132 lines (973 loc) 29.6 kB
import { composeAndValidate } from '../composeAndValidate'; import { GraphQLObjectType, DocumentNode, specifiedDirectives, printSchema, } from 'graphql'; import { astSerializer, typeSerializer, graphqlErrorSerializer, gql, } from 'apollo-federation-integration-testsuite'; import { assertCompositionFailure, assertCompositionSuccess, compositionHasErrors, CompositionResult, } from '../utils'; import { isNotNullOrUndefined } from '../../utilities'; expect.addSnapshotSerializer(astSerializer); expect.addSnapshotSerializer(typeSerializer); expect.addSnapshotSerializer(graphqlErrorSerializer); const productsService = { name: 'Products', typeDefs: gql` extend type Query { topProducts(first: Int): [Product] } type Product @key(fields: "upc") { upc: String! sku: String! name: String price: String } `, }; const reviewsService = { name: 'Reviews', typeDefs: gql` type Review @key(fields: "id") { id: ID! body: String author: User product: Product } extend type User @key(fields: "id") { id: ID! @external reviews: [Review] } extend type Product @key(fields: "upc") { upc: String! @external reviews: [Review] } `, }; const accountsService = { name: 'Accounts', typeDefs: gql` extend type Query { me: User } type User @key(fields: "id") { id: ID! name: String username: String birthDate: String } `, }; const inventoryService = { name: 'Inventory', typeDefs: gql` extend type Product @key(fields: "upc") { upc: String! @external inStock: Boolean # quantity: Int } `, }; function permutateList<T>(inputArr: T[]) { let result: T[][] = []; function permute(arr: T[], m: T[] = []) { if (arr.length === 0) { result.push(m); } else { for (let i = 0; i < arr.length; i++) { let curr = arr.slice(); let next = curr.splice(i, 1); permute(curr.slice(), m.concat(next)); } } } permute(inputArr); return result; } it('composes and validates all (24) permutations without error', () => { permutateList([ inventoryService, reviewsService, accountsService, productsService, ]).map((config) => { const compositionResult = composeAndValidate(config); expect(!compositionHasErrors(compositionResult)); }); }); describe('unknown types', () => { it("doesn't throw errors when a type is unknown, but captures them instead", () => { const serviceA = { typeDefs: gql` type Query { foo: Bar! } extend type Bar @key(fields: "id") { id: ID! @external thing: String } `, name: 'serviceA', }; let compositionResult: CompositionResult; expect( () => (compositionResult = composeAndValidate([serviceA])), ).not.toThrow(); assertCompositionFailure(compositionResult!); const { errors } = compositionResult; expect(errors).toMatchInlineSnapshot(` Array [ Object { "code": "MISSING_ERROR", "locations": Array [ Object { "column": 8, "line": 3, }, ], "message": "Unknown type \\"Bar\\".", }, Object { "code": "EXTENSION_WITH_NO_BASE", "locations": Array [ Object { "column": 1, "line": 6, }, ], "message": "[serviceA] Bar -> \`Bar\` is an extension type, but \`Bar\` is not defined in any service", }, Object { "code": "MISSING_ERROR", "locations": Array [], "message": "Type Query must define one or more fields.", }, ] `); }); it("doesn't throw errors when a type is unknown, and the type has directive usages which we've captured", () => { const inventory = { name: 'inventory', typeDefs: gql` directive @tag( name: String! ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type Product @key(fields: "id") { id: ID! @external @tag(name: "from-inventory") } `, }; const compositionResult = composeAndValidate([inventory]); assertCompositionFailure(compositionResult); expect(compositionResult.errors[0]).toMatchInlineSnapshot(` Object { "code": "EXTENSION_WITH_NO_BASE", "locations": Array [ Object { "column": 1, "line": 5, }, ], "message": "[inventory] Product -> \`Product\` is an extension type, but \`Product\` is not defined in any service", } `); }); }); it('treats types with @extends as type extensions', () => { const serviceA = { typeDefs: gql` type Query { products: [Product]! } type Product @key(fields: "sku") { sku: String! upc: String! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` type Product @extends @key(fields: "sku") { sku: String! @external price: Int! @requires(fields: "sku") } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionSuccess(compositionResult); const product = compositionResult.schema.getType( 'Product', ) as GraphQLObjectType; expect(product).toMatchInlineSnapshot(` type Product { price: Int! sku: String! upc: String! } `); }); it('treats interfaces with @extends as interface extensions', () => { const serviceA = { typeDefs: gql` type Query { products: [Product]! } interface Product @key(fields: "sku") { sku: String! upc: String! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` interface Product @extends @key(fields: "sku") { sku: String! @external price: Int! @requires(fields: "sku") } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionSuccess(compositionResult); const product = compositionResult.schema.getType( 'Product', ) as GraphQLObjectType; expect(product).toMatchInlineSnapshot(` interface Product { price: Int! sku: String! upc: String! } `); }); describe('composition of value types', () => { function getSchemaWithValueType(valueType: DocumentNode) { const serviceA = { typeDefs: gql` ${valueType} type Query { filler: String } `, name: 'serviceA', }; const serviceB = { typeDefs: valueType, name: 'serviceB', }; return composeAndValidate([serviceA, serviceB]); } describe('success', () => { it('scalars', () => { const compositionResult = getSchemaWithValueType( gql` scalar Date `, ); assertCompositionSuccess(compositionResult); expect(compositionResult.schema.getType('Date')).toMatchInlineSnapshot( `scalar Date`, ); }); it('unions and object types', () => { const compositionResult = getSchemaWithValueType( gql` union CatalogItem = Couch | Mattress type Couch { sku: ID! material: String! } type Mattress { sku: ID! size: String! } `, ); assertCompositionSuccess(compositionResult); const { schema } = compositionResult; expect(schema.getType('CatalogItem')).toMatchInlineSnapshot( `union CatalogItem = Couch | Mattress`, ); expect(schema.getType('Couch')).toMatchInlineSnapshot(` type Couch { material: String! sku: ID! } `); }); it('input types', () => { const compositionResult = getSchemaWithValueType(gql` input NewProductInput { sku: ID! type: String } `); assertCompositionSuccess(compositionResult); expect(compositionResult.schema.getType('NewProductInput')) .toMatchInlineSnapshot(` input NewProductInput { sku: ID! type: String } `); }); it('interfaces', () => { const compositionResult = getSchemaWithValueType(gql` interface Product { sku: ID! } `); assertCompositionSuccess(compositionResult); expect(compositionResult.schema.getType('Product')) .toMatchInlineSnapshot(` interface Product { sku: ID! } `); }); it('enums', () => { const compositionResult = getSchemaWithValueType(gql` enum CatalogItemEnum { COUCH MATTRESS } `); assertCompositionSuccess(compositionResult); expect(compositionResult.schema.getType('CatalogItemEnum')) .toMatchInlineSnapshot(` enum CatalogItemEnum { COUCH MATTRESS } `); }); }); describe('errors', () => { it('on invalid usages of default operation names', () => { const serviceA = { typeDefs: gql` schema { query: RootQuery } type RootQuery { product: Product } type Product @key(fields: "id") { id: ID! query: Query } type Query { invalidUseOfQuery: Boolean } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` type Query { validUseOfQuery: Boolean } extend type Product @key(fields: "id") { id: ID! @external sku: String } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionFailure(compositionResult); expect(compositionResult.errors).toMatchInlineSnapshot(` Array [ Object { "code": "ROOT_QUERY_USED", "locations": Array [ Object { "column": 1, "line": 15, }, ], "message": "[serviceA] Query -> Found invalid use of default root operation name \`Query\`. \`Query\` is disallowed when \`Schema.query\` is set to a type other than \`Query\`.", }, ] `); }); it('when a type extension has no base', () => { const serviceA = { typeDefs: gql` schema { query: MyRoot } type MyRoot { products: [Product]! } type Product @key(fields: "sku") { sku: String! upc: String! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` extend type Location { id: ID } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionFailure(compositionResult); expect(compositionResult.errors).toHaveLength(1); expect(compositionResult.errors).toMatchInlineSnapshot(` Array [ Object { "code": "EXTENSION_WITH_NO_BASE", "locations": Array [ Object { "column": 1, "line": 2, }, ], "message": "[serviceB] Location -> \`Location\` is an extension type, but \`Location\` is not defined in any service", }, ] `); }); it('when used as an entity', () => { const serviceA = { typeDefs: gql` type Query { product: Product } type Product { sku: ID! color: String! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` type Query { topProducts: [Product] } type Product @key(fields: "sku") { sku: ID! color: String! } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionFailure(compositionResult); expect(compositionResult.errors).toHaveLength(1); expect(compositionResult.errors[0]).toMatchInlineSnapshot(` Object { "code": "VALUE_TYPE_NO_ENTITY", "locations": Array [ Object { "column": 1, "line": 6, }, Object { "column": 1, "line": 6, }, ], "message": "[serviceB] Product -> Value types cannot be entities (using the \`@key\` directive). Please ensure that the \`Product\` type is extended properly or remove the \`@key\` directive if this is not an entity.", } `); }); it('on field type mismatch', () => { const serviceA = { typeDefs: gql` type Query { product: Product } type Product { sku: ID! color: String! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` type Query { topProducts: [Product] } type Product { sku: ID! color: String } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionFailure(compositionResult); expect(compositionResult.errors).toHaveLength(1); expect(compositionResult.errors[0]).toMatchInlineSnapshot(` Object { "code": "VALUE_TYPE_FIELD_TYPE_MISMATCH", "locations": Array [ Object { "column": 10, "line": 8, }, Object { "column": 10, "line": 8, }, ], "message": "[serviceA] Product.color -> A field was defined differently in different services. \`serviceA\` and \`serviceB\` define \`Product.color\` as a String! and String respectively. In order to define \`Product\` in multiple places, the fields and their types must be identical.", } `); }); it('on kind mismatch', () => { const serviceA = { typeDefs: gql` type Query { product: Product } interface Product { sku: ID! color: String! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` type Query { topProducts: [Product] } type Product { sku: ID! color: String! } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionFailure(compositionResult); expect(compositionResult.errors).toHaveLength(1); expect(compositionResult.errors[0]).toMatchInlineSnapshot(` Object { "code": "VALUE_TYPE_KIND_MISMATCH", "locations": Array [ Object { "column": 1, "line": 6, }, Object { "column": 1, "line": 6, }, ], "message": "[serviceA] Product -> Found kind mismatch on expected value type belonging to services \`serviceA\` and \`serviceB\`. \`Product\` is defined as both a \`ObjectTypeDefinition\` and a \`InterfaceTypeDefinition\`. In order to define \`Product\` in multiple places, the kinds must be identical.", } `); }); it('on union types mismatch', () => { const serviceA = { typeDefs: gql` type Query { product: Product } type Couch { sku: ID! } type Mattress { sku: ID! } union Product = Couch | Mattress `, name: 'serviceA', }; const serviceB = { typeDefs: gql` type Query { topProducts: [Product] } type Couch { sku: ID! } type Cabinet { sku: ID! } union Product = Couch | Cabinet `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); assertCompositionFailure(compositionResult); expect(compositionResult.errors).toHaveLength(1); expect(compositionResult.errors[0]).toMatchInlineSnapshot(` Object { "code": "VALUE_TYPE_UNION_TYPES_MISMATCH", "locations": Array [ Object { "column": 1, "line": 14, }, Object { "column": 1, "line": 14, }, ], "message": "[serviceA] Product -> The union \`Product\` is defined in services \`serviceA\` and \`serviceB\`, however their types do not match. Union types with the same name must also consist of identical types. The types Cabinet, Mattress are mismatched.", } `); }); }); 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 compositionResult = composeAndValidate([ serviceA, serviceB, serviceC, serviceD, ]); assertCompositionSuccess(compositionResult); const { schema, supergraphSdl } = compositionResult; expect( (schema.getType('Product') as GraphQLObjectType).getInterfaces(), ).toHaveLength(2); expect(printSchema(schema)).toContain( 'type Product implements Named & Node', ); expect(supergraphSdl).toContain('type Product implements Named & Node'); }); }); describe('composition of schemas with directives', () => { /** * To see which usage sites indicate whether a directive is "executable" or * merely for use by the type-system ("type-system"), see the GraphQL spec: * https://graphql.github.io/graphql-spec/June2018/#sec-Type-System.Directives */ it('preserves executable and purges type-system directives', () => { const serviceA = { typeDefs: gql` "directives at FIELDs are executable" directive @audit(risk: Int!) on FIELD "directives at FIELD_DEFINITIONs are for the type-system" directive @transparency(concealment: Int!) on FIELD_DEFINITION type EarthConcern { environmental: String! @transparency(concealment: 5) } extend type Query { importantDirectives: [EarthConcern!]! } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` "directives at FIELDs are executable" directive @audit(risk: Int!) on FIELD "directives at FIELD_DEFINITIONs are for the type-system" directive @transparency(concealment: Int!) on FIELD_DEFINITION "directives at OBJECTs are for the type-system" directive @experimental on OBJECT extend type EarthConcern @experimental { societal: String! @transparency(concealment: 6) } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); const { schema } = compositionResult; expect(!compositionHasErrors(compositionResult)); const audit = schema.getDirective('audit'); expect(audit).toMatchInlineSnapshot(`"@audit"`); const transparency = schema.getDirective('transparency'); expect(transparency).toBeUndefined(); const type = schema.getType('EarthConcern') as GraphQLObjectType; expect(type.astNode).toMatchInlineSnapshot(` type EarthConcern { environmental: String! } `); const fields = type.getFields(); expect(fields['environmental'].astNode).toMatchInlineSnapshot( `environmental: String!`, ); expect(fields['societal'].astNode).toMatchInlineSnapshot( `societal: String!`, ); }); it(`doesn't strip the special case @deprecated and @specifiedBy type-system directives`, () => { 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 const isAtLeastGraphqlVersionFifteenPointOne = specifiedDirectives.length >= 4; const serviceA = { 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}")` : '' } type EarthConcern { environmental: String! } extend type Query { importantDirectives: [EarthConcern!]! @deprecated(reason: "${deprecationReason}") } `, name: 'serviceA', }; const compositionResult = composeAndValidate([serviceA]); const { schema } = compositionResult; expect(!compositionHasErrors(compositionResult)); const deprecated = schema.getDirective('deprecated'); expect(deprecated).toMatchInlineSnapshot(`"@deprecated"`); const queryType = schema.getType('Query') as GraphQLObjectType; const field = queryType.getFields()['importantDirectives']; expect(isNotNullOrUndefined(field.deprecationReason)).toBe(true); expect(field.deprecationReason).toEqual(deprecationReason); if (isAtLeastGraphqlVersionFifteenPointOne) { const specifiedBy = schema.getDirective('specifiedBy'); expect(specifiedBy).toMatchInlineSnapshot(`"@specifiedBy"`); const customScalar = schema.getType('MyScalar'); // graphql v15 -> v16 incompatibility with specifiedByURL vs specifiedByUrl const specifiedByUrl = (customScalar as any).specifiedByURL ?? (customScalar as any).specifiedByUrl; expect(specifiedByUrl).toEqual(specUrl); } }); it('should merge @tag on Object Type fields', () => { const users = { name: 'users', url: 'https://users.api.com', typeDefs: gql` directive @tag( name: String! ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type Product @key(fields: "upc") { upc: String @external } `, }; const products = { name: 'products', url: 'https://products.api.com', typeDefs: gql` directive @tag( name: String! ) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION extend type Query { topProducts: [Product] } type Product @key(fields: "upc") { upc: String @tag(name: "internal") } `, }; const compositionResult = composeAndValidate([users, products]); expect(compositionHasErrors(compositionResult)).toBe(false); expect(compositionResult.supergraphSdl).toMatchInlineSnapshot(` "schema @core(feature: \\"https://specs.apollo.dev/core/v0.2\\"), @core(feature: \\"https://specs.apollo.dev/join/v0.1\\", for: EXECUTION), @core(feature: \\"https://specs.apollo.dev/tag/v0.1\\") { query: Query } directive @core(as: String, feature: String!, for: core__Purpose) repeatable on SCHEMA directive @join__field(graph: join__Graph, provides: join__FieldSet, requires: join__FieldSet) on FIELD_DEFINITION directive @join__graph(name: String!, url: String!) on ENUM_VALUE directive @join__owner(graph: join__Graph!) on INTERFACE | OBJECT directive @join__type(graph: join__Graph!, key: join__FieldSet) repeatable on INTERFACE | OBJECT directive @tag(name: String!) repeatable on FIELD_DEFINITION | INTERFACE | OBJECT | UNION type Product @join__owner(graph: PRODUCTS) @join__type(graph: PRODUCTS, key: \\"upc\\") @join__type(graph: USERS, key: \\"upc\\") { upc: String @join__field(graph: PRODUCTS) @tag(name: \\"internal\\") } type Query { topProducts: [Product] @join__field(graph: PRODUCTS) } enum core__Purpose { \\"\\"\\" \`EXECUTION\` features provide metadata necessary to for operation execution. \\"\\"\\" EXECUTION \\"\\"\\" \`SECURITY\` features provide metadata necessary to securely resolve fields. \\"\\"\\" SECURITY } scalar join__FieldSet enum join__Graph { PRODUCTS @join__graph(name: \\"products\\" url: \\"https://products.api.com\\") USERS @join__graph(name: \\"users\\" url: \\"https://users.api.com\\") } " `); }); }); it('composition of full-SDL schemas without any errors', () => { const serviceA = { typeDefs: gql` # Default directives directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE directive @specifiedBy(url: String!) on SCALAR directive @include( if: Boolean ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @skip(if: Boolean) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT # Federation directives directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE directive @external on FIELD_DEFINITION directive @requires(fields: _FieldSet!) on FIELD_DEFINITION directive @provides(fields: _FieldSet!) on FIELD_DEFINITION directive @extends on OBJECT | INTERFACE # Custom type system directive (disregarded by gateway, unconcerned with serviceB's implementation) directive @myTypeSystemDirective on FIELD_DEFINITION # Custom executable directive (must be implemented in all services, definition must be identical) directive @myExecutableDirective on FIELD scalar _Any scalar _FieldSet union _Entity type _Service { sdl: String } schema { query: RootQuery mutation: RootMutation } type RootQuery { _service: _Service! _entities(representations: [_Any!]!): [_Entity]! product: Product } type Product @key(fields: "sku") { sku: String! price: Float } type RootMutation { updateProduct: Product } `, name: 'serviceA', }; const serviceB = { typeDefs: gql` # Default directives directive @deprecated( reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE directive @specifiedBy(url: String!) on SCALAR directive @include( if: String = "Included when true." ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT directive @skip( if: String = "Skipped when true." ) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT # Federation directives directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE directive @external on FIELD_DEFINITION directive @requires(fields: _FieldSet!) on FIELD_DEFINITION directive @provides(fields: _FieldSet!) on FIELD_DEFINITION directive @extends on OBJECT | INTERFACE # Custom type system directive (disregarded by gateway, unconcerned with serviceA's implementation) directive @myDirective on FIELD_DEFINITION # Custom executable directive (must be implemented in all services, definition must be identical) directive @myExecutableDirective on FIELD scalar _Any scalar _FieldSet union _Entity type _Service { sdl: String } type Query { _service: _Service! _entities(representations: [_Any!]!): [_Entity]! review: Review } type Review @key(fields: "id") { id: String! content: String } type Mutation { createReview: Review } `, name: 'serviceB', }; const compositionResult = composeAndValidate([serviceA, serviceB]); expect(!compositionHasErrors(compositionResult)); });