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
22 changes: 22 additions & 0 deletions src/apis.registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, '');
});
});
81 changes: 81 additions & 0 deletions src/middleware/etag.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
61 changes: 61 additions & 0 deletions src/middleware/etag.ts
Original file line number Diff line number Diff line change
@@ -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();
}
3 changes: 2 additions & 1 deletion src/routes/apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string>);
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
Expand Down