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
37 changes: 36 additions & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,15 +703,50 @@ registry.registerPath({
method: 'get',
path: '/api/streams/{id}',
summary: 'Get stream by ID',
description:
'Returns a single stream record. Supports conditional GET via the ' +
'`If-None-Match` request header (RFC 7232 §3.2). ' +
'When the ETag matches, a 304 Not Modified response is returned ' +
'with no body, indicating the client\'s cached representation is still fresh. ' +
'The server emits a weak ETag (`W/"…"`) derived from the stream id and ' +
'`updated_at` timestamp.',
tags: ['streams'],
request: { params: z.object({ id: z.string().openapi({ example: 'stream-abc123' }) }) },
request: {
params: z.object({ id: z.string().openapi({ example: 'stream-abc123' }) }),
headers: z.object({
'If-None-Match': z.string().optional().openapi({
description:
'Conditional request header (RFC 7232 §3.2). ' +
'Accepts `*`, a single entity-tag, or a comma-separated list of ' +
'entity-tags. Weak comparison is used per the spec.',
example: 'W/"abc123..."',
}),
}),
},
responses: {
'200': {
description: 'Stream record',
content: {
'application/json': { schema: successSchema(z.object({ stream: StreamObject })) },
},
},
'304': {
description:
'Not Modified — the ETag matches the `If-None-Match` value. ' +
'Body is empty. The response includes the same `ETag` and `Last-Modified` ' +
'headers that would accompany a 200 response.',
headers: {
...commonResponseHeaders,
'ETag': {
description: 'Weak entity-tag of the unchanged representation.',
schema: { type: 'string', example: 'W/"abc123..."' },
},
'Last-Modified': {
description: 'Last modification timestamp of the stream.',
schema: { type: 'string', format: 'http-date', example: 'Thu, 01 Jan 2026 00:00:00 GMT' },
},
},
},
'404': errorResponses['404'],
'503': errorResponses['503'],
},
Expand Down
41 changes: 41 additions & 0 deletions src/routes/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,31 @@ function setStreamResourceHeaders(
res.set('Last-Modified', new Date(metadata.updated_at).toUTCString());
}

/**
* RFC 7232 §3.2 weak comparison for If-None-Match.
*
* The `*` wildcard matches any current representation.
* Otherwise the field value is a comma-separated list of entity-tags and the
* recipient uses the weak comparison function (strip `W/` prefix before
* character-for-character comparison of the opaque-tag, including DQUOTES).
*
* @param ifNoneMatch - raw value of the If-None-Match request header
* @param etag - the server-computed ETag for the current representation
* @returns `true` when any entry in the list matches
*/
function matchesIfNoneMatch(ifNoneMatch: string, etag: string): boolean {
const trimmed = ifNoneMatch.trim();
if (trimmed === '*') return true;

const normalize = (tag: string): string => tag.replace(/^W\//i, '');
const normalizedEtag = normalize(etag);

return trimmed
.split(',')
.map((t) => normalize(t.trim()))
.some((t) => t === normalizedEtag);
}

// ── Cursor helpers ────────────────────────────────────────────────────────────

function encodeCursor(lastId: string): string {
Expand Down Expand Up @@ -547,6 +572,22 @@ streamsRouter.get(
}

if (!record) throw notFound('Stream', id);

// Conditional GET (RFC 7232 §3.2)
const etag = streamEntityTag(record!);
const rawIfNoneMatch = req.headers['if-none-match'];
if (rawIfNoneMatch !== undefined) {
const header = Array.isArray(rawIfNoneMatch)
? rawIfNoneMatch.join(', ')
: rawIfNoneMatch;
if (matchesIfNoneMatch(header, etag)) {
res.set('ETag', etag);
res.set('Last-Modified', new Date(record!.updated_at).toUTCString());
res.status(304).end();
return;
}
}

const stream = toApiStream(record!);
setStreamResourceHeaders(res, record!);
res.set(
Expand Down
91 changes: 91 additions & 0 deletions tests/routes/streams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,97 @@ describe('streams routes', () => {
mockGetById.mockRejectedValue(new PoolExhaustedError());
expect((await request(app).get('/api/streams/stream-x')).status).toBe(503);
});

// ── Conditional GET (RFC 7232) ───────────────────────────────────────

it('returns 304 when If-None-Match matches the ETag', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const first = await request(app).get('/api/streams/stream-abc-0');
const etag = first.headers['etag'] as string;

const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', etag);
expect(res.status).toBe(304);
expect(res.text).toBe('');
expect(res.headers['etag']).toBe(etag);
});

it('returns 304 for If-None-Match: *', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', '*');
expect(res.status).toBe(304);
expect(res.text).toBe('');
});

it('returns 304 when If-None-Match is a comma-separated list containing the ETag', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const first = await request(app).get('/api/streams/stream-abc-0');
const etag = first.headers['etag'] as string;

const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', `W/"other", ${etag}, W/"another"`);
expect(res.status).toBe(304);
});

it('returns 200 when If-None-Match does not match', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', 'W/"some-other-tag"');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.stream.id).toBe('stream-abc-0');
});

it('returns 200 when If-None-Match is absent', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const res = await request(app).get('/api/streams/stream-abc-0');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});

it('sets ETag and Last-Modified headers on 304', async () => {
mockGetById.mockResolvedValue(makeDbRecord({
id: 'stream-abc-0', updated_at: '2024-01-01T00:00:00.000Z',
}));
const first = await request(app).get('/api/streams/stream-abc-0');
const etag = first.headers['etag'] as string;

const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', etag);
expect(res.status).toBe(304);
expect(res.headers['etag']).toBeDefined();
expect(res.headers['last-modified']).toBeDefined();
});

it('304 has no body', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const first = await request(app).get('/api/streams/stream-abc-0');
const etag = first.headers['etag'] as string;

const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', etag);
expect(res.status).toBe(304);
expect(res.text).toBe('');
});

it('does not break the success envelope shape for 200', async () => {
mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' }));
const res = await request(app)
.get('/api/streams/stream-abc-0')
.set('If-None-Match', 'W/"non-matching"');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data).toBeDefined();
expect(res.body.data.stream).toBeDefined();
expect(res.body.meta.timestamp).toBeDefined();
});
});


Expand Down