diff --git a/.changeset/@graphql-hive_plugin-aws-sigv4-1625-dependencies.md b/.changeset/@graphql-hive_plugin-aws-sigv4-1625-dependencies.md new file mode 100644 index 000000000..45fd4e58e --- /dev/null +++ b/.changeset/@graphql-hive_plugin-aws-sigv4-1625-dependencies.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/plugin-aws-sigv4': patch +--- + +dependencies updates: + +- Updated dependency [`@aws-sdk/client-sts@^3.916.0` ↗︎](https://www.npmjs.com/package/@aws-sdk/client-sts/v/3.916.0) (from `^3.914.0`, in `dependencies`) diff --git a/.changeset/dry-numbers-deliver.md b/.changeset/dry-numbers-deliver.md new file mode 100644 index 000000000..535c3fa31 --- /dev/null +++ b/.changeset/dry-numbers-deliver.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/gateway-testing': major +--- + +Hive Gateway tester and testing utilities diff --git a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts index 26d452c60..4377370c5 100644 --- a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts +++ b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts @@ -1,12 +1,7 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; import { createInlineSigningKeyProvider, useJWT } from '@graphql-hive/gateway'; -import { - createGatewayRuntime, - useCustomFetch, -} from '@graphql-hive/gateway-runtime'; -import { composeLocalSchemasWithApollo } from '@internal/testing'; +import { createGatewayTester } from '@graphql-hive/gateway-testing'; import { parse } from 'graphql'; -import { createYoga } from 'graphql-yoga'; import { describe, expect, it } from 'vitest'; import { useAWSSigv4 } from '../src'; @@ -23,18 +18,14 @@ describe('AWS Sigv4 Incoming requests', () => { }, }, }); - const subgraphServer = createYoga({ - schema: subgraphSchema, - }); it('validates incoming requests', async () => { - await using gw = createGatewayRuntime({ - supergraph: composeLocalSchemasWithApollo([ + await using gw = createGatewayTester({ + subgraphs: [ { name: 'subgraph', schema: subgraphSchema, - url: 'http://localhost:4000/graphql', }, - ]), + ], landingPage: false, graphqlEndpoint: '/', plugins: () => [ @@ -43,10 +34,6 @@ describe('AWS Sigv4 Incoming requests', () => { secretAccessKey: () => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', }, }), - useCustomFetch( - // @ts-expect-error - MeshFetch is not compatible with Yoga.fetch - subgraphServer.fetch, - ), ], }); const response = await gw.fetch( @@ -59,7 +46,7 @@ describe('AWS Sigv4 Incoming requests', () => { Date: 'Mon, 29 Dec 2015 00:00:00 GMT', 'content-type': 'application/json', Host: 'sigv4examplegraphqlbucket.s3-eu-central-1.amazonaws.com', - 'Content-Length': 30, + 'Content-Length': '30', 'X-Amz-Content-Sha256': '34c77dc7b593717e0231ac99a16ae3be5ee2e8d652bce6518738a6449dfd2647', 'X-Amz-Date': '20151229T000000Z', @@ -82,14 +69,13 @@ describe('AWS Sigv4 Incoming requests', () => { }); it('works with JWT', async () => { const JWT_SECRET = 'a-string-secret-at-least-256-bits-long'; - await using gw = createGatewayRuntime({ - supergraph: composeLocalSchemasWithApollo([ + await using gw = createGatewayTester({ + subgraphs: [ { name: 'subgraph', schema: subgraphSchema, - url: 'http://localhost:4000/graphql', }, - ]), + ], landingPage: false, graphqlEndpoint: '/', plugins: () => [ @@ -108,10 +94,6 @@ describe('AWS Sigv4 Incoming requests', () => { secretAccessKey: () => 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', }, }), - useCustomFetch( - // @ts-expect-error - MeshFetch is not compatible with Yoga.fetch - subgraphServer.fetch, - ), ], }); @@ -125,7 +107,7 @@ describe('AWS Sigv4 Incoming requests', () => { Date: 'Mon, 29 Dec 2015 00:00:00 GMT', 'content-type': 'application/json', Host: 'sigv4examplegraphqlbucket.s3-eu-central-1.amazonaws.com', - 'Content-Length': 30, + 'Content-Length': '30', 'X-Amz-Content-Sha256': '34c77dc7b593717e0231ac99a16ae3be5ee2e8d652bce6518738a6449dfd2647', 'X-Amz-Date': '20151229T000000Z', diff --git a/packages/plugins/aws-sigv4/tests/aws-sigv4-outgoing.test.ts b/packages/plugins/aws-sigv4/tests/aws-sigv4-outgoing.test.ts index fb25e06f3..2dab9d59a 100644 --- a/packages/plugins/aws-sigv4/tests/aws-sigv4-outgoing.test.ts +++ b/packages/plugins/aws-sigv4/tests/aws-sigv4-outgoing.test.ts @@ -1,12 +1,7 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; -import { - createGatewayRuntime, - useCustomFetch, -} from '@graphql-hive/gateway-runtime'; +import { createGatewayTester } from '@graphql-hive/gateway-testing'; import { useAWSSigv4 } from '@graphql-hive/plugin-aws-sigv4'; -import { composeLocalSchemasWithApollo } from '@internal/testing'; import { parse } from 'graphql'; -import { createYoga } from 'graphql-yoga'; import { describe, expect, it } from 'vitest'; describe('AWS Sigv4', () => { @@ -24,26 +19,25 @@ describe('AWS Sigv4', () => { }, }); let receivedSubgraphRequest: Request | undefined; - await using subgraphServer = createYoga({ - schema: subgraphSchema, - plugins: [ - { - onRequest({ request }) { - receivedSubgraphRequest = request; - }, - }, - ], - landingPage: false, - graphqlEndpoint: '/', - }); - await using gw = createGatewayRuntime({ - supergraph: await composeLocalSchemasWithApollo([ + await using gw = createGatewayTester({ + subgraphs: [ { name: 'subgraph', schema: subgraphSchema, - url: 'http://sigv4examplegraphqlbucket.s3-eu-central-1.amazonaws.com', + host: 'sigv4examplegraphqlbucket.s3-eu-central-1.amazonaws.com', + yoga: { + plugins: [ + { + onRequest({ request }) { + receivedSubgraphRequest = request; + }, + }, + ], + landingPage: false, + graphqlEndpoint: '/', + }, }, - ]), + ], transportEntries: { subgraph: { headers: [['Date', 'Mon, 29 Dec 2015 00:00:00 GMT']], @@ -57,10 +51,6 @@ describe('AWS Sigv4', () => { secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', }, }), - useCustomFetch( - // @ts-expect-error - MeshFetch is not compatible with Yoga.fetch - subgraphServer.fetch, - ), ], }); const res = await gw.fetch('http://localhost:4000/graphql', { diff --git a/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts b/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts index 2100ecfcc..5b0fa61e9 100644 --- a/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts +++ b/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts @@ -1,35 +1,18 @@ import { createHmac } from 'node:crypto'; +import { GatewayPlugin } from '@graphql-hive/gateway-runtime'; import { - createGatewayRuntime, - GatewayPlugin, - useCustomFetch, -} from '@graphql-hive/gateway-runtime'; -import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; -import { MeshFetch } from '@graphql-mesh/types'; -import { GraphQLSchema, stripIgnoredCharacters } from 'graphql'; -import { createSchema, createYoga, type Plugin } from 'graphql-yoga'; + createGatewayTester, + GatewayTesterConfig, + GatewayTesterRemoteSchemaConfigYoga, +} from '@graphql-hive/gateway-testing'; +import { stripIgnoredCharacters } from 'graphql'; +import { createSchema, type Plugin } from 'graphql-yoga'; import { beforeEach, describe, expect, it, test, vi } from 'vitest'; import { defaultParamsSerializer, useHmacSignatureValidation, } from '../src/index'; -const cases = { - asProxy: () => ({ - proxy: { - endpoint: 'https://upstream/graphql', - }, - }), - asSubgraph: (upstreamSchema: GraphQLSchema) => ({ - supergraph: getUnifiedGraphGracefully([ - { - name: 'upstream', - schema: upstreamSchema, - url: 'http://upstream/graphql', - }, - ]), - }), -}; const upstreamSchema = createSchema({ typeDefs: /* GraphQL */ ` type Query { @@ -48,92 +31,78 @@ const exampleQuery = stripIgnoredCharacters(/* GraphQL */ ` hello } `); -for (const [name, createConfig] of Object.entries(cases)) { +for (const [name, configure] of Object.entries({ + 'as proxy': (yoga?: GatewayTesterRemoteSchemaConfigYoga) => + ({ + proxy: { name: 'upstream', schema: upstreamSchema, yoga }, + }) as GatewayTesterConfig, + 'as subgraph': (yoga?: GatewayTesterRemoteSchemaConfigYoga) => + ({ + subgraphs: [ + { + name: 'upstream', + schema: upstreamSchema, + yoga, + }, + ], + }) as GatewayTesterConfig, +})) { describe(`when used ${name}`, () => { describe('useHmacSignatureValidation', () => { test('should throw when header is missing or invalid', async () => { - await using upstream = createYoga({ - schema: upstreamSchema, - plugins: [], - logging: false, - }); - await using gateway = createGatewayRuntime({ - ...createConfig(upstreamSchema), + await using gateway = createGatewayTester({ + ...configure(), plugins: () => [ useHmacSignatureValidation({ secret: 'topSecret', }), - useCustomFetch(upstream.fetch as MeshFetch), ], - logging: false, }); - let response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: exampleQuery, }), - }); - - expect(await response.json()).toEqual({ + ).resolves.toEqual({ errors: [ - { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - }, - message: 'Unexpected error.', - }, + expect.objectContaining({ + message: + 'Missing HMAC signature: extension hmac-signature not found in request.', + }), ], }); - response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + + await expect( + gateway.execute({ query: exampleQuery, extensions: { 'hmac-signature': 'invalid', }, }), - }); - - expect(await response.json()).toEqual({ + ).resolves.toEqual({ errors: [ - { - extensions: { - code: 'INTERNAL_SERVER_ERROR', - }, - message: 'Unexpected error.', - }, + expect.objectContaining({ + message: + 'Invalid HMAC signature: extension hmac-signature does not match the body content.', + }), ], }); }); test('should build a valid hmac and validate it correctly in a Yoga setup', async () => { const sharedSecret = 'topSecret'; - await using upstream = createYoga({ - schema: upstreamSchema, - plugins: [ - useHmacSignatureValidation({ - secret: sharedSecret, - }), - ], - logging: false, - }); - await using gateway = createGatewayRuntime({ - ...createConfig(upstreamSchema), + await using gateway = createGatewayTester({ + ...configure({ + plugins: [ + useHmacSignatureValidation({ + secret: sharedSecret, + }), + ], + }), hmacSignature: { secret: sharedSecret, }, plugins: () => [ - useCustomFetch( - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - upstream.fetch, - ), { onSubgraphExecute(payload) { payload.executionRequest.extensions ||= {}; @@ -141,20 +110,20 @@ for (const [name, createConfig] of Object.entries(cases)) { }, } satisfies GatewayPlugin, ], - logging: false, }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: exampleQuery, }), - }); - - expect(response.status).toBe(200); + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "__typename": "Query", + "hello": "world", + }, + } + `); }); }); @@ -169,31 +138,18 @@ for (const [name, createConfig] of Object.entries(cases)) { const requestTrackerPlugin = { onParams: vi.fn((() => {}) as Plugin['onParams']), }; - function createUpstream() { - return createYoga({ - schema: upstreamSchema, - plugins: [requestTrackerPlugin], - logging: false, - }); - } beforeEach(() => { requestTrackerPlugin.onParams.mockClear(); }); it('should build valid hmac signature based on the request body even when its modified in other plugins', async () => { const secret = 'secret'; - await using upstream = createUpstream(); - await using gateway = createGatewayRuntime({ - ...createConfig(upstreamSchema), + await using gateway = createGatewayTester({ + ...configure({ plugins: [requestTrackerPlugin] }), hmacSignature: { secret, }, plugins: () => [ - useCustomFetch( - // We cast instead of using @ts-expect-error because when `upstream` is not defined, it doesn't error - // If you want to try, remove `upstream` variable above, then add ts-expect-error here. - upstream.fetch as MeshFetch, - ), { onSubgraphExecute(payload) { payload.executionRequest.extensions ||= {}; @@ -201,146 +157,150 @@ for (const [name, createConfig] of Object.entries(cases)) { }, } satisfies GatewayPlugin, ], - logging: false, }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: exampleQuery, }), - }); + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "__typename": "Query", + "hello": "world", + }, + } + `); - expect(response.status).toBe(200); expect(requestTrackerPlugin.onParams).toHaveBeenCalledTimes( - name === 'asProxy' ? 2 : 1, + name === 'as proxy' ? 2 : 1, ); - const callIndex = name === 'asProxy' ? 1 : 0; const upstreamReqParams = - requestTrackerPlugin.onParams.mock.calls[callIndex]![0].params; - const upstreamExtensions = upstreamReqParams.extensions!; - expect(upstreamExtensions['hmac-signature']).toBeDefined(); + requestTrackerPlugin.onParams.mock.calls[ + name === 'as proxy' ? 1 : 0 + ]![0].params; const upstreamReqBody = defaultParamsSerializer(upstreamReqParams); - expect(upstreamReqParams.extensions?.['addedToPayload']).toBeTruthy(); - // Signature on the upstream call should match when manually validated - expect(upstreamExtensions['hmac-signature']).toEqual( - hashSHA256(secret, upstreamReqBody), - ); + expect(upstreamReqParams).toEqual({ + extensions: { + addedToPayload: true, + 'hmac-signature': hashSHA256(secret, upstreamReqBody), + }, + query: '{__typename hello}', + }); }); it('should include hmac signature based on the request body', async () => { const secret = 'secret'; - await using upstream = createUpstream(); - await using gateway = createGatewayRuntime({ - ...createConfig(upstreamSchema), + await using gateway = createGatewayTester({ + ...configure({ plugins: [requestTrackerPlugin] }), hmacSignature: { secret, }, - plugins: () => [useCustomFetch(upstream.fetch as MeshFetch)], - logging: false, }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: exampleQuery, }), - }); + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "__typename": "Query", + "hello": "world", + }, + } + `); - expect(response.status).toBe(200); expect(requestTrackerPlugin.onParams).toHaveBeenCalledTimes( - name === 'asProxy' ? 2 : 1, + name === 'as proxy' ? 2 : 1, ); - const callIndex = name === 'asProxy' ? 1 : 0; const upstreamReqParams = - requestTrackerPlugin.onParams.mock.calls[callIndex]![0].params; - const upstreamExtensions = upstreamReqParams.extensions!; - const upstreamHmacExtension = upstreamExtensions['hmac-signature']; - expect(upstreamHmacExtension).toBeDefined(); + requestTrackerPlugin.onParams.mock.calls[ + name === 'as proxy' ? 1 : 0 + ]![0].params; const upstreamReqBody = defaultParamsSerializer(upstreamReqParams); - // Signature on the upstream call should match when manually validated - expect(upstreamHmacExtension).toEqual( - hashSHA256(secret, upstreamReqBody), - ); + expect(upstreamReqParams).toEqual({ + extensions: { + // addedToPayload: true, not added by other plugin + 'hmac-signature': hashSHA256(secret, upstreamReqBody), + }, + query: '{__typename hello}', + }); }); it('should allow to customize header name', async () => { const secret = 'secret'; const customExtensionName = 'custom-hmac-signature'; - await using upstream = createUpstream(); - await using gateway = createGatewayRuntime({ - ...createConfig(upstreamSchema), + await using gateway = createGatewayTester({ + ...configure({ plugins: [requestTrackerPlugin] }), hmacSignature: { secret, extensionName: customExtensionName, }, - plugins: () => [useCustomFetch(upstream.fetch as MeshFetch)], - logging: false, }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: exampleQuery, }), - }); + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "__typename": "Query", + "hello": "world", + }, + } + `); - expect(response.status).toBe(200); expect(requestTrackerPlugin.onParams).toHaveBeenCalledTimes( - name === 'asProxy' ? 2 : 1, + name === 'as proxy' ? 2 : 1, ); - const callIndex = name === 'asProxy' ? 1 : 0; const upstreamReqParams = - requestTrackerPlugin.onParams.mock.calls[callIndex]![0].params; - const upstreamExtensions = upstreamReqParams.extensions!; - const upstreamHmacExtension = upstreamExtensions[customExtensionName]; - expect(upstreamHmacExtension).toBeDefined(); + requestTrackerPlugin.onParams.mock.calls[ + name === 'as proxy' ? 1 : 0 + ]![0].params; const upstreamReqBody = defaultParamsSerializer(upstreamReqParams); - // Signature on the upstream call should match when manually validated - expect(upstreamHmacExtension).toEqual( - hashSHA256(secret, upstreamReqBody), - ); + expect(upstreamReqParams).toEqual({ + extensions: { + [customExtensionName]: hashSHA256(secret, upstreamReqBody), + }, + query: '{__typename hello}', + }); }); it('should allow to filter upstream calls', async () => { const secret = 'secret'; - await using upstream = createUpstream(); - await using gateway = createGatewayRuntime({ - ...createConfig(upstreamSchema), + await using gateway = createGatewayTester({ + ...configure({ plugins: [requestTrackerPlugin] }), hmacSignature: { secret, shouldSign: () => false, }, - plugins: () => [useCustomFetch(upstream.fetch as MeshFetch)], - logging: false, }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: exampleQuery, }), - }); + ).resolves.toMatchInlineSnapshot(` + { + "data": { + "__typename": "Query", + "hello": "world", + }, + } + `); - expect(response.status).toBe(200); expect(requestTrackerPlugin.onParams).toHaveBeenCalledTimes( - name === 'asProxy' ? 2 : 1, + name === 'as proxy' ? 2 : 1, ); - for (const call of requestTrackerPlugin.onParams.mock.calls) { - expect(call[0].params.extensions?.['hmac-signature']).toBeUndefined(); - } + const upstreamReqParams = + requestTrackerPlugin.onParams.mock.calls[ + name === 'as proxy' ? 1 : 0 + ]![0].params; + expect(upstreamReqParams).toEqual({ + query: '{__typename hello}', + }); }); }); }); diff --git a/packages/plugins/prometheus/tests/prometheus.spec.ts b/packages/plugins/prometheus/tests/prometheus.spec.ts index c4d6f6971..a07cdb77f 100644 --- a/packages/plugins/prometheus/tests/prometheus.spec.ts +++ b/packages/plugins/prometheus/tests/prometheus.spec.ts @@ -1,12 +1,9 @@ -import { - createGatewayRuntime, - useCustomFetch, -} from '@graphql-hive/gateway-runtime'; +import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; +import { createGatewayTester } from '@graphql-hive/gateway-testing'; import InMemoryLRUCache from '@graphql-mesh/cache-inmemory-lru'; import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; import { createDefaultExecutor } from '@graphql-mesh/transport-common'; -import { isDebug } from '@internal/testing'; -import { createSchema, createYoga } from 'graphql-yoga'; +import { createSchema } from 'graphql-yoga'; import { Registry, register as registry } from 'prom-client'; import { beforeEach, describe, expect, it } from 'vitest'; import usePrometheus, { @@ -64,7 +61,6 @@ describe('Prometheus', () => { }, }), ], - logging: isDebug(), maskedErrors: false, }); } @@ -281,23 +277,19 @@ describe('Prometheus', () => { it('should not increment fetch count on cached responses', async () => { const registry = new Registry(); - const subgraph = createYoga({ schema: subgraphSchema }); await using cache = new InMemoryLRUCache(); - const gateway = createGatewayRuntime({ - supergraph: getUnifiedGraphGracefully([ + const gateway = createGatewayTester({ + subgraphs: [ { name: 'TEST_SUBGRAPH', schema: subgraphSchema, - url: 'http://subgraph/graphql', }, - ]), + ], cache, responseCaching: { session: () => null, }, plugins: (ctx) => [ - // @ts-expect-error - useCustomFetch(subgraph.fetch), usePrometheus({ ...ctx, registry, @@ -311,20 +303,15 @@ describe('Prometheus', () => { }); for (let i = 0; i < 3; i++) { - const res = await gateway.fetch('http://gateway/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: /* GraphQL */ ` { hello } `, }), - }); - await expect(res.json()).resolves.toEqual({ + ).resolves.toEqual({ data: { hello: 'Hello world!' }, }); } diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 4642499ce..25da635b6 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -246,7 +246,7 @@ export interface GatewayConfigSubgraph< subgraph: UnifiedGraphConfig; } -interface GatewayConfigSchemaBase> +export interface GatewayConfigSchemaBase> extends GatewayConfigBase { /** * Additional GraphQL schema type definitions. @@ -435,7 +435,7 @@ export interface GatewayHivePersistedDocumentsOptions { | ((request: Request) => MaybePromise); } -interface GatewayConfigBase> { +export interface GatewayConfigBase> { /** Usage reporting options. */ reporting?: GatewayHiveReportingOptions | GatewayGraphOSReportingOptions; /** Persisted documents options. */ diff --git a/packages/runtime/tests/propagateHeaders.spec.ts b/packages/runtime/tests/propagateHeaders.spec.ts index d486da7ff..191eb3184 100644 --- a/packages/runtime/tests/propagateHeaders.spec.ts +++ b/packages/runtime/tests/propagateHeaders.spec.ts @@ -1,41 +1,43 @@ +import { + createGatewayTester, + GatewayTesterRemoteSchemaConfig, +} from '@graphql-hive/gateway-testing'; import InMemoryLRUCache from '@graphql-mesh/cache-inmemory-lru'; -import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; import useHttpCache from '@graphql-mesh/plugin-http-cache'; -import { isDebug } from '@internal/testing'; -import { createSchema, createYoga, type Plugin } from 'graphql-yoga'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createGatewayRuntime } from '../src/createGatewayRuntime'; -import { useCustomFetch } from '../src/plugins/useCustomFetch'; +import { createSchema, type Plugin } from 'graphql-yoga'; +import { describe, expect, it, vi } from 'vitest'; describe('usePropagateHeaders', () => { describe('From Client to the Subgraphs', () => { - const requestTrackerPlugin = { - onParams: vi.fn((() => {}) as Plugin['onParams']), - }; - const upstream = createYoga({ - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello: String - } - `, - resolvers: { - Query: { - hello: () => 'world', + function prepare() { + const requestTrackerPlugin = { + onParams: vi.fn((() => {}) as Plugin['onParams']), + }; + return { + requestTrackerPlugin, + name: 'upstream', + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => 'world', + }, }, + }), + yoga: { + plugins: [requestTrackerPlugin], }, - }), - plugins: [requestTrackerPlugin], - logging: isDebug(), - }); - beforeEach(() => { - requestTrackerPlugin.onParams.mockClear(); - }); + }; + } + it('forwards specified headers', async () => { - await using gateway = createGatewayRuntime({ - proxy: { - endpoint: 'http://localhost:4001/graphql', - }, + const { requestTrackerPlugin, ...config } = prepare(); + await using gateway = createGatewayTester({ + proxy: config, propagateHeaders: { fromClientToSubgraphs({ request }) { return { @@ -44,13 +46,6 @@ describe('usePropagateHeaders', () => { }; }, }, - plugins: () => [ - useCustomFetch( - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - upstream.fetch, - ), - ], - logging: isDebug(), }); const response = await gateway.fetch('http://localhost:4000/graphql', { method: 'POST', @@ -91,71 +86,10 @@ describe('usePropagateHeaders', () => { expect(headersObj['x-my-other']).toBe('other-value'); expect(headersObj['x-extra-header']).toBeUndefined(); }); - it.skip("forwards specified headers but doesn't override the provided headers", async () => { - await using gateway = createGatewayRuntime({ - logging: isDebug(), - proxy: { - endpoint: 'http://localhost:4001/graphql', - headers: { - 'x-my-header': 'my-value', - 'x-extra-header': 'extra-value', - }, - }, - propagateHeaders: { - fromClientToSubgraphs({ request }) { - return { - 'x-my-header': request.headers.get('x-my-header')!, - 'x-my-other': request.headers.get('x-my-other')!, - }; - }, - }, - plugins: () => [ - useCustomFetch( - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - upstream.fetch, - ), - ], - }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-my-header': 'my-new-value', - 'x-my-other': 'other-value', - }, - body: JSON.stringify({ - query: /* GraphQL */ ` - query { - hello - } - `, - }), - }); - - const resJson = await response.json(); - expect(resJson).toEqual({ - data: { - hello: 'world', - }, - }); - - // The first call is for the introspection - expect(requestTrackerPlugin.onParams).toHaveBeenCalledTimes(2); - const onParamsPayload = requestTrackerPlugin.onParams.mock.calls[1]?.[0]!; - // Do not pass extensions - expect(onParamsPayload.params.extensions).toBeUndefined(); - const headersObj = Object.fromEntries( - onParamsPayload.request.headers.entries(), - ); - expect(headersObj['x-my-header']).toBe('my-value'); - expect(headersObj['x-extra-header']).toBe('extra-value'); - expect(headersObj['x-my-other']).toBe('other-value'); - }); it("won't forward empty headers", async () => { - await using gateway = createGatewayRuntime({ - proxy: { - endpoint: 'http://localhost:4001/graphql', - }, + const { requestTrackerPlugin, ...config } = prepare(); + await using gateway = createGatewayTester({ + proxy: config, propagateHeaders: { fromClientToSubgraphs({ request }) { return { @@ -163,20 +97,10 @@ describe('usePropagateHeaders', () => { }; }, }, - plugins: () => [ - useCustomFetch( - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - upstream.fetch, - ), - ], - logging: isDebug(), }); - const response = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ + + await expect( + gateway.execute({ query: /* GraphQL */ ` query { hello @@ -186,10 +110,7 @@ describe('usePropagateHeaders', () => { randomThing: 'randomValue', }, }), - }); - - const resJson = await response.json(); - expect(resJson).toEqual({ + ).resolves.toEqual({ data: { hello: 'world', }, @@ -206,72 +127,67 @@ describe('usePropagateHeaders', () => { expect(headersObj['x-empty-header']).toBeUndefined(); }); }); + describe('From Subgraphs to the Client', () => { - const upstream1 = createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello1: String - } - `, - resolvers: { - Query: { - hello1: () => 'world1', - }, - }, - }); - const upstream1Fetch = createYoga({ - schema: upstream1, - plugins: [ - { - onResponse: ({ response }) => { - response.headers.set('cache-control', 'max-age=60, private'); - response.headers.set('upstream1', 'upstream1'); - response.headers.append('set-cookie', 'cookie1=value1'); - response.headers.append('set-cookie', 'cookie2=value2'); + const subgraphs: GatewayTesterRemoteSchemaConfig[] = [ + { + name: 'upstream1', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello1: String + } + `, + resolvers: { + Query: { + hello1: () => 'world1', + }, }, }, - ], - }).fetch; - const upstream2 = createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello2: String - } - `, - resolvers: { - Query: { - hello2: () => 'world2', + yoga: { + plugins: [ + { + onResponse: ({ response }) => { + response.headers.set('cache-control', 'max-age=60, private'); + response.headers.set('upstream1', 'upstream1'); + response.headers.append('set-cookie', 'cookie1=value1'); + response.headers.append('set-cookie', 'cookie2=value2'); + }, + } as Plugin, + ], }, }, - }); - const upstream2Fetch = createYoga({ - schema: upstream2, - plugins: [ - { - onResponse: ({ response }) => { - response.headers.set('upstream2', 'upstream2'); - response.headers.append('set-cookie', 'cookie3=value3'); - response.headers.append('set-cookie', 'cookie4=value4'); + { + name: 'upstream2', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello2: String + } + `, + resolvers: { + Query: { + hello2: () => 'world2', + }, }, }, - ], - }).fetch; - it('Aggregates cookies from all subgraphs', async () => { - await using gateway = createGatewayRuntime({ - supergraph: () => { - return getUnifiedGraphGracefully([ + yoga: { + plugins: [ { - name: 'upstream1', - schema: upstream1, - url: 'http://localhost:4001/graphql', - }, - { - name: 'upstream2', - schema: upstream2, - url: 'http://localhost:4002/graphql', - }, - ]); + onResponse: ({ response }) => { + response.headers.set('upstream2', 'upstream2'); + response.headers.append('set-cookie', 'cookie3=value3'); + response.headers.append('set-cookie', 'cookie4=value4'); + }, + } as Plugin, + ], }, + }, + ]; + + it('Aggregates cookies from all subgraphs', async () => { + await using gateway = createGatewayTester({ + subgraphs, propagateHeaders: { fromSubgraphsToClient({ response }) { const cookies = response.headers.getSetCookie(); @@ -292,22 +208,8 @@ describe('usePropagateHeaders', () => { return returns; }, }, - plugins: () => [ - useCustomFetch((url, options, context, info) => { - switch (url) { - case 'http://localhost:4001/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream1Fetch(url, options, context, info); - case 'http://localhost:4002/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream2Fetch(url, options, context, info); - default: - throw new Error('Invalid URL'); - } - }), - ], - logging: isDebug(), }); + const response = await gateway.fetch('http://localhost:4000/graphql', { method: 'POST', headers: { @@ -323,8 +225,7 @@ describe('usePropagateHeaders', () => { }), }); - const resJson = await response.json(); - expect(resJson).toEqual({ + await expect(response.json()).resolves.toEqual({ data: { hello1: 'world1', hello2: 'world2', @@ -340,22 +241,9 @@ describe('usePropagateHeaders', () => { it('should propagate headers when caching upstream', async () => { await using cache = new InMemoryLRUCache(); - await using gateway = createGatewayRuntime({ + await using gateway = createGatewayTester({ + subgraphs, cache, - supergraph: () => { - return getUnifiedGraphGracefully([ - { - name: 'upstream1', - schema: upstream1, - url: 'http://localhost:4001/graphql', - }, - { - name: 'upstream2', - schema: upstream2, - url: 'http://localhost:4002/graphql', - }, - ]); - }, propagateHeaders: { fromSubgraphsToClient({ response }) { const cookies = response.headers.getSetCookie(); @@ -377,22 +265,7 @@ describe('usePropagateHeaders', () => { return returns; }, }, - plugins: (context) => [ - useHttpCache(context), - useCustomFetch((url, options, context, info) => { - switch (url) { - case 'http://localhost:4001/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream1Fetch(url, options, context, info); - case 'http://localhost:4002/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream2Fetch(url, options, context, info); - default: - throw new Error('Invalid URL'); - } - }), - ], - logging: isDebug(), + plugins: (context) => [useHttpCache(context)], }); for (let i = 0; i < 3; i++) { @@ -426,77 +299,65 @@ describe('usePropagateHeaders', () => { } }); it('should deduplicate non-cookie headers from multiple subgraphs when deduplicateHeaders is true', async () => { - const upstream1WithDuplicates = createYoga({ - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello1: String - } - `, - resolvers: { - Query: { - hello1: () => 'world1', - }, - }, - }), - plugins: [ + await using gateway = createGatewayTester({ + subgraphs: [ { - onResponse: ({ response }) => { - response.headers.set('x-shared-header', 'value-from-upstream1'); - response.headers.append('set-cookie', 'cookie1=value1'); + name: 'upstream1', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello1: String + } + `, + resolvers: { + Query: { + hello1: () => 'world1', + }, + }, }, - }, - ], - }).fetch; - const upstream2WithDuplicates = createYoga({ - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello2: String - } - `, - resolvers: { - Query: { - hello2: () => 'world2', + yoga: { + plugins: [ + { + onResponse: ({ response }) => { + response.headers.set( + 'x-shared-header', + 'value-from-upstream1', + ); + response.headers.append('set-cookie', 'cookie1=value1'); + }, + }, + ], }, }, - }), - plugins: [ { - onResponse: ({ response }) => { - response.headers.set('x-shared-header', 'value-from-upstream2'); - response.headers.append('set-cookie', 'cookie2=value2'); + name: 'upstream2', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello2: String + } + `, + resolvers: { + Query: { + hello2: () => 'world2', + }, + }, + }, + yoga: { + plugins: [ + { + onResponse: ({ response }) => { + response.headers.set( + 'x-shared-header', + 'value-from-upstream2', + ); + response.headers.append('set-cookie', 'cookie2=value2'); + }, + }, + ], }, }, ], - }).fetch; - await using gateway = createGatewayRuntime({ - supergraph: () => { - return getUnifiedGraphGracefully([ - { - name: 'upstream1', - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello1: String - } - `, - }), - url: 'http://localhost:4001/graphql', - }, - { - name: 'upstream2', - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello2: String - } - `, - }), - url: 'http://localhost:4002/graphql', - }, - ]); - }, propagateHeaders: { deduplicateHeaders: true, fromSubgraphsToClient({ response }) { @@ -514,21 +375,6 @@ describe('usePropagateHeaders', () => { return returns; }, }, - plugins: () => [ - useCustomFetch((url, options, context, info) => { - switch (url) { - case 'http://localhost:4001/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream1WithDuplicates(url, options, context, info); - case 'http://localhost:4002/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream2WithDuplicates(url, options, context, info); - default: - throw new Error('Invalid URL'); - } - }), - ], - logging: isDebug(), }); const response = await gateway.fetch('http://localhost:4000/graphql', { method: 'POST', @@ -564,77 +410,65 @@ describe('usePropagateHeaders', () => { ); }); it('should append all non-cookie headers from multiple subgraphs when deduplicateHeaders is false', async () => { - const upstream1WithDuplicates = createYoga({ - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello1: String - } - `, - resolvers: { - Query: { - hello1: () => 'world1', - }, - }, - }), - plugins: [ + await using gateway = createGatewayTester({ + subgraphs: [ { - onResponse: ({ response }) => { - response.headers.set('x-shared-header', 'value-from-upstream1'); - response.headers.append('set-cookie', 'cookie1=value1'); + name: 'upstream1', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello1: String + } + `, + resolvers: { + Query: { + hello1: () => 'world1', + }, + }, }, - }, - ], - }).fetch; - const upstream2WithDuplicates = createYoga({ - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello2: String - } - `, - resolvers: { - Query: { - hello2: () => 'world2', + yoga: { + plugins: [ + { + onResponse: ({ response }) => { + response.headers.set( + 'x-shared-header', + 'value-from-upstream1', + ); + response.headers.append('set-cookie', 'cookie1=value1'); + }, + }, + ], }, }, - }), - plugins: [ { - onResponse: ({ response }) => { - response.headers.set('x-shared-header', 'value-from-upstream2'); - response.headers.append('set-cookie', 'cookie2=value2'); + name: 'upstream2', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello2: String + } + `, + resolvers: { + Query: { + hello2: () => 'world2', + }, + }, + }, + yoga: { + plugins: [ + { + onResponse: ({ response }) => { + response.headers.set( + 'x-shared-header', + 'value-from-upstream2', + ); + response.headers.append('set-cookie', 'cookie2=value2'); + }, + }, + ], }, }, ], - }).fetch; - await using gateway = createGatewayRuntime({ - supergraph: () => { - return getUnifiedGraphGracefully([ - { - name: 'upstream1', - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello1: String - } - `, - }), - url: 'http://localhost:4001/graphql', - }, - { - name: 'upstream2', - schema: createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello2: String - } - `, - }), - url: 'http://localhost:4002/graphql', - }, - ]); - }, propagateHeaders: { deduplicateHeaders: false, fromSubgraphsToClient({ response }) { @@ -652,21 +486,6 @@ describe('usePropagateHeaders', () => { return returns; }, }, - plugins: () => [ - useCustomFetch((url, options, context, info) => { - switch (url) { - case 'http://localhost:4001/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream1WithDuplicates(url, options, context, info); - case 'http://localhost:4002/graphql': - // @ts-expect-error TODO: url can be a string, not only an instance of URL - return upstream2WithDuplicates(url, options, context, info); - default: - throw new Error('Invalid URL'); - } - }), - ], - logging: isDebug(), }); const response = await gateway.fetch('http://localhost:4000/graphql', { method: 'POST', diff --git a/packages/runtime/tests/subscriptions.test.ts b/packages/runtime/tests/subscriptions.test.ts index e0e0327b1..6044aff60 100644 --- a/packages/runtime/tests/subscriptions.test.ts +++ b/packages/runtime/tests/subscriptions.test.ts @@ -1,12 +1,9 @@ -import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; +import { createGatewayTester } from '@graphql-hive/gateway-testing'; import { type MaybePromise } from '@graphql-tools/utils'; -import { isDebug } from '@internal/testing'; import { DisposableSymbols } from '@whatwg-node/disposablestack'; import { createClient as createSSEClient } from 'graphql-sse'; -import { createSchema, createYoga, Repeater } from 'graphql-yoga'; +import { createSchema, Repeater } from 'graphql-yoga'; import { afterAll, describe, expect, it } from 'vitest'; -import { createGatewayRuntime } from '../src/createGatewayRuntime'; -import { useCustomFetch } from '../src/plugins/useCustomFetch'; describe('Subscriptions', () => { const leftovers: (() => MaybePromise)[] = []; @@ -35,25 +32,14 @@ describe('Subscriptions', () => { }, }, }); - const upstream = createYoga({ schema: upstreamSchema }); it('should terminate subscriptions gracefully on shutdown', async () => { - await using serve = createGatewayRuntime({ - logging: false, - supergraph() { - return getUnifiedGraphGracefully([ - { - name: 'upstream', - schema: upstreamSchema, - url: 'http://upstream/graphql', - }, - ]); - }, - plugins: () => [ - useCustomFetch( - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - upstream.fetch, - ), + await using serve = createGatewayTester({ + subgraphs: [ + { + name: 'upstream', + schema: upstreamSchema, + }, ], }); @@ -98,32 +84,31 @@ describe('Subscriptions', () => { it('should terminate subscriptions gracefully on schema update', async () => { let changeSchema = false; - await using serve = createGatewayRuntime({ - logging: isDebug(), + await using serve = createGatewayTester({ pollingInterval: 500, - async supergraph() { + subgraphs: () => { if (changeSchema) { - return /* GraphQL */ ` - type Query { - foo: String! - } - `; + return [ + { + name: 'upstream', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + foo: String! + } + `, + }, + }, + ]; } changeSchema = true; - return getUnifiedGraphGracefully([ + return [ { name: 'upstream', schema: upstreamSchema, - url: 'http://upstream/graphql', }, - ]); + ]; }, - plugins: () => [ - useCustomFetch( - // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch - upstream.fetch, - ), - ], }); const sse = createSSEClient({ @@ -141,20 +126,15 @@ describe('Subscriptions', () => { const msgs: unknown[] = []; globalThis.setTimeout(async () => { - const res = await serve.fetch('http://mesh/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + await expect( + serve.execute({ query: /* GraphQL */ ` query { __typename } `, }), - }); - expect(await res.json()).toMatchObject({ + ).resolves.toMatchObject({ data: { __typename: 'Query', }, diff --git a/packages/runtime/tests/upstream-timeout.test.ts b/packages/runtime/tests/upstream-timeout.test.ts index b613c7928..902ad44b3 100644 --- a/packages/runtime/tests/upstream-timeout.test.ts +++ b/packages/runtime/tests/upstream-timeout.test.ts @@ -1,9 +1,5 @@ -import { - createGatewayRuntime, - useCustomFetch, -} from '@graphql-hive/gateway-runtime'; +import { createGatewayTester } from '@graphql-hive/gateway-testing'; import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; -import { MeshFetch } from '@graphql-mesh/types'; import { createDeferred } from '@graphql-tools/utils'; import { createDisposableServer } from '@internal/testing'; import { createSchema, createYoga } from 'graphql-yoga'; @@ -12,32 +8,24 @@ import { describe, expect, it } from 'vitest'; describe('Upstream Timeout', () => { it('times out based on factory function', async () => { const greetingsDeferred = createDeferred(); - const upstreamSchema = createSchema({ - typeDefs: /* GraphQL */ ` - type Query { - hello: String - } - `, - resolvers: { - Query: { - hello: () => greetingsDeferred.promise, - }, - }, - }); - await using upstreamServer = createYoga({ - schema: upstreamSchema, - }); - await using gateway = createGatewayRuntime({ - supergraph: getUnifiedGraphGracefully([ + await using gateway = createGatewayTester({ + subgraphs: [ { name: 'upstream', - schema: upstreamSchema, - url: 'http://localhost:4001/graphql', + schema: { + typeDefs: /* GraphQL */ ` + type Query { + hello: String + } + `, + resolvers: { + Query: { + hello: () => greetingsDeferred.promise, + }, + }, + }, }, - ]), - plugins() { - return [useCustomFetch(upstreamServer.fetch as MeshFetch)]; - }, + ], upstreamTimeout({ subgraphName }) { if (subgraphName === 'upstream') { return 1000; @@ -45,21 +33,15 @@ describe('Upstream Timeout', () => { throw new Error('Unexpected subgraph'); }, }); - const res = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: /* GraphQL */ ` query { hello } `, }), - }); - const resJson = await res.json(); - expect(resJson).toEqual({ + ).resolves.toEqual({ data: { hello: null, }, @@ -91,7 +73,7 @@ describe('Upstream Timeout', () => { schema: upstreamSchema, }); await using upstreamHttpServer = await createDisposableServer(upstreamYoga); - await using gateway = createGatewayRuntime({ + await using gateway = createGatewayTester({ supergraph: getUnifiedGraphGracefully([ { name: 'upstream', @@ -101,21 +83,15 @@ describe('Upstream Timeout', () => { ]), upstreamTimeout: 10_000, }); - const res = await gateway.fetch('http://localhost:4000/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + await expect( + gateway.execute({ query: /* GraphQL */ ` query { hello } `, }), - }); - const resJson = await res.json(); - expect(resJson).toEqual({ + ).resolves.toEqual({ data: { hello: 'Hello, World!', }, diff --git a/packages/testing/package.json b/packages/testing/package.json new file mode 100644 index 000000000..0ce33db12 --- /dev/null +++ b/packages/testing/package.json @@ -0,0 +1,57 @@ +{ + "name": "@graphql-hive/gateway-testing", + "version": "0.0.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/graphql-hive/gateway.git", + "directory": "packages/gateway-testing" + }, + "author": { + "email": "contact@the-guild.dev", + "name": "The Guild", + "url": "https://the-guild.dev" + }, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "pkgroll --clean-dist", + "prepack": "yarn build" + }, + "peerDependencies": { + "@graphql-hive/gateway-runtime": "workspace:^", + "graphql": "^15.9.0 || ^16.9.0" + }, + "dependencies": { + "@apollo/subgraph": "^2.11.3", + "@graphql-mesh/fusion-composition": "^0.8.17", + "@graphql-tools/executor-http": "workspace:^", + "@graphql-tools/utils": "^10.9.1", + "graphql-yoga": "^5.16.0" + }, + "devDependencies": { + "graphql": "^16.9.0", + "pkgroll": "2.20.1" + }, + "sideEffects": false +} diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts new file mode 100644 index 000000000..745b99809 --- /dev/null +++ b/packages/testing/src/index.ts @@ -0,0 +1,249 @@ +import { buildSubgraphSchema } from '@apollo/subgraph'; +import { + createGatewayRuntime, + GatewayConfigBase, + GatewayConfigSupergraph, + GatewayPlugin, + GatewayRuntime, + UnifiedGraphConfig, + useCustomFetch, +} from '@graphql-hive/gateway-runtime'; +import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; +import { buildHTTPExecutor } from '@graphql-tools/executor-http'; +import type { ExecutionResult, MaybeAsyncIterable } from '@graphql-tools/utils'; +import { isDebug } from '~internal/env'; +import { + GraphQLFieldResolver, + GraphQLScalarType, + parse, + type GraphQLSchema, +} from 'graphql'; +import { + createYoga, + DisposableSymbols, + YogaServerOptions, + type YogaServerInstance, +} from 'graphql-yoga'; + +/** Thanks @apollo/subgraph for not re-exporting this! */ +export interface GraphQLResolverMap { + [typeName: string]: + | { + [fieldName: string]: + | GraphQLFieldResolver + | { + requires?: string; + resolve?: GraphQLFieldResolver; + subscribe?: GraphQLFieldResolver; + }; + } + | GraphQLScalarType + | { + [enumValue: string]: string | number; + }; +} + +export type GatewayTesterRemoteSchemaConfigYoga = + | Exclude, 'schema'> + | ((schema: GraphQLSchema) => YogaServerInstance); + +export interface GatewayTesterRemoteSchemaConfig { + /** The name of the remote schema / subgraph / proxied server. */ + name: string; + /** The remote schema. */ + schema: GraphQLSchema | { typeDefs: string; resolvers?: GraphQLResolverMap }; + /** The hostname of the remote schema. URL will become `http://${host}${yoga.graphqlEndpoint}`. */ + host?: string; + /** An optional GraphQL Yoga server instance that runs the {@link schema built schema}. */ + yoga?: GatewayTesterRemoteSchemaConfigYoga; +} + +export type GatewayTesterConfig< + TContext extends Record = Record, +> = + | ({ + // gateway + supergraph: UnifiedGraphConfig; + } & Omit, 'supergraph'>) + | ({ + // gateway (composes subgraphs) + subgraphs: + | GatewayTesterRemoteSchemaConfig[] + | (() => GatewayTesterRemoteSchemaConfig[]); + } & Omit, 'supergraph'>) + | ({ + // proxy + proxy: GatewayTesterRemoteSchemaConfig & { + /** Additional headers to be sent to the remote schema on every request. */ + headers?: Record; + }; + } & GatewayConfigBase); +// TODO: subgraph mode + +export interface GatewayTester< + TContext extends Record = Record, +> extends AsyncDisposable { + runtime: GatewayRuntime; + fetch: typeof fetch; + execute(args: { + query: string; + variables?: Record; + operationName?: string; + extensions?: Record; + headers?: Record; + }): Promise>>; + dispose(): Promise; +} + +export function createGatewayTester< + TContext extends Record = Record, +>(config: GatewayTesterConfig): GatewayTester { + let runtime: GatewayRuntime; + if ('supergraph' in config) { + // use supergraph + runtime = createGatewayRuntime({ + maskedErrors: false, + logging: isDebug(), + ...config, + }); + } else if ('subgraphs' in config) { + // compose subgraphs + const subgraphsConfig = config.subgraphs; + function buildSubgraphs() { + subgraphsRef.ref = ( + typeof subgraphsConfig === 'function' + ? subgraphsConfig() + : subgraphsConfig + ).reduce( + (acc, subgraph) => { + const remoteSchema = buildRemoteSchema(subgraph); + return { + ...acc, + [remoteSchema.name]: remoteSchema, + }; + }, + {} as { [subgraphName: string]: GatewayTesterRemoteSchema }, + ); + return Object.values(subgraphsRef.ref); + } + const subgraphsRef = { + ref: null as Record | null, + }; + runtime = createGatewayRuntime({ + maskedErrors: false, + logging: isDebug(), + ...config, + supergraph: + typeof config.subgraphs === 'function' + ? () => getUnifiedGraphGracefully(buildSubgraphs()) + : getUnifiedGraphGracefully(buildSubgraphs()), + plugins: (ctx) => [ + { + onFetch({ executionRequest, setFetchFn }) { + const subgraphName = executionRequest?.subgraphName; + if (!subgraphName) { + return; + } + if (!subgraphsRef.ref) { + throw new Error('Subgraphs are not built yet'); + } + const subgraph = subgraphsRef.ref[subgraphName]; + if (!subgraph) { + throw new Error(`Subgraph "${subgraphName}" not found`); + } + setFetchFn(subgraph.yoga.fetch); + }, + } as GatewayPlugin, + ...(config.plugins?.(ctx) || []), + ], + }); + } else if ('proxy' in config) { + // build remote schema and proxy + const remoteSchema = buildRemoteSchema(config.proxy); + runtime = createGatewayRuntime({ + maskedErrors: false, + logging: isDebug(), + ...config, + proxy: { endpoint: remoteSchema.url, headers: config.proxy.headers }, + plugins: (ctx) => [ + useCustomFetch((url, options, context, info) => { + return remoteSchema.yoga!.fetch( + // @ts-expect-error TODO: url can be a string, not only an instance of URL + url, + options, + context, + info, + ); + }), + ...(config.plugins?.(ctx) || []), + ], + }); + } else { + throw new Error('Unsupported gateway tester configuration'); + } + + const runtimeExecute = buildHTTPExecutor({ + endpoint: 'http://gateway/graphql', + fetch: runtime.fetch, + headers: (execReq) => execReq?.rootValue.headers, + }); + + return { + runtime, + // @ts-expect-error native and whatwg-node fetch has conflicts + fetch: runtime.fetch, + async execute(args) { + return runtimeExecute({ + document: parse(args.query), + variables: args.variables, + operationName: args.operationName, + extensions: args.extensions, + rootValue: { headers: args.headers }, + }); + }, + [DisposableSymbols.asyncDispose]() { + return runtime[DisposableSymbols.asyncDispose](); + }, + async dispose() { + await runtime.dispose(); + }, + }; +} + +interface GatewayTesterRemoteSchema { + name: string; + url: string; + schema: GraphQLSchema; + yoga: YogaServerInstance; +} + +function buildRemoteSchema( + config: GatewayTesterRemoteSchemaConfig, +): GatewayTesterRemoteSchema { + const schema = + 'typeDefs' in config.schema + ? buildSubgraphSchema([ + { + ...config.schema, + typeDefs: parse(config.schema.typeDefs), + }, + ]) + : config.schema; + const yoga = + typeof config.yoga === 'function' + ? config.yoga?.(schema) + : createYoga({ + maskedErrors: false, + logging: isDebug(), + ...config.yoga, + schema, + }); + const host = config.host || config.name; + const url = `http://${host}${yoga.graphqlEndpoint}`; + return { + name: config.name, + url, + schema, + yoga, + }; +} diff --git a/tsconfig.json b/tsconfig.json index c8a611862..5628a701b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,6 +38,7 @@ "./packages/gateway/src/opentelemetry/setup.ts" ], "@graphql-hive/gateway-runtime": ["./packages/runtime/src/index.ts"], + "@graphql-hive/gateway-testing": ["./packages/testing/src/index.ts"], "@graphql-mesh/fusion-runtime": [ "./packages/fusion-runtime/src/index.ts" ], diff --git a/yarn.lock b/yarn.lock index 7b41d38af..8580b8891 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4102,6 +4102,23 @@ __metadata: languageName: unknown linkType: soft +"@graphql-hive/gateway-testing@workspace:packages/testing": + version: 0.0.0-use.local + resolution: "@graphql-hive/gateway-testing@workspace:packages/testing" + dependencies: + "@apollo/subgraph": "npm:^2.11.3" + "@graphql-mesh/fusion-composition": "npm:^0.8.17" + "@graphql-tools/executor-http": "workspace:^" + "@graphql-tools/utils": "npm:^10.9.1" + graphql: "npm:^16.9.0" + graphql-yoga: "npm:^5.16.0" + pkgroll: "npm:2.20.1" + peerDependencies: + "@graphql-hive/gateway-runtime": "workspace:^" + graphql: ^15.9.0 || ^16.9.0 + languageName: unknown + linkType: soft + "@graphql-hive/gateway@workspace:*, @graphql-hive/gateway@workspace:^, @graphql-hive/gateway@workspace:packages/gateway": version: 0.0.0-use.local resolution: "@graphql-hive/gateway@workspace:packages/gateway"