diff --git a/.changeset/two-baboons-tie.md b/.changeset/two-baboons-tie.md deleted file mode 100644 index 546a2aa3e2..0000000000 --- a/.changeset/two-baboons-tie.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'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. - -CDN artifact responses now include the `x-hive-schema-version-id` header, providing the version ID of the schema being served. diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index 0150b2848c..837bf088cc 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -69,40 +69,6 @@ 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 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 ( @@ -459,777 +425,6 @@ 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('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(); - 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 ', - }); - }); - - 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 bddd9c2fb5..fce17b7a75 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,7 +41,6 @@ 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: { @@ -49,87 +48,25 @@ export class ArtifactStorageWriter { artifactType: keyof typeof artifactMeta; artifact: unknown; contractName: null | string; - versionId?: string | null; }) { - const latestKey = buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName); - const versionedKey = args.versionId - ? buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName, args.versionId) - : null; + const key = buildArtifactStorageKey(args.targetId, args.artifactType, args.contractName); const meta = artifactMeta[args.artifactType]; - const body = meta.preprocessor(args.artifact); for (const s3 of this.s3Mirrors) { - 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 && 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: { - signQuery: true, - }, - }, - ); - - 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})`, - ); - } - } - - // Write to latest key (always) - only after versioned succeeds - const latestResult = await s3.client.fetch([s3.endpoint, s3.bucket, latestKey].join('/'), { + const result = await s3.client.fetch([s3.endpoint, s3.bucket, key].join('/'), { 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, + body: meta.preprocessor(args.artifact), aws: { // This boolean makes Google Cloud Storage & AWS happy. signQuery: true, }, }); - 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.`, - ); + if (result.statusCode !== 200) { + throw new Error(`Unexpected status code ${result.statusCode} when writing artifact.`); } } } 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 b6f4fcdc7e..45c4b40c50 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(versionId: string): Promise; + actionFn(): 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 e07df15286..3e4f7d45b1 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 (versionId: string) => { + actionFn: async () => { if (deleteResult.state.composable) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of deleteResult.state.contracts ?? []) { @@ -1451,7 +1451,6 @@ export class SchemaPublisher { // pass all schemas except the one we are deleting schemas: deleteResult.state.schemas, contracts, - versionId, }); } }, @@ -1946,7 +1945,7 @@ export class SchemaPublisher { metadata: input.metadata ?? null, projectType: project.type, github, - actionFn: async (versionId: string) => { + actionFn: async () => { if (composable && fullSchemaSdl) { const contracts: Array<{ name: string; sdl: string; supergraph: string }> = []; for (const contract of publishState.contracts ?? []) { @@ -1966,7 +1965,6 @@ export class SchemaPublisher { fullSchemaSdl, schemas, contracts, - versionId, }); } }, @@ -2246,7 +2244,6 @@ export class SchemaPublisher { fullSchemaSdl, schemas, contracts, - versionId, }: { target: Target; project: Project; @@ -2254,7 +2251,6 @@ 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> = []; @@ -2272,7 +2268,6 @@ export class SchemaPublisher { artifact: project.type === ProjectType.SINGLE ? metadata[0] : metadata, artifactType: 'metadata', contractName: null, - versionId, }); } }; @@ -2290,14 +2285,12 @@ export class SchemaPublisher { url: s.service_url, })), contractName: null, - versionId, }), this.artifactStorageWriter.writeArtifact({ targetId: target.id, artifactType: 'sdl', artifact: fullSchemaSdl, contractName: null, - versionId, }), ]); }; @@ -2308,7 +2301,6 @@ export class SchemaPublisher { artifactType: 'sdl', artifact: fullSchemaSdl, contractName: null, - versionId, }); }; @@ -2327,7 +2319,6 @@ export class SchemaPublisher { artifactType: 'supergraph', artifact: supergraph, contractName: null, - versionId, }), ); } @@ -2342,14 +2333,12 @@ 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 cc797a072c..31eacb8016 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(versionId: string): Promise; + actionFn(): 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(versionId: string): Promise; + actionFn(): 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 e638b4ef27..ba21f6e343 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' | 'v1-versioned'; + version: 'v0' | 'v1'; value: | 'schema' | 'supergraph' diff --git a/packages/services/cdn-worker/src/artifact-handler.ts b/packages/services/cdn-worker/src/artifact-handler.ts index 5e22940ef7..1eeaa6aadf 100644 --- a/packages/services/cdn-worker/src/artifact-handler.ts +++ b/packages/services/cdn-worker/src/artifact-handler.ts @@ -52,23 +52,6 @@ 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(), @@ -181,8 +164,6 @@ 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') { @@ -210,124 +191,6 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { headers: { 'Content-Type': 'application/json', ...(etag ? { etag } : {}), - ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), - }, - }, - params.targetId, - request, - ); - } - } - - return createResponse( - analytics, - text, - { - status: 200, - headers: { - 'Content-Type': - params.artifactType === 'metadata' || params.artifactType === 'services' - ? '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) { - 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'); - // 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') { - // 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 } : {}), - ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, params.targetId, @@ -346,29 +209,15 @@ export const createArtifactRequestHandler = (deps: ArtifactRequestHandler) => { 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 } : {}), - ...(schemaVersionId ? { 'x-hive-schema-version-id': schemaVersionId } : {}), }, }, 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 51bfcb64df..ddb14f0a42 100644 --- a/packages/services/cdn-worker/src/artifact-storage-reader.ts +++ b/packages/services/cdn-worker/src/artifact-storage-reader.ts @@ -7,12 +7,8 @@ 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); } @@ -236,17 +232,16 @@ 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}${versionId ? `, versionId=${versionId}` : ''})`, + `Reading artifact (targetId=${targetId}, artifactType=${artifactType}, contractName=${contractName})`, ); - const key = buildArtifactStorageKey(targetId, artifactType, contractName, versionId); + const key = buildArtifactStorageKey(targetId, artifactType, contractName); 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 dc2a5aeb3c..a71a38fcd9 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(newVersion.id); + await args.actionFn(); return { kind: 'composite', @@ -2616,7 +2616,7 @@ export async function createStorage( }); } - await input.actionFn(version.id); + await input.actionFn(); return { version,