Skip to content

Commit ce28f67

Browse files
committed
Progressive Override
1 parent 49fbd81 commit ce28f67

22 files changed

+877
-14
lines changed

.changeset/cool-wolves-sort.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@graphql-mesh/fusion-runtime': minor
3+
'@graphql-tools/federation': minor
4+
'@graphql-tools/delegate': minor
5+
'@graphql-hive/gateway-runtime': minor
6+
'@graphql-tools/stitch': minor
7+
---
8+
9+
Progressive Override Implementation
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig, GatewayContext } from '@graphql-hive/gateway';
2+
3+
export const gatewayConfig = defineConfig({
4+
progressiveOverride(label, context: GatewayContext) {
5+
if (
6+
label === 'use_inventory_service' &&
7+
context.request.headers.get('x-use-inventory-service') === 'true'
8+
) {
9+
return true;
10+
}
11+
return false;
12+
},
13+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "@e2e/progressive-override-example",
3+
"private": true,
4+
"dependencies": {
5+
"@apollo/subgraph": "2.11.3",
6+
"graphql": "16.11.0",
7+
"graphql-yoga": "5.16.0",
8+
"tslib": "^2.8.1"
9+
}
10+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { createTenv } from '@internal/e2e';
2+
import { describe, expect, it } from 'vitest';
3+
4+
const { service, gateway } = createTenv(__dirname);
5+
describe('Progressive Override E2E', async () => {
6+
it('overrides products if the header exists', async () => {
7+
const gw = await gateway({
8+
supergraph: {
9+
with: 'apollo',
10+
services: [
11+
await service('inventory'),
12+
await service('products'),
13+
await service('reviews'),
14+
],
15+
},
16+
});
17+
const result = await gw.execute({
18+
query: /* GraphQL */ `
19+
query {
20+
reviews {
21+
id
22+
product {
23+
id
24+
name
25+
inStock
26+
count
27+
}
28+
}
29+
}
30+
`,
31+
headers: {
32+
'x-use-inventory-service': 'true',
33+
},
34+
});
35+
expect(result).toEqual({
36+
data: {
37+
reviews: [
38+
{
39+
id: '1',
40+
product: {
41+
id: '101',
42+
name: 'Product 101',
43+
inStock: true,
44+
count: 42,
45+
},
46+
},
47+
{
48+
id: '2',
49+
product: {
50+
id: '102',
51+
name: 'Product 102',
52+
inStock: true,
53+
count: 42,
54+
},
55+
},
56+
],
57+
},
58+
});
59+
});
60+
it('does not override products if the header does not exist', async () => {
61+
const gw = await gateway({
62+
pipeLogs: 'gw.log',
63+
supergraph: {
64+
with: 'apollo',
65+
services: [
66+
await service('inventory'),
67+
await service('products'),
68+
await service('reviews'),
69+
],
70+
},
71+
});
72+
const result = await gw.execute({
73+
query: /* GraphQL */ `
74+
query {
75+
reviews {
76+
id
77+
product {
78+
id
79+
name
80+
inStock
81+
count
82+
}
83+
}
84+
}
85+
`,
86+
});
87+
expect(result).toEqual({
88+
data: {
89+
reviews: [
90+
{
91+
id: '1',
92+
product: {
93+
id: '101',
94+
name: 'Product 101',
95+
inStock: false,
96+
count: 42,
97+
},
98+
},
99+
{
100+
id: '2',
101+
product: {
102+
id: '102',
103+
name: 'Product 102',
104+
inStock: false,
105+
count: 42,
106+
},
107+
},
108+
],
109+
},
110+
});
111+
});
112+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createServer } from 'http';
2+
import { buildSubgraphSchema } from '@apollo/subgraph';
3+
import { Opts } from '@internal/testing';
4+
import { parse } from 'graphql';
5+
import { createYoga } from 'graphql-yoga';
6+
7+
const port = Opts(process.argv).getServicePort('inventory');
8+
9+
createServer(
10+
createYoga({
11+
schema: buildSubgraphSchema({
12+
typeDefs: parse(/* GraphQL */ `
13+
type Product @key(fields: "id") {
14+
id: ID!
15+
inStock: Boolean
16+
@override(from: "products", label: "use_inventory_service")
17+
count: Int
18+
}
19+
20+
extend schema
21+
@link(
22+
url: "https://specs.apollo.dev/federation/v2.7"
23+
import: ["@key", "@override"]
24+
)
25+
`),
26+
resolvers: {
27+
Product: {
28+
__resolveReference(reference: { id: string }) {
29+
return {
30+
id: reference.id,
31+
};
32+
},
33+
inStock: () => true,
34+
count: () => 42,
35+
},
36+
},
37+
}),
38+
}),
39+
).listen(port);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createServer } from 'http';
2+
import { buildSubgraphSchema } from '@apollo/subgraph';
3+
import { Opts } from '@internal/testing';
4+
import { parse } from 'graphql';
5+
import { createYoga } from 'graphql-yoga';
6+
7+
const port = Opts(process.argv).getServicePort('products');
8+
9+
createServer(
10+
createYoga({
11+
schema: buildSubgraphSchema({
12+
typeDefs: parse(/* GraphQL */ `
13+
type Product @key(fields: "id") {
14+
id: ID!
15+
name: String
16+
price: Float
17+
inStock: Boolean
18+
}
19+
20+
type Query {
21+
product(id: ID!): Product
22+
}
23+
`),
24+
resolvers: {
25+
Product: {
26+
__resolveReference(reference: { id: string }) {
27+
return {
28+
id: reference.id,
29+
};
30+
},
31+
name: (parent: { id: string }) => `Product ${parent.id}`,
32+
price: () => 9.99,
33+
inStock: () => false,
34+
},
35+
Query: {
36+
product(_: any, args: { id: string }) {
37+
return { id: args.id };
38+
},
39+
},
40+
},
41+
}),
42+
}),
43+
).listen(port);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createServer } from 'http';
2+
import { buildSubgraphSchema } from '@apollo/subgraph';
3+
import { Opts } from '@internal/testing';
4+
import { parse } from 'graphql';
5+
import { createYoga } from 'graphql-yoga';
6+
7+
const port = Opts(process.argv).getServicePort('reviews');
8+
9+
createServer(
10+
createYoga({
11+
schema: buildSubgraphSchema({
12+
typeDefs: parse(/* GraphQL */ `
13+
type Review @key(fields: "id") {
14+
id: ID!
15+
content: String
16+
product: Product
17+
}
18+
19+
type Query {
20+
reviews: [Review]
21+
}
22+
23+
type Product @key(fields: "id") {
24+
id: ID!
25+
}
26+
`),
27+
resolvers: {
28+
Query: {
29+
reviews() {
30+
return [
31+
{ id: '1', content: 'Great product!', product: { id: '101' } },
32+
{ id: '2', content: 'Not bad', product: { id: '102' } },
33+
];
34+
},
35+
},
36+
},
37+
}),
38+
}),
39+
).listen(port);

packages/delegate/src/finalizeGatewayRequest.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
} from 'graphql';
3737
import { getDocumentMetadata } from './getDocumentMetadata.js';
3838
import { getTypeInfo, getTypeInfoWithType } from './getTypeInfo.js';
39+
import { handleOverrideByDelegation } from './handleOverrideByDelegation.js';
3940
import { Subschema } from './Subschema.js';
4041
import { DelegationContext, StitchingInfo } from './types.js';
4142
import {
@@ -82,6 +83,7 @@ function finalizeGatewayDocument<TContext>(
8283
validFragmentsWithType,
8384
operation.selectionSet,
8485
onOverlappingAliases,
86+
delegationContext,
8587
);
8688

8789
usedFragments = union(usedFragments, operationUsedFragments);
@@ -97,6 +99,7 @@ function finalizeGatewayDocument<TContext>(
9799
validFragmentsWithType,
98100
usedFragments,
99101
onOverlappingAliases,
102+
delegationContext,
100103
);
101104
const operationOrFragmentVariables = union(
102105
operationUsedVariables,
@@ -393,6 +396,7 @@ function collectFragmentVariables(
393396
validFragmentsWithType: { [name: string]: GraphQLType },
394397
usedFragments: Array<string>,
395398
onOverlappingAliases: () => void,
399+
delegationContext: DelegationContext<any>,
396400
) {
397401
let remainingFragments = usedFragments.slice();
398402

@@ -423,6 +427,7 @@ function collectFragmentVariables(
423427
validFragmentsWithType,
424428
fragment.selectionSet,
425429
onOverlappingAliases,
430+
delegationContext,
426431
);
427432
remainingFragments = union(remainingFragments, fragmentUsedFragments);
428433
usedVariables = union(usedVariables, fragmentUsedVariables);
@@ -477,6 +482,7 @@ function finalizeSelectionSet(
477482
validFragments: { [name: string]: GraphQLType },
478483
selectionSet: SelectionSetNode,
479484
onOverlappingAliases: () => void,
485+
delegationContext: DelegationContext<any>,
480486
) {
481487
const usedFragments: Array<string> = [];
482488
const usedVariables: Array<string> = [];
@@ -494,6 +500,7 @@ function finalizeSelectionSet(
494500
usedFragments,
495501
seenNonNullableMap,
496502
seenNullableMap,
503+
delegationContext,
497504
);
498505

499506
visit(
@@ -525,15 +532,37 @@ function filterSelectionSet(
525532
usedFragments: Array<string>,
526533
seenNonNullableMap: WeakMap<readonly ASTNode[], Set<string>>,
527534
seenNullableMap: WeakMap<readonly ASTNode[], Set<string>>,
535+
delegationContext: DelegationContext<any>,
528536
) {
529537
return visit(
530538
selectionSet,
531539
visitWithTypeInfo(typeInfo, {
532540
[Kind.FIELD]: {
533541
enter: (node) => {
534542
const parentType = typeInfo.getParentType();
543+
const field = typeInfo.getFieldDef();
544+
if (
545+
delegationContext.context != null &&
546+
delegationContext.info != null &&
547+
parentType != null &&
548+
field != null
549+
) {
550+
const parentTypeName = parentType.name;
551+
const overrideConfig =
552+
delegationContext.subschemaConfig?.merge?.[parentTypeName]
553+
?.fields?.[field.name]?.override;
554+
if (overrideConfig != null) {
555+
const overridden = handleOverrideByDelegation(
556+
delegationContext.info,
557+
delegationContext.context,
558+
overrideConfig.handle,
559+
);
560+
if (!overridden) {
561+
return null;
562+
}
563+
}
564+
}
535565
if (isObjectType(parentType) || isInterfaceType(parentType)) {
536-
const field = typeInfo.getFieldDef();
537566
if (!field) {
538567
return null;
539568
}
@@ -624,6 +653,7 @@ function filterSelectionSet(
624653
usedFragments,
625654
seenNonNullableMap,
626655
seenNullableMap,
656+
delegationContext,
627657
);
628658

629659
if (!fieldFilteredSelectionSet.selections.length) {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { GraphQLResolveInfo, memoize3 } from '@graphql-tools/utils';
2+
3+
export const handleOverrideByDelegation = memoize3(
4+
function handleOverrideByDelegation(
5+
_info: GraphQLResolveInfo,
6+
context: any,
7+
handle: (context: any) => boolean,
8+
): boolean {
9+
return handle(context);
10+
},
11+
);

packages/delegate/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './leftOver.js';
1313
export * from './symbols.js';
1414
export * from './getTypeInfo.js';
1515
export * from './isPrototypePollutingKey.js';
16+
export * from './handleOverrideByDelegation.js';

0 commit comments

Comments
 (0)