From a82e46d68eaf1b03159e446e589d5ffd2e0a25a5 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Sun, 28 Jun 2026 11:58:10 +0100 Subject: [PATCH] feat: ETag/304 listings --- src/apis.registration.test.ts | 22 ++++++++++ src/middleware/etag.test.ts | 81 +++++++++++++++++++++++++++++++++++ src/middleware/etag.ts | 61 ++++++++++++++++++++++++++ src/routes/apis.ts | 3 +- 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/middleware/etag.test.ts create mode 100644 src/middleware/etag.ts diff --git a/src/apis.registration.test.ts b/src/apis.registration.test.ts index 8802d3e..d72e5de 100644 --- a/src/apis.registration.test.ts +++ b/src/apis.registration.test.ts @@ -105,4 +105,26 @@ describe('POST /api/apis', () => { assert.equal(detailResponse.status, 200); assert.equal(detailResponse.body.endpoints[0].price_per_call_usdc, '0.01'); }); + + test('GET /api/apis returns ETag and supports conditional GET (304 Not Modified)', async () => { + const app = buildApp(); + + await request(app) + .post('/api/apis') + .set('x-user-id', 'dev-1') + .send(validBody); + + const firstResponse = await request(app).get('/api/apis'); + assert.equal(firstResponse.status, 200); + + const etag = firstResponse.headers.etag; + assert.ok(etag, 'ETag header should be present'); + + const secondResponse = await request(app) + .get('/api/apis') + .set('if-none-match', etag); + + assert.equal(secondResponse.status, 304); + assert.equal(secondResponse.text, ''); + }); }); diff --git a/src/middleware/etag.test.ts b/src/middleware/etag.test.ts new file mode 100644 index 0000000..9424bfa --- /dev/null +++ b/src/middleware/etag.test.ts @@ -0,0 +1,81 @@ +import request from 'supertest'; +import express from 'express'; +import { etagMiddleware, generateETag } from './etag.js'; + +describe('etagMiddleware', () => { + test('should set ETag header and return 200 for a GET request', async () => { + const app = express(); + app.get('/test', etagMiddleware, (req, res) => { + res.json({ message: 'hello world' }); + }); + + const res = await request(app).get('/test'); + expect(res.status).toBe(200); + expect(res.headers.etag).toBeDefined(); + expect(res.headers.etag).toMatch(/^W\/"/); + expect(res.body).toEqual({ message: 'hello world' }); + }); + + test('should return 304 Not Modified when If-None-Match matches ETag', async () => { + const app = express(); + app.get('/test', etagMiddleware, (req, res) => { + res.json({ message: 'hello world' }); + }); + + const res1 = await request(app).get('/test'); + const etag = res1.headers.etag; + expect(etag).toBeDefined(); + + const res2 = await request(app) + .get('/test') + .set('If-None-Match', etag); + + expect(res2.status).toBe(304); + expect(res2.text).toBe(''); + }); + + test('should return 304 Not Modified when If-None-Match matches weak ETag without W/', async () => { + const app = express(); + app.get('/test', etagMiddleware, (req, res) => { + res.json({ message: 'hello world' }); + }); + + const res1 = await request(app).get('/test'); + const etag = res1.headers.etag; + expect(etag).toBeDefined(); + const rawHash = etag.replace('W/', ''); + + const res2 = await request(app) + .get('/test') + .set('If-None-Match', rawHash); + + expect(res2.status).toBe(304); + expect(res2.text).toBe(''); + }); + + test('should return 200 when If-None-Match does not match ETag', async () => { + const app = express(); + app.get('/test', etagMiddleware, (req, res) => { + res.json({ message: 'hello world' }); + }); + + const res = await request(app) + .get('/test') + .set('If-None-Match', 'W/"different-hash"'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'hello world' }); + }); + + test('should not set ETag for non-GET/HEAD requests', async () => { + const app = express(); + app.use(express.json()); + app.post('/test', etagMiddleware, (req, res) => { + res.json({ message: 'hello world' }); + }); + + const res = await request(app).post('/test').send({}); + expect(res.status).toBe(200); + expect(res.headers.etag).toBeUndefined(); + }); +}); diff --git a/src/middleware/etag.ts b/src/middleware/etag.ts new file mode 100644 index 0000000..05985c8 --- /dev/null +++ b/src/middleware/etag.ts @@ -0,0 +1,61 @@ +import type { Request, Response, NextFunction } from 'express'; +import { createHash } from 'crypto'; + +/** + * Generates a weak ETag based on the content string or buffer. + */ +export function generateETag(content: string | Buffer): string { + const hash = createHash('sha1').update(content).digest('base64'); + return `W/"${hash.substring(0, 27)}"`; +} + +/** + * ETag middleware for conditional GETs. + * Checks If-None-Match header and returns 304 if matches. + */ +export function etagMiddleware(req: Request, res: Response, next: NextFunction) { + // Only process GET and HEAD requests + if (req.method !== 'GET' && req.method !== 'HEAD') { + return next(); + } + + const originalSend = res.send; + + res.send = function (body?: any): Response { + // Only generate ETag for 200 OK responses where ETag is not already set + if (res.statusCode !== 200 || res.get('ETag')) { + return originalSend.call(this, body); + } + + let entityTag: string | undefined; + if (body !== undefined && body !== null) { + let content: string | Buffer; + if (typeof body === 'string') { + content = body; + } else if (Buffer.isBuffer(body)) { + content = body; + } else { + content = JSON.stringify(body); + } + entityTag = generateETag(content); + } + + if (entityTag) { + res.setHeader('ETag', entityTag); + + const ifNoneMatch = req.header('if-none-match'); + if (ifNoneMatch) { + // Handle client sending multiple ETags or wrapped in quotes + const clientTags = ifNoneMatch.split(',').map(t => t.trim()); + if (clientTags.includes(entityTag) || clientTags.includes(entityTag.replace('W/', ''))) { + res.status(304); + return originalSend.call(this, ''); + } + } + } + + return originalSend.call(this, body); + }; + + next(); +} diff --git a/src/routes/apis.ts b/src/routes/apis.ts index 2bd5208..9838f45 100644 --- a/src/routes/apis.ts +++ b/src/routes/apis.ts @@ -5,6 +5,7 @@ import { buildCacheKey, listingsCache, type ListingsCache } from '../lib/listing import { recordCacheHit, recordCacheMiss } from '../metrics.js'; import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; import { bodyValidator } from '../middleware/validate.js'; +import { etagMiddleware } from '../middleware/etag.js'; import { defaultApiRepository, type ApiRepository, @@ -28,7 +29,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { const developerRepository = deps.developerRepository ?? defaultDeveloperRepository; const cache = deps.cache ?? listingsCache; - router.get('/', async (req, res, next) => { + router.get('/', etagMiddleware, async (req, res, next) => { try { const { limit, offset } = parsePagination(req.query as Record); const category = typeof req.query.category === 'string' ? req.query.category : undefined;