Skip to content

Commit d1d1b6f

Browse files
committed
feat(gateway): allow pinning the gateway to a specific version of the schema
1 parent a439e91 commit d1d1b6f

File tree

7 files changed

+630
-23
lines changed

7 files changed

+630
-23
lines changed

integration-tests/tests/api/artifacts-cdn.spec.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
PutObjectCommand,
1111
S3Client,
1212
} from '@aws-sdk/client-s3';
13-
import { createSupergraphManager } from '@graphql-hive/apollo';
13+
import { createServicesFetcher, createSupergraphManager } from '@graphql-hive/apollo';
1414
import { graphql } from '../../testkit/gql';
1515
import { execute } from '../../testkit/graphql';
1616
import { initSeed } from '../../testkit/seed';
@@ -460,6 +460,103 @@ function runArtifactsCDNTests(
460460
}
461461
});
462462

463+
test.concurrent(
464+
'createSupergraphManager with schemaVersionId pins to specific version',
465+
async ({ expect }) => {
466+
const endpointBaseUrl = await getBaseEndpoint();
467+
const { createOrg } = await initSeed().createOwner();
468+
const { createProject } = await createOrg();
469+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
470+
ProjectType.Federation,
471+
);
472+
const writeToken = await createTargetAccessToken({});
473+
474+
// Publish V1 Schema
475+
await writeToken
476+
.publishSchema({
477+
author: 'Kamil',
478+
commit: 'v1',
479+
sdl: `type Query { ping: String }`,
480+
service: 'ping',
481+
url: 'http://ping.com',
482+
})
483+
.then(r => r.expectNoGraphQLErrors());
484+
485+
const cdnAccessResult = await createCdnAccess();
486+
487+
// Create manager without pinning to get initial version
488+
const manager = createSupergraphManager({
489+
endpoint: endpointBaseUrl + target.id,
490+
key: cdnAccessResult.secretAccessToken,
491+
});
492+
493+
const gateway = new ApolloGateway({ supergraphSdl: manager });
494+
const server = new ApolloServer({ gateway });
495+
496+
try {
497+
await startStandaloneServer(server);
498+
499+
// Capture the version ID
500+
const v1VersionId = manager.getSchemaVersionId();
501+
expect(v1VersionId).toBeDefined();
502+
503+
await server.stop();
504+
505+
// Publish V2 Schema with different content
506+
await writeToken
507+
.publishSchema({
508+
author: 'Kamil',
509+
commit: 'v2',
510+
sdl: `type Query { ping: String, pong: String }`,
511+
service: 'ping',
512+
url: 'http://ping.com',
513+
})
514+
.then(r => r.expectNoGraphQLErrors());
515+
516+
// Create a new manager pinned to V1
517+
const pinnedManager = createSupergraphManager({
518+
endpoint: endpointBaseUrl + target.id,
519+
key: cdnAccessResult.secretAccessToken,
520+
schemaVersionId: v1VersionId!,
521+
});
522+
523+
const pinnedGateway = new ApolloGateway({ supergraphSdl: pinnedManager });
524+
const pinnedServer = new ApolloServer({ gateway: pinnedGateway });
525+
526+
try {
527+
const { url } = await startStandaloneServer(pinnedServer);
528+
529+
// Query the schema - should only have 'ping', not 'pong'
530+
const response = await fetch(url, {
531+
method: 'POST',
532+
headers: {
533+
accept: 'application/json',
534+
'content-type': 'application/json',
535+
},
536+
body: JSON.stringify({
537+
query: `{ __schema { types { name fields { name } } } }`,
538+
}),
539+
});
540+
541+
expect(response.status).toBe(200);
542+
const result = await response.json();
543+
const queryType = result.data.__schema.types.find(
544+
(t: { name: string }) => t.name === 'Query',
545+
);
546+
expect(queryType.fields).toContainEqual({ name: 'ping' });
547+
expect(queryType.fields).not.toContainEqual({ name: 'pong' });
548+
549+
// Verify the pinned manager returns V1 version ID
550+
expect(pinnedManager.getSchemaVersionId()).toBe(v1VersionId);
551+
} finally {
552+
await pinnedServer.stop();
553+
}
554+
} finally {
555+
// Server already stopped in try block
556+
}
557+
},
558+
);
559+
463560
test.concurrent('access versioned SDL artifact with valid credentials', async ({ expect }) => {
464561
const { createOrg } = await initSeed().createOwner();
465562
const { createProject } = await createOrg();
@@ -1230,6 +1327,78 @@ function runArtifactsCDNTests(
12301327
expect(contractSupergraphResponse.headers.get('x-hive-schema-version-id')).toBe(versionId);
12311328
},
12321329
);
1330+
1331+
test.concurrent(
1332+
'createServicesFetcher with schemaVersionId fetches pinned version',
1333+
async ({ expect }) => {
1334+
const endpointBaseUrl = await getBaseEndpoint();
1335+
const { createOrg } = await initSeed().createOwner();
1336+
const { createProject } = await createOrg();
1337+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
1338+
ProjectType.Federation,
1339+
);
1340+
const writeToken = await createTargetAccessToken({});
1341+
1342+
// Publish V1 Schema
1343+
await writeToken
1344+
.publishSchema({
1345+
author: 'Kamil',
1346+
commit: 'v1',
1347+
sdl: `type Query { ping: String }`,
1348+
service: 'ping',
1349+
url: 'http://ping.com',
1350+
})
1351+
.then(r => r.expectNoGraphQLErrors());
1352+
1353+
// Get V1 version ID
1354+
const v1Version = await writeToken.fetchLatestValidSchema();
1355+
const v1VersionId = v1Version.latestValidVersion?.id;
1356+
expect(v1VersionId).toBeDefined();
1357+
1358+
const cdnAccessResult = await createCdnAccess();
1359+
1360+
// Fetch V1 and capture content
1361+
const v1Fetcher = createServicesFetcher({
1362+
endpoint: endpointBaseUrl + target.id,
1363+
key: cdnAccessResult.secretAccessToken,
1364+
});
1365+
const v1Result = await v1Fetcher();
1366+
expect(v1Result[0].schemaVersionId).toBe(v1VersionId);
1367+
1368+
// Publish V2 Schema with different content
1369+
await writeToken
1370+
.publishSchema({
1371+
author: 'Kamil',
1372+
commit: 'v2',
1373+
sdl: `type Query { ping: String, pong: String }`,
1374+
service: 'ping',
1375+
url: 'http://ping.com',
1376+
})
1377+
.then(r => r.expectNoGraphQLErrors());
1378+
1379+
// Create a pinned fetcher for V1
1380+
const pinnedFetcher = createServicesFetcher({
1381+
endpoint: endpointBaseUrl + target.id,
1382+
key: cdnAccessResult.secretAccessToken,
1383+
schemaVersionId: v1VersionId!,
1384+
});
1385+
1386+
const pinnedResult = await pinnedFetcher();
1387+
1388+
// Should still return V1 content even after V2 was published
1389+
expect(pinnedResult[0].sdl).toBe(v1Result[0].sdl);
1390+
expect(pinnedResult[0].sdl).not.toContain('pong');
1391+
1392+
// Latest fetcher should return V2
1393+
const latestFetcher = createServicesFetcher({
1394+
endpoint: endpointBaseUrl + target.id,
1395+
key: cdnAccessResult.secretAccessToken,
1396+
});
1397+
const latestResult = await latestFetcher();
1398+
expect(latestResult[0].sdl).toContain('pong');
1399+
expect(latestResult[0].schemaVersionId).not.toBe(v1VersionId);
1400+
},
1401+
);
12331402
});
12341403
}
12351404

packages/libraries/apollo/src/index.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,30 @@ export type CreateSupergraphManagerArgs = {
5555
* Default: currents package version
5656
*/
5757
version?: string;
58+
/**
59+
* Pin to a specific schema version.
60+
* When provided, fetches from the versioned endpoint
61+
*/
62+
schemaVersionId?: string;
5863
};
5964

6065
export function createSupergraphManager(args: CreateSupergraphManagerArgs) {
6166
const logger = args.logger ?? new Logger({ level: false });
6267
const pollIntervalInMs = args.pollIntervalInMs ?? 30_000;
6368
let endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint];
6469

65-
const endpoint = endpoints.map(endpoint =>
66-
endpoint.endsWith('/supergraph') ? endpoint : joinUrl(endpoint, 'supergraph'),
67-
);
70+
// Build endpoints - use versioned path if schemaVersionId is provided
71+
const endpoint = endpoints.map(ep => {
72+
// Remove trailing /supergraph if present to get the base
73+
const base = ep.endsWith('/supergraph') ? ep.slice(0, -'/supergraph'.length) : ep;
74+
75+
if (args.schemaVersionId) {
76+
// Versioned endpoint: /artifacts/v1/{targetId}/version/{versionId}/supergraph
77+
return joinUrl(joinUrl(joinUrl(base, 'version'), args.schemaVersionId), 'supergraph');
78+
}
79+
80+
return joinUrl(base, 'supergraph');
81+
});
6882

6983
const artifactsFetcher = createCDNArtifactFetcher({
7084
endpoint: endpoint as [string, string],
@@ -79,19 +93,23 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) {
7993
});
8094

8195
let timer: ReturnType<typeof setTimeout> | null = null;
96+
let currentSchemaVersionId: string | undefined = args.schemaVersionId;
8297

8398
return {
8499
async initialize(hooks: { update(supergraphSdl: string): void }): Promise<{
85100
supergraphSdl: string;
101+
schemaVersionId?: string;
86102
cleanup?: () => Promise<void>;
87103
}> {
88104
const initialResult = await artifactsFetcher.fetch();
105+
currentSchemaVersionId = initialResult.schemaVersionId ?? args.schemaVersionId;
89106

90107
function poll() {
91108
timer = setTimeout(async () => {
92109
try {
93110
const result = await artifactsFetcher.fetch();
94111
if (result.contents) {
112+
currentSchemaVersionId = result.schemaVersionId ?? undefined;
95113
hooks.update?.(result.contents);
96114
}
97115
} catch (error) {
@@ -105,6 +123,7 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) {
105123

106124
return {
107125
supergraphSdl: initialResult.contents,
126+
schemaVersionId: currentSchemaVersionId,
108127
cleanup: async () => {
109128
if (timer) {
110129
clearTimeout(timer);
@@ -113,6 +132,9 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) {
113132
},
114133
};
115134
},
135+
getSchemaVersionId(): string | undefined {
136+
return currentSchemaVersionId;
137+
},
116138
};
117139
}
118140

packages/libraries/core/src/client/gateways.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,46 @@ interface Schema {
99
name: string;
1010
}
1111

12+
function buildServicesEndpoint(baseEndpoint: string, schemaVersionId?: string): string {
13+
// Remove trailing /services if present to get the base
14+
const base = baseEndpoint.endsWith('/services')
15+
? baseEndpoint.slice(0, -'/services'.length)
16+
: baseEndpoint;
17+
18+
if (schemaVersionId) {
19+
// Versioned endpoint: /artifacts/v1/{targetId}/version/{versionId}/services
20+
return joinUrl(joinUrl(joinUrl(base, 'version'), schemaVersionId), 'services');
21+
}
22+
23+
// Latest endpoint: /artifacts/v1/{targetId}/services
24+
return joinUrl(base, 'services');
25+
}
26+
1227
function createFetcher(options: SchemaFetcherOptions & ServicesFetcherOptions) {
28+
if (options.schemaVersionId !== undefined) {
29+
const trimmed = options.schemaVersionId.trim();
30+
if (trimmed.length === 0) {
31+
throw new Error(
32+
'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.',
33+
);
34+
}
35+
}
36+
1337
const logger = chooseLogger(options.logger ?? console);
1438
let cacheETag: string | null = null;
1539
let cached: {
16-
id: string;
40+
schemas: readonly Schema[] | Schema;
1741
supergraphSdl: string;
42+
schemaVersionId: string | null;
1843
} | null = null;
1944

20-
const endpoint = options.endpoint.endsWith('/services')
21-
? options.endpoint
22-
: joinUrl(options.endpoint, 'services');
45+
const endpoint = buildServicesEndpoint(options.endpoint, options.schemaVersionId);
2346

24-
return function fetcher(): Promise<readonly Schema[] | Schema> {
47+
return function fetcher(): Promise<{
48+
schemas: readonly Schema[] | Schema;
49+
supergraphSdl: string;
50+
schemaVersionId: string | null;
51+
}> {
2552
const headers: {
2653
[key: string]: string;
2754
} = {
@@ -48,14 +75,23 @@ function createFetcher(options: SchemaFetcherOptions & ServicesFetcherOptions) {
4875
.then(async response => {
4976
if (response.ok) {
5077
const result = await response.json();
78+
const schemaVersionId = response.headers.get('x-hive-schema-version-id');
79+
80+
if (!schemaVersionId) {
81+
logger.info(
82+
`Response from ${endpoint} did not include x-hive-schema-version-id header. Version pinning will not be available.`,
83+
);
84+
}
85+
86+
const data = { schemas: result, schemaVersionId };
5187

5288
const etag = response.headers.get('etag');
5389
if (etag) {
54-
cached = result;
90+
cached = data;
5591
cacheETag = etag;
5692
}
5793

58-
return result;
94+
return data;
5995
}
6096

6197
if (response.status === 304 && cached !== null) {
@@ -71,24 +107,26 @@ export function createSchemaFetcher(options: SchemaFetcherOptions) {
71107
const fetcher = createFetcher(options);
72108

73109
return function schemaFetcher() {
74-
return fetcher().then(schema => {
110+
return fetcher().then(data => {
111+
const { schemas, schemaVersionId } = data;
75112
let service: Schema;
76113
// Before the new artifacts endpoint the body returned an array or a single object depending on the project type.
77114
// This handles both in a backwards-compatible way.
78-
if (schema instanceof Array) {
79-
if (schema.length !== 1) {
115+
if (schemas instanceof Array) {
116+
if (schemas.length !== 1) {
80117
throw new Error(
81118
'Encountered multiple services instead of a single service. Please use createServicesFetcher instead.',
82119
);
83120
}
84-
service = schema[0];
121+
service = schemas[0];
85122
} else {
86-
service = schema;
123+
service = schemas;
87124
}
88125

89126
return {
90127
id: createSchemaId(service),
91128
...service,
129+
...(schemaVersionId ? { schemaVersionId } : {}),
92130
};
93131
});
94132
};
@@ -98,10 +136,17 @@ export function createServicesFetcher(options: ServicesFetcherOptions) {
98136
const fetcher = createFetcher(options);
99137

100138
return function schemaFetcher() {
101-
return fetcher().then(async services => {
102-
if (services instanceof Array) {
139+
return fetcher().then(async data => {
140+
const { schemas, schemaVersionId } = data;
141+
if (schemas instanceof Array) {
103142
return Promise.all(
104-
services.map(service => createSchemaId(service).then(id => ({ id, ...service }))),
143+
schemas.map(service =>
144+
createSchemaId(service).then(id => ({
145+
id,
146+
...service,
147+
...(schemaVersionId ? { schemaVersionId } : {}),
148+
})),
149+
),
105150
);
106151
}
107152
throw new Error(

0 commit comments

Comments
 (0)