diff --git a/.changeset/funny-phones-grab.md b/.changeset/funny-phones-grab.md new file mode 100644 index 000000000..e6ae86d64 --- /dev/null +++ b/.changeset/funny-phones-grab.md @@ -0,0 +1,21 @@ +--- +'@graphql-hive/gateway-runtime': patch +--- + +Introduce `deduplicateHeaders` option for `propagateHeaders` configuration to control header handling behavior when multiple subgraphs return the same header + +When `deduplicateHeaders` is enabled (set to `true`), only the last value from subgraphs will be set for each header. When disabled (default `false`), all values are appended. + +The `set-cookie` header is always appended regardless of this setting, as per HTTP standards. + +```ts +import { defineConfig } from '@graphql-hive/gateway' +export const gatewayConfig = defineConfig({ + propagateHeaders: { + deduplicateHeaders: true, // default: false + fromSubgraphsToClient({ response }) { + // ... + } + } +}) +``` diff --git a/packages/runtime/src/plugins/usePropagateHeaders.ts b/packages/runtime/src/plugins/usePropagateHeaders.ts index c98b8a795..0864ef98f 100644 --- a/packages/runtime/src/plugins/usePropagateHeaders.ts +++ b/packages/runtime/src/plugins/usePropagateHeaders.ts @@ -14,6 +14,16 @@ interface FromSubgraphsToClientPayload> { } export interface PropagateHeadersOpts> { + /** + * When multiple subgraphs send the same header, should they be deduplicated? + * If so, the last subgraphs's header will be _set_ and propagated; otherwise, + * all headers will _appended_ and propagated. + * + * The only exception is `set-cookie`, which is always appended. + * + * @default false + */ + deduplicateHeaders?: boolean; fromClientToSubgraphs?: ( payload: FromClientToSubgraphsPayload, ) => @@ -118,7 +128,15 @@ export function usePropagateHeaders>( const value = headers[key]; if (value) { for (const v of value) { - response.headers.append(key, v); + if ( + !opts.deduplicateHeaders || + key === 'set-cookie' // only set-cookie allows duplicated headers + ) { + response.headers.append(key, v); + } else { + // deduplicate headers active + response.headers.set(key, v); + } } } } diff --git a/packages/runtime/tests/propagateHeaders.spec.ts b/packages/runtime/tests/propagateHeaders.spec.ts index 1ec00e32a..d486da7ff 100644 --- a/packages/runtime/tests/propagateHeaders.spec.ts +++ b/packages/runtime/tests/propagateHeaders.spec.ts @@ -425,5 +425,281 @@ 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: [ + { + onResponse: ({ response }) => { + response.headers.set('x-shared-header', 'value-from-upstream1'); + response.headers.append('set-cookie', 'cookie1=value1'); + }, + }, + ], + }).fetch; + const upstream2WithDuplicates = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello2: String + } + `, + resolvers: { + Query: { + hello2: () => 'world2', + }, + }, + }), + 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 }) { + const cookies = response.headers.getSetCookie(); + const sharedHeader = response.headers.get('x-shared-header'); + + const returns: Record = { + 'set-cookie': cookies, + }; + + if (sharedHeader) { + returns['x-shared-header'] = sharedHeader; + } + + 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', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query { + hello1 + hello2 + } + `, + }), + }); + + const resJson = await response.json(); + expect(resJson).toEqual({ + data: { + hello1: 'world1', + hello2: 'world2', + }, + }); + + // Non-cookie headers should be deduplicated (only the last value is kept) + expect(response.headers.get('x-shared-header')).toBe( + 'value-from-upstream2', + ); + + // set-cookie headers should still be aggregated (not deduplicated) + expect(response.headers.get('set-cookie')).toBe( + 'cookie1=value1, cookie2=value2', + ); + }); + 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: [ + { + onResponse: ({ response }) => { + response.headers.set('x-shared-header', 'value-from-upstream1'); + response.headers.append('set-cookie', 'cookie1=value1'); + }, + }, + ], + }).fetch; + const upstream2WithDuplicates = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello2: String + } + `, + resolvers: { + Query: { + hello2: () => 'world2', + }, + }, + }), + 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 }) { + const cookies = response.headers.getSetCookie(); + const sharedHeader = response.headers.get('x-shared-header'); + + const returns: Record = { + 'set-cookie': cookies, + }; + + if (sharedHeader) { + returns['x-shared-header'] = sharedHeader; + } + + 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', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query { + hello1 + hello2 + } + `, + }), + }); + + const resJson = await response.json(); + expect(resJson).toEqual({ + data: { + hello1: 'world1', + hello2: 'world2', + }, + }); + + // Non-cookie headers should NOT be deduplicated (all values are appended) + expect(response.headers.get('x-shared-header')).toBe( + 'value-from-upstream1, value-from-upstream2', + ); + + // set-cookie headers should be aggregated as usual + expect(response.headers.get('set-cookie')).toBe( + 'cookie1=value1, cookie2=value2', + ); + }); }); });