@@ -425,5 +425,281 @@ describe('usePropagateHeaders', () => {
425
425
) ;
426
426
}
427
427
} ) ;
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
+ } ) ;
428
704
} ) ;
429
705
} ) ;
0 commit comments