Skip to content

Commit d03545b

Browse files
committed
feat(cdn): add versioned artifact endpoints
1 parent aa2d445 commit d03545b

File tree

10 files changed

+590
-15
lines changed

10 files changed

+590
-15
lines changed

.changeset/two-baboons-tie.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
Add support for retrieving CDN artifacts by version ID.
6+
7+
New CDN endpoints allow fetching schema artifacts for a specific version:
8+
- `/artifacts/v1/:targetId/version/:versionId/:artifactType`
9+
- `/artifacts/v1/:targetId/version/:versionId/contracts/:contractName/:artifactType`
10+
11+
Artifacts are now written to both the latest path and a versioned path during schema publish, enabling retrieval of historical versions.

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

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ function buildEndpointUrl(
6969
return `${baseUrl}${targetId}/${resourceType}`;
7070
}
7171

72+
function buildVersionedEndpointUrl(
73+
baseUrl: string,
74+
targetId: string,
75+
versionId: string,
76+
resourceType: 'sdl' | 'supergraph' | 'services' | 'metadata',
77+
) {
78+
return `${baseUrl}${targetId}/version/${versionId}/${resourceType}`;
79+
}
80+
7281
function generateLegacyToken(targetId: string) {
7382
const encoder = new TextEncoder();
7483
return (
@@ -425,6 +434,353 @@ function runArtifactsCDNTests(
425434
await server.stop();
426435
}
427436
});
437+
438+
test.concurrent('access versioned SDL artifact with valid credentials', async ({ expect }) => {
439+
const { createOrg } = await initSeed().createOwner();
440+
const { createProject } = await createOrg();
441+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
442+
ProjectType.Single,
443+
);
444+
const writeToken = await createTargetAccessToken({});
445+
446+
// Publish Schema
447+
const publishSchemaResult = await writeToken
448+
.publishSchema({
449+
author: 'Kamil',
450+
commit: 'abc123',
451+
sdl: `type Query { ping: String }`,
452+
})
453+
.then(r => r.expectNoGraphQLErrors());
454+
455+
expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
456+
457+
// Fetch the latest valid version to get the version ID
458+
const latestVersion = await writeToken.fetchLatestValidSchema();
459+
const versionId = latestVersion.latestValidVersion?.id;
460+
expect(versionId).toBeDefined();
461+
462+
const cdnAccessResult = await createCdnAccess();
463+
const endpointBaseUrl = await getBaseEndpoint();
464+
465+
// Test latest endpoint
466+
const latestUrl = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl');
467+
const latestResponse = await fetch(latestUrl, {
468+
method: 'GET',
469+
headers: {
470+
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
471+
},
472+
});
473+
expect(latestResponse.status).toBe(200);
474+
const latestBody = await latestResponse.text();
475+
476+
// Test versioned endpoint
477+
const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl');
478+
const versionedResponse = await fetch(versionedUrl, {
479+
method: 'GET',
480+
headers: {
481+
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
482+
},
483+
});
484+
485+
expect(versionedResponse.status).toBe(200);
486+
const versionedBody = await versionedResponse.text();
487+
488+
// Both should return the same content
489+
expect(versionedBody).toBe(latestBody);
490+
expect(versionedBody).toMatchInlineSnapshot(`
491+
type Query {
492+
ping: String
493+
}
494+
`);
495+
496+
// Verify the versioned S3 key exists
497+
const versionedArtifact = await fetchS3ObjectArtifact(
498+
'artifacts',
499+
`artifact/${target.id}/version/${versionId}/sdl`,
500+
);
501+
expect(versionedArtifact.body).toBe(latestBody);
502+
503+
expect(versionedResponse.headers.get('cache-control')).toBe(
504+
'public, max-age=31536000, immutable',
505+
);
506+
});
507+
508+
test.concurrent(
509+
'versioned artifact returns 404 for non-existent version',
510+
async ({ expect }) => {
511+
const { createOrg } = await initSeed().createOwner();
512+
const { createProject } = await createOrg();
513+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
514+
ProjectType.Single,
515+
);
516+
const writeToken = await createTargetAccessToken({});
517+
518+
// Publish Schema
519+
await writeToken
520+
.publishSchema({
521+
author: 'Kamil',
522+
commit: 'abc123',
523+
sdl: `type Query { ping: String }`,
524+
})
525+
.then(r => r.expectNoGraphQLErrors());
526+
527+
const cdnAccessResult = await createCdnAccess();
528+
const endpointBaseUrl = await getBaseEndpoint();
529+
530+
// Use a non-existent but valid UUID
531+
const nonExistentVersionId = '00000000-0000-0000-0000-000000000000';
532+
const versionedUrl = buildVersionedEndpointUrl(
533+
endpointBaseUrl,
534+
target.id,
535+
nonExistentVersionId,
536+
'sdl',
537+
);
538+
539+
const response = await fetch(versionedUrl, {
540+
method: 'GET',
541+
headers: {
542+
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
543+
},
544+
});
545+
546+
expect(response.status).toBe(404);
547+
},
548+
);
549+
550+
test.concurrent(
551+
'versioned artifact returns 404 for invalid UUID format',
552+
async ({ expect }) => {
553+
const { createOrg } = await initSeed().createOwner();
554+
const { createProject } = await createOrg();
555+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
556+
ProjectType.Single,
557+
);
558+
const writeToken = await createTargetAccessToken({});
559+
560+
// Publish Schema
561+
await writeToken
562+
.publishSchema({
563+
author: 'Kamil',
564+
commit: 'abc123',
565+
sdl: `type Query { ping: String }`,
566+
})
567+
.then(r => r.expectNoGraphQLErrors());
568+
569+
const cdnAccessResult = await createCdnAccess();
570+
const endpointBaseUrl = await getBaseEndpoint();
571+
572+
// Use an invalid UUID format
573+
const invalidVersionId = 'not-a-valid-uuid';
574+
const versionedUrl = buildVersionedEndpointUrl(
575+
endpointBaseUrl,
576+
target.id,
577+
invalidVersionId,
578+
'sdl',
579+
);
580+
581+
const response = await fetch(versionedUrl, {
582+
method: 'GET',
583+
headers: {
584+
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
585+
},
586+
});
587+
588+
expect(response.status).toBe(404);
589+
},
590+
);
591+
592+
test.concurrent('access versioned federation supergraph artifact', async ({ expect }) => {
593+
const { createOrg } = await initSeed().createOwner();
594+
const { createProject } = await createOrg();
595+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
596+
ProjectType.Federation,
597+
);
598+
const writeToken = await createTargetAccessToken({});
599+
600+
// Publish Schema
601+
const publishSchemaResult = await writeToken
602+
.publishSchema({
603+
author: 'Kamil',
604+
commit: 'abc123',
605+
sdl: `type Query { ping: String }`,
606+
service: 'ping',
607+
url: 'http://ping.com',
608+
})
609+
.then(r => r.expectNoGraphQLErrors());
610+
611+
expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
612+
613+
// Fetch the latest valid version to get the version ID
614+
const latestVersion = await writeToken.fetchLatestValidSchema();
615+
const versionId = latestVersion.latestValidVersion?.id;
616+
expect(versionId).toBeDefined();
617+
618+
const cdnAccessResult = await createCdnAccess();
619+
const endpointBaseUrl = await getBaseEndpoint();
620+
621+
// Test versioned supergraph endpoint
622+
const versionedUrl = buildVersionedEndpointUrl(
623+
endpointBaseUrl,
624+
target.id,
625+
versionId!,
626+
'supergraph',
627+
);
628+
const versionedResponse = await fetch(versionedUrl, {
629+
method: 'GET',
630+
headers: {
631+
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
632+
},
633+
});
634+
635+
expect(versionedResponse.status).toBe(200);
636+
const supergraphBody = await versionedResponse.text();
637+
expect(supergraphBody).toContain('schema');
638+
639+
// Verify the versioned S3 key exists
640+
const versionedArtifact = await fetchS3ObjectArtifact(
641+
'artifacts',
642+
`artifact/${target.id}/version/${versionId}/supergraph`,
643+
);
644+
expect(versionedArtifact.body).toBe(supergraphBody);
645+
646+
expect(versionedResponse.headers.get('cache-control')).toBe(
647+
'public, max-age=31536000, immutable',
648+
);
649+
});
650+
651+
test.concurrent('access versioned federation services artifact', async ({ expect }) => {
652+
const { createOrg } = await initSeed().createOwner();
653+
const { createProject } = await createOrg();
654+
const { createTargetAccessToken, createCdnAccess, target } = await createProject(
655+
ProjectType.Federation,
656+
);
657+
const writeToken = await createTargetAccessToken({});
658+
659+
// Publish Schema
660+
const publishSchemaResult = await writeToken
661+
.publishSchema({
662+
author: 'Kamil',
663+
commit: 'abc123',
664+
sdl: `type Query { ping: String }`,
665+
service: 'ping',
666+
url: 'http://ping.com',
667+
})
668+
.then(r => r.expectNoGraphQLErrors());
669+
670+
expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
671+
672+
// Fetch the latest valid version to get the version ID
673+
const latestVersion = await writeToken.fetchLatestValidSchema();
674+
const versionId = latestVersion.latestValidVersion?.id;
675+
expect(versionId).toBeDefined();
676+
677+
const cdnAccessResult = await createCdnAccess();
678+
const endpointBaseUrl = await getBaseEndpoint();
679+
680+
// Test versioned services endpoint
681+
const versionedUrl = buildVersionedEndpointUrl(
682+
endpointBaseUrl,
683+
target.id,
684+
versionId!,
685+
'services',
686+
);
687+
const versionedResponse = await fetch(versionedUrl, {
688+
method: 'GET',
689+
headers: {
690+
'x-hive-cdn-key': cdnAccessResult.secretAccessToken,
691+
},
692+
});
693+
694+
expect(versionedResponse.status).toBe(200);
695+
expect(versionedResponse.headers.get('content-type')).toContain('application/json');
696+
const servicesBody = await versionedResponse.text();
697+
expect(servicesBody).toMatchInlineSnapshot(
698+
'[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]',
699+
);
700+
701+
// Verify the versioned S3 key exists
702+
const versionedArtifact = await fetchS3ObjectArtifact(
703+
'artifacts',
704+
`artifact/${target.id}/version/${versionId}/services`,
705+
);
706+
expect(versionedArtifact.body).toBe(servicesBody);
707+
708+
expect(versionedResponse.headers.get('cache-control')).toBe(
709+
'public, max-age=31536000, immutable',
710+
);
711+
});
712+
713+
test.concurrent('versioned artifact access without credentials', async ({ expect }) => {
714+
const { createOrg } = await initSeed().createOwner();
715+
const { createProject } = await createOrg();
716+
const { createTargetAccessToken, target } = await createProject(ProjectType.Single);
717+
const writeToken = await createTargetAccessToken({});
718+
719+
await writeToken
720+
.publishSchema({
721+
author: 'Kamil',
722+
commit: 'abc123',
723+
sdl: `type Query { ping: String }`,
724+
})
725+
.then(r => r.expectNoGraphQLErrors());
726+
727+
const latestVersion = await writeToken.fetchLatestValidSchema();
728+
const versionId = latestVersion.latestValidVersion?.id;
729+
expect(versionId).toBeDefined();
730+
731+
const endpointBaseUrl = await getBaseEndpoint();
732+
const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl');
733+
734+
// Request without credentials
735+
const response = await fetch(versionedUrl, { method: 'GET' });
736+
expect(response.status).toBe(400);
737+
expect(response.headers.get('content-type')).toContain('application/json');
738+
expect(await response.json()).toEqual({
739+
code: 'MISSING_AUTH_KEY',
740+
error: 'Hive CDN authentication key is missing',
741+
description:
742+
'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ',
743+
});
744+
});
745+
746+
test.concurrent('versioned artifact access with invalid credentials', async ({ expect }) => {
747+
const { createOrg } = await initSeed().createOwner();
748+
const { createProject } = await createOrg();
749+
const { createTargetAccessToken, target } = await createProject(ProjectType.Single);
750+
const writeToken = await createTargetAccessToken({});
751+
752+
await writeToken
753+
.publishSchema({
754+
author: 'Kamil',
755+
commit: 'abc123',
756+
sdl: `type Query { ping: String }`,
757+
})
758+
.then(r => r.expectNoGraphQLErrors());
759+
760+
const latestVersion = await writeToken.fetchLatestValidSchema();
761+
const versionId = latestVersion.latestValidVersion?.id;
762+
expect(versionId).toBeDefined();
763+
764+
const endpointBaseUrl = await getBaseEndpoint();
765+
const versionedUrl = buildVersionedEndpointUrl(endpointBaseUrl, target.id, versionId!, 'sdl');
766+
767+
// Request with invalid credentials
768+
const response = await fetch(versionedUrl, {
769+
method: 'GET',
770+
headers: {
771+
'x-hive-cdn-key': 'invalid-key',
772+
},
773+
});
774+
expect(response.status).toBe(403);
775+
expect(response.headers.get('content-type')).toContain('application/json');
776+
expect(await response.json()).toEqual({
777+
code: 'INVALID_AUTH_KEY',
778+
error:
779+
'Hive CDN authentication key is invalid, or it does not match the requested target ID.',
780+
description:
781+
'Please refer to the documentation for more details: https://docs.graphql-hive.com/features/registry-usage ',
782+
});
783+
});
428784
});
429785
}
430786

0 commit comments

Comments
 (0)