Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/controllers/cdnController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 13 additions & 3 deletions src/controllers/reportController.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
sendValidationError,
getLatestDate,
handleControllerError,
validateArrayParameter
validateArrayParameter,
generateETag,
isModified
} from '../utils/controllerHelpers.js';

/**
Expand Down Expand Up @@ -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`);
Expand Down
29 changes: 4 additions & 25 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 8 additions & 8 deletions src/tests/headers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
})
Expand All @@ -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');
Expand All @@ -46,16 +46,16 @@ 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');
});

it('should set correct headers for health check', async () => {
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');
});
});
44 changes: 44 additions & 0 deletions src/tests/routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('*');
Expand Down
35 changes: 32 additions & 3 deletions src/utils/controllerHelpers.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import crypto from 'crypto';
import { convertToArray } from './helpers.js';

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -170,5 +196,8 @@ export {
validateArrayParameter,
handleControllerError,
executeQuery,
validateTechnologyArray
validateTechnologyArray,
generateETag,
sendJSONResponse,
isModified
};