mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 09:38:26 +00:00
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
This commit is contained in:
parent
2c5a2896bd
commit
36413f1682
7 changed files with 601 additions and 30 deletions
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,21 +9,50 @@ interface Schema {
|
|||
}
|
||||
|
||||
function createFetcher<T>({ endpoint, key }: SchemaFetcherOptions & ServicesFetcherOptions) {
|
||||
let cacheETag: string | null = null;
|
||||
let cached: {
|
||||
id: string;
|
||||
supergraphSdl: string;
|
||||
} | null = null;
|
||||
|
||||
return function fetcher(): Promise<T> {
|
||||
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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue