Skip to content

Commit 3cb43cc

Browse files
authored
Merge pull request #1454 from misrasamuelisiguzor-oss/feat/api-client-1332-1336
feat(api): import-boundary guard, path-encoding, cache-invalidation, pagination (#1332 #1333 #1335 #1336)
2 parents f4e2682 + 0fa280c commit 3cb43cc

10 files changed

Lines changed: 499 additions & 8 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# API Client: import boundary guard, path-encoding, cache-invalidation, pagination
2+
3+
Four `frontend/src/lib/api/` issues. Three are "regression trap" issues whose fix already
4+
landed in an earlier commit - the delta here is the guard test that keeps it from
5+
regressing, plus one missed call site. #1336 is a new helper.
6+
7+
## What changed and why
8+
9+
### #1332 - public/admin client split guard
10+
The split (`public-client.ts` / `admin-client.ts`, commit `895748e`) and an *export*
11+
boundary test (`public-client.test.ts`) already exist. Added the missing *import* boundary
12+
guard: `client-import-boundary.test.ts` statically scans `src/app/**` and fails if any route
13+
outside `src/app/admin/**` (and the privileged `markets/<id>/resolve` route) imports
14+
`admin-client` directly. It also re-asserts that `public-client.ts` never imports
15+
`admin-client` and exposes no `/api/v1/admin`, `/api/v1/audit`, or `/api/v1/email` path.
16+
17+
### #1333 - centralize path-parameter encoding
18+
`fillPath()` was the intended single encoder (commit `dd027a9`) but lived in
19+
`public-client.ts` and three `src/lib/api/` call sites still called `encodeURIComponent`
20+
directly (`admin-client.ts` email preview, `tts-client.ts` job status + audio).
21+
22+
- Moved `fillPath` (plus a new `fillPathParams` for multi-segment templates) into a
23+
dedicated `paths.ts`; `public-client.ts` re-exports it so existing importers are
24+
unaffected.
25+
- Routed the three stray call sites through `fillPath`.
26+
- `path-encoding.test.ts`: (a) a `market_id` / `tx_hash` containing `/`, `?`, `#`
27+
round-trips as a single encoded path segment (asserted against a mocked `fetch`);
28+
(b) `fillPath` encodes exactly once; (c) a grep guard - no `src/lib/api/*.ts` file
29+
except `paths.ts` calls `encodeURIComponent`.
30+
- Out of scope: raw `fetch()` calls in app pages/components that never used the client -
31+
a broader refactor with its own issues.
32+
33+
### #1335 - invalidate cache tags only on mutation success
34+
The `succeeded` guard (a 200 body with `success: false` must not bust the cache, commit
35+
`4a15eda`) already exists in both request helpers. Added
36+
`cache-invalidation-on-success.test.ts`: a POST returning `{ success: false }` leaves the
37+
tagged entry untouched; `{ success: true }` and non-envelope bodies invalidate as before.
38+
(The guard lives in the request helper, not `cache.ts`, because that is where the response
39+
body is parsed.)
40+
41+
### #1336 - offset/cursor pagination helper (new `pagination.ts`)
42+
- `buildPaginationParams({ mode: 'offset' | 'cursor', limit?, offset?/cursor? })` builds the
43+
query params for either mode. `limit` defaults to 20 and **throws `RangeError` before the
44+
request is sent** when it exceeds 100, mirroring the server's documented 400 message.
45+
- The cursor is passed through verbatim - never parsed or mutated client-side.
46+
- `CursorPager` holds the opaque cursor for cursor-mode paging; `setSort(key)` drops the
47+
stale cursor when the sort order actually changes, so the next page restarts from the top.
48+
49+
## How to test
50+
51+
```
52+
cd frontend
53+
npm ci --legacy-peer-deps
54+
./node_modules/.bin/jest src/lib/api
55+
```
56+
57+
- `src/lib/api` Jest suite: **135 pre-existing + 21 new tests pass**.
58+
- `tsc --noEmit`: the new/changed files add no errors over the repo's pre-existing count.
59+
- `npm run build` (`generate-client && next build`) not run here - needs the full monorepo
60+
build; nothing in this change touches build config.
61+
62+
## Breaking changes
63+
64+
None. `fillPath` keeps its `public-client` export; the request/cache behaviour is unchanged.
65+
66+
## Related issues
67+
68+
Closes #1332
69+
Closes #1333
70+
Closes #1335
71+
Closes #1336
72+
73+
## PR Checklist
74+
75+
- [x] Branch is up to date with `main`
76+
- [x] Commit messages follow Conventional Commits
77+
- [x] Tests added for the change
78+
- [x] Documentation updated if behaviour changed (n/a - behaviour preserved)
79+
- [x] No secrets or credentials committed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* #1335 — cache tags are invalidated only when a mutation actually succeeded.
3+
*
4+
* Some endpoints return HTTP 200 with `{ success: false, message: '...' }` to signal a
5+
* business-logic failure (regression trap, commit `4a15eda`). Such a response must leave
6+
* the existing cache entry untouched. The guard lives in the request helper (that is
7+
* where the response body is parsed), shared by both the public and admin clients.
8+
*/
9+
import { apiCache, CACHE_TTL } from '../cache';
10+
import { CacheTag } from '../public-client';
11+
12+
const BASE_URL = process.env.NEXT_PUBLIC_API_URL!.replace(/\/$/, '');
13+
const STATS_KEY = `${BASE_URL}/api/v1/statistics`;
14+
15+
function mockJsonResponse(body: unknown) {
16+
return {
17+
ok: true,
18+
status: 200,
19+
headers: { get: () => null },
20+
text: async () => JSON.stringify(body),
21+
json: async () => body,
22+
};
23+
}
24+
25+
describe('cache invalidation is gated on mutation success (#1335)', () => {
26+
const originalFetch = global.fetch;
27+
28+
beforeEach(() => {
29+
apiCache.clear();
30+
apiCache.set(STATS_KEY, { totalMarkets: 7 }, CACHE_TTL.MEDIUM, [CacheTag.STATISTICS]);
31+
});
32+
afterEach(() => {
33+
global.fetch = originalFetch;
34+
apiCache.clear();
35+
});
36+
37+
it('a 200 response whose body reports failure does NOT invalidate the tag', async () => {
38+
global.fetch = jest.fn(async () =>
39+
mockJsonResponse({ success: false, message: 'Email already subscribed' }),
40+
) as unknown as typeof fetch;
41+
42+
const { api } = await import('../public-client');
43+
const result = await api.newsletterSubscribe({ email: 'a@b.co' });
44+
45+
expect(result.success).toBe(false);
46+
// The statistics cache entry is still present and unchanged.
47+
expect(apiCache.get<{ totalMarkets: number }>(STATS_KEY)).toEqual({ totalMarkets: 7 });
48+
});
49+
50+
it('a 200 response reporting success DOES invalidate the tag', async () => {
51+
global.fetch = jest.fn(async () =>
52+
mockJsonResponse({ success: true, message: 'Check your inbox' }),
53+
) as unknown as typeof fetch;
54+
55+
const { api } = await import('../public-client');
56+
await api.newsletterSubscribe({ email: 'a@b.co' });
57+
58+
expect(apiCache.get(STATS_KEY)).toBeNull();
59+
});
60+
61+
it('a non-envelope 200 body (no `success` field) invalidates as before', async () => {
62+
global.fetch = jest.fn(async () => mockJsonResponse({ id: 'sub_1' })) as unknown as typeof fetch;
63+
64+
const { api } = await import('../public-client');
65+
await api.newsletterSubscribe({ email: 'a@b.co' });
66+
67+
expect(apiCache.get(STATS_KEY)).toBeNull();
68+
});
69+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { readdirSync, readFileSync, statSync } from 'fs';
2+
import { join, relative, sep } from 'path';
3+
4+
const SRC_DIR = join(__dirname, '..', '..', '..');
5+
const APP_DIR = join(SRC_DIR, 'app');
6+
7+
/**
8+
* Routes allowed to import the admin client directly:
9+
* - anything under `src/app/admin/**` (the admin area)
10+
* - the privileged `markets/<id>/resolve` route (market resolution is an
11+
* admin/guardian action; it lives outside `admin/` only because of its URL shape)
12+
*/
13+
function isAllowed(relPath: string): boolean {
14+
const p = relPath.split(sep).join('/');
15+
return p.startsWith('app/admin/') || /^app\/markets\/[^/]+\/resolve\//.test(p);
16+
}
17+
18+
function walk(dir: string, out: string[] = []): string[] {
19+
for (const entry of readdirSync(dir)) {
20+
const full = join(dir, entry);
21+
if (statSync(full).isDirectory()) walk(full, out);
22+
else if (/\.(ts|tsx)$/.test(entry)) out.push(full);
23+
}
24+
return out;
25+
}
26+
27+
const ADMIN_CLIENT_IMPORT = /from\s+['"][^'"]*\/api\/admin-client['"]/;
28+
29+
describe('module boundary: admin-client import sites (#1332)', () => {
30+
it('no non-admin route under src/app imports admin-client directly', () => {
31+
const offenders: string[] = [];
32+
for (const file of walk(APP_DIR)) {
33+
const relPath = relative(SRC_DIR, file);
34+
if (isAllowed(relPath)) continue;
35+
if (ADMIN_CLIENT_IMPORT.test(readFileSync(file, 'utf8'))) {
36+
offenders.push(relPath.split(sep).join('/'));
37+
}
38+
}
39+
expect(offenders).toEqual([]);
40+
});
41+
42+
it('the public client never imports the admin client', () => {
43+
const publicSrc = readFileSync(join(__dirname, '..', 'public-client.ts'), 'utf8');
44+
expect(publicSrc).not.toMatch(/from\s+['"]\.\/admin-client['"]/);
45+
});
46+
47+
it('the public client exposes no admin-only path prefixes', () => {
48+
const publicSrc = readFileSync(join(__dirname, '..', 'public-client.ts'), 'utf8');
49+
for (const prefix of ['/api/v1/admin', '/api/v1/audit', '/api/v1/email']) {
50+
expect(publicSrc).not.toContain(prefix);
51+
}
52+
});
53+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
buildPaginationParams,
3+
CursorPager,
4+
DEFAULT_LIMIT,
5+
MAX_LIMIT,
6+
} from '../pagination';
7+
8+
describe('buildPaginationParams', () => {
9+
it('defaults limit to 20 in offset mode', () => {
10+
expect(buildPaginationParams({ mode: 'offset' })).toEqual({ limit: String(DEFAULT_LIMIT) });
11+
});
12+
13+
it('emits offset only when non-zero', () => {
14+
expect(buildPaginationParams({ mode: 'offset', offset: 0 })).toEqual({ limit: '20' });
15+
expect(buildPaginationParams({ mode: 'offset', limit: 50, offset: 100 })).toEqual({
16+
limit: '50',
17+
offset: '100',
18+
});
19+
});
20+
21+
it('passes an opaque cursor through unchanged', () => {
22+
const cursor = 'eyJpZCI6NDJ9';
23+
expect(buildPaginationParams({ mode: 'cursor', cursor })).toEqual({ limit: '20', cursor });
24+
});
25+
26+
it('omits the cursor param on the first cursor-mode page', () => {
27+
expect(buildPaginationParams({ mode: 'cursor' })).toEqual({ limit: '20' });
28+
});
29+
30+
it('throws before sending when limit exceeds the server maximum', () => {
31+
expect(() => buildPaginationParams({ mode: 'offset', limit: MAX_LIMIT + 1 })).toThrow(RangeError);
32+
expect(() => buildPaginationParams({ mode: 'cursor', limit: 500 })).toThrow(
33+
/exceeds the maximum allowed value of 100/,
34+
);
35+
});
36+
37+
it('rejects a non-positive limit and a negative offset', () => {
38+
expect(() => buildPaginationParams({ mode: 'offset', limit: 0 })).toThrow(RangeError);
39+
expect(() => buildPaginationParams({ mode: 'offset', limit: 1.5 })).toThrow(RangeError);
40+
expect(() => buildPaginationParams({ mode: 'offset', offset: -1 })).toThrow(RangeError);
41+
});
42+
});
43+
44+
describe('CursorPager', () => {
45+
it('advances through cursors and reports position', () => {
46+
const pager = new CursorPager('created_at');
47+
expect(pager.atStart).toBe(true);
48+
expect(pager.params()).toEqual({ limit: '20' });
49+
50+
pager.advance('cursor-page-2');
51+
expect(pager.atStart).toBe(false);
52+
expect(pager.params(50)).toEqual({ limit: '50', cursor: 'cursor-page-2' });
53+
54+
pager.advance(null); // end of list
55+
expect(pager.atStart).toBe(true);
56+
});
57+
58+
it('discards the stale cursor when the sort order changes', () => {
59+
const pager = new CursorPager('created_at');
60+
pager.advance('cursor-mid-list');
61+
expect(pager.atStart).toBe(false);
62+
63+
pager.setSort('volume'); // opaque cursor is no longer valid for the new order
64+
expect(pager.atStart).toBe(true);
65+
expect(pager.params()).toEqual({ limit: '20' });
66+
});
67+
68+
it('keeps the cursor when setSort is called with the same key', () => {
69+
const pager = new CursorPager('created_at');
70+
pager.advance('cursor-mid-list');
71+
pager.setSort('created_at');
72+
expect(pager.atStart).toBe(false);
73+
});
74+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { readdirSync, readFileSync } from 'fs';
2+
import { join } from 'path';
3+
import { fillPath, fillPathParams } from '../paths';
4+
5+
const API_DIR = join(__dirname, '..');
6+
7+
describe('fillPath', () => {
8+
it('URI-encodes the substituted value', () => {
9+
expect(fillPath('/api/v1/markets/{market_id}/resolve', 'market_id', 'a/b?c#d')).toBe(
10+
'/api/v1/markets/a%2Fb%3Fc%23d/resolve',
11+
);
12+
});
13+
14+
it('encodes exactly once (no double-encoding through a chain)', () => {
15+
const once = fillPath('/x/{id}', 'id', 'a b');
16+
expect(once).toBe('/x/a%20b');
17+
// Feeding an already-filled path back in is a no-op (nothing left to replace).
18+
expect(fillPath(once, 'id', 'ignored')).toBe(once);
19+
});
20+
21+
it('fillPathParams substitutes every placeholder', () => {
22+
expect(
23+
fillPathParams('/u/{user}/tx/{hash}', { user: 'a/b', hash: 'x#y' }),
24+
).toBe('/u/a%2Fb/tx/x%23y');
25+
});
26+
});
27+
28+
describe('path values containing / ? # round-trip through a request', () => {
29+
const originalFetch = global.fetch;
30+
31+
beforeEach(() => {
32+
global.fetch = jest.fn().mockResolvedValue({
33+
ok: true,
34+
text: async () => JSON.stringify({ ok: true }),
35+
});
36+
});
37+
afterEach(() => {
38+
global.fetch = originalFetch;
39+
});
40+
41+
const requestedUrl = () => (global.fetch as jest.Mock).mock.calls[0][0] as string;
42+
43+
it('a market id with special characters lands in the path, not as a new segment or query', async () => {
44+
const { api } = await import('../public-client');
45+
await api.getBlockchainMarket('m/1?x#y');
46+
47+
const url = new URL(requestedUrl());
48+
// The whole value is one encoded path segment - no stray `/`, `?`, or `#`.
49+
expect(url.pathname).toBe('/api/v1/blockchain/markets/m%2F1%3Fx%23y');
50+
expect(url.search).toBe('');
51+
});
52+
53+
it('a tx hash with a slash does not escape the path', async () => {
54+
const { api } = await import('../public-client');
55+
await api.getTransactionStatus('deadbeef/../secret');
56+
57+
const url = new URL(requestedUrl());
58+
expect(url.pathname).toBe('/api/v1/blockchain/tx/deadbeef%2F..%2Fsecret');
59+
});
60+
});
61+
62+
describe('encodeURIComponent is centralized in paths.ts', () => {
63+
it('no other src/lib/api module calls encodeURIComponent directly', () => {
64+
const offenders: string[] = [];
65+
for (const entry of readdirSync(API_DIR, { withFileTypes: true })) {
66+
if (!entry.isFile() || !entry.name.endsWith('.ts')) continue;
67+
if (entry.name === 'paths.ts' || entry.name.endsWith('.d.ts')) continue;
68+
const source = readFileSync(join(API_DIR, entry.name), 'utf8');
69+
if (source.includes('encodeURIComponent')) offenders.push(entry.name);
70+
}
71+
expect(offenders).toEqual([]);
72+
});
73+
});

frontend/src/lib/api/admin-client.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export {
1616
CacheTag,
1717
} from './public-client';
1818

19-
import { api as publicApi, CacheTag, fillPath } from './public-client';
19+
import { api as publicApi, CacheTag } from './public-client';
20+
import { fillPath } from './paths';
2021
import { apiCache, CACHE_TTL } from './cache';
2122
import { getEnvConfig } from '../env';
2223
import type { paths, components } from './schema';
@@ -337,7 +338,7 @@ export const api = {
337338
}),
338339

339340
emailPreview: (templateName: string, signal?: AbortSignal) =>
340-
request<Record<string, unknown>>("GET", `/api/v1/email/preview/${encodeURIComponent(templateName)}`, {
341+
request<Record<string, unknown>>("GET", fillPath("/api/v1/email/preview/{template_name}", 'template_name', templateName), {
341342
cacheTtl: CACHE_TTL.LONG,
342343
cacheTags: [CacheTag.EMAIL],
343344
signal,

0 commit comments

Comments
 (0)