Skip to content

Commit 8c4138d

Browse files
zoontekenisdenjo
andauthored
Avoid usePropagateHeaders potential header duplication (#1563)
Co-authored-by: Denis Badurina <[email protected]>
1 parent fd4856d commit 8c4138d

File tree

3 files changed

+316
-1
lines changed

3 files changed

+316
-1
lines changed

.changeset/funny-phones-grab.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@graphql-hive/gateway-runtime': patch
3+
---
4+
5+
Introduce `deduplicateHeaders` option for `propagateHeaders` configuration to control header handling behavior when multiple subgraphs return the same header
6+
7+
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.
8+
9+
The `set-cookie` header is always appended regardless of this setting, as per HTTP standards.
10+
11+
```ts
12+
import { defineConfig } from '@graphql-hive/gateway'
13+
export const gatewayConfig = defineConfig({
14+
propagateHeaders: {
15+
deduplicateHeaders: true, // default: false
16+
fromSubgraphsToClient({ response }) {
17+
// ...
18+
}
19+
}
20+
})
21+
```

packages/runtime/src/plugins/usePropagateHeaders.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ interface FromSubgraphsToClientPayload<TContext extends Record<string, any>> {
1414
}
1515

1616
export interface PropagateHeadersOpts<TContext extends Record<string, any>> {
17+
/**
18+
* When multiple subgraphs send the same header, should they be deduplicated?
19+
* If so, the last subgraphs's header will be _set_ and propagated; otherwise,
20+
* all headers will _appended_ and propagated.
21+
*
22+
* The only exception is `set-cookie`, which is always appended.
23+
*
24+
* @default false
25+
*/
26+
deduplicateHeaders?: boolean;
1727
fromClientToSubgraphs?: (
1828
payload: FromClientToSubgraphsPayload<TContext>,
1929
) =>
@@ -118,7 +128,15 @@ export function usePropagateHeaders<TContext extends Record<string, any>>(
118128
const value = headers[key];
119129
if (value) {
120130
for (const v of value) {
121-
response.headers.append(key, v);
131+
if (
132+
!opts.deduplicateHeaders ||
133+
key === 'set-cookie' // only set-cookie allows duplicated headers
134+
) {
135+
response.headers.append(key, v);
136+
} else {
137+
// deduplicate headers active
138+
response.headers.set(key, v);
139+
}
122140
}
123141
}
124142
}

packages/runtime/tests/propagateHeaders.spec.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,5 +425,281 @@ describe('usePropagateHeaders', () => {
425425
);
426426
}
427427
});
428+
it('should deduplicate non-cookie headers from multiple subgraphs when deduplicateHeaders is true', async () => {
429+
const upstream1WithDuplicates = createYoga({
430+
schema: createSchema({
431+
typeDefs: /* GraphQL */ `
432+
type Query {
433+
hello1: String
434+
}
435+
`,
436+
resolvers: {
437+
Query: {
438+
hello1: () => 'world1',
439+
},
440+
},
441+
}),
442+
plugins: [
443+
{
444+
onResponse: ({ response }) => {
445+
response.headers.set('x-shared-header', 'value-from-upstream1');
446+
response.headers.append('set-cookie', 'cookie1=value1');
447+
},
448+
},
449+
],
450+
}).fetch;
451+
const upstream2WithDuplicates = createYoga({
452+
schema: createSchema({
453+
typeDefs: /* GraphQL */ `
454+
type Query {
455+
hello2: String
456+
}
457+
`,
458+
resolvers: {
459+
Query: {
460+
hello2: () => 'world2',
461+
},
462+
},
463+
}),
464+
plugins: [
465+
{
466+
onResponse: ({ response }) => {
467+
response.headers.set('x-shared-header', 'value-from-upstream2');
468+
response.headers.append('set-cookie', 'cookie2=value2');
469+
},
470+
},
471+
],
472+
}).fetch;
473+
await using gateway = createGatewayRuntime({
474+
supergraph: () => {
475+
return getUnifiedGraphGracefully([
476+
{
477+
name: 'upstream1',
478+
schema: createSchema({
479+
typeDefs: /* GraphQL */ `
480+
type Query {
481+
hello1: String
482+
}
483+
`,
484+
}),
485+
url: 'http://localhost:4001/graphql',
486+
},
487+
{
488+
name: 'upstream2',
489+
schema: createSchema({
490+
typeDefs: /* GraphQL */ `
491+
type Query {
492+
hello2: String
493+
}
494+
`,
495+
}),
496+
url: 'http://localhost:4002/graphql',
497+
},
498+
]);
499+
},
500+
propagateHeaders: {
501+
deduplicateHeaders: true,
502+
fromSubgraphsToClient({ response }) {
503+
const cookies = response.headers.getSetCookie();
504+
const sharedHeader = response.headers.get('x-shared-header');
505+
506+
const returns: Record<string, string | string[]> = {
507+
'set-cookie': cookies,
508+
};
509+
510+
if (sharedHeader) {
511+
returns['x-shared-header'] = sharedHeader;
512+
}
513+
514+
return returns;
515+
},
516+
},
517+
plugins: () => [
518+
useCustomFetch((url, options, context, info) => {
519+
switch (url) {
520+
case 'http://localhost:4001/graphql':
521+
// @ts-expect-error TODO: url can be a string, not only an instance of URL
522+
return upstream1WithDuplicates(url, options, context, info);
523+
case 'http://localhost:4002/graphql':
524+
// @ts-expect-error TODO: url can be a string, not only an instance of URL
525+
return upstream2WithDuplicates(url, options, context, info);
526+
default:
527+
throw new Error('Invalid URL');
528+
}
529+
}),
530+
],
531+
logging: isDebug(),
532+
});
533+
const response = await gateway.fetch('http://localhost:4000/graphql', {
534+
method: 'POST',
535+
headers: {
536+
'Content-Type': 'application/json',
537+
},
538+
body: JSON.stringify({
539+
query: /* GraphQL */ `
540+
query {
541+
hello1
542+
hello2
543+
}
544+
`,
545+
}),
546+
});
547+
548+
const resJson = await response.json();
549+
expect(resJson).toEqual({
550+
data: {
551+
hello1: 'world1',
552+
hello2: 'world2',
553+
},
554+
});
555+
556+
// Non-cookie headers should be deduplicated (only the last value is kept)
557+
expect(response.headers.get('x-shared-header')).toBe(
558+
'value-from-upstream2',
559+
);
560+
561+
// set-cookie headers should still be aggregated (not deduplicated)
562+
expect(response.headers.get('set-cookie')).toBe(
563+
'cookie1=value1, cookie2=value2',
564+
);
565+
});
566+
it('should append all non-cookie headers from multiple subgraphs when deduplicateHeaders is false', async () => {
567+
const upstream1WithDuplicates = createYoga({
568+
schema: createSchema({
569+
typeDefs: /* GraphQL */ `
570+
type Query {
571+
hello1: String
572+
}
573+
`,
574+
resolvers: {
575+
Query: {
576+
hello1: () => 'world1',
577+
},
578+
},
579+
}),
580+
plugins: [
581+
{
582+
onResponse: ({ response }) => {
583+
response.headers.set('x-shared-header', 'value-from-upstream1');
584+
response.headers.append('set-cookie', 'cookie1=value1');
585+
},
586+
},
587+
],
588+
}).fetch;
589+
const upstream2WithDuplicates = createYoga({
590+
schema: createSchema({
591+
typeDefs: /* GraphQL */ `
592+
type Query {
593+
hello2: String
594+
}
595+
`,
596+
resolvers: {
597+
Query: {
598+
hello2: () => 'world2',
599+
},
600+
},
601+
}),
602+
plugins: [
603+
{
604+
onResponse: ({ response }) => {
605+
response.headers.set('x-shared-header', 'value-from-upstream2');
606+
response.headers.append('set-cookie', 'cookie2=value2');
607+
},
608+
},
609+
],
610+
}).fetch;
611+
await using gateway = createGatewayRuntime({
612+
supergraph: () => {
613+
return getUnifiedGraphGracefully([
614+
{
615+
name: 'upstream1',
616+
schema: createSchema({
617+
typeDefs: /* GraphQL */ `
618+
type Query {
619+
hello1: String
620+
}
621+
`,
622+
}),
623+
url: 'http://localhost:4001/graphql',
624+
},
625+
{
626+
name: 'upstream2',
627+
schema: createSchema({
628+
typeDefs: /* GraphQL */ `
629+
type Query {
630+
hello2: String
631+
}
632+
`,
633+
}),
634+
url: 'http://localhost:4002/graphql',
635+
},
636+
]);
637+
},
638+
propagateHeaders: {
639+
deduplicateHeaders: false,
640+
fromSubgraphsToClient({ response }) {
641+
const cookies = response.headers.getSetCookie();
642+
const sharedHeader = response.headers.get('x-shared-header');
643+
644+
const returns: Record<string, string | string[]> = {
645+
'set-cookie': cookies,
646+
};
647+
648+
if (sharedHeader) {
649+
returns['x-shared-header'] = sharedHeader;
650+
}
651+
652+
return returns;
653+
},
654+
},
655+
plugins: () => [
656+
useCustomFetch((url, options, context, info) => {
657+
switch (url) {
658+
case 'http://localhost:4001/graphql':
659+
// @ts-expect-error TODO: url can be a string, not only an instance of URL
660+
return upstream1WithDuplicates(url, options, context, info);
661+
case 'http://localhost:4002/graphql':
662+
// @ts-expect-error TODO: url can be a string, not only an instance of URL
663+
return upstream2WithDuplicates(url, options, context, info);
664+
default:
665+
throw new Error('Invalid URL');
666+
}
667+
}),
668+
],
669+
logging: isDebug(),
670+
});
671+
const response = await gateway.fetch('http://localhost:4000/graphql', {
672+
method: 'POST',
673+
headers: {
674+
'Content-Type': 'application/json',
675+
},
676+
body: JSON.stringify({
677+
query: /* GraphQL */ `
678+
query {
679+
hello1
680+
hello2
681+
}
682+
`,
683+
}),
684+
});
685+
686+
const resJson = await response.json();
687+
expect(resJson).toEqual({
688+
data: {
689+
hello1: 'world1',
690+
hello2: 'world2',
691+
},
692+
});
693+
694+
// Non-cookie headers should NOT be deduplicated (all values are appended)
695+
expect(response.headers.get('x-shared-header')).toBe(
696+
'value-from-upstream1, value-from-upstream2',
697+
);
698+
699+
// set-cookie headers should be aggregated as usual
700+
expect(response.headers.get('set-cookie')).toBe(
701+
'cookie1=value1, cookie2=value2',
702+
);
703+
});
428704
});
429705
});

0 commit comments

Comments
 (0)