diff --git a/packages/runtime/src/plugins/useDemandControl.ts b/packages/runtime/src/plugins/useDemandControl.ts index 649f03ff4..f20efc849 100644 --- a/packages/runtime/src/plugins/useDemandControl.ts +++ b/packages/runtime/src/plugins/useDemandControl.ts @@ -70,6 +70,9 @@ export function useDemandControl>({ const costByContextMap = new WeakMap(); return { onSubgraphExecute({ subgraph, executionRequest, log }) { + if (!subgraph) { + return; + } let costByContext = executionRequest.context ? costByContextMap.get(executionRequest.context) || 0 : 0; diff --git a/packages/runtime/tests/demand-control.test.ts b/packages/runtime/tests/demand-control.test.ts index a33e9534c..666f97589 100644 --- a/packages/runtime/tests/demand-control.test.ts +++ b/packages/runtime/tests/demand-control.test.ts @@ -4,7 +4,7 @@ import { useCustomFetch, } from '@graphql-hive/gateway-runtime'; import { composeLocalSchemasWithApollo } from '@internal/testing'; -import { parse } from 'graphql'; +import { GraphQLSchema, parse } from 'graphql'; import { createYoga } from 'graphql-yoga'; import { describe, expect, it } from 'vitest'; import { useDemandControl } from '../src/plugins/useDemandControl'; @@ -22,1168 +22,1099 @@ describe('Demand Control', () => { }, }, }; - /** - * 1 Query (0) + 1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (1) = 4 total cost - */ - it('basic query', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - book(id: ID): Book - } + type TestGatewayMode = 'proxy' | 'supergraph'; + function createTestGateway( + mode: TestGatewayMode, + subgraph: GraphQLSchema, + demandControlConfig: Parameters[0] = { + includeExtensionMetadata: true, + }, + introspectFromEndpoint = false, + ) { + const subgraphServer = createYoga({ + schema: subgraph, + }); + const plugins = () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(subgraphServer.fetch), + useDemandControl(demandControlConfig), + ]; + return mode === 'proxy' + ? createGatewayRuntime({ + proxy: { + endpoint: 'http://upstream/graphql', + }, + schema: introspectFromEndpoint ? undefined : subgraph, + plugins, + }) + : createGatewayRuntime({ + supergraph: composeLocalSchemasWithApollo([ + { + name: 'subgraph', + schema: subgraph, + url: 'http://subgraph/graphql', + }, + ]), + plugins, + }); + } + const testModes: TestGatewayMode[] = ['proxy', 'supergraph']; + testModes.forEach((mode: TestGatewayMode) => { + describe(`in ${mode} mode`, () => { + /** + * 1 Query (0) + 1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (1) = 4 total cost + */ + it('basic query', async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + book(id: ID): Book + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address { - zipCode: Int! - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; + type Address { + zipCode: Int! } - throw new Error('Book not found'); + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + }, }, - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query BookQuery { - book(id: 1) { - title - author { - name - } - publisher { - name - address { - zipCode + }); + await using gateway = createTestGateway(mode, booksSubgraph); + const query = /* GraphQL */ ` + query BookQuery { + book(id: 1) { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - book, - }, - extensions: { - cost: { - estimated: 4, - }, - }, - }); - }); - /** - * 1 Query (0) + 1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5) = 8 total cost - */ - it('@cost in object', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - book(id: ID): Book - } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + book, + }, + extensions: { + cost: { + estimated: 4, + }, + }, + }); + }); + /** + * 1 Query (0) + 1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5) = 8 total cost + */ + it('@cost in object', async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + book(id: ID): Book + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address @cost(weight: 5) { - zipCode: Int! - } - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@cost"] - ) { - query: Query - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; + type Address @cost(weight: 5) { + zipCode: Int! + } + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@cost"] + ) { + query: Query } - throw new Error('Book not found'); + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + }, }, - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query BookQuery { - book(id: 1) { - title - author { - name - } - publisher { - name - address { - zipCode + }); + await using gateway = createTestGateway(mode, booksSubgraph); + const query = /* GraphQL */ ` + query BookQuery { + book(id: 1) { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - book, - }, - extensions: { - cost: { - estimated: 8, - }, - }, - }); - }); - /** - * 1 Query (0) + 5 book objects (5 * (1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5))) = 40 total cost - */ - it('@listSize(assumedSize:)', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - bestsellers: [Book] @listSize(assumedSize: 5) - } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + book, + }, + extensions: { + cost: { + estimated: 8, + }, + }, + }); + }); + /** + * 1 Query (0) + 5 book objects (5 * (1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5))) = 40 total cost + */ + it('@listSize(assumedSize:)', async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + bestsellers: [Book] @listSize(assumedSize: 5) + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address @cost(weight: 5) { - zipCode: Int! - } - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@cost", "@listSize"] - ) { - query: Query - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; + type Address @cost(weight: 5) { + zipCode: Int! } - throw new Error('Book not found'); + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@cost", "@listSize"] + ) { + query: Query + } + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + bestsellers: () => [book], + }, }, - bestsellers: () => [book], - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query BestsellersQuery { - bestsellers { - title - author { - name - } - publisher { - name - address { - zipCode + }); + await using gateway = createTestGateway(mode, booksSubgraph); + const query = /* GraphQL */ ` + query BestsellersQuery { + bestsellers { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - bestsellers: [book], - }, - extensions: { - cost: { - estimated: 40, - }, - }, - }); - }); - /** - * When requesting 3 books: - * 1 Query (0) + 3 book objects (3 * (1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5))) = 24 total cost - * - * When requesting 7 books: - * 1 Query (0) + 3 book objects (7 * (1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5))) = 56 total cost - */ - it('@listSize(slicingArguments:)', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - newestAdditions(after: ID, limit: Int!): [Book] - @listSize(slicingArguments: ["limit"]) - } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + bestsellers: [book], + }, + extensions: { + cost: { + estimated: 40, + }, + }, + }); + }); + /** + * When requesting 3 books: + * 1 Query (0) + 3 book objects (3 * (1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5))) = 24 total cost + * + * When requesting 7 books: + * 1 Query (0) + 3 book objects (7 * (1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5))) = 56 total cost + */ + it('@listSize(slicingArguments:)', async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + newestAdditions(after: ID, limit: Int!): [Book] + @listSize(slicingArguments: ["limit"]) + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address @cost(weight: 5) { - zipCode: Int! - } - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@cost", "@listSize"] - ) { - query: Query - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; + type Address @cost(weight: 5) { + zipCode: Int! } - throw new Error('Book not found'); + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@cost", "@listSize"] + ) { + query: Query + } + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + bestsellers: () => [book], + newestAdditions: () => [book], + }, }, - bestsellers: () => [book], - newestAdditions: () => [book], - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - /* Querying 3 books start */ - const queryWith3 = /* GraphQL */ ` - query NewestAdditions { - newestAdditions(limit: 3) { - title - author { - name - } - publisher { - name - address { - zipCode + }); + await using gateway = createTestGateway(mode, booksSubgraph); + /* Querying 3 books start */ + const queryWith3 = /* GraphQL */ ` + query NewestAdditions { + newestAdditions(limit: 3) { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const responseWith3 = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query: queryWith3 }), - }); - const resultWith3 = await responseWith3.json(); - expect(resultWith3).toEqual({ - data: { - newestAdditions: [book], - }, - extensions: { - cost: { - estimated: 24, - }, - }, - }); - /* Querying 3 books end */ - /* Querying 7 books start */ - const queryWith7 = /* GraphQL */ ` - query NewestAdditions { - newestAdditions(limit: 7) { - title - author { - name - } - publisher { - name - address { - zipCode + `; + const responseWith3 = await gateway.fetch( + 'http://localhost:4000/graphql', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: queryWith3 }), + }, + ); + const resultWith3 = await responseWith3.json(); + expect(resultWith3).toEqual({ + data: { + newestAdditions: [book], + }, + extensions: { + cost: { + estimated: 24, + }, + }, + }); + /* Querying 3 books end */ + /* Querying 7 books start */ + const queryWith7 = /* GraphQL */ ` + query NewestAdditions { + newestAdditions(limit: 7) { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const responseWith7 = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query: queryWith7 }), - }); - const resultWith7 = await responseWith7.json(); - expect(resultWith7).toEqual({ - data: { - newestAdditions: [book], - }, - extensions: { - cost: { - estimated: 56, - }, - }, - }); - /* Querying 7 books end */ - }); + `; + const responseWith7 = await gateway.fetch( + 'http://localhost:4000/graphql', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query: queryWith7 }), + }, + ); + const resultWith7 = await responseWith7.json(); + expect(resultWith7).toEqual({ + data: { + newestAdditions: [book], + }, + extensions: { + cost: { + estimated: 56, + }, + }, + }); + /* Querying 7 books end */ + }); - it('"max" option', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - book(id: ID): Book - } + it('"max" option', async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + book(id: ID): Book + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address { - zipCode: Int! - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; + type Address { + zipCode: Int! } - throw new Error('Book not found'); + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + }, }, - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ - maxCost: 3, - }), - ], - }); - const query = /* GraphQL */ ` - query BookQuery { - book(id: 1) { - title - author { - name - } - publisher { - name - address { - zipCode + }); + await using gateway = createTestGateway( + mode, + booksSubgraph, + { + maxCost: 3, + }, + true, + ); + const query = /* GraphQL */ ` + query BookQuery { + book(id: 1) { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - book: null, - }, - errors: [ - { - message: 'Operation estimated cost 4 exceeded configured maximum 3', - extensions: { - code: 'COST_ESTIMATED_TOO_EXPENSIVE', - cost: { - estimated: 4, - max: 3, - }, + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - locations: [ + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result.data?.book).toBeFalsy(); + expect(result).toMatchObject({ + errors: [ { - line: 3, - column: 9, + message: + 'Operation estimated cost 4 exceeded configured maximum 3', + extensions: { + code: 'COST_ESTIMATED_TOO_EXPENSIVE', + cost: { + estimated: 4, + max: 3, + }, + }, }, ], - path: ['book'], - }, - ], - }); - }); + }); + }); - it('"defaultAssumedListSize" option', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - bestsellers: [Book] - } + it('"defaultAssumedListSize" option', async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + bestsellers: [Book] + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address @cost(weight: 5) { - zipCode: Int! - } - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@cost"] - ) { - query: Query - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; + type Address @cost(weight: 5) { + zipCode: Int! + } + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@cost"] + ) { + query: Query } - throw new Error('Book not found'); + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + bestsellers: () => [book], + }, }, - bestsellers: () => [book], - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ + }); + await using gateway = createTestGateway(mode, booksSubgraph, { includeExtensionMetadata: true, listSize: 5, - }), - ], - }); - const query = /* GraphQL */ ` - query BestsellersQuery { - bestsellers { - title - author { - name - } - publisher { - name - address { - zipCode + }); + const query = /* GraphQL */ ` + query BestsellersQuery { + bestsellers { + title + author { + name + } + publisher { + name + address { + zipCode + } + } } } - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - bestsellers: [book], - }, - extensions: { - cost: { - estimated: 40, - }, - }, - }); - }); + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + bestsellers: [book], + }, + extensions: { + cost: { + estimated: 40, + }, + }, + }); + }); - it('@listSize(slicingArguments:, requireOneSlicingArgument:true)', async () => { - const itemsSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@listSize"] - ) { - query: Query - } + it('@listSize(slicingArguments:, requireOneSlicingArgument:true)', async () => { + const itemsSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@listSize"] + ) { + query: Query + } - type Query { - items(first: Int, last: Int): [Item!] - @listSize(slicingArguments: ["first", "last"]) - } + type Query { + items(first: Int, last: Int): [Item!] + @listSize(slicingArguments: ["first", "last"]) + } - type Item { - id: ID - } - `), - resolvers: { - Query: { - items: () => [{ id: 'Item 1' }, { id: 'Item 2' }], - }, - }, - }); - await using itemsServer = createYoga({ - schema: itemsSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'items', + type Item { + id: ID + } + `), + resolvers: { + Query: { + items: () => [{ id: 'Item 1' }, { id: 'Item 2' }], + }, + }, + }); + await using itemsServer = createYoga({ schema: itemsSubgraph, - url: 'http://items/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(itemsServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query ItemsQuery { - items(first: 2, last: 3) { - id - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - items: null, - }, - errors: [ - { - message: - 'Only one slicing argument is allowed on field "items"; found multiple slicing arguments "first, last"', - extensions: { - code: 'COST_QUERY_PARSE_FAILURE', + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'items', + schema: itemsSubgraph, + url: 'http://items/graphql', + }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(itemsServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + }), + ], + }); + const query = /* GraphQL */ ` + query ItemsQuery { + items(first: 2, last: 3) { + id + } + } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + items: null, }, - locations: [ + errors: [ { - line: 3, - column: 9, + message: + 'Only one slicing argument is allowed on field "items"; found multiple slicing arguments "first, last"', + extensions: { + code: 'COST_QUERY_PARSE_FAILURE', + }, + locations: [ + { + line: 3, + column: 13, + }, + ], + path: ['items'], }, ], - path: ['items'], - }, - ], - extensions: { - cost: { - estimated: 0, - }, - }, - }); - }); - it('@listSize(slicingArguments:, requireOneSlicingArgument:false)', async () => { - const itemsSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@listSize"] - ) { - query: Query - } + extensions: { + cost: { + estimated: 0, + }, + }, + }); + }); + it('@listSize(slicingArguments:, requireOneSlicingArgument:false)', async () => { + const itemsSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@listSize"] + ) { + query: Query + } - type Query { - items(first: Int, last: Int): [Item!] - @listSize( - slicingArguments: ["first", "last"] - requireOneSlicingArgument: false - ) - } + type Query { + items(first: Int, last: Int): [Item!] + @listSize( + slicingArguments: ["first", "last"] + requireOneSlicingArgument: false + ) + } - type Item { - id: ID - } - `), - resolvers: { - Query: { - items: () => [{ id: 'Item 1' }, { id: 'Item 2' }], - }, - }, - }); - await using itemsServer = createYoga({ - schema: itemsSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'items', + type Item { + id: ID + } + `), + resolvers: { + Query: { + items: () => [{ id: 'Item 1' }, { id: 'Item 2' }], + }, + }, + }); + await using itemsServer = createYoga({ schema: itemsSubgraph, - url: 'http://items/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(itemsServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query ItemsQuery { - items(first: 2, last: 3) { - id - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - items: [{ id: 'Item 1' }, { id: 'Item 2' }], - }, - extensions: { - cost: { - estimated: 3, - }, - }, - }); - }); - it('@listSize(sizedFields:)', async () => { - const itemsSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: ["@listSize"] - ) { - query: Query - } + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'items', + schema: itemsSubgraph, + url: 'http://items/graphql', + }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(itemsServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + }), + ], + }); + const query = /* GraphQL */ ` + query ItemsQuery { + items(first: 2, last: 3) { + id + } + } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + items: [{ id: 'Item 1' }, { id: 'Item 2' }], + }, + extensions: { + cost: { + estimated: 3, + }, + }, + }); + }); + it('@listSize(sizedFields:)', async () => { + const itemsSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: ["@listSize"] + ) { + query: Query + } - type Query { - items(first: Int): Cursor! - @listSize(slicingArguments: ["first"], sizedFields: ["page"]) - } + type Query { + items(first: Int): Cursor! + @listSize(slicingArguments: ["first"], sizedFields: ["page"]) + } - type Cursor { - page: [Item!] - nextPageToken: String - } + type Cursor { + page: [Item!] + nextPageToken: String + } - type Item { - id: ID - } - `), - resolvers: { - Query: { - items: () => ({ - page: [{ id: 'Item 1' }, { id: 'Item 2' }], - nextPageToken: 'token', - }), - }, - }, - }); - await using itemsServer = createYoga({ - schema: itemsSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'items', + type Item { + id: ID + } + `), + resolvers: { + Query: { + items: () => ({ + page: [{ id: 'Item 1' }, { id: 'Item 2' }], + nextPageToken: 'token', + }), + }, + }, + }); + await using itemsServer = createYoga({ schema: itemsSubgraph, - url: 'http://items/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(itemsServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query ItemsQuery { - items(first: 5) { - page { - id + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'items', + schema: itemsSubgraph, + url: 'http://items/graphql', + }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(itemsServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + }), + ], + }); + const query = /* GraphQL */ ` + query ItemsQuery { + items(first: 5) { + page { + id + } + nextPageToken + } } - nextPageToken - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - items: { - page: [{ id: 'Item 1' }, { id: 'Item 2' }], - nextPageToken: 'token', - }, - }, - extensions: { - cost: { - estimated: 6, - }, - }, - }); - }); + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + items: { + page: [{ id: 'Item 1' }, { id: 'Item 2' }], + nextPageToken: 'token', + }, + }, + extensions: { + cost: { + estimated: 6, + }, + }, + }); + }); - /** - * 1 Query (0) + 1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5) = 8 total cost - */ - it('@cost in object but aliased as @myCost', async () => { - const booksSubgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - book(id: ID): Book - } + /** + * 1 Query (0) + 1 book object (1) + 1 author object (1) + 1 publisher object (1) + 1 address object (5) = 8 total cost + */ + // Skipped in proxy mode because the proxy does not support custom directive aliasing (e.g., @cost as @myCost). + it.skipIf(mode === 'proxy')( + '@cost in object but aliased as @myCost', + async () => { + const booksSubgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + book(id: ID): Book + } - type Book { - title: String - author: Author - publisher: Publisher - } + type Book { + title: String + author: Author + publisher: Publisher + } - type Author { - name: String - } + type Author { + name: String + } - type Publisher { - name: String - address: Address - } + type Publisher { + name: String + address: Address + } - type Address @myCost(weight: 5) { - zipCode: Int! - } - extend schema - @link( - url: "https://specs.apollo.dev/federation/v2.9" - import: [{ name: "@cost", as: "@myCost" }] - ) { - query: Query - } - `), - resolvers: { - Query: { - book: (_root, { id }) => { - if (id === '1') { - return book; - } - throw new Error('Book not found'); - }, - }, - }, - }); - await using booksServer = createYoga({ - schema: booksSubgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'books', - schema: booksSubgraph, - url: 'http://books/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(booksServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query BookQuery { - book(id: 1) { - title - author { - name - } - publisher { - name - address { - zipCode + type Address @myCost(weight: 5) { + zipCode: Int! + } + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.9" + import: [{ name: "@cost", as: "@myCost" }] + ) { + query: Query + } + `), + resolvers: { + Query: { + book: (_root, { id }) => { + if (id === '1') { + return book; + } + throw new Error('Book not found'); + }, + }, + }, + }); + await using gateway = createTestGateway(mode, booksSubgraph); + const query = /* GraphQL */ ` + query BookQuery { + book(id: 1) { + title + author { + name + } + publisher { + name + address { + zipCode + } + } + } } - } - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - book, - }, - extensions: { - cost: { - estimated: 8, + `; + const response = await gateway.fetch( + 'http://localhost:4000/graphql', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }, + ); + const result = await response.json(); + expect(result).toEqual({ + data: { + book, + }, + extensions: { + cost: { + estimated: 8, + }, + }, + }); }, - }, - }); - }); + ); - it('returns cost even if it does not hit the subgraph', async () => { - const subgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - foo: String - } - `), - }); - await using subgraphServer = createYoga({ - schema: subgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'subgraph', + it('returns cost even if it does not hit the subgraph', async () => { + const subgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + foo: String + } + `), + }); + await using subgraphServer = createYoga({ schema: subgraph, - url: 'http://subgraph/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(subgraphServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - }), - ], - }); - const query = /* GraphQL */ ` - query EmptyQuery { - __typename - a: __typename - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - __typename: 'Query', - a: 'Query', - }, - extensions: { - cost: { - estimated: 0, - }, - }, - }); - }); + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'subgraph', + schema: subgraph, + url: 'http://subgraph/graphql', + }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(subgraphServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + }), + ], + }); + const query = /* GraphQL */ ` + query EmptyQuery { + __typename + a: __typename + } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + __typename: 'Query', + a: 'Query', + }, + extensions: { + cost: { + estimated: 0, + }, + }, + }); + }); - it('handles batched requests', async () => { - const subgraph = buildSubgraphSchema({ - typeDefs: parse(/* GraphQL */ ` - type Query { - foo: Foo - bar: Bar - } + it('handles batched requests', async () => { + const subgraph = buildSubgraphSchema({ + typeDefs: parse(/* GraphQL */ ` + type Query { + foo: Foo + bar: Bar + } - type Foo { - id: ID - } + type Foo { + id: ID + } - type Bar { - id: ID - } - `), - resolvers: { - Query: { - foo: async () => ({ id: 'foo' }), - bar: async () => ({ id: 'bar' }), - }, - }, - }); - await using subgraphServer = createYoga({ - schema: subgraph, - }); - await using gateway = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ - { - name: 'subgraph', + type Bar { + id: ID + } + `), + resolvers: { + Query: { + foo: async () => ({ id: 'foo' }), + bar: async () => ({ id: 'bar' }), + }, + }, + }); + await using subgraphServer = createYoga({ schema: subgraph, - url: 'http://subgraph/graphql', - }, - ]), - plugins: () => [ - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - useCustomFetch(subgraphServer.fetch), - useDemandControl({ - includeExtensionMetadata: true, - maxCost: 1, - }), - ], - }); - const query = /* GraphQL */ ` - query FooQuery { - foo { - id - } - bar { - id - } - } - `; - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query }), - }); - const result = await response.json(); - expect(result).toEqual({ - data: { - foo: null, - bar: null, - }, - errors: [ - { - extensions: { - code: 'COST_ESTIMATED_TOO_EXPENSIVE', - cost: { - estimated: 2, - max: 1, + }); + await using gateway = createGatewayRuntime({ + supergraph: await composeLocalSchemasWithApollo([ + { + name: 'subgraph', + schema: subgraph, + url: 'http://subgraph/graphql', }, + ]), + plugins: () => [ + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + useCustomFetch(subgraphServer.fetch), + useDemandControl({ + includeExtensionMetadata: true, + maxCost: 1, + }), + ], + }); + const query = /* GraphQL */ ` + query FooQuery { + foo { + id + } + bar { + id + } + } + `; + const response = await gateway.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query }), + }); + const result = await response.json(); + expect(result).toEqual({ + data: { + foo: null, + bar: null, }, - locations: [ + errors: [ + { + extensions: { + code: 'COST_ESTIMATED_TOO_EXPENSIVE', + cost: { + estimated: 2, + max: 1, + }, + }, + locations: [ + { + column: 13, + line: 3, + }, + ], + message: + 'Operation estimated cost 2 exceeded configured maximum 1', + path: ['foo'], + }, { - column: 9, - line: 3, + extensions: { + code: 'COST_ESTIMATED_TOO_EXPENSIVE', + cost: { + estimated: 2, + max: 1, + }, + }, + locations: [ + { + column: 13, + line: 6, + }, + ], + message: + 'Operation estimated cost 2 exceeded configured maximum 1', + path: ['bar'], }, ], - message: 'Operation estimated cost 2 exceeded configured maximum 1', - path: ['foo'], - }, - { extensions: { - code: 'COST_ESTIMATED_TOO_EXPENSIVE', cost: { estimated: 2, max: 1, }, }, - locations: [ - { - column: 9, - line: 6, - }, - ], - message: 'Operation estimated cost 2 exceeded configured maximum 1', - path: ['bar'], - }, - ], - extensions: { - cost: { - estimated: 2, - max: 1, - }, - }, + }); + }); }); }); });