Skip to content

Commit b6bbfd8

Browse files
Improve caching support (#100)
* feat: add /v1/geo-breakdown endpoint for geographic CWV breakdown (#94) * feat: add /v1/geo-breakdown endpoint for geographic CWV breakdown Adds a new controller and route that returns core_web_vitals data for all geographies for a given technology. Unlike /cwv, this endpoint omits the geo filter so callers can build a geographic breakdown chart without issuing one request per country. * refactor: merge geo-breakdown into reportController factory Add crossGeo option to createReportController; delete standalone geoBreakdownController.js. Endpoint now returns a single-month snapshot (latest by default, or the month specified by the end param). --------- Co-authored-by: Max Ostapenko <1611259+max-ostapenko@users.noreply.github.com> * test: add tests for /v1/geo-breakdown * fix: update CDN cache duration in setCommonHeaders function * fix: update CDN cache tag and duration in response headers * feat: add ETag support for caching in report responses * test: add ETag header tests for /v1/technologies and /v1/adoption routes --------- Co-authored-by: Alon Kochba <alonko@wix.com>
1 parent 3d50b3d commit b6bbfd8

File tree

6 files changed

+104
-42
lines changed

6 files changed

+104
-42
lines changed

src/controllers/cdnController.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ export const proxyReportsFile = async (req, res, filePath) => {
7070
// Set response headers
7171
res.setHeader('Content-Type', contentType);
7272
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
73-
res.setHeader('Cloud-CDN-Cache-Tag', 'bucket-proxy');
74-
// Browser cache: 1 hour, CDN cache: 30 days
75-
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=2592000');
73+
res.setHeader('Cache-Tag', 'bucket-proxy');
74+
// Browser cache: 1 hour, CDN cache: 1 days
75+
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400');
7676

7777
if (metadata.etag) {
7878
res.setHeader('ETag', metadata.etag);

src/controllers/reportController.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
sendValidationError,
88
getLatestDate,
99
handleControllerError,
10-
validateArrayParameter
10+
validateArrayParameter,
11+
generateETag,
12+
isModified
1113
} from '../utils/controllerHelpers.js';
1214

1315
/**
@@ -123,9 +125,17 @@ const createReportController = (reportType, { crossGeo = false } = {}) => {
123125
data.push(doc.data());
124126
});
125127

126-
// Send response
128+
// Send response with ETag support
129+
const jsonData = JSON.stringify(data);
130+
const etag = generateETag(jsonData);
131+
res.setHeader('ETag', `"${etag}"`);
132+
if (!isModified(req, etag)) {
133+
res.statusCode = 304;
134+
res.end();
135+
return;
136+
}
127137
res.statusCode = 200;
128-
res.end(JSON.stringify(data));
138+
res.end(jsonData);
129139

130140
} catch (error) {
131141
handleControllerError(res, error, `fetching ${reportType} data`);

src/index.js

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import crypto from 'crypto';
21
import functions from '@google-cloud/functions-framework';
2+
import { sendJSONResponse, isModified } from './utils/controllerHelpers.js';
33

44
// Dynamic imports for better performance - only load when needed
55
const controllers = {
@@ -67,33 +67,12 @@ const setCORSHeaders = (res) => {
6767
const setCommonHeaders = (res) => {
6868
setCORSHeaders(res);
6969
res.setHeader('Content-Type', 'application/json');
70-
// Browser cache: 1 hour, CDN cache: 30 days
71-
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=2592000');
72-
res.setHeader('Cloud-CDN-Cache-Tag', 'report-api');
70+
// Browser cache: 1 hour, CDN cache: 1 day
71+
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400');
72+
res.setHeader('Cache-Tag', 'report-api');
7373
res.setHeader('Timing-Allow-Origin', '*');
7474
};
7575

76-
// Helper function to generate ETag
77-
const generateETag = (jsonData) => {
78-
return crypto.createHash('md5').update(jsonData).digest('hex');
79-
};
80-
81-
// Helper function to send JSON response with ETag support
82-
const sendJSONResponse = (res, data, statusCode = 200) => {
83-
const jsonData = JSON.stringify(data);
84-
const etag = generateETag(jsonData);
85-
86-
res.setHeader('ETag', `"${etag}"`);
87-
res.statusCode = statusCode;
88-
res.end(jsonData);
89-
};
90-
91-
// Helper function to check if resource is modified
92-
const isModified = (req, etag) => {
93-
const ifNoneMatch = req.headers['if-none-match'] || (req.get && req.get('if-none-match'));
94-
return !ifNoneMatch || ifNoneMatch !== `"${etag}"`;
95-
};
96-
9776
// Route handler function
9877
const handleRequest = async (req, res) => {
9978
try {

src/tests/headers.test.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ jest.unstable_mockModule('../controllers/cdnController.js', () => ({
1212
proxyReportsFile: jest.fn((req, res) => {
1313
res.setHeader('Content-Type', 'application/json');
1414
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
15-
res.setHeader('Cloud-CDN-Cache-Tag', 'bucket-proxy');
16-
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=2592000');
15+
res.setHeader('Cache-Tag', 'bucket-proxy');
16+
res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400');
1717
res.statusCode = 200;
1818
res.end(JSON.stringify({ mocked: true }));
1919
})
@@ -34,8 +34,8 @@ describe('CDN Headers', () => {
3434
const res = await request(app).get('/v1/technologies');
3535

3636
expect(res.statusCode).toEqual(200);
37-
expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=2592000');
38-
expect(res.headers['cloud-cdn-cache-tag']).toBe('report-api');
37+
expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=86400');
38+
expect(res.headers['cache-tag']).toBe('report-api');
3939
expect(res.headers['access-control-allow-origin']).toBe('*');
4040
expect(res.headers['access-control-allow-headers']).toContain('Content-Type');
4141
expect(res.headers['access-control-allow-headers']).toContain('If-None-Match');
@@ -46,16 +46,16 @@ describe('CDN Headers', () => {
4646
const res = await request(app).get('/v1/static/test.json');
4747

4848
expect(res.statusCode).toEqual(200);
49-
expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=2592000');
50-
expect(res.headers['cloud-cdn-cache-tag']).toBe('bucket-proxy');
49+
expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=86400');
50+
expect(res.headers['cache-tag']).toBe('bucket-proxy');
5151
expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin');
5252
});
5353

5454
it('should set correct headers for health check', async () => {
5555
const res = await request(app).get('/');
5656

5757
expect(res.statusCode).toEqual(200);
58-
expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=2592000');
59-
expect(res.headers['cloud-cdn-cache-tag']).toBe('report-api');
58+
expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=86400');
59+
expect(res.headers['cache-tag']).toBe('report-api');
6060
});
6161
});

src/tests/routes.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,50 @@ describe('API Routes', () => {
455455
expect(res.headers).toHaveProperty('etag');
456456
});
457457

458+
it('should include ETag headers on executeQuery-based routes', async () => {
459+
const res = await request(app).get('/v1/technologies');
460+
expect(res.statusCode).toEqual(200);
461+
expect(res.headers).toHaveProperty('etag');
462+
expect(res.headers['etag']).toMatch(/^"[a-f0-9]+"$/);
463+
});
464+
465+
it('should include ETag headers on reportController-based routes', async () => {
466+
const res = await request(app).get('/v1/adoption');
467+
expect(res.statusCode).toEqual(200);
468+
expect(res.headers).toHaveProperty('etag');
469+
expect(res.headers['etag']).toMatch(/^"[a-f0-9]+"$/);
470+
});
471+
472+
it('should return 304 for executeQuery-based routes when ETag matches', async () => {
473+
const first = await request(app).get('/v1/technologies');
474+
expect(first.statusCode).toEqual(200);
475+
const etag = first.headers['etag'];
476+
477+
const second = await request(app)
478+
.get('/v1/technologies')
479+
.set('If-None-Match', etag);
480+
expect(second.statusCode).toEqual(304);
481+
});
482+
483+
it('should return 304 for reportController-based routes when ETag matches', async () => {
484+
const first = await request(app).get('/v1/adoption');
485+
expect(first.statusCode).toEqual(200);
486+
const etag = first.headers['etag'];
487+
488+
const second = await request(app)
489+
.get('/v1/adoption')
490+
.set('If-None-Match', etag);
491+
expect(second.statusCode).toEqual(304);
492+
});
493+
494+
it('should return 200 when If-None-Match does not match', async () => {
495+
const res = await request(app)
496+
.get('/v1/technologies')
497+
.set('If-None-Match', '"stale-etag"');
498+
expect(res.statusCode).toEqual(200);
499+
expect(res.headers).toHaveProperty('etag');
500+
});
501+
458502
it('should include timing headers', async () => {
459503
const res = await request(app).get('/v1/technologies');
460504
expect(res.headers['timing-allow-origin']).toEqual('*');

src/utils/controllerHelpers.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from 'crypto';
12
import { convertToArray } from './helpers.js';
23

34
/**
@@ -99,6 +100,23 @@ const handleControllerError = (res, error, operation) => {
99100
}));
100101
};
101102

103+
const generateETag = (jsonData) => {
104+
return crypto.createHash('md5').update(jsonData).digest('hex');
105+
};
106+
107+
const sendJSONResponse = (res, data, statusCode = 200) => {
108+
const jsonData = JSON.stringify(data);
109+
const etag = generateETag(jsonData);
110+
res.setHeader('ETag', `"${etag}"`);
111+
res.statusCode = statusCode;
112+
res.end(jsonData);
113+
};
114+
115+
const isModified = (req, etag) => {
116+
const ifNoneMatch = req.headers['if-none-match'] || (req.get && req.get('if-none-match'));
117+
return !ifNoneMatch || ifNoneMatch !== `"${etag}"`;
118+
};
119+
102120
/**
103121
* Generic query executor
104122
* Handles query execution and response for simple queries
@@ -126,9 +144,17 @@ const executeQuery = async (req, res, collection, queryBuilder, dataProcessor =
126144
data = dataProcessor(data, params);
127145
}
128146

129-
// Send response
147+
// Send response with ETag support
148+
const jsonData = JSON.stringify(data);
149+
const etag = generateETag(jsonData);
150+
res.setHeader('ETag', `"${etag}"`);
151+
if (!isModified(req, etag)) {
152+
res.statusCode = 304;
153+
res.end();
154+
return;
155+
}
130156
res.statusCode = 200;
131-
res.end(JSON.stringify(data));
157+
res.end(jsonData);
132158

133159
} catch (error) {
134160
// Handle validation errors specifically
@@ -170,5 +196,8 @@ export {
170196
validateArrayParameter,
171197
handleControllerError,
172198
executeQuery,
173-
validateTechnologyArray
199+
validateTechnologyArray,
200+
generateETag,
201+
sendJSONResponse,
202+
isModified
174203
};

0 commit comments

Comments
 (0)