From db134b29bd4acff8b0a68bc398b3ca57ce7ca2e2 Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Mon, 1 Dec 2025 15:24:56 +0100 Subject: [PATCH 1/3] feat(cdn): add versioned artifact endpoints --- .changeset/two-baboons-tie.md | 11 + .../tests/api/artifacts-cdn.spec.ts | 356 ++++++++++++++++++ .../providers/artifact-storage-writer.ts | 61 ++- .../schema/providers/schema-manager.ts | 2 +- .../schema/providers/schema-publisher.ts | 15 +- .../src/modules/shared/providers/storage.ts | 4 +- packages/services/cdn-worker/src/analytics.ts | 2 +- .../cdn-worker/src/artifact-handler.ts | 141 +++++++ .../cdn-worker/src/artifact-storage-reader.ts | 9 +- packages/services/storage/src/index.ts | 4 +- 10 files changed, 590 insertions(+), 15 deletions(-) create mode 100644 .changeset/two-baboons-tie.md diff --git a/.changeset/two-baboons-tie.md b/.changeset/two-baboons-tie.md new file mode 100644 index 00000000000..10794b712cc --- /dev/null +++ b/.changeset/two-baboons-tie.md @@ -0,0 +1,11 @@ +--- +'hive': minor +--- + +Add support for retrieving CDN artifacts by version ID. + +New CDN endpoints allow fetching schema artifacts for a specific version: +- `/artifacts/v1/:targetId/version/:versionId/:artifactType` +- `/artifacts/v1/:targetId/version/:versionId/contracts/:contractName/:artifactType` + +Artifacts are now written to both the latest path and a versioned path during schema publish, enabling retrieval of historical versions. diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index 837bf088cc4..c3d88777ce2 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -69,6 +69,15 @@ function buildEndpointUrl( return `${baseUrl}${targetId}/${resourceType}`; } +function buildVersionedEndpointUrl( + baseUrl: string, + targetId: string, + versionId: string, + resourceType: 'sdl' | 'supergraph' | 'services' | 'metadata', +) { + return `${baseUrl}${targetId}/version/${versionId}/${resourceType}`; +} + function generateLegacyToken(targetId: string) { const encoder = new TextEncoder(); return ( @@ -425,6 +434,353 @@ function runArtifactsCDNTests( await server.stop(); } }); + + test.concurrent('access versioned SDL artifact with valid credentials', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test latest endpoint + const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const latestResponse = await fetch(latestUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + expect(latestResponse.status).toBe(200); + const latestBody = await latestResponse.text(); + + // Test versioned endpoint + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + const versionedBody = await versionedResponse.text(); + + // Both should return the same content + expect(versionedBody).toBe(latestBody); + expect(versionedBody).toMatchInlineSnapshot(` + type Query { + ping: String + } + `); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/sdl`, + ); + expect(versionedArtifact.body).toBe(latestBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + test.concurrent( + 'versioned artifact returns 404 for non-existent version', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Use a non-existent but valid UUID + const nonExistentVersionId = '00000000-0000-0000-0000-000000000000'; + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + nonExistentVersionId, + 'sdl', + ); + + const response = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(response.status).toBe(404); + }, + ); + + test.concurrent( + 'versioned artifact returns 404 for invalid UUID format', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Use an invalid UUID format + const invalidVersionId = 'not-a-valid-uuid'; + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + invalidVersionId, + 'sdl', + ); + + const response = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(response.status).toBe(404); + }, + ); + + test.concurrent('access versioned federation supergraph artifact', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned supergraph endpoint + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'supergraph', + ); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + const supergraphBody = await versionedResponse.text(); + expect(supergraphBody).toContain('schema'); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/supergraph`, + ); + expect(versionedArtifact.body).toBe(supergraphBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + test.concurrent('access versioned federation services artifact', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned services endpoint + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'services', + ); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + expect(versionedResponse.headers.get('content-type')).toContain('application/json'); + const servicesBody = await versionedResponse.text(); + expect(servicesBody).toMatchInlineSnapshot( + '[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]', + ); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/services`, + ); + expect(versionedArtifact.body).toBe(servicesBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + }); + + test.concurrent('versioned artifact access without credentials', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, target } = await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const endpointBaseUrl = await getBaseEndpoint(); + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + + // Request without credentials + const response = await fetch(versionedUrl, { method: 'GET' }); + expect(response.status).toBe(400); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(await response.json()).toEqual({ + code: 'MISSING_AUTH_KEY', + error: 'Hive CDN authentication key is missing', + description: + 'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ', + }); + }); + + test.concurrent('versioned artifact access with invalid credentials', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, target } = await createProject(ProjectType.Single); + const writeToken = await createTargetAccessToken({}); + + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const endpointBaseUrl = await getBaseEndpoint(); + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + + // Request with invalid credentials + const response = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': 'invalid-key', + }, + }); + expect(response.status).toBe(403); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(await response.json()).toEqual({ + code: 'INVALID_AUTH_KEY', + error: + 'Hive CDN authentication key is invalid, or it does not match the requested target ID.', + description: + 'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ', + }); + }); }); } diff --git a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts index fce17b7a754..2ce3f4b67b1 100644 --- a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts +++ b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts @@ -41,6 +41,7 @@ export class ArtifactStorageWriter { 'hive.target.id': args.targetId, 'hive.artifact.type': args.artifactType, 'hive.contract.name': args.contractName || '', + 'hive.version.id': args.versionId || '', }), }) async writeArtifact(args: { @@ -48,25 +49,75 @@ export class ArtifactStorageWriter { artifactType: keyof typeof artifactMeta; artifact: unknown; contractName: null | string; + versionId?: string | null; }) { - const key = buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName); + const latestKey = buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName); + const versionedKey = args.versionId + ? buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName, args.versionId) + : null; const meta = artifactMeta[args.artifactType]; + const body = meta.preprocessor(args.artifact); for (const s3 of this.s3Mirrors) { - const result = await s3.client.fetch([s3.endpoint, s3.bucket, key].join('/'), { + this.logger.debug( + 'Writing artifact to S3 (targetId=%s, artifactType=%s, contractName=%s, versionId=%s, latestKey=%s, versionedKey=%s)', + args.targetId, + args.artifactType, + args.contractName, + args.versionId, + latestKey, + versionedKey, + ); + + // Write versioned key first (if versionId provided) + // This order ensures that if versioned write fails, "latest" still points to the previous version + if (versionedKey) { + const versionedResult = await s3.client.fetch( + [s3.endpoint, s3.bucket, versionedKey].join('/'), + { + method: 'PUT', + headers: { + 'content-type': meta.contentType, + }, + body, + aws: { + signQuery: true, + }, + }, + ); + + if (versionedResult.statusCode !== 200) { + throw new Error( + `Unexpected status code ${versionedResult.statusCode} when writing versioned artifact (targetId=${args.targetId}, artifactType=${args.artifactType}, versionId=${args.versionId}, key=${versionedKey})`, + ); + } + } + + // Write to latest key (always) - only after versioned succeeds + const latestResult = await s3.client.fetch([s3.endpoint, s3.bucket, latestKey].join('/'), { method: 'PUT', headers: { 'content-type': meta.contentType, }, - body: meta.preprocessor(args.artifact), + body, aws: { // This boolean makes Google Cloud Storage & AWS happy. signQuery: true, }, }); - if (result.statusCode !== 200) { - throw new Error(`Unexpected status code ${result.statusCode} when writing artifact.`); + if (latestResult.statusCode !== 200) { + this.logger.error( + 'Failed to write latest artifact after versioned succeeded (targetId=%s, artifactType=%s, versionId=%s, versionedKey=%s written, latestKey=%s failed)', + args.targetId, + args.artifactType, + args.versionId, + versionedKey, + latestKey, + ); + throw new Error( + `Unexpected status code ${latestResult.statusCode} when writing latest artifact (targetId=${args.targetId}, artifactType=${args.artifactType}, contractName=${args.contractName}, key=${latestKey}). Note: versioned artifact was already written.`, + ); } } } diff --git a/packages/services/api/src/modules/schema/providers/schema-manager.ts b/packages/services/api/src/modules/schema/providers/schema-manager.ts index 45c4b40c506..b6f4fcdc7e9 100644 --- a/packages/services/api/src/modules/schema/providers/schema-manager.ts +++ b/packages/services/api/src/modules/schema/providers/schema-manager.ts @@ -399,7 +399,7 @@ export class SchemaManager { base_schema: string | null; metadata: string | null; projectType: ProjectType; - actionFn(): Promise; + actionFn(versionId: string): Promise; changes: Array; coordinatesDiff: SchemaCoordinatesDiffResult | null; previousSchemaVersion: string | null; diff --git a/packages/services/api/src/modules/schema/providers/schema-publisher.ts b/packages/services/api/src/modules/schema/providers/schema-publisher.ts index 3e4f7d45b1c..e07df152861 100644 --- a/packages/services/api/src/modules/schema/providers/schema-publisher.ts +++ b/packages/services/api/src/modules/schema/providers/schema-publisher.ts @@ -1430,7 +1430,7 @@ export class SchemaPublisher { schemaMetadata: null, metadataAttributes: null, }), - actionFn: async () => { + actionFn: async (versionId: string) => { if (deleteResult.state.composable) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of deleteResult.state.contracts ?? []) { @@ -1451,6 +1451,7 @@ export class SchemaPublisher { // pass all schemas except the one we are deleting schemas: deleteResult.state.schemas, contracts, + versionId, }); } }, @@ -1945,7 +1946,7 @@ export class SchemaPublisher { metadata: input.metadata ?? null, projectType: project.type, github, - actionFn: async () => { + actionFn: async (versionId: string) => { if (composable && fullSchemaSdl) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of publishState.contracts ?? []) { @@ -1965,6 +1966,7 @@ export class SchemaPublisher { fullSchemaSdl, schemas, contracts, + versionId, }); } }, @@ -2244,6 +2246,7 @@ export class SchemaPublisher { fullSchemaSdl, schemas, contracts, + versionId, }: { target: Target; project: Project; @@ -2251,6 +2254,7 @@ export class SchemaPublisher { fullSchemaSdl: string; schemas: readonly Schema[]; contracts: null | Array<{ name: string; supergraph: string; sdl: string }>; + versionId: string; }) { const publishMetadata = async () => { const metadata: Array> = []; @@ -2268,6 +2272,7 @@ export class SchemaPublisher { artifact: project.type === ProjectType.SINGLE ? metadata[0] : metadata, artifactType: 'metadata', contractName: null, + versionId, }); } }; @@ -2285,12 +2290,14 @@ export class SchemaPublisher { url: s.service_url, })), contractName: null, + versionId, }), this.artifactStorageWriter.writeArtifact({ targetId: target.id, artifactType: 'sdl', artifact: fullSchemaSdl, contractName: null, + versionId, }), ]); }; @@ -2301,6 +2308,7 @@ export class SchemaPublisher { artifactType: 'sdl', artifact: fullSchemaSdl, contractName: null, + versionId, }); }; @@ -2319,6 +2327,7 @@ export class SchemaPublisher { artifactType: 'supergraph', artifact: supergraph, contractName: null, + versionId, }), ); } @@ -2333,12 +2342,14 @@ export class SchemaPublisher { artifactType: 'sdl', artifact: contract.sdl, contractName: contract.name, + versionId, }), this.artifactStorageWriter.writeArtifact({ targetId: target.id, artifactType: 'supergraph', artifact: contract.supergraph, contractName: contract.name, + versionId, }), ); } diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index 31eacb8016d..cc797a072c6 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -410,7 +410,7 @@ export interface Storage { _: { serviceName: string; composable: boolean; - actionFn(): Promise; + actionFn(versionId: string): Promise; changes: Array | null; diffSchemaVersionId: string | null; conditionalBreakingChangeMetadata: null | ConditionalBreakingChangeMetadata; @@ -451,7 +451,7 @@ export interface Storage { commit: string; logIds: string[]; base_schema: string | null; - actionFn(): Promise; + actionFn(versionId: string): Promise; changes: Array; previousSchemaVersion: null | string; diffSchemaVersionId: null | string; diff --git a/packages/services/cdn-worker/src/analytics.ts b/packages/services/cdn-worker/src/analytics.ts index ba21f6e343b..e638b4ef27f 100644 --- a/packages/services/cdn-worker/src/analytics.ts +++ b/packages/services/cdn-worker/src/analytics.ts @@ -7,7 +7,7 @@ export type Analytics = ReturnType; type Event = | { type: 'artifact'; - version: 'v0' | 'v1'; + version: 'v0' | 'v1' | 'v1-versioned'; value: | 'schema' | 'supergraph' diff --git a/packages/services/cdn-worker/src/artifact-handler.ts b/packages/services/cdn-worker/src/artifact-handler.ts index 1eeaa6aadf3..81e6f363f25 100644 --- a/packages/services/cdn-worker/src/artifact-handler.ts +++ b/packages/services/cdn-worker/src/artifact-handler.ts @@ -52,6 +52,23 @@ const ParamsModel = zod.object({ .transform(value => value ?? null), }); +const VersionedParamsModel = zod.object({ + targetId: zod.string(), + versionId: zod.string().uuid(), + artifactType: zod.union([ + zod.literal('metadata'), + zod.literal('sdl'), + zod.literal('sdl.graphql'), + zod.literal('sdl.graphqls'), + zod.literal('services'), + zod.literal('supergraph'), + ]), + contractName: zod + .string() + .optional() + .transform(value => value ?? null), +}); + const PersistedOperationParamsModel = zod.object({ targetId: zod.string(), appName: zod.string(), @@ -218,6 +235,130 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { } } + async function handlerV1Versioned(request: itty.IRequest & Request) { + const parseResult = VersionedParamsModel.safeParse(request.params); + + if (parseResult.success === false) { + analytics.track( + { type: 'error', value: ['invalid-params'] }, + request.params?.targetId ?? 'unknown', + ); + return createResponse( + analytics, + 'Not found.', + { + status: 404, + }, + request.params?.targetId ?? 'unknown', + request, + ); + } + + const params = parseResult.data; + + breadcrumb( + `Artifact v1 versioned handler (type=${params.artifactType}, targetId=${params.targetId}, versionId=${params.versionId}, contractName=${params.contractName})`, + ); + + const maybeResponse = await authenticate(request, params.targetId); + + if (maybeResponse !== null) { + return maybeResponse; + } + + analytics.track( + { type: 'artifact', value: params.artifactType, version: 'v1-versioned' }, + params.targetId, + ); + + const eTag = request.headers.get('if-none-match'); + + const result = await deps.artifactStorageReader.readArtifact( + params.targetId, + params.contractName, + params.artifactType, + eTag, + params.versionId, + ); + + if (result.type === 'notModified') { + return createResponse( + analytics, + null, + { + status: 304, + }, + params.targetId, + request, + ); + } + + if (result.type === 'notFound') { + return createResponse(analytics, 'Not found.', { status: 404 }, params.targetId, request); + } + + if (result.type === 'response') { + const etag = result.headers.get('etag'); + const text = result.body; + + if (params.artifactType === 'metadata') { + // Legacy handling for SINGLE project metadata (same as handlerV1) + // Metadata in SINGLE projects is only Mesh's Metadata, and it always defines _schema + const isMeshArtifact = text.includes(`"#/definitions/_schema"`); + const hasTopLevelArray = text.startsWith('[') && text.endsWith(']'); + + // Mesh's Metadata shared by Mesh is always an object. + // The top-level array was caused #3291 and fixed now, but we still need to handle the old data. + if (isMeshArtifact && hasTopLevelArray) { + return createResponse( + analytics, + text.substring(1, text.length - 1), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=31536000, immutable', + ...(etag ? { etag } : {}), + }, + }, + params.targetId, + request, + ); + } + } + + return createResponse( + analytics, + text, + { + status: 200, + headers: { + 'Content-Type': + params.artifactType === 'metadata' || params.artifactType === 'services' + ? 'application/json' + : 'text/plain', + // Versioned artifacts are immutable - aggressive caching + 'Cache-Control': 'public, max-age=31536000, immutable', + ...(etag ? { etag } : {}), + }, + }, + params.targetId, + request, + ); + } + + // Exhaustive check - should never reach here + result satisfies never; + } + + // Versioned artifact routes (must be before non-versioned routes) + router.get( + '/artifacts/v1/:targetId/version/:versionId/contracts/:contractName/:artifactType', + handlerV1Versioned, + ); + router.get('/artifacts/v1/:targetId/version/:versionId/:artifactType', handlerV1Versioned); + + // Non-versioned artifact routes (latest) router.get('/artifacts/v1/:targetId/contracts/:contractName/:artifactType', handlerV1); router.get('/artifacts/v1/:targetId/:artifactType', handlerV1); router.get( diff --git a/packages/services/cdn-worker/src/artifact-storage-reader.ts b/packages/services/cdn-worker/src/artifact-storage-reader.ts index ddb14f0a42e..51bfcb64df6 100644 --- a/packages/services/cdn-worker/src/artifact-storage-reader.ts +++ b/packages/services/cdn-worker/src/artifact-storage-reader.ts @@ -7,8 +7,12 @@ export function buildArtifactStorageKey( targetId: string, artifactType: string, contractName: null | string, + versionId?: string | null, ) { const parts = ['artifact', targetId]; + if (versionId) { + parts.push('version', versionId); + } if (contractName) { parts.push('contracts', contractName); } @@ -232,16 +236,17 @@ export class ArtifactStorageReader { contractName: string | null, artifactType: ArtifactsType, etagValue: string | null, + versionId?: string | null, ) { if (artifactType.startsWith('sdl')) { artifactType = 'sdl'; } this.breadcrumb( - `Reading artifact (targetId=${targetId}, artifactType=${artifactType}, contractName=${contractName})`, + `Reading artifact (targetId=${targetId}, artifactType=${artifactType}, contractName=${contractName}${versionId ? `, versionId=${versionId}` : ''})`, ); - const key = buildArtifactStorageKey(targetId, artifactType, contractName); + const key = buildArtifactStorageKey(targetId, artifactType, contractName, versionId); this.breadcrumb(`Reading artifact from S3 key: ${key}`); diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 39964a54dea..a018d2ef50f 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -2509,7 +2509,7 @@ export async function createStorage( }); } - await args.actionFn(); + await args.actionFn(newVersion.id); return { kind: 'composite', @@ -2616,7 +2616,7 @@ export async function createStorage( }); } - await input.actionFn(); + await input.actionFn(version.id); return { version, From a439e91456b50080c4263758b72803cf97e908ce Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Tue, 2 Dec 2025 21:19:05 +0100 Subject: [PATCH 2/3] include schema version id as a header for responses --- .../tests/api/artifacts-cdn.spec.ts | 449 ++++++++++++++++++ .../providers/artifact-storage-writer.ts | 14 +- .../cdn-worker/src/artifact-handler.ts | 10 + 3 files changed, 472 insertions(+), 1 deletion(-) diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index c3d88777ce2..0150b2848c0 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -78,6 +78,31 @@ function buildVersionedEndpointUrl( return `${baseUrl}${targetId}/version/${versionId}/${resourceType}`; } +function buildVersionedContractEndpointUrl( + baseUrl: string, + targetId: string, + versionId: string, + contractName: string, + resourceType: 'sdl' | 'supergraph', +) { + return `${baseUrl}${targetId}/version/${versionId}/contracts/${contractName}/${resourceType}`; +} + +const CreateContractMutation = graphql(` + mutation CreateContractMutationCDN($input: CreateContractInput!) { + createContract(input: $input) { + ok { + createdContract { + id + } + } + error { + message + } + } + } +`); + function generateLegacyToken(targetId: string) { const encoder = new TextEncoder(); return ( @@ -710,6 +735,69 @@ function runArtifactsCDNTests( ); }); + test.concurrent('access versioned federation metadata artifact', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema with metadata + const publishSchemaResult = await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + metadata: JSON.stringify({ version: '1.0' }), + }) + .then(r => r.expectNoGraphQLErrors()); + + expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess'); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned metadata endpoint + const versionedUrl = buildVersionedEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'metadata', + ); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(versionedResponse.status).toBe(200); + expect(versionedResponse.headers.get('content-type')).toContain('application/json'); + const metadataBody = await versionedResponse.text(); + // Federation metadata contains the metadata we published + expect(metadataBody).toContain('version'); + + // Verify the versioned S3 key exists + const versionedArtifact = await fetchS3ObjectArtifact( + 'artifacts', + `artifact/${target.id}/version/${versionId}/metadata`, + ); + expect(versionedArtifact.body).toBe(metadataBody); + + expect(versionedResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(versionedResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }); + test.concurrent('versioned artifact access without credentials', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject } = await createOrg(); @@ -781,6 +869,367 @@ function runArtifactsCDNTests( 'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ', }); }); + + test.concurrent('CDN response includes x-hive-schema-version-id header', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test latest endpoint returns x-hive-schema-version-id header + const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const latestResponse = await fetch(latestUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + expect(latestResponse.status).toBe(200); + expect(latestResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test versioned endpoint also returns x-hive-schema-version-id header + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + const versionedResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + expect(versionedResponse.status).toBe(200); + expect(versionedResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }); + + test.concurrent('versioned artifact with if-none-match returns 304', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // First request to get ETag + const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl'); + const firstResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(firstResponse.status).toBe(200); + const etag = firstResponse.headers.get('etag'); + expect(etag).toBeDefined(); + + // Second request with If-None-Match should return 304 + const secondResponse = await fetch(versionedUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + 'if-none-match': etag!, + }, + }); + + expect(secondResponse.status).toBe(304); + }); + + test.concurrent( + 'versioned artifact remains immutable after new schema publish', + async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Single, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish V1 schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v1', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const v1Version = await writeToken.fetchLatestValidSchema(); + const v1VersionId = v1Version.latestValidVersion?.id; + expect(v1VersionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Fetch V1 content + const v1Url = buildVersionedEndpointUrl(endpointBaseUrl, target.id, v1VersionId!, 'sdl'); + const v1Response = await fetch(v1Url, { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(v1Response.status).toBe(200); + const v1Content = await v1Response.text(); + expect(v1Content).toContain('ping'); + + // Publish V2 schema with different content + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v2', + sdl: `type Query { ping: String, pong: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + const v2Version = await writeToken.fetchLatestValidSchema(); + const v2VersionId = v2Version.latestValidVersion?.id; + expect(v2VersionId).toBeDefined(); + expect(v2VersionId).not.toBe(v1VersionId); + + // Verify V1 versioned endpoint still returns original content + const v1ResponseAfterV2 = await fetch(v1Url, { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(v1ResponseAfterV2.status).toBe(200); + const v1ContentAfterV2 = await v1ResponseAfterV2.text(); + expect(v1ContentAfterV2).toBe(v1Content); + expect(v1ContentAfterV2).not.toContain('pong'); + + // Verify latest endpoint returns V2 content + const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const latestResponse = await fetch(latestUrl, { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(latestResponse.status).toBe(200); + const latestContent = await latestResponse.text(); + expect(latestContent).toContain('pong'); + + // Verify headers point to correct versions + expect(v1ResponseAfterV2.headers.get('x-hive-schema-version-id')).toBe(v1VersionId); + expect(latestResponse.headers.get('x-hive-schema-version-id')).toBe(v2VersionId); + }, + ); + + test.concurrent('x-hive-schema-version-id header on all artifact types', async ({ expect }) => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish Federation schema with metadata (required for metadata artifact) + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + metadata: JSON.stringify({ version: '1.0' }), + }) + .then(r => r.expectNoGraphQLErrors()); + + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test SDL artifact + const sdlResponse = await fetch(buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'), { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }); + expect(sdlResponse.status).toBe(200); + expect(sdlResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test services artifact + const servicesResponse = await fetch( + buildEndpointUrl(endpointBaseUrl, target.id, 'services'), + { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }, + ); + expect(servicesResponse.status).toBe(200); + expect(servicesResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test supergraph artifact + const supergraphResponse = await fetch( + buildEndpointUrl(endpointBaseUrl, target.id, 'supergraph'), + { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }, + ); + expect(supergraphResponse.status).toBe(200); + expect(supergraphResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test metadata artifact + const metadataResponse = await fetch( + buildEndpointUrl(endpointBaseUrl, target.id, 'metadata'), + { + method: 'GET', + headers: { 'x-hive-cdn-key': cdnAccessResult.secretAccessToken }, + }, + ); + expect(metadataResponse.status).toBe(200); + expect(metadataResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }); + + test.concurrent( + 'access versioned contract artifact with valid credentials', + async ({ expect }) => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { createProject, setFeatureFlag } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target, setNativeFederation } = + await createProject(ProjectType.Federation); + await setFeatureFlag('compareToPreviousComposableVersion', true); + await setNativeFederation(true); + + const writeToken = await createTargetAccessToken({}); + + // Publish initial schema with @tag directive + await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type Query { + hello: String + helloHidden: String @tag(name: "internal") + } + `, + service: 'hello', + url: 'http://hello.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Create a contract + const createContractResult = await execute({ + document: CreateContractMutation, + variables: { + input: { + target: { byId: target.id }, + contractName: 'my-contract', + removeUnreachableTypesFromPublicApiSchema: true, + includeTags: ['internal'], + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + expect(createContractResult.createContract.error).toBeNull(); + + // Publish schema again to generate contract artifacts + await writeToken + .publishSchema({ + sdl: /* GraphQL */ ` + extend schema + @link(url: "https://specs.apollo.dev/link/v1.0") + @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@tag"]) + + type Query { + hello: String + helloHidden: String @tag(name: "internal") + } + `, + service: 'hello', + url: 'http://hello.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Fetch the latest valid version to get the version ID + const latestVersion = await writeToken.fetchLatestValidSchema(); + const versionId = latestVersion.latestValidVersion?.id; + expect(versionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + const endpointBaseUrl = await getBaseEndpoint(); + + // Test versioned contract SDL endpoint + const versionedContractSdlUrl = buildVersionedContractEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'my-contract', + 'sdl', + ); + const contractSdlResponse = await fetch(versionedContractSdlUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(contractSdlResponse.status).toBe(200); + const contractSdlBody = await contractSdlResponse.text(); + expect(contractSdlBody).toContain('helloHidden'); + expect(contractSdlResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(contractSdlResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + + // Test versioned contract supergraph endpoint + const versionedContractSupergraphUrl = buildVersionedContractEndpointUrl( + endpointBaseUrl, + target.id, + versionId!, + 'my-contract', + 'supergraph', + ); + const contractSupergraphResponse = await fetch(versionedContractSupergraphUrl, { + method: 'GET', + headers: { + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, + }, + }); + + expect(contractSupergraphResponse.status).toBe(200); + expect(contractSupergraphResponse.headers.get('cache-control')).toBe( + 'public, max-age=31536000, immutable', + ); + expect(contractSupergraphResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); + }, + ); }); } diff --git a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts index 2ce3f4b67b1..bddd9c2fb5f 100644 --- a/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts +++ b/packages/services/api/src/modules/schema/providers/artifact-storage-writer.ts @@ -71,13 +71,15 @@ export class ArtifactStorageWriter { // Write versioned key first (if versionId provided) // This order ensures that if versioned write fails, "latest" still points to the previous version - if (versionedKey) { + if (versionedKey && args.versionId) { const versionedResult = await s3.client.fetch( [s3.endpoint, s3.bucket, versionedKey].join('/'), { method: 'PUT', headers: { 'content-type': meta.contentType, + // Store version ID as S3 object metadata for CDN response headers + 'x-amz-meta-x-hive-schema-version-id': args.versionId, }, body, aws: { @@ -87,6 +89,14 @@ export class ArtifactStorageWriter { ); if (versionedResult.statusCode !== 200) { + this.logger.error( + 'Failed to write versioned artifact (targetId=%s, artifactType=%s, versionId=%s, key=%s, statusCode=%s)', + args.targetId, + args.artifactType, + args.versionId, + versionedKey, + versionedResult.statusCode, + ); throw new Error( `Unexpected status code ${versionedResult.statusCode} when writing versioned artifact (targetId=${args.targetId}, artifactType=${args.artifactType}, versionId=${args.versionId}, key=${versionedKey})`, ); @@ -98,6 +108,8 @@ export class ArtifactStorageWriter { method: 'PUT', headers: { 'content-type': meta.contentType, + // Store version ID as S3 object metadata for CDN response headers + ...(args.versionId ? { 'x-amz-meta-x-hive-schema-version-id': args.versionId } : {}), }, body, aws: { diff --git a/packages/services/cdn-worker/src/artifact-handler.ts b/packages/services/cdn-worker/src/artifact-handler.ts index 81e6f363f25..5e22940ef72 100644 --- a/packages/services/cdn-worker/src/artifact-handler.ts +++ b/packages/services/cdn-worker/src/artifact-handler.ts @@ -181,6 +181,8 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { if (result.type === 'response') { const etag = result.headers.get('etag'); + // S3/R2 returns custom metadata with x-amz-meta- prefix + const schemaVersionId = result.headers.get('x-amz-meta-x-hive-schema-version-id'); const text = result.body; if (params.artifactType === 'metadata') { @@ -208,6 +210,7 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { headers: { 'Content-Type': 'application/json', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, @@ -227,12 +230,15 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { ? 'application/json' : 'text/plain', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, request, ); } + + result satisfies never; } async function handlerV1Versioned(request: itty.IRequest & Request) { @@ -299,6 +305,8 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { if (result.type === 'response') { const etag = result.headers.get('etag'); + // S3/R2 returns custom metadata with x-amz-meta- prefix + const schemaVersionId = result.headers.get('x-amz-meta-x-hive-schema-version-id'); const text = result.body; if (params.artifactType === 'metadata') { @@ -319,6 +327,7 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=31536000, immutable', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, @@ -340,6 +349,7 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { // Versioned artifacts are immutable - aggressive caching 'Cache-Control': 'public, max-age=31536000, immutable', ...(etag ? { etag } : {}), + ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, From 8ef2b7460343ab1df89257b26e66c780593848cf Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Wed, 3 Dec 2025 00:48:43 +0100 Subject: [PATCH 3/3] feat(gateway): allow pinning the gateway to a specific version of the schema --- .../tests/api/artifacts-cdn.spec.ts | 171 +++++++++++++++- packages/libraries/apollo/src/index.ts | 28 ++- .../libraries/core/src/client/gateways.ts | 75 +++++-- .../libraries/core/src/client/supergraph.ts | 38 +++- packages/libraries/core/src/client/types.ts | 2 + .../libraries/core/tests/gateways.spec.ts | 184 ++++++++++++++++++ .../libraries/core/tests/supergraph.spec.ts | 155 +++++++++++++++ 7 files changed, 629 insertions(+), 24 deletions(-) diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index 0150b2848c0..d3b27376362 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -10,7 +10,7 @@ import { PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; -import { createSupergraphManager } from '@graphql-hive/apollo'; +import { createServicesFetcher, createSupergraphManager } from '@graphql-hive/apollo'; import { graphql } from '../../testkit/gql'; import { execute } from '../../testkit/graphql'; import { initSeed } from '../../testkit/seed'; @@ -460,6 +460,103 @@ function runArtifactsCDNTests( } }); + test.concurrent( + 'createSupergraphManager with schemaVersionId pins to specific version', + async ({ expect }) => { + const endpointBaseUrl = await getBaseEndpoint(); + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish V1 Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v1', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + const cdnAccessResult = await createCdnAccess(); + + // Create manager without pinning to get initial version + const manager = createSupergraphManager({ + endpoint: endpointBaseUrl + target.id, + key: cdnAccessResult.secretAccessToken, + }); + + const gateway = new ApolloGateway({ supergraphSdl: manager }); + const server = new ApolloServer({ gateway }); + + try { + await startStandaloneServer(server); + + // Capture the version ID + const v1VersionId = manager.getSchemaVersionId(); + expect(v1VersionId).toBeDefined(); + + await server.stop(); + + // Publish V2 Schema with different content + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v2', + sdl: `type Query { ping: String, pong: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Create a new manager pinned to V1 + const pinnedManager = createSupergraphManager({ + endpoint: endpointBaseUrl + target.id, + key: cdnAccessResult.secretAccessToken, + schemaVersionId: v1VersionId!, + }); + + const pinnedGateway = new ApolloGateway({ supergraphSdl: pinnedManager }); + const pinnedServer = new ApolloServer({ gateway: pinnedGateway }); + + try { + const { url } = await startStandaloneServer(pinnedServer); + + // Query the schema - should only have 'ping', not 'pong' + const response = await fetch(url, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: `{ __schema { types { name fields { name } } } }`, + }), + }); + + expect(response.status).toBe(200); + const result = await response.json(); + const queryType = result.data.__schema.types.find( + (t: { name: string }) => t.name === 'Query', + ); + expect(queryType.fields).toContainEqual({ name: 'ping' }); + expect(queryType.fields).not.toContainEqual({ name: 'pong' }); + + // Verify the pinned manager returns V1 version ID + expect(pinnedManager.getSchemaVersionId()).toBe(v1VersionId); + } finally { + await pinnedServer.stop(); + } + } finally { + // Server already stopped in try block + } + }, + ); + test.concurrent('access versioned SDL artifact with valid credentials', async ({ expect }) => { const { createOrg } = await initSeed().createOwner(); const { createProject } = await createOrg(); @@ -1230,6 +1327,78 @@ function runArtifactsCDNTests( expect(contractSupergraphResponse.headers.get('x-hive-schema-version-id')).toBe(versionId); }, ); + + test.concurrent( + 'createServicesFetcher with schemaVersionId fetches pinned version', + async ({ expect }) => { + const endpointBaseUrl = await getBaseEndpoint(); + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createTargetAccessToken, createCdnAccess, target } = await createProject( + ProjectType.Federation, + ); + const writeToken = await createTargetAccessToken({}); + + // Publish V1 Schema + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v1', + sdl: `type Query { ping: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Get V1 version ID + const v1Version = await writeToken.fetchLatestValidSchema(); + const v1VersionId = v1Version.latestValidVersion?.id; + expect(v1VersionId).toBeDefined(); + + const cdnAccessResult = await createCdnAccess(); + + // Fetch V1 and capture content + const v1Fetcher = createServicesFetcher({ + endpoint: endpointBaseUrl + target.id, + key: cdnAccessResult.secretAccessToken, + }); + const v1Result = await v1Fetcher(); + expect(v1Result[0].schemaVersionId).toBe(v1VersionId); + + // Publish V2 Schema with different content + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'v2', + sdl: `type Query { ping: String, pong: String }`, + service: 'ping', + url: 'http://ping.com', + }) + .then(r => r.expectNoGraphQLErrors()); + + // Create a pinned fetcher for V1 + const pinnedFetcher = createServicesFetcher({ + endpoint: endpointBaseUrl + target.id, + key: cdnAccessResult.secretAccessToken, + schemaVersionId: v1VersionId!, + }); + + const pinnedResult = await pinnedFetcher(); + + // Should still return V1 content even after V2 was published + expect(pinnedResult[0].sdl).toBe(v1Result[0].sdl); + expect(pinnedResult[0].sdl).not.toContain('pong'); + + // Latest fetcher should return V2 + const latestFetcher = createServicesFetcher({ + endpoint: endpointBaseUrl + target.id, + key: cdnAccessResult.secretAccessToken, + }); + const latestResult = await latestFetcher(); + expect(latestResult[0].sdl).toContain('pong'); + expect(latestResult[0].schemaVersionId).not.toBe(v1VersionId); + }, + ); }); } diff --git a/packages/libraries/apollo/src/index.ts b/packages/libraries/apollo/src/index.ts index d5812bc2b83..8e272a980e3 100644 --- a/packages/libraries/apollo/src/index.ts +++ b/packages/libraries/apollo/src/index.ts @@ -55,6 +55,11 @@ export type CreateSupergraphManagerArgs = { * Default: currents package version */ version?: string; + /** + * Pin to a specific schema version. + * When provided, fetches from the versioned endpoint + */ + schemaVersionId?: string; }; export function createSupergraphManager(args: CreateSupergraphManagerArgs) { @@ -62,9 +67,18 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { const pollIntervalInMs = args.pollIntervalInMs ?? 30_000; let endpoints = Array.isArray(args.endpoint) ? args.endpoint : [args.endpoint]; - const endpoint = endpoints.map(endpoint => - endpoint.endsWith('/supergraph') ? endpoint : joinUrl(endpoint, 'supergraph'), - ); + // Build endpoints - use versioned path if schemaVersionId is provided + const endpoint = endpoints.map(ep => { + // Remove trailing /supergraph if present to get the base + const base = ep.endsWith('/supergraph') ? ep.slice(0, -'/supergraph'.length) : ep; + + if (args.schemaVersionId) { + // Versioned endpoint: /artifacts/v1/{targetId}/version/{versionId}/supergraph + return joinUrl(joinUrl(joinUrl(base, 'version'), args.schemaVersionId), 'supergraph'); + } + + return joinUrl(base, 'supergraph'); + }); const artifactsFetcher = createCDNArtifactFetcher({ endpoint: endpoint as [string, string], @@ -79,19 +93,23 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { }); let timer: ReturnType | null = null; + let currentSchemaVersionId: string | undefined = args.schemaVersionId; return { async initialize(hooks: { update(supergraphSdl: string): void }): Promise<{ supergraphSdl: string; + schemaVersionId?: string; cleanup?: () => Promise; }> { const initialResult = await artifactsFetcher.fetch(); + currentSchemaVersionId = initialResult.schemaVersionId ?? args.schemaVersionId; function poll() { timer = setTimeout(async () => { try { const result = await artifactsFetcher.fetch(); if (result.contents) { + currentSchemaVersionId = result.schemaVersionId ?? undefined; hooks.update?.(result.contents); } } catch (error) { @@ -105,6 +123,7 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { return { supergraphSdl: initialResult.contents, + schemaVersionId: currentSchemaVersionId, cleanup: async () => { if (timer) { clearTimeout(timer); @@ -113,6 +132,9 @@ export function createSupergraphManager(args: CreateSupergraphManagerArgs) { }, }; }, + getSchemaVersionId(): string | undefined { + return currentSchemaVersionId; + }, }; } diff --git a/packages/libraries/core/src/client/gateways.ts b/packages/libraries/core/src/client/gateways.ts index c71aee1c2f7..140a0c108e9 100644 --- a/packages/libraries/core/src/client/gateways.ts +++ b/packages/libraries/core/src/client/gateways.ts @@ -9,19 +9,44 @@ interface Schema { name: string; } +function buildServicesEndpoint(baseEndpoint: string, schemaVersionId?: string): string { + // Remove trailing /services if present to get the base + const base = baseEndpoint.endsWith('/services') + ? baseEndpoint.slice(0, -'/services'.length) + : baseEndpoint; + + if (schemaVersionId) { + // Versioned endpoint: /artifacts/v1/{targetId}/version/{versionId}/services + return joinUrl(joinUrl(joinUrl(base, 'version'), schemaVersionId), 'services'); + } + + // Latest endpoint: /artifacts/v1/{targetId}/services + return joinUrl(base, 'services'); +} + function createFetcher(options: SchemaFetcherOptions & ServicesFetcherOptions) { + if (options.schemaVersionId !== undefined) { + const trimmed = options.schemaVersionId.trim(); + if (trimmed.length === 0) { + throw new Error( + 'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.', + ); + } + } + const logger = chooseLogger(options.logger ?? console); let cacheETag: string | null = null; let cached: { - id: string; - supergraphSdl: string; + schemas: readonly Schema[] | Schema; + schemaVersionId: string | null; } | null = null; - const endpoint = options.endpoint.endsWith('/services') - ? options.endpoint - : joinUrl(options.endpoint, 'services'); + const endpoint = buildServicesEndpoint(options.endpoint, options.schemaVersionId); - return function fetcher(): Promise { + return function fetcher(): Promise<{ + schemas: readonly Schema[] | Schema; + schemaVersionId: string | null; + }> { const headers: { [key: string]: string; } = { @@ -48,14 +73,23 @@ function createFetcher(options: SchemaFetcherOptions & ServicesFetcherOptions) { .then(async response => { if (response.ok) { const result = await response.json(); + const schemaVersionId = response.headers.get('x-hive-schema-version-id'); + + if (!schemaVersionId) { + logger.info( + `Response from ${endpoint} did not include x-hive-schema-version-id header. Version pinning will not be available.`, + ); + } + + const data = { schemas: result, schemaVersionId }; const etag = response.headers.get('etag'); if (etag) { - cached = result; + cached = data; cacheETag = etag; } - return result; + return data; } if (response.status === 304 && cached !== null) { @@ -71,24 +105,26 @@ export function createSchemaFetcher(options: SchemaFetcherOptions) { const fetcher = createFetcher(options); return function schemaFetcher() { - return fetcher().then(schema => { + return fetcher().then(data => { + const { schemas, schemaVersionId } = data; let service: Schema; // Before the new artifacts endpoint the body returned an array or a single object depending on the project type. // This handles both in a backwards-compatible way. - if (schema instanceof Array) { - if (schema.length !== 1) { + if (schemas instanceof Array) { + if (schemas.length !== 1) { throw new Error( 'Encountered multiple services instead of a single service. Please use createServicesFetcher instead.', ); } - service = schema[0]; + service = schemas[0]; } else { - service = schema; + service = schemas; } return { id: createSchemaId(service), ...service, + ...(schemaVersionId ? { schemaVersionId } : {}), }; }); }; @@ -98,10 +134,17 @@ export function createServicesFetcher(options: ServicesFetcherOptions) { const fetcher = createFetcher(options); return function schemaFetcher() { - return fetcher().then(async services => { - if (services instanceof Array) { + return fetcher().then(async data => { + const { schemas, schemaVersionId } = data; + if (schemas instanceof Array) { return Promise.all( - services.map(service => createSchemaId(service).then(id => ({ id, ...service }))), + schemas.map(service => + createSchemaId(service).then(id => ({ + id, + ...service, + ...(schemaVersionId ? { schemaVersionId } : {}), + })), + ), ); } throw new Error( diff --git a/packages/libraries/core/src/client/supergraph.ts b/packages/libraries/core/src/client/supergraph.ts index 9c1bcfe74d7..6e967800fbf 100644 --- a/packages/libraries/core/src/client/supergraph.ts +++ b/packages/libraries/core/src/client/supergraph.ts @@ -14,22 +14,50 @@ export interface SupergraphSDLFetcherOptions { fetchImplementation?: typeof fetch; name?: string; version?: string; + schemaVersionId?: string; +} + +function buildSupergraphEndpoint(baseEndpoint: string, schemaVersionId?: string): string { + // remove trailing /supergraph if present to get the base + const base = baseEndpoint.endsWith('/supergraph') + ? baseEndpoint.slice(0, -'/supergraph'.length) + : baseEndpoint; + + if (schemaVersionId) { + // versioned endpoint: /artifacts/v1/{targetId}/version/{versionId}/supergraph + return joinUrl(joinUrl(joinUrl(base, 'version'), schemaVersionId), 'supergraph'); + } + + // latest endpoint: /artifacts/v1/{targetId}/supergraph + return joinUrl(base, 'supergraph'); } /** * @deprecated Please use {createCDNArtifactFetcher} instead. */ export function createSupergraphSDLFetcher(options: SupergraphSDLFetcherOptions) { + if (options.schemaVersionId !== undefined) { + const trimmed = options.schemaVersionId.trim(); + if (trimmed.length === 0) { + throw new Error( + 'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.', + ); + } + } + let cacheETag: string | null = null; let cached: { id: string; supergraphSdl: string; + schemaVersionId?: string; } | null = null; - const endpoint = options.endpoint.endsWith('/supergraph') - ? options.endpoint - : joinUrl(options.endpoint, 'supergraph'); + const endpoint = buildSupergraphEndpoint(options.endpoint, options.schemaVersionId); - return function supergraphSDLFetcher(): Promise<{ id: string; supergraphSdl: string }> { + return function supergraphSDLFetcher(): Promise<{ + id: string; + supergraphSdl: string; + schemaVersionId?: string; + }> { const headers: { [key: string]: string; } = { @@ -56,9 +84,11 @@ export function createSupergraphSDLFetcher(options: SupergraphSDLFetcherOptions) .then(async response => { if (response.ok) { const supergraphSdl = await response.text(); + const schemaVersionId = response.headers.get('x-hive-schema-version-id'); const result = { id: await createHash('SHA-256').update(supergraphSdl).digest('base64'), supergraphSdl, + ...(schemaVersionId ? { schemaVersionId } : {}), }; const etag = response.headers.get('etag'); diff --git a/packages/libraries/core/src/client/types.ts b/packages/libraries/core/src/client/types.ts index 5ef0e06d800..4b0058336c3 100644 --- a/packages/libraries/core/src/client/types.ts +++ b/packages/libraries/core/src/client/types.ts @@ -289,11 +289,13 @@ export interface SchemaFetcherOptions { logger?: Logger; name?: string; version?: string; + schemaVersionId?: string; } export interface ServicesFetcherOptions { endpoint: string; key: string; + schemaVersionId?: string; } export type PersistedDocumentsConfiguration = { diff --git a/packages/libraries/core/tests/gateways.spec.ts b/packages/libraries/core/tests/gateways.spec.ts index cfecfa717d4..06014cb8e4b 100644 --- a/packages/libraries/core/tests/gateways.spec.ts +++ b/packages/libraries/core/tests/gateways.spec.ts @@ -295,3 +295,187 @@ test('fail in case of unexpected CDN status code (nRetryCount=11)', async () => ); } }); + +test('createSchemaFetcher extracts schemaVersionId from response header', async () => { + const schema = { + sdl: 'type Query { ping: String }', + url: 'service-url', + name: 'service-name', + }; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const key = 'secret-key'; + + nock('http://localhost') + .get('/services') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, schema, { + ETag: 'first', + 'x-hive-schema-version-id': versionId, + }); + + const fetcher = createSchemaFetcher({ + endpoint: 'http://localhost', + key, + }); + + const result = await fetcher(); + + expect(result.schemaVersionId).toEqual(versionId); + expect(result.sdl).toEqual(schema.sdl); +}); + +test('createSchemaFetcher uses versioned endpoint when schemaVersionId option is provided', async () => { + const schema = { + sdl: 'type Query { ping: String }', + url: 'service-url', + name: 'service-name', + }; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const key = 'secret-key'; + + nock('http://localhost') + .get(`/version/${versionId}/services`) + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, schema, { + 'x-hive-schema-version-id': versionId, + }); + + const fetcher = createSchemaFetcher({ + endpoint: 'http://localhost', + key, + schemaVersionId: versionId, + }); + + const result = await fetcher(); + + expect(result.schemaVersionId).toEqual(versionId); + expect(result.sdl).toEqual(schema.sdl); +}); + +test('createServicesFetcher extracts schemaVersionId into each item', async () => { + const services = [ + { sdl: 'type Query { ping: String }', url: 'http://ping.com', name: 'ping' }, + { sdl: 'type Query { pong: String }', url: 'http://pong.com', name: 'pong' }, + ]; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const key = 'secret-key'; + + nock('http://localhost') + .get('/services') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, services, { + ETag: 'first', + 'x-hive-schema-version-id': versionId, + }); + + const fetcher = createServicesFetcher({ + endpoint: 'http://localhost', + key, + }); + + const result = await fetcher(); + + expect(result).toHaveLength(2); + expect(result[0].schemaVersionId).toEqual(versionId); + expect(result[1].schemaVersionId).toEqual(versionId); + expect(result[0].name).toEqual('ping'); + expect(result[1].name).toEqual('pong'); +}); + +test('createServicesFetcher uses versioned endpoint when schemaVersionId option is provided', async () => { + const services = [{ sdl: 'type Query { ping: String }', url: 'http://ping.com', name: 'ping' }]; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + const key = 'secret-key'; + + nock('http://localhost') + .get(`/version/${versionId}/services`) + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, services, { + 'x-hive-schema-version-id': versionId, + }); + + const fetcher = createServicesFetcher({ + endpoint: 'http://localhost', + key, + schemaVersionId: versionId, + }); + + const result = await fetcher(); + + expect(result).toHaveLength(1); + expect(result[0].schemaVersionId).toEqual(versionId); +}); + +test('createSchemaFetcher omits schemaVersionId when header is absent', async () => { + const schema = { + sdl: 'type Query { ping: String }', + url: 'service-url', + name: 'service-name', + }; + const key = 'secret-key'; + + nock('http://localhost') + .get('/services') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, schema); + + const fetcher = createSchemaFetcher({ + endpoint: 'http://localhost', + key, + }); + + const result = await fetcher(); + + expect(result.schemaVersionId).toBeUndefined(); + expect(result.sdl).toEqual(schema.sdl); +}); + +test('createSchemaFetcher throws error for empty schemaVersionId', () => { + expect(() => + createSchemaFetcher({ + endpoint: 'http://localhost', + key: 'secret-key', + schemaVersionId: '', + }), + ).toThrowError( + 'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.', + ); +}); + +test('createSchemaFetcher throws error for whitespace-only schemaVersionId', () => { + expect(() => + createSchemaFetcher({ + endpoint: 'http://localhost', + key: 'secret-key', + schemaVersionId: ' ', + }), + ).toThrowError( + 'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.', + ); +}); + +test('createSchemaFetcher returns 404 error for non-existent schemaVersionId', async () => { + const key = 'secret-key'; + const invalidVersionId = 'non-existent-version-id'; + + nock('http://localhost') + .get(`/version/${invalidVersionId}/services`) + .times(11) + .matchHeader('X-Hive-CDN-Key', key) + .reply(404, 'Not Found'); + + const fetcher = createSchemaFetcher({ + endpoint: 'http://localhost', + key, + schemaVersionId: invalidVersionId, + }); + + await expect(fetcher()).rejects.toThrowError( + /GET http:\/\/localhost\/version\/non-existent-version-id\/services .* failed with status 404/, + ); +}); diff --git a/packages/libraries/core/tests/supergraph.spec.ts b/packages/libraries/core/tests/supergraph.spec.ts index 57b8d1ac880..6c474f4391a 100644 --- a/packages/libraries/core/tests/supergraph.spec.ts +++ b/packages/libraries/core/tests/supergraph.spec.ts @@ -170,4 +170,159 @@ describe('supergraph SDL fetcher', async () => { }, }); }); + + test('extracts schemaVersionId from response header', async () => { + const supergraphSdl = 'type SuperQuery { sdl: String }'; + const key = 'secret-key'; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + const fetcher = createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key, + async fetchImplementation(): Promise { + return new Response(supergraphSdl, { + status: 200, + headers: { + ETag: 'first', + 'x-hive-schema-version-id': versionId, + }, + }); + }, + }); + + const result = await fetcher(); + + expect(result.schemaVersionId).toEqual(versionId); + expect(result.supergraphSdl).toEqual(supergraphSdl); + }); + + test('uses versioned endpoint when schemaVersionId is provided', async () => { + const supergraphSdl = 'type SuperQuery { sdl: String }'; + const key = 'secret-key'; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + nock('http://localhost') + .get(`/version/${versionId}/supergraph`) + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, supergraphSdl, { + ETag: 'immutable', + 'x-hive-schema-version-id': versionId, + }); + + const fetcher = createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key, + schemaVersionId: versionId, + }); + + const result = await fetcher(); + + expect(result.schemaVersionId).toEqual(versionId); + expect(result.supergraphSdl).toEqual(supergraphSdl); + }); + + test('versioned endpoint with trailing /supergraph in base endpoint', async () => { + const supergraphSdl = 'type SuperQuery { sdl: String }'; + const key = 'secret-key'; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + nock('http://localhost') + .get(`/version/${versionId}/supergraph`) + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, supergraphSdl, { + 'x-hive-schema-version-id': versionId, + }); + + const fetcher = createSupergraphSDLFetcher({ + endpoint: 'http://localhost/supergraph', // trailing /supergraph should be handled + key, + schemaVersionId: versionId, + }); + + const result = await fetcher(); + + expect(result.schemaVersionId).toEqual(versionId); + }); + + test('versioned endpoint caches with ETag', async () => { + const supergraphSdl = 'type SuperQuery { sdl: String }'; + const key = 'secret-key'; + const versionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; + + nock('http://localhost') + .get(`/version/${versionId}/supergraph`) + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, supergraphSdl, { + ETag: 'immutable-etag', + 'x-hive-schema-version-id': versionId, + }) + .get(`/version/${versionId}/supergraph`) + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('If-None-Match', 'immutable-etag') + .reply(304); + + const fetcher = createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key, + schemaVersionId: versionId, + }); + + const result = await fetcher(); + expect(result.supergraphSdl).toEqual(supergraphSdl); + expect(result.schemaVersionId).toEqual(versionId); + + // Second fetch should use cached version via 304 + const cachedResult = await fetcher(); + expect(cachedResult.supergraphSdl).toEqual(supergraphSdl); + expect(cachedResult.schemaVersionId).toEqual(versionId); + }); + + test('throws error for empty schemaVersionId', () => { + expect(() => + createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key: 'secret-key', + schemaVersionId: '', + }), + ).toThrowError( + 'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.', + ); + }); + + test('throws error for whitespace-only schemaVersionId', () => { + expect(() => + createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key: 'secret-key', + schemaVersionId: ' ', + }), + ).toThrowError( + 'Invalid schemaVersionId: cannot be empty or whitespace. Provide a valid version ID or omit the option to fetch the latest version.', + ); + }); + + test('returns 404 error for non-existent schemaVersionId', async () => { + const key = 'secret-key'; + const invalidVersionId = 'non-existent-version-id'; + + nock('http://localhost') + .get(`/version/${invalidVersionId}/supergraph`) + .times(11) + .matchHeader('X-Hive-CDN-Key', key) + .reply(404, 'Not Found'); + + const fetcher = createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key, + schemaVersionId: invalidVersionId, + }); + + await expect(fetcher()).rejects.toThrowError( + /GET http:\/\/localhost\/version\/non-existent-version-id\/supergraph .* failed with status 404/, + ); + }); });