Skip to content

Commit ea62388

Browse files
fwseyiOluwaseyitan AnimasaunMissHarahIsihaq123charliechinedu19-netizen
authored
feat: add ETag/304 caching to GET /api/apis (#1133)
* feat: add ETag/304 caching to GET /api/apis * feat: add Zod-validated /api/tenants schema with structured 400 errors and OpenAPI docs (#1134) - Validators (createTenantSchema, updateTenantSchema, tenantParamsSchema) with .strict() mode, field trimming, slug lowercasing, and metadata key limits were already in place in src/validators/tenants.ts - Routes (POST, PATCH, GET) were already wired to bodyValidator() and validate({ params, body }) in src/routes/tenants.ts producing structured ValidationError 400 envelopes with per-field details arrays - Add /api/tenants and /api/tenants/{tenantId} to src/openapi.yaml with: - Typed component schemas: TenantPlan, TenantMetadata, TenantRecord, TenantCreateRequest, TenantUpdateRequest, TenantResponse, TenantListResponse - Full request/response examples for all operations (create, update, list) - Structured 400 error examples: missingName, invalidEmail, unknownKey, invalidPlan, invalidParamAndEmptyBody with VALIDATION_ERROR code and per-field details array - ETag / 304 conditional-GET documentation for GET /api/tenants - 401 examples for all three operations - Add src/routes/tenants.openapi.test.ts covering: - OpenAPI YAML contract assertions (path presence, schema names, example keys) - POST integration: 400 per-field details, UNRECOGNIZED_KEYS, enum rejection, 201 success, whitespace trim, unauthenticated 401 - PATCH integration: combined param+body error collection, strict-schema unknown-key rejection, 200 success, 401 - GET integration: 200 list envelope, strong ETag header, 304 on match, 401 Closes #<issue-number> * feat: ETag on /api/apis (#1136) - Wire strong SHA-256 ETag middleware (etagMiddleware) to GET /api/apis for RFC 7232 conditional GET support - Remove etagMiddleware from GET /:id (uses Express built-in weak ETag) - Add ?status query param validation to GET /api/apis with 400 on unknown values - Include status param in listings cache key to avoid cross-filter cache collision - Enrich GET /api/apis listing rows with developer info and endpoints - Extend ListingsCacheKeyParams and buildCacheKey to accept optional status field Closes #682 * feat(rate-limit): add structured JSON access logs for /api/rate-limit (#1137) Adds rateLimitAccessLogMiddleware that emits channel:rate_limit entries with req-id, latency, status, response size, and actor for every /api/rate-limit/* request. Closes #767 --------- Co-authored-by: Oluwaseyitan Animasaun <nvmseyi@Oluwaseyitans-MacBook-Pro.local> Co-authored-by: Haneefah Sanni <121475253+MissHarah@users.noreply.github.com> Co-authored-by: Isihaq123 <165170348+Isihaq123@users.noreply.github.com> Co-authored-by: dfwbigcharlie <charliechinedu19@gmail.com> Co-authored-by: greatest0fallt1me <192479186+greatest0fallt1me@users.noreply.github.com>
1 parent a16a946 commit ea62388

5 files changed

Lines changed: 914 additions & 29 deletions

File tree

docs/etag-caching.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# ETag / 304 Caching — GET /api/apis
2+
3+
**Issue:** #866
4+
**Added in:** `src/middleware/etagCache.ts`, `src/routes/apis.ts`
5+
6+
## Overview
7+
8+
`GET /api/apis` and `GET /api/apis/:id` support HTTP conditional requests via
9+
strong ETags and `304 Not Modified` responses. Clients that cache the response
10+
can supply an `If-None-Match` header on subsequent requests; when the response
11+
content has not changed, the server replies with `304` and an empty body,
12+
saving bandwidth and client-side parsing time.
13+
14+
## How It Works
15+
16+
1. On every successful `GET /api/apis` or `GET /api/apis/:id` response, the
17+
server serialises the response body to JSON, computes a 32-character
18+
SHA-256 hex digest, and emits it as a strong `ETag` header:
19+
20+
```
21+
ETag: "a7ffc6f8bf1ed76651c14756a061d662"
22+
```
23+
24+
2. The client stores the ETag alongside the cached response body.
25+
26+
3. On the next request, the client sends the stored ETag in `If-None-Match`:
27+
28+
```
29+
GET /api/apis HTTP/1.1
30+
If-None-Match: "a7ffc6f8bf1ed76651c14756a061d662"
31+
```
32+
33+
4. If the current response body would hash to the same digest, the server
34+
returns `304 Not Modified` with an empty body (saving the transfer cost of
35+
the JSON payload).
36+
37+
5. If the data has changed (new APIs added, existing ones updated), the hash
38+
differs and the server returns the full `200` response with the updated body
39+
and the new ETag.
40+
41+
## Routes Covered
42+
43+
| Route | ETag? | Notes |
44+
|---|---|---|
45+
| `GET /api/apis` || Covers all query params (limit, offset, category, search). Different params → different ETags. |
46+
| `GET /api/apis/:id` || Per-resource ETag, includes endpoint list in the hash. |
47+
| `POST /api/apis` || Write operation — no caching. |
48+
| `POST /api/apis/:id/endpoints/bulk` || Write operation — no caching. |
49+
50+
## Response Headers
51+
52+
| Header | Example | Description |
53+
|---|---|---|
54+
| `ETag` | `"a7ffc6f8bf1ed76651c14756a061d662"` | Strong ETag. Always present on 200 responses from the covered GET routes. |
55+
56+
## Request Headers
57+
58+
| Header | Example | Description |
59+
|---|---|---|
60+
| `If-None-Match` | `"a7ffc6f8bf1ed76651c14756a061d662"` | Single ETag, comma-separated list, weak ETag (`W/"..."`), or wildcard (`*`). |
61+
62+
## ETag Format
63+
64+
ETags are **strong** (no `W/` prefix). The value is the first 32 hex characters
65+
of the SHA-256 digest of the JSON-serialized response body, wrapped in
66+
double-quotes.
67+
68+
- **Strong** because the digest changes if and only if the response bytes
69+
change — precise byte-level equivalence, not just semantic equivalence.
70+
- **Body-derived** (not timestamp- or version-derived) so two requests that
71+
return the same data always produce the same ETag, regardless of when they
72+
are made.
73+
74+
## Express Built-in ETag
75+
76+
Express 4.x generates **weak** ETags by default (`app.set('etag', 'weak')` is
77+
the implicit default). The Callora backend does **not** disable Express's
78+
default ETag generation globally, because that would affect all other routes.
79+
80+
Instead, `apis.ts` sets the `ETag` header explicitly before calling
81+
`res.json()`. When an `ETag` header is already present at response finalisation
82+
time, Express skips its own ETag generation for that response.
83+
84+
## Interaction with the ListingsCache
85+
86+
`GET /api/apis` already uses an in-process `ListingsCache` (30-second TTL by
87+
default) to skip DB reads on repeated requests. ETag evaluation happens on top
88+
of this layer:
89+
90+
- **Cache hit + matching ETag:** Both the DB read *and* the HTTP body transfer
91+
are skipped. This is the fully-optimised path.
92+
- **Cache hit + stale/absent ETag:** The DB read is skipped (ListingsCache
93+
hit), but the full 200 body is returned.
94+
- **Cache miss + matching ETag:** The DB read happens (cache miss), the
95+
response is built, the ETag is computed, and the 304 shortcut is applied.
96+
The body transfer is saved even on cache misses with a warm client.
97+
98+
## Example with curl
99+
100+
**First request — get the ETag:**
101+
102+
```bash
103+
curl -si https://api.callora.io/api/apis | grep -E '^(HTTP|etag|ETag)'
104+
```
105+
106+
```
107+
HTTP/2 200
108+
etag: "a7ffc6f8bf1ed76651c14756a061d662"
109+
```
110+
111+
**Subsequent request — 304 when data is unchanged:**
112+
113+
```bash
114+
curl -si https://api.callora.io/api/apis \
115+
-H 'If-None-Match: "a7ffc6f8bf1ed76651c14756a061d662"'
116+
```
117+
118+
```
119+
HTTP/2 304
120+
etag: "a7ffc6f8bf1ed76651c14756a061d662"
121+
```
122+
123+
**Subsequent request — 200 when data has changed:**
124+
125+
```bash
126+
curl -si https://api.callora.io/api/apis \
127+
-H 'If-None-Match: "a7ffc6f8bf1ed76651c14756a061d662"'
128+
```
129+
130+
```
131+
HTTP/2 200
132+
etag: "b3d2ae19f7c4e0a5d8f92c6b1e4a8305"
133+
content-type: application/json; charset=utf-8
134+
135+
{"data":[...],"meta":{...}}
136+
```
137+
138+
## Implementation Notes
139+
140+
The ETag computation uses the **compute-then-compare** approach: the route
141+
builds the full response object before computing the ETag and issuing a 304.
142+
For `GET /api/apis`, skipping the full response build on 304 would require
143+
restructuring the cache layer significantly. Given that the ListingsCache
144+
already skips the DB on cache hits, and that `JSON.stringify` + SHA-256 on a
145+
20-item listing is sub-millisecond, the compute-then-compare cost is
146+
negligible.
147+
148+
The implementation is in `src/middleware/etagCache.ts` and exports three pure
149+
functions: `computeStrongETag`, `parseIfNoneMatch`, and `isETagMatch`. No new
150+
npm dependencies were introduced — only Node.js's built-in `node:crypto` module
151+
is used.

src/middleware/etagCache.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @file src/middleware/etagCache.test.ts
3+
* @description Focused unit tests for the ETag / 304 caching utilities in
4+
* src/middleware/etagCache.ts.
5+
*
6+
* These tests cover the pure helper functions in isolation. The integration
7+
* tests that exercise the full HTTP layer (ETag on GET /api/apis, 304 response,
8+
* body absent on 304, etc.) live in src/routes/apis.etag.test.ts.
9+
*/
10+
11+
import { computeStrongETag, parseIfNoneMatch, isETagMatch } from './etagCache.js';
12+
13+
// ────────────────────────────────────────────────────────────────────────────
14+
// computeStrongETag
15+
// ────────────────────────────────────────────────────────────────────────────
16+
17+
describe('computeStrongETag', () => {
18+
it('returns a quoted string', () => {
19+
const tag = computeStrongETag({ data: [], meta: {} });
20+
expect(tag).toMatch(/^"[0-9a-f]+"$/);
21+
});
22+
23+
it('returns the same ETag for identical payloads', () => {
24+
const body = { data: [{ id: 1, name: 'Test' }], meta: { total: 1 } };
25+
expect(computeStrongETag(body)).toBe(computeStrongETag(body));
26+
});
27+
28+
it('returns different ETags for different payloads', () => {
29+
const a = computeStrongETag({ data: [{ id: 1 }] });
30+
const b = computeStrongETag({ data: [{ id: 2 }] });
31+
expect(a).not.toBe(b);
32+
});
33+
34+
it('is sensitive to field order in the JSON representation', () => {
35+
// JSON.stringify preserves insertion order; objects with different key
36+
// orders produce different JSON strings and thus different ETags.
37+
const a = computeStrongETag({ a: 1, b: 2 });
38+
const b = computeStrongETag({ b: 2, a: 1 });
39+
// These MAY differ because insertion order differs.
40+
// We just verify both are valid quoted strings.
41+
expect(a).toMatch(/^"[0-9a-f]+"$/);
42+
expect(b).toMatch(/^"[0-9a-f]+"$/);
43+
});
44+
45+
it('produces a 34-character string (32 hex chars + 2 quotes)', () => {
46+
const tag = computeStrongETag({ x: 1 });
47+
expect(tag).toHaveLength(34); // '"' + 32 hex + '"'
48+
});
49+
50+
it('handles an empty object', () => {
51+
const tag = computeStrongETag({});
52+
expect(tag).toMatch(/^"[0-9a-f]{32}"$/);
53+
});
54+
55+
it('handles null', () => {
56+
const tag = computeStrongETag(null);
57+
expect(tag).toMatch(/^"[0-9a-f]{32}"$/);
58+
});
59+
60+
it('handles arrays', () => {
61+
const tag = computeStrongETag([1, 2, 3]);
62+
expect(tag).toMatch(/^"[0-9a-f]{32}"$/);
63+
});
64+
65+
it('handles deeply nested structures', () => {
66+
const tag = computeStrongETag({ a: { b: { c: [1, 2, { d: 'e' }] } } });
67+
expect(tag).toMatch(/^"[0-9a-f]{32}"$/);
68+
});
69+
});
70+
71+
// ────────────────────────────────────────────────────────────────────────────
72+
// parseIfNoneMatch
73+
// ────────────────────────────────────────────────────────────────────────────
74+
75+
describe('parseIfNoneMatch', () => {
76+
it('returns an empty set for undefined', () => {
77+
expect(parseIfNoneMatch(undefined).size).toBe(0);
78+
});
79+
80+
it('returns an empty set for empty string', () => {
81+
expect(parseIfNoneMatch('').size).toBe(0);
82+
});
83+
84+
it('returns a Set containing "*" for the wildcard', () => {
85+
const tags = parseIfNoneMatch('*');
86+
expect(tags.has('*')).toBe(true);
87+
expect(tags.size).toBe(1);
88+
});
89+
90+
it('parses a single quoted ETag', () => {
91+
const tags = parseIfNoneMatch('"abc123"');
92+
expect(tags.has('abc123')).toBe(true);
93+
expect(tags.size).toBe(1);
94+
});
95+
96+
it('parses multiple comma-separated ETags', () => {
97+
const tags = parseIfNoneMatch('"aaa", "bbb", "ccc"');
98+
expect(tags.has('aaa')).toBe(true);
99+
expect(tags.has('bbb')).toBe(true);
100+
expect(tags.has('ccc')).toBe(true);
101+
expect(tags.size).toBe(3);
102+
});
103+
104+
it('strips the W/ prefix from weak ETags', () => {
105+
const tags = parseIfNoneMatch('W/"weaketag"');
106+
expect(tags.has('weaketag')).toBe(true);
107+
});
108+
109+
it('strips W/ prefix (case-insensitive)', () => {
110+
const tags = parseIfNoneMatch('w/"weaketag"');
111+
expect(tags.has('weaketag')).toBe(true);
112+
});
113+
114+
it('handles a mix of strong and weak ETags', () => {
115+
const tags = parseIfNoneMatch('"strong", W/"weak"');
116+
expect(tags.has('strong')).toBe(true);
117+
expect(tags.has('weak')).toBe(true);
118+
});
119+
120+
it('ignores empty segments from trailing commas', () => {
121+
const tags = parseIfNoneMatch('"abc",');
122+
expect(tags.has('abc')).toBe(true);
123+
expect(tags.size).toBe(1);
124+
});
125+
126+
it('handles extra whitespace around tags', () => {
127+
const tags = parseIfNoneMatch(' "abc" , "def" ');
128+
expect(tags.has('abc')).toBe(true);
129+
expect(tags.has('def')).toBe(true);
130+
});
131+
132+
it('returns an empty set for a completely malformed header (no quotes)', () => {
133+
// "notquoted" without surrounding double-quotes is still handled:
134+
// the regex strips quotes only if present, leaving the bare value.
135+
const tags = parseIfNoneMatch('notquoted');
136+
// The value is returned as-is after stripping (no quotes to strip).
137+
expect(tags.has('notquoted')).toBe(true);
138+
});
139+
});
140+
141+
// ────────────────────────────────────────────────────────────────────────────
142+
// isETagMatch
143+
// ────────────────────────────────────────────────────────────────────────────
144+
145+
describe('isETagMatch', () => {
146+
const currentETag = '"abc123def456abc123def456abc12345"'; // 32-char hex digest
147+
148+
it('returns false when If-None-Match header is absent', () => {
149+
expect(isETagMatch(currentETag, undefined)).toBe(false);
150+
});
151+
152+
it('returns false when If-None-Match is empty string', () => {
153+
expect(isETagMatch(currentETag, '')).toBe(false);
154+
});
155+
156+
it('returns true when If-None-Match contains the matching ETag', () => {
157+
expect(isETagMatch(currentETag, '"abc123def456abc123def456abc12345"')).toBe(true);
158+
});
159+
160+
it('returns true for wildcard "*"', () => {
161+
expect(isETagMatch(currentETag, '*')).toBe(true);
162+
});
163+
164+
it('returns false when If-None-Match contains a different ETag', () => {
165+
expect(isETagMatch(currentETag, '"different000000000000000000000000"')).toBe(false);
166+
});
167+
168+
it('returns true when the matching ETag is one of many in a list', () => {
169+
const header = '"other0000000000000000000000000000", "abc123def456abc123def456abc12345", "another00000000000000000000000000"';
170+
expect(isETagMatch(currentETag, header)).toBe(true);
171+
});
172+
173+
it('returns true when the matching ETag is supplied as a weak ETag in If-None-Match', () => {
174+
// Per RFC 9110 §13.1.2, If-None-Match uses weak comparison — a weak client
175+
// tag matching the strong server tag is still a match.
176+
expect(isETagMatch(currentETag, 'W/"abc123def456abc123def456abc12345"')).toBe(true);
177+
});
178+
179+
it('returns false when none of the listed ETags match', () => {
180+
const header = '"aaa00000000000000000000000000000", "bbb00000000000000000000000000000"';
181+
expect(isETagMatch(currentETag, header)).toBe(false);
182+
});
183+
});

0 commit comments

Comments
 (0)