diff --git a/src/controllers/cdnController.js b/src/controllers/cdnController.js index d34b9ed..b44576b 100644 --- a/src/controllers/cdnController.js +++ b/src/controllers/cdnController.js @@ -70,9 +70,9 @@ export const proxyReportsFile = async (req, res, filePath) => { // Set response headers res.setHeader('Content-Type', contentType); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - res.setHeader('Cloud-CDN-Cache-Tag', 'bucket-proxy'); - // Browser cache: 1 hour, CDN cache: 30 days - res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=2592000'); + res.setHeader('Cache-Tag', 'bucket-proxy'); + // Browser cache: 1 hour, CDN cache: 1 days + res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400'); if (metadata.etag) { res.setHeader('ETag', metadata.etag); diff --git a/src/controllers/reportController.js b/src/controllers/reportController.js index 248cd44..71ea30a 100644 --- a/src/controllers/reportController.js +++ b/src/controllers/reportController.js @@ -7,7 +7,9 @@ import { sendValidationError, getLatestDate, handleControllerError, - validateArrayParameter + validateArrayParameter, + generateETag, + isModified } from '../utils/controllerHelpers.js'; /** @@ -123,9 +125,17 @@ const createReportController = (reportType, { crossGeo = false } = {}) => { data.push(doc.data()); }); - // Send response + // Send response with ETag support + const jsonData = JSON.stringify(data); + const etag = generateETag(jsonData); + res.setHeader('ETag', `"${etag}"`); + if (!isModified(req, etag)) { + res.statusCode = 304; + res.end(); + return; + } res.statusCode = 200; - res.end(JSON.stringify(data)); + res.end(jsonData); } catch (error) { handleControllerError(res, error, `fetching ${reportType} data`); diff --git a/src/index.js b/src/index.js index cfe2b32..ec23e22 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ -import crypto from 'crypto'; import functions from '@google-cloud/functions-framework'; +import { sendJSONResponse, isModified } from './utils/controllerHelpers.js'; // Dynamic imports for better performance - only load when needed const controllers = { @@ -67,33 +67,12 @@ const setCORSHeaders = (res) => { const setCommonHeaders = (res) => { setCORSHeaders(res); res.setHeader('Content-Type', 'application/json'); - // Browser cache: 1 hour, CDN cache: 30 days - res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=2592000'); - res.setHeader('Cloud-CDN-Cache-Tag', 'report-api'); + // Browser cache: 1 hour, CDN cache: 1 day + res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400'); + res.setHeader('Cache-Tag', 'report-api'); res.setHeader('Timing-Allow-Origin', '*'); }; -// Helper function to generate ETag -const generateETag = (jsonData) => { - return crypto.createHash('md5').update(jsonData).digest('hex'); -}; - -// Helper function to send JSON response with ETag support -const sendJSONResponse = (res, data, statusCode = 200) => { - const jsonData = JSON.stringify(data); - const etag = generateETag(jsonData); - - res.setHeader('ETag', `"${etag}"`); - res.statusCode = statusCode; - res.end(jsonData); -}; - -// Helper function to check if resource is modified -const isModified = (req, etag) => { - const ifNoneMatch = req.headers['if-none-match'] || (req.get && req.get('if-none-match')); - return !ifNoneMatch || ifNoneMatch !== `"${etag}"`; -}; - // Route handler function const handleRequest = async (req, res) => { try { diff --git a/src/tests/headers.test.js b/src/tests/headers.test.js index d930d75..6673387 100644 --- a/src/tests/headers.test.js +++ b/src/tests/headers.test.js @@ -12,8 +12,8 @@ jest.unstable_mockModule('../controllers/cdnController.js', () => ({ proxyReportsFile: jest.fn((req, res) => { res.setHeader('Content-Type', 'application/json'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - res.setHeader('Cloud-CDN-Cache-Tag', 'bucket-proxy'); - res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=2592000'); + res.setHeader('Cache-Tag', 'bucket-proxy'); + res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400'); res.statusCode = 200; res.end(JSON.stringify({ mocked: true })); }) @@ -34,8 +34,8 @@ describe('CDN Headers', () => { const res = await request(app).get('/v1/technologies'); expect(res.statusCode).toEqual(200); - expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=2592000'); - expect(res.headers['cloud-cdn-cache-tag']).toBe('report-api'); + expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=86400'); + expect(res.headers['cache-tag']).toBe('report-api'); expect(res.headers['access-control-allow-origin']).toBe('*'); expect(res.headers['access-control-allow-headers']).toContain('Content-Type'); expect(res.headers['access-control-allow-headers']).toContain('If-None-Match'); @@ -46,8 +46,8 @@ describe('CDN Headers', () => { const res = await request(app).get('/v1/static/test.json'); expect(res.statusCode).toEqual(200); - expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=2592000'); - expect(res.headers['cloud-cdn-cache-tag']).toBe('bucket-proxy'); + expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=86400'); + expect(res.headers['cache-tag']).toBe('bucket-proxy'); expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin'); }); @@ -55,7 +55,7 @@ describe('CDN Headers', () => { const res = await request(app).get('/'); expect(res.statusCode).toEqual(200); - expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=2592000'); - expect(res.headers['cloud-cdn-cache-tag']).toBe('report-api'); + expect(res.headers['cache-control']).toBe('public, max-age=3600, s-maxage=86400'); + expect(res.headers['cache-tag']).toBe('report-api'); }); }); diff --git a/src/tests/routes.test.js b/src/tests/routes.test.js index 8a93593..5c065e1 100644 --- a/src/tests/routes.test.js +++ b/src/tests/routes.test.js @@ -455,6 +455,50 @@ describe('API Routes', () => { expect(res.headers).toHaveProperty('etag'); }); + it('should include ETag headers on executeQuery-based routes', async () => { + const res = await request(app).get('/v1/technologies'); + expect(res.statusCode).toEqual(200); + expect(res.headers).toHaveProperty('etag'); + expect(res.headers['etag']).toMatch(/^"[a-f0-9]+"$/); + }); + + it('should include ETag headers on reportController-based routes', async () => { + const res = await request(app).get('/v1/adoption'); + expect(res.statusCode).toEqual(200); + expect(res.headers).toHaveProperty('etag'); + expect(res.headers['etag']).toMatch(/^"[a-f0-9]+"$/); + }); + + it('should return 304 for executeQuery-based routes when ETag matches', async () => { + const first = await request(app).get('/v1/technologies'); + expect(first.statusCode).toEqual(200); + const etag = first.headers['etag']; + + const second = await request(app) + .get('/v1/technologies') + .set('If-None-Match', etag); + expect(second.statusCode).toEqual(304); + }); + + it('should return 304 for reportController-based routes when ETag matches', async () => { + const first = await request(app).get('/v1/adoption'); + expect(first.statusCode).toEqual(200); + const etag = first.headers['etag']; + + const second = await request(app) + .get('/v1/adoption') + .set('If-None-Match', etag); + expect(second.statusCode).toEqual(304); + }); + + it('should return 200 when If-None-Match does not match', async () => { + const res = await request(app) + .get('/v1/technologies') + .set('If-None-Match', '"stale-etag"'); + expect(res.statusCode).toEqual(200); + expect(res.headers).toHaveProperty('etag'); + }); + it('should include timing headers', async () => { const res = await request(app).get('/v1/technologies'); expect(res.headers['timing-allow-origin']).toEqual('*'); diff --git a/src/utils/controllerHelpers.js b/src/utils/controllerHelpers.js index 03d2372..58583d6 100644 --- a/src/utils/controllerHelpers.js +++ b/src/utils/controllerHelpers.js @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import { convertToArray } from './helpers.js'; /** @@ -99,6 +100,23 @@ const handleControllerError = (res, error, operation) => { })); }; +const generateETag = (jsonData) => { + return crypto.createHash('md5').update(jsonData).digest('hex'); +}; + +const sendJSONResponse = (res, data, statusCode = 200) => { + const jsonData = JSON.stringify(data); + const etag = generateETag(jsonData); + res.setHeader('ETag', `"${etag}"`); + res.statusCode = statusCode; + res.end(jsonData); +}; + +const isModified = (req, etag) => { + const ifNoneMatch = req.headers['if-none-match'] || (req.get && req.get('if-none-match')); + return !ifNoneMatch || ifNoneMatch !== `"${etag}"`; +}; + /** * Generic query executor * Handles query execution and response for simple queries @@ -126,9 +144,17 @@ const executeQuery = async (req, res, collection, queryBuilder, dataProcessor = data = dataProcessor(data, params); } - // Send response + // Send response with ETag support + const jsonData = JSON.stringify(data); + const etag = generateETag(jsonData); + res.setHeader('ETag', `"${etag}"`); + if (!isModified(req, etag)) { + res.statusCode = 304; + res.end(); + return; + } res.statusCode = 200; - res.end(JSON.stringify(data)); + res.end(jsonData); } catch (error) { // Handle validation errors specifically @@ -170,5 +196,8 @@ export { validateArrayParameter, handleControllerError, executeQuery, - validateTechnologyArray + validateTechnologyArray, + generateETag, + sendJSONResponse, + isModified };