From 36413f16821b1a3e8ba002209a90eaf7df7605a4 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Fri, 30 Sep 2022 13:37:57 +0200 Subject: [PATCH] Use ETag and If-None-Match in CDN and clients (#427) * Use ETag and If-None-Match in CDN and clients * Document it in 'Using registry' chapter --- packages/libraries/client/src/apollo.ts | 45 +++- packages/libraries/client/src/gateways.ts | 39 ++- .../libraries/client/tests/apollo.spec.ts | 62 ++++- .../libraries/client/tests/gateways.spec.ts | 170 +++++++++++- packages/services/cdn-worker/src/handler.ts | 51 +++- .../services/cdn-worker/tests/cdn.spec.ts | 250 ++++++++++++++++++ .../docs/pages/features/registry-usage.mdx | 14 + 7 files changed, 601 insertions(+), 30 deletions(-) diff --git a/packages/libraries/client/src/apollo.ts b/packages/libraries/client/src/apollo.ts index be8824ada..4a7c5d35b 100644 --- a/packages/libraries/client/src/apollo.ts +++ b/packages/libraries/client/src/apollo.ts @@ -7,24 +7,53 @@ import { createHive } from './client'; import { isHiveClient } from './internal/utils'; export function createSupergraphSDLFetcher({ endpoint, key }: SupergraphSDLFetcherOptions) { + let cacheETag: string | null = null; + let cached: { + id: string; + supergraphSdl: string; + } | null = null; + return function supergraphSDLFetcher() { + const headers: { + [key: string]: string; + } = { + 'X-Hive-CDN-Key': key, + }; + + if (cacheETag) { + headers['If-None-Match'] = cacheETag; + } + return axios .get(endpoint + '/supergraph', { - headers: { - 'X-Hive-CDN-Key': key, - }, + headers, }) .then(response => { if (response.status >= 200 && response.status < 300) { - return response.data; + const supergraphSdl = response.data; + const result = { + id: createHash('sha256').update(supergraphSdl).digest('base64'), + supergraphSdl, + }; + + const etag = response.headers['etag']; + if (etag) { + cached = result; + cacheETag = etag; + } + + return result; } return Promise.reject(new Error(`Failed to fetch supergraph [${response.status}]`)); }) - .then(supergraphSdl => ({ - id: createHash('sha256').update(supergraphSdl).digest('base64'), - supergraphSdl, - })); + .catch(async error => { + if (axios.isAxiosError(error) && error.response?.status === 304 && cached !== null) { + return cached; + } + + throw error; + }); }; } diff --git a/packages/libraries/client/src/gateways.ts b/packages/libraries/client/src/gateways.ts index 0ff5005b7..60f25f3ec 100644 --- a/packages/libraries/client/src/gateways.ts +++ b/packages/libraries/client/src/gateways.ts @@ -9,21 +9,50 @@ interface Schema { } function createFetcher({ endpoint, key }: SchemaFetcherOptions & ServicesFetcherOptions) { + let cacheETag: string | null = null; + let cached: { + id: string; + supergraphSdl: string; + } | null = null; + return function fetcher(): Promise { + const headers: { + [key: string]: string; + } = { + 'X-Hive-CDN-Key': key, + accept: 'application/json', + }; + + if (cacheETag) { + headers['If-None-Match'] = cacheETag; + } + return axios .get(endpoint + '/schema', { - headers: { - 'X-Hive-CDN-Key': key, - accept: 'application/json', - }, + headers, responseType: 'json', }) .then(response => { if (response.status >= 200 && response.status < 300) { - return response.data; + const result = response.data; + + const etag = response.headers['etag']; + if (etag) { + cached = result; + cacheETag = etag; + } + + return result; } return Promise.reject(new Error(`Failed to fetch [${response.status}]`)); + }) + .catch(async error => { + if (axios.isAxiosError(error) && error.response?.status === 304 && cached !== null) { + return cached; + } + + throw error; }); }; } diff --git a/packages/libraries/client/tests/apollo.spec.ts b/packages/libraries/client/tests/apollo.spec.ts index 6f0e41030..1a023d6b7 100644 --- a/packages/libraries/client/tests/apollo.spec.ts +++ b/packages/libraries/client/tests/apollo.spec.ts @@ -2,14 +2,23 @@ import nock from 'nock'; import { createSupergraphSDLFetcher } from '../src/apollo'; -test('createSupergraphSDLFetcher', async () => { +test('createSupergraphSDLFetcher without ETag', async () => { const supergraphSdl = 'type SuperQuery { sdl: String }'; + const newSupergraphSdl = 'type NewSuperQuery { sdl: String }'; const key = 'secret-key'; nock('http://localhost') .get('/supergraph') .once() .matchHeader('X-Hive-CDN-Key', key) - .reply(() => [200, supergraphSdl]); + .reply(200, supergraphSdl, { + ETag: 'first', + }) + .get('/supergraph') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, newSupergraphSdl, { + ETag: 'second', + }); const fetcher = createSupergraphSDLFetcher({ endpoint: 'http://localhost', @@ -20,4 +29,53 @@ test('createSupergraphSDLFetcher', async () => { expect(result.id).toBeDefined(); expect(result.supergraphSdl).toEqual(supergraphSdl); + + const secondResult = await fetcher(); + + expect(secondResult.id).toBeDefined(); + expect(secondResult.supergraphSdl).toEqual(newSupergraphSdl); +}); + +test('createSupergraphSDLFetcher', async () => { + const supergraphSdl = 'type SuperQuery { sdl: String }'; + const newSupergraphSdl = 'type Query { sdl: String }'; + const key = 'secret-key'; + nock('http://localhost') + .get('/supergraph') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .reply(200, supergraphSdl, { + ETag: 'first', + }) + .get('/supergraph') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('If-None-Match', 'first') + .reply(304) + .get('/supergraph') + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('If-None-Match', 'first') + .reply(200, newSupergraphSdl, { + ETag: 'changed', + }); + + const fetcher = createSupergraphSDLFetcher({ + endpoint: 'http://localhost', + key, + }); + + const result = await fetcher(); + + expect(result.id).toBeDefined(); + expect(result.supergraphSdl).toEqual(supergraphSdl); + + const cachedResult = await fetcher(); + + expect(cachedResult.id).toBeDefined(); + expect(cachedResult.supergraphSdl).toEqual(supergraphSdl); + + const staleResult = await fetcher(); + + expect(staleResult.id).toBeDefined(); + expect(staleResult.supergraphSdl).toEqual(newSupergraphSdl); }); diff --git a/packages/libraries/client/tests/gateways.spec.ts b/packages/libraries/client/tests/gateways.spec.ts index 3e4714b8d..2de933f64 100644 --- a/packages/libraries/client/tests/gateways.spec.ts +++ b/packages/libraries/client/tests/gateways.spec.ts @@ -6,19 +6,29 @@ afterEach(() => { nock.cleanAll(); }); -test('createServicesFetcher', async () => { +test('createServicesFetcher without ETag', async () => { const schema = { sdl: 'type Query { noop: String }', url: 'service-url', name: 'service-name', }; + const newSchema = { + sdl: 'type NewQuery { noop: String }', + url: 'new-service-url', + name: 'new-service-name', + }; const key = 'secret-key'; nock('http://localhost') .get('/schema') .once() .matchHeader('X-Hive-CDN-Key', key) .matchHeader('accept', 'application/json') - .reply(() => [200, [schema]]); + .reply(() => [200, [schema]]) + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .reply(() => [200, [newSchema]]); const fetcher = createServicesFetcher({ endpoint: 'http://localhost', @@ -32,21 +42,104 @@ test('createServicesFetcher', async () => { expect(result[0].name).toEqual(schema.name); expect(result[0].sdl).toEqual(schema.sdl); expect(result[0].url).toEqual(schema.url); + + const secondResult = await fetcher(); + + expect(secondResult).toHaveLength(1); + expect(secondResult[0].id).toBeDefined(); + expect(secondResult[0].name).toEqual(newSchema.name); + expect(secondResult[0].sdl).toEqual(newSchema.sdl); + expect(secondResult[0].url).toEqual(newSchema.url); }); -test('createSchemaFetcher', async () => { +test('createServicesFetcher with ETag', async () => { const schema = { sdl: 'type Query { noop: String }', url: 'service-url', name: 'service-name', }; + const newSchema = { + sdl: 'type NewQuery { noop: String }', + url: 'new-service-url', + name: 'new-service-name', + }; const key = 'secret-key'; nock('http://localhost') .get('/schema') .once() .matchHeader('X-Hive-CDN-Key', key) .matchHeader('accept', 'application/json') - .reply(() => [200, schema]); + .reply(200, [schema], { + ETag: 'first', + }) + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .matchHeader('If-None-Match', 'first') + .reply(304) + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .matchHeader('If-None-Match', 'first') + .reply(200, [newSchema], { + ETag: 'changed', + }); + + const fetcher = createServicesFetcher({ + endpoint: 'http://localhost', + key, + }); + + const firstResult = await fetcher(); + + expect(firstResult).toHaveLength(1); + expect(firstResult[0].id).toBeDefined(); + expect(firstResult[0].name).toEqual(schema.name); + expect(firstResult[0].sdl).toEqual(schema.sdl); + expect(firstResult[0].url).toEqual(schema.url); + + const secondResult = await fetcher(); + + expect(secondResult).toHaveLength(1); + expect(secondResult[0].id).toBeDefined(); + expect(secondResult[0].name).toEqual(schema.name); + expect(secondResult[0].sdl).toEqual(schema.sdl); + expect(secondResult[0].url).toEqual(schema.url); + + const staleResult = await fetcher(); + + expect(staleResult).toHaveLength(1); + expect(staleResult[0].id).toBeDefined(); + expect(staleResult[0].name).toEqual(newSchema.name); + expect(staleResult[0].sdl).toEqual(newSchema.sdl); + expect(staleResult[0].url).toEqual(newSchema.url); +}); + +test('createSchemaFetcher without ETag (older versions)', async () => { + const schema = { + sdl: 'type Query { noop: String }', + url: 'service-url', + name: 'service-name', + }; + const newSchema = { + sdl: 'type NewQuery { noop: String }', + url: 'new-service-url', + name: 'new-service-name', + }; + const key = 'secret-key'; + nock('http://localhost') + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .reply(() => [200, schema]) + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .reply(() => [200, newSchema]); const fetcher = createSchemaFetcher({ endpoint: 'http://localhost', @@ -59,4 +152,73 @@ test('createSchemaFetcher', async () => { expect(result.name).toEqual(schema.name); expect(result.sdl).toEqual(schema.sdl); expect(result.url).toEqual(schema.url); + + const newResult = await fetcher(); + + expect(newResult.id).toBeDefined(); + expect(newResult.name).toEqual(newSchema.name); + expect(newResult.sdl).toEqual(newSchema.sdl); + expect(newResult.url).toEqual(newSchema.url); +}); + +test('createSchemaFetcher with ETag', async () => { + const schema = { + sdl: 'type Query { noop: String }', + url: 'service-url', + name: 'service-name', + }; + const newSchema = { + sdl: 'type NewQuery { noop: String }', + url: 'new-service-url', + name: 'new-service-name', + }; + const key = 'secret-key'; + nock('http://localhost') + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .reply(200, schema, { + ETag: 'first', + }) + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .matchHeader('If-None-Match', 'first') + .reply(304) + .get('/schema') + .once() + .matchHeader('X-Hive-CDN-Key', key) + .matchHeader('accept', 'application/json') + .matchHeader('If-None-Match', 'first') + .reply(200, newSchema, { + ETag: 'changed', + }); + + const fetcher = createSchemaFetcher({ + endpoint: 'http://localhost', + key, + }); + + const firstResult = await fetcher(); + + expect(firstResult.id).toBeDefined(); + expect(firstResult.name).toEqual(schema.name); + expect(firstResult.sdl).toEqual(schema.sdl); + expect(firstResult.url).toEqual(schema.url); + + const secondResult = await fetcher(); + + expect(secondResult.id).toBeDefined(); + expect(secondResult.name).toEqual(schema.name); + expect(secondResult.sdl).toEqual(schema.sdl); + expect(secondResult.url).toEqual(schema.url); + + const staleResult = await fetcher(); + + expect(staleResult.id).toBeDefined(); + expect(staleResult.name).toEqual(newSchema.name); + expect(staleResult.sdl).toEqual(newSchema.sdl); + expect(staleResult.url).toEqual(newSchema.url); }); diff --git a/packages/services/cdn-worker/src/handler.ts b/packages/services/cdn-worker/src/handler.ts index 31ee32f6b..a6dd682a9 100644 --- a/packages/services/cdn-worker/src/handler.ts +++ b/packages/services/cdn-worker/src/handler.ts @@ -9,6 +9,14 @@ import { import { isKeyValid } from './auth'; import { buildSchema, introspectionFromSchema } from 'graphql'; +async function createETag(value: string) { + const myText = new TextEncoder().encode(value); + const myDigest = await crypto.subtle.digest({ name: 'SHA-256' }, myText); + const hashArray = Array.from(new Uint8Array(myDigest)); + + return `"${hashArray.map(b => b.toString(16).padStart(2, '0')).join('')}"`; +} + type SchemaArtifact = { sdl: string; url?: string; @@ -20,37 +28,50 @@ const artifactTypesHandlers = { /** * Returns SchemaArtifact or SchemaArtifact[], same way as it's stored in the storage */ - schema: (targetId: string, artifactType: string, rawValue: string) => + schema: (targetId: string, artifactType: string, rawValue: string, etag: string) => new Response(rawValue, { status: 200, headers: { 'Content-Type': 'application/json', + etag, }, }), /** * Returns Federation Supergraph, we store it as-is. */ - supergraph: (targetId: string, artifactType: string, rawValue: string) => new Response(rawValue, { status: 200 }), - sdl: (targetId: string, artifactType: string, rawValue: string) => { + supergraph: (targetId: string, artifactType: string, rawValue: string, etag: string) => + new Response(rawValue, { + status: 200, + headers: { + etag, + }, + }), + sdl: (targetId: string, artifactType: string, rawValue: string, etag: string) => { if (rawValue.startsWith('[')) { return new InvalidArtifactMatch(artifactType, targetId); } const parsed = JSON.parse(rawValue) as SchemaArtifact; - return new Response(parsed.sdl, { status: 200 }); + return new Response(parsed.sdl, { + status: 200, + headers: { + etag, + }, + }); }, /** * Returns Metadata same way as it's stored in the storage */ - metadata: (targetId: string, artifactType: string, rawValue: string) => + metadata: (targetId: string, artifactType: string, rawValue: string, etag: string) => new Response(rawValue, { status: 200, headers: { 'Content-Type': 'application/json', + etag, }, }), - introspection: (targetId: string, artifactType: string, rawValue: string) => { + introspection: (targetId: string, artifactType: string, rawValue: string, etag: string) => { if (rawValue.startsWith('[')) { return new InvalidArtifactMatch(artifactType, targetId); } @@ -64,6 +85,7 @@ const artifactTypesHandlers = { status: 200, headers: { 'Content-Type': 'application/json', + etag, }, }); }, @@ -142,17 +164,24 @@ export async function handleRequest(request: Request, keyValidator: typeof isKey const rawValue = await HIVE_DATA.get(kvStorageKey); if (rawValue) { + const etag = await createETag(`${kvStorageKey}|${rawValue}`); + const ifNoneMatch = request.headers.get('if-none-match'); + + if (ifNoneMatch && ifNoneMatch === etag) { + return new Response(null, { status: 304 }); + } + switch (artifactType) { case 'schema': - return artifactTypesHandlers.schema(targetId, artifactType, rawValue); + return artifactTypesHandlers.schema(targetId, artifactType, rawValue, etag); case 'supergraph': - return artifactTypesHandlers.supergraph(targetId, artifactType, rawValue); + return artifactTypesHandlers.supergraph(targetId, artifactType, rawValue, etag); case 'sdl': - return artifactTypesHandlers.sdl(targetId, artifactType, rawValue); + return artifactTypesHandlers.sdl(targetId, artifactType, rawValue, etag); case 'introspection': - return artifactTypesHandlers.introspection(targetId, artifactType, rawValue); + return artifactTypesHandlers.introspection(targetId, artifactType, rawValue, etag); case 'metadata': - return artifactTypesHandlers.metadata(targetId, artifactType, rawValue); + return artifactTypesHandlers.metadata(targetId, artifactType, rawValue, etag); default: return new Response(null, { status: 500, diff --git a/packages/services/cdn-worker/tests/cdn.spec.ts b/packages/services/cdn-worker/tests/cdn.spec.ts index 3f130bb38..38934fa72 100644 --- a/packages/services/cdn-worker/tests/cdn.spec.ts +++ b/packages/services/cdn-worker/tests/cdn.spec.ts @@ -69,6 +69,256 @@ describe('CDN Worker', () => { expect(metadataResponse.headers.get('content-type')).toBe('application/json'); }); + test('etag + if-none-match for schema', async () => { + const SECRET = '123456'; + const targetId = 'fake-target-id'; + const map = new Map(); + map.set(`target:${targetId}:schema`, JSON.stringify({ sdl: `type Query { dummy: String }` })); + + mockWorkerEnv({ + HIVE_DATA: map, + KEY_DATA: SECRET, + }); + + const token = createToken(SECRET, targetId); + + const firstRequest = new Request(`https://fake-worker.com/${targetId}/schema`, { + headers: { + 'x-hive-cdn-key': token, + }, + }); + const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const etag = firstResponse.headers.get('etag'); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body).toBeDefined(); + // Every request receives the etag + expect(etag).toBeDefined(); + + // Sending the etag in the if-none-match header should result in a 304 + const secondRequest = new Request(`https://fake-worker.com/${targetId}/schema`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': etag!, + }, + }); + const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + expect(secondResponse.status).toBe(304); + expect(secondResponse.body).toBeNull(); + + // Sending the etag in the if-none-match header should result in a 304 + const wrongEtagRequest = new Request(`https://fake-worker.com/${targetId}/schema`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': '"non-existing-etag"', + }, + }); + const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + expect(wrongEtagResponse.status).toBe(200); + expect(wrongEtagResponse.body).toBeDefined(); + }); + + test('etag + if-none-match for supergraph', async () => { + const SECRET = '123456'; + const targetId = 'fake-target-id'; + const map = new Map(); + map.set(`target:${targetId}:supergraph`, JSON.stringify({ sdl: `type Query { dummy: String }` })); + + mockWorkerEnv({ + HIVE_DATA: map, + KEY_DATA: SECRET, + }); + + const token = createToken(SECRET, targetId); + + const firstRequest = new Request(`https://fake-worker.com/${targetId}/supergraph`, { + headers: { + 'x-hive-cdn-key': token, + }, + }); + const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const etag = firstResponse.headers.get('etag'); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body).toBeDefined(); + // Every request receives the etag + expect(etag).toBeDefined(); + + // Sending the etag in the if-none-match header should result in a 304 + const secondRequest = new Request(`https://fake-worker.com/${targetId}/supergraph`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': etag!, + }, + }); + const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + expect(secondResponse.status).toBe(304); + expect(secondResponse.body).toBeNull(); + + // Sending the etag in the if-none-match header should result in a 304 + const wrongEtagRequest = new Request(`https://fake-worker.com/${targetId}/supergraph`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': '"non-existing-etag"', + }, + }); + const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + expect(wrongEtagResponse.status).toBe(200); + expect(wrongEtagResponse.body).toBeDefined(); + }); + + test('etag + if-none-match for metadata', async () => { + const SECRET = '123456'; + const targetId = 'fake-target-id'; + const map = new Map(); + map.set(`target:${targetId}:metadata`, JSON.stringify({ sdl: `type Query { dummy: String }` })); + + mockWorkerEnv({ + HIVE_DATA: map, + KEY_DATA: SECRET, + }); + + const token = createToken(SECRET, targetId); + + const firstRequest = new Request(`https://fake-worker.com/${targetId}/metadata`, { + headers: { + 'x-hive-cdn-key': token, + }, + }); + const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const etag = firstResponse.headers.get('etag'); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body).toBeDefined(); + // Every request receives the etag + expect(etag).toBeDefined(); + + // Sending the etag in the if-none-match header should result in a 304 + const secondRequest = new Request(`https://fake-worker.com/${targetId}/metadata`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': etag!, + }, + }); + const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + expect(secondResponse.status).toBe(304); + expect(secondResponse.body).toBeNull(); + + // Sending the etag in the if-none-match header should result in a 304 + const wrongEtagRequest = new Request(`https://fake-worker.com/${targetId}/metadata`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': '"non-existing-etag"', + }, + }); + const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + expect(wrongEtagResponse.status).toBe(200); + expect(wrongEtagResponse.body).toBeDefined(); + }); + + test('etag + if-none-match for introspection', async () => { + const SECRET = '123456'; + const targetId = 'fake-target-id'; + const map = new Map(); + map.set(`target:${targetId}:schema`, JSON.stringify({ sdl: `type Query { dummy: String }` })); + + mockWorkerEnv({ + HIVE_DATA: map, + KEY_DATA: SECRET, + }); + + const token = createToken(SECRET, targetId); + + const firstRequest = new Request(`https://fake-worker.com/${targetId}/introspection`, { + headers: { + 'x-hive-cdn-key': token, + }, + }); + const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const etag = firstResponse.headers.get('etag'); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body).toBeDefined(); + // Every request receives the etag + expect(etag).toBeDefined(); + + // Sending the etag in the if-none-match header should result in a 304 + const secondRequest = new Request(`https://fake-worker.com/${targetId}/introspection`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': etag!, + }, + }); + const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + expect(secondResponse.status).toBe(304); + expect(secondResponse.body).toBeNull(); + + // Sending the etag in the if-none-match header should result in a 304 + const wrongEtagRequest = new Request(`https://fake-worker.com/${targetId}/introspection`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': '"non-existing-etag"', + }, + }); + const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + expect(wrongEtagResponse.status).toBe(200); + expect(wrongEtagResponse.body).toBeDefined(); + }); + + test('etag + if-none-match for sdl', async () => { + const SECRET = '123456'; + const targetId = 'fake-target-id'; + const map = new Map(); + map.set( + `target:${targetId}:schema`, + JSON.stringify({ + sdl: `type Query { dummy: String }`, + }) + ); + + mockWorkerEnv({ + HIVE_DATA: map, + KEY_DATA: SECRET, + }); + + const token = createToken(SECRET, targetId); + + const firstRequest = new Request(`https://fake-worker.com/${targetId}/sdl`, { + headers: { + 'x-hive-cdn-key': token, + }, + }); + const firstResponse = await handleRequest(firstRequest, KeyValidators.Bcrypt); + const etag = firstResponse.headers.get('etag'); + + expect(firstResponse.status).toBe(200); + expect(firstResponse.body).toBeDefined(); + // Every request receives the etag + expect(etag).toBeDefined(); + + // Sending the etag in the if-none-match header should result in a 304 + const secondRequest = new Request(`https://fake-worker.com/${targetId}/sdl`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': etag!, + }, + }); + const secondResponse = await handleRequest(secondRequest, KeyValidators.Bcrypt); + expect(secondResponse.status).toBe(304); + expect(secondResponse.body).toBeNull(); + + // Sending the etag in the if-none-match header should result in a 304 + const wrongEtagRequest = new Request(`https://fake-worker.com/${targetId}/sdl`, { + headers: { + 'x-hive-cdn-key': token, + 'if-none-match': '"non-existing-etag"', + }, + }); + const wrongEtagResponse = await handleRequest(wrongEtagRequest, KeyValidators.Bcrypt); + expect(wrongEtagResponse.status).toBe(200); + expect(wrongEtagResponse.body).toBeDefined(); + }); + describe('Basic parsing errors', () => { it('Should throw when target id is missing', async () => { mockWorkerEnv({ diff --git a/packages/web/docs/pages/features/registry-usage.mdx b/packages/web/docs/pages/features/registry-usage.mdx index 6cc223f0f..cc3f88f8d 100644 --- a/packages/web/docs/pages/features/registry-usage.mdx +++ b/packages/web/docs/pages/features/registry-usage.mdx @@ -169,3 +169,17 @@ Here's a list of available endpoints: ``` The value returned is the value you stored, as JSON, with `Content-Type: application/json` header. + +## Notes + +### ETag and If-None-Match headers + +The CDN service accepts the [`ETag`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) and [`If-None-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match) headers. + +Every successful response from the CDN service (`200 OK`) contains the `ETag` header with a checksum. +If you send the same checksum in the `If-None-Match` header, the CDN service will return `304 Not Modified`, but only if the data hasn't changed. +If the data has changed, the CDN service will return `200 OK` with the new data and new `ETag` header. + +Using `ETag` and `If-None-Mathc` helps to prevent unnecessary data transfer. + +The `@graphql-hive/client` package uses this feature to save bandwidth and improve performance.