diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b1e2a3f..45f317c 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -703,8 +703,26 @@ 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', @@ -712,6 +730,23 @@ registry.registerPath({ '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'], }, diff --git a/src/routes/streams.ts b/src/routes/streams.ts index 4a5657a..62dfe38 100644 --- a/src/routes/streams.ts +++ b/src/routes/streams.ts @@ -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 { @@ -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( diff --git a/tests/routes/streams.test.ts b/tests/routes/streams.test.ts index ee28e9a..e8d4f0f 100644 --- a/tests/routes/streams.test.ts +++ b/tests/routes/streams.test.ts @@ -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(); + }); });