Skip to content

Commit df68c36

Browse files
Merge pull request #1038 from Vicistar-V/feat/cursor-pagination-apis-947
feat: make cursor pagination the default on GET /api/apis (closes #947)
2 parents 527a658 + 2aa3345 commit df68c36

6 files changed

Lines changed: 82 additions & 115 deletions

File tree

README.md

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ API gateway, usage metering, and billing services for the Callora API marketplac
44

55
## API Catalog Pagination (`GET /api/apis`)
66

7-
The public API catalog endpoint supports two pagination modes. Cursor pagination is preferred for stable, gap-free traversal over large catalogs; offset pagination is available for backward compatibility.
7+
The public API catalog endpoint uses **keyset cursor pagination** over `(created_at DESC, id DESC)` for stable, gap-free traversal under concurrent writes. Offset-based pagination has been removed; all requests now return cursor-based responses.
88

9-
### Cursor pagination (recommended)
10-
11-
Results are ordered **newest-first** by `(created_at DESC, id DESC)`. Pass the opaque `nextCursor` value returned in one response as the `cursor` query parameter on the next request.
9+
Results are ordered **newest-first** by `(created_at DESC, id DESC)`. Pass the opaque `nextCursor` value returned in one response as the `cursor` query parameter on the next request. Omit `cursor` for the first page.
1210

1311
| Parameter | Type | Description |
1412
|-----------|------|-------------|
@@ -51,19 +49,7 @@ When `hasMore` is `false` and `nextCursor` is absent, you have reached the last
5149

5250
A malformed or tampered cursor returns `HTTP 400` with `code: "VALIDATION_ERROR"`.
5351

54-
### Offset pagination (legacy)
55-
56-
Omit `cursor` and use `limit` + `offset` (or `page`). Results may shift if new APIs are inserted during traversal.
57-
58-
```
59-
GET /api/apis?limit=20&offset=40
60-
```
61-
```json
62-
{
63-
"data": [ ... ],
64-
"meta": { "limit": 20, "offset": 40 }
65-
}
66-
```
52+
The `offset` and `page` query parameters are ignored (cursor pagination does not support random-access jumping).
6753

6854
## Fee Abstraction
6955

@@ -136,7 +122,7 @@ The migration is in `migrations/0019_disputes.sql` (rollback: `migrations/0019_d
136122

137123
- Health check: `GET /api/health`
138124
- Marketplace routes:
139-
- `GET /api/apis` — list public (active, non-deleted) APIs with cursor **or** offset pagination
125+
- `GET /api/apis` — list public (active, non-deleted) APIs with cursor pagination over `(created_at, id)`
140126
- `GET /api/apis/:id`
141127
- `POST /api/apis` for authenticated developers to register an API with priced endpoints
142128
- Usage route: `GET /api/usage`

docs/openapi.json

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1731,10 +1731,9 @@
17311731
]
17321732
}
17331733
],
1734-
"pagination": {
1734+
"meta": {
17351735
"limit": 20,
1736-
"offset": 0,
1737-
"total": 2
1736+
"hasMore": false
17381737
}
17391738
}
17401739
}
@@ -4946,7 +4945,7 @@
49464945
"type": "object",
49474946
"required": [
49484947
"data",
4949-
"pagination"
4948+
"meta"
49504949
],
49514950
"properties": {
49524951
"data": {
@@ -4955,22 +4954,22 @@
49554954
"$ref": "#/components/schemas/ApiDetailsResponse"
49564955
}
49574956
},
4958-
"pagination": {
4957+
"meta": {
49594958
"type": "object",
49604959
"required": [
49614960
"limit",
4962-
"offset",
4963-
"total"
4961+
"hasMore"
49644962
],
49654963
"properties": {
49664964
"limit": {
49674965
"type": "integer"
49684966
},
4969-
"offset": {
4970-
"type": "integer"
4967+
"hasMore": {
4968+
"type": "boolean"
49714969
},
4972-
"total": {
4973-
"type": "integer"
4970+
"nextCursor": {
4971+
"type": "string",
4972+
"description": "Opaque base64 cursor for the next page. Absent when hasMore is false."
49744973
}
49754974
}
49764975
}

src/routes/apis.cursor.test.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,18 @@ describe('GET /api/apis — cursor pagination', () => {
185185

186186
// ── No-cursor first page ───────────────────────────────────────────────────
187187

188-
it('first page (no cursor) returns newest-first items and a nextCursor', async () => {
188+
it('first page (no cursor) returns cursor-based response with nextCursor and hasMore', async () => {
189189
const res = await request(buildFixtureApp()).get('/api/apis?limit=2');
190190

191191
expect(res.status).toBe(200);
192192
expect(res.body.data).toHaveLength(2);
193193
expect(res.body.data[0].id).toBe(5);
194194
expect(res.body.data[1].id).toBe(4);
195-
// The offset path uses paginatedResponse, so meta has `offset`.
195+
// Cursor path is now the default; meta has cursor fields, not offset.
196196
expect(res.body.meta).toHaveProperty('limit', 2);
197+
expect(res.body.meta).toHaveProperty('hasMore', true);
198+
expect(typeof res.body.meta.nextCursor).toBe('string');
199+
expect(res.body.meta).not.toHaveProperty('offset');
197200
});
198201

199202
// ── Tie-breaking on identical timestamps ──────────────────────────────────
@@ -293,26 +296,25 @@ describe('GET /api/apis — cursor pagination', () => {
293296
expect(res.body.data[0].id).toBe(8);
294297
});
295298

296-
// ── Backward compatibility: offset path still works ────────────────────────
299+
// ── Cursor pagination is always the default ────────────────────────────────
297300

298-
it('falls back to offset pagination when no cursor is supplied', async () => {
299-
const res = await request(buildFixtureApp()).get('/api/apis?limit=2&offset=2');
301+
it('uses cursor pagination even when no cursor is supplied', async () => {
302+
const res = await request(buildFixtureApp()).get('/api/apis?limit=2');
300303

301304
expect(res.status).toBe(200);
302-
expect(res.body.meta).toHaveProperty('offset', 2);
303305
expect(res.body.meta).toHaveProperty('limit', 2);
304-
// In the offset path meta should NOT include cursor fields.
305-
expect(res.body.meta).not.toHaveProperty('nextCursor');
306-
expect(res.body.meta).not.toHaveProperty('hasMore');
306+
expect(res.body.meta).toHaveProperty('hasMore');
307+
expect(res.body.meta).toHaveProperty('nextCursor');
308+
expect(res.body.meta).not.toHaveProperty('offset');
307309
});
308310

309-
it('ignores an empty cursor string and uses the offset path', async () => {
311+
it('treats empty cursor string as first page (cursor-based)', async () => {
310312
const res = await request(buildFixtureApp()).get('/api/apis?cursor=');
311313

312314
expect(res.status).toBe(200);
313-
// Response should be the plain offset-style envelope.
314-
expect(res.body.meta).toHaveProperty('offset');
315-
expect(res.body.meta).not.toHaveProperty('nextCursor');
315+
expect(res.body.meta).toHaveProperty('hasMore');
316+
expect(res.body.meta).toHaveProperty('nextCursor');
317+
expect(res.body.meta).not.toHaveProperty('offset');
316318
});
317319

318320
// ── Cache isolation ────────────────────────────────────────────────────────

src/routes/apis.openapi.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,9 @@ describe('OpenAPI examples for API marketplace routes', () => {
3838
expect(listExample).toBeDefined();
3939
expect(listExample.summary).toBe('Active API listings page');
4040
const listExampleValue = asObject(listExample.value);
41-
expect(listExampleValue.pagination).toEqual({
41+
expect(listExampleValue.meta).toMatchObject({
4242
limit: 20,
43-
offset: 0,
44-
total: 2,
43+
hasMore: false,
4544
});
4645
expect((listExampleValue.data as unknown[])[0]).toEqual(
4746
expect.objectContaining({

src/routes/apis.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,14 @@ describe('createApisRouter', () => {
9696
return app;
9797
}
9898

99-
it('returns only active apis by default with pagination metadata', async () => {
99+
it('returns only active apis by default with cursor pagination metadata', async () => {
100100
const app = buildApp();
101101

102102
const res = await request(app).get('/api/apis');
103103

104104
expect(res.status).toBe(200);
105-
expect(res.body.meta).toEqual({ total: 1, limit: 20, offset: 0 });
105+
expect(res.body.meta).toMatchObject({ limit: 20, hasMore: false });
106+
expect(res.body.meta).not.toHaveProperty('offset');
106107
expect(res.body.data).toHaveLength(1);
107108
expect(res.body.data[0]).toEqual(
108109
expect.objectContaining({
@@ -126,7 +127,8 @@ describe('createApisRouter', () => {
126127
const res = await request(app).get('/api/apis?status=draft');
127128

128129
expect(res.status).toBe(200);
129-
expect(res.body.meta).toEqual({ total: 1, limit: 20, offset: 0 });
130+
expect(res.body.meta).toMatchObject({ limit: 20, hasMore: false });
131+
expect(res.body.meta).not.toHaveProperty('offset');
130132
expect(res.body.data).toHaveLength(1);
131133
expect(res.body.data[0].id).toBe(2);
132134
expect(res.body.data[0].status).toBe('draft');
@@ -167,10 +169,11 @@ describe('createApisRouter', () => {
167169
it('applies pagination params to the response metadata and items', async () => {
168170
const app = buildApp();
169171

170-
const res = await request(app).get('/api/apis?status=active&limit=1&offset=0');
172+
const res = await request(app).get('/api/apis?status=active&limit=1');
171173

172174
expect(res.status).toBe(200);
173-
expect(res.body.meta).toEqual({ total: 1, limit: 1, offset: 0 });
175+
expect(res.body.meta).toMatchObject({ limit: 1, hasMore: false });
176+
expect(res.body.meta).not.toHaveProperty('offset');
174177
expect(res.body.data).toHaveLength(1);
175178
});
176179

src/routes/apis.ts

Lines changed: 43 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import {
55
UnauthorizedError,
66
} from "../errors/index.js";
77
import {
8-
parsePagination,
9-
paginatedResponse,
108
parseCursorPagination,
119
decodeCursor,
1210
generateCursor,
@@ -139,16 +137,15 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
139137
const search =
140138
typeof req.query.search === "string" ? req.query.search : undefined;
141139

142-
// ── Cursor-based pagination path ───────────────────────────────────────
143-
if (query.cursor !== undefined && query.cursor.trim() !== "") {
144-
const { limit, cursor: rawCursor } = parseCursorPagination(query);
140+
const { limit, cursor: rawCursor } = parseCursorPagination(query);
145141

146-
// decodeCursor throws a ValidationError (400) on malformed input.
147-
const { created_at: cursorCreatedAt, id: cursorId } = decodeCursor(
148-
rawCursor!,
149-
);
150-
const cursorDate = new Date(cursorCreatedAt);
151-
const cursorIdNum = parseInt(cursorId, 10);
142+
let cursorDate: Date | undefined;
143+
let cursorIdNum: number | undefined;
144+
145+
if (rawCursor) {
146+
const decoded = decodeCursor(rawCursor);
147+
cursorDate = new Date(decoded.created_at);
148+
cursorIdNum = parseInt(decoded.id, 10);
152149
if (!Number.isFinite(cursorIdNum) || cursorIdNum <= 0) {
153150
next(
154151
new BadRequestError(
@@ -157,58 +154,15 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
157154
);
158155
return;
159156
}
160-
161-
const cacheKey = buildCacheKey({
162-
limit,
163-
offset: 0,
164-
category,
165-
search,
166-
cursor: rawCursor,
167-
});
168-
const cached = cache.get(cacheKey);
169-
if (cached !== undefined) {
170-
recordCacheHit();
171-
res.json(cached);
172-
return;
173-
}
174-
175-
recordCacheMiss();
176-
// Fetch limit+1 rows; the repository already applies +1 internally.
177-
const rows = await apiRepository.listPublic({
178-
limit,
179-
category,
180-
search,
181-
cursor: { after_created_at: cursorDate, after_id: cursorIdNum },
182-
});
183-
184-
const hasMore = rows.length > limit;
185-
const pageRows = rows.slice(0, limit);
186-
187-
// Generate the next cursor from the last item in this page.
188-
let nextCursor: string | undefined;
189-
if (hasMore && pageRows.length > 0) {
190-
const last = pageRows[pageRows.length - 1];
191-
nextCursor = generateCursor(
192-
last.created_at.toISOString(),
193-
String(last.id),
194-
);
195-
}
196-
197-
const response = cursorPaginatedResponse(pageRows, {
198-
limit,
199-
nextCursor,
200-
hasMore,
201-
});
202-
203-
cache.set(cacheKey, response);
204-
res.json(response);
205-
return;
206157
}
207158

208-
// ── Offset-based pagination path (legacy / default) ────────────────────
209-
const { limit, offset } = parsePagination(query);
210-
211-
const cacheKey = buildCacheKey({ limit, offset, category, search });
159+
const cacheKey = buildCacheKey({
160+
limit,
161+
offset: 0,
162+
category,
163+
search,
164+
cursor: rawCursor,
165+
});
212166
const cached = cache.get(cacheKey);
213167
if (cached !== undefined) {
214168
recordCacheHit();
@@ -217,13 +171,37 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
217171
}
218172

219173
recordCacheMiss();
220-
const apis = await apiRepository.listPublic({
221-
limit,
222-
offset,
174+
// Fetch limit+1 rows for hasMore detection.
175+
// The repository already applies +1 internally when cursor is set,
176+
// so we only pass the extra row when no cursor is present.
177+
const fetchLimit = rawCursor ? limit : limit + 1;
178+
const rows = await apiRepository.listPublic({
179+
limit: fetchLimit,
223180
category,
224181
search,
182+
cursor:
183+
cursorDate && cursorIdNum
184+
? { after_created_at: cursorDate, after_id: cursorIdNum }
185+
: undefined,
186+
});
187+
188+
const hasMore = rows.length > limit;
189+
const pageRows = rows.slice(0, limit);
190+
191+
let nextCursor: string | undefined;
192+
if (hasMore && pageRows.length > 0) {
193+
const last = pageRows[pageRows.length - 1];
194+
nextCursor = generateCursor(
195+
last.created_at.toISOString(),
196+
String(last.id),
197+
);
198+
}
199+
200+
const response = cursorPaginatedResponse(pageRows, {
201+
limit,
202+
nextCursor,
203+
hasMore,
225204
});
226-
const response = paginatedResponse(apis, { limit, offset });
227205

228206
cache.set(cacheKey, response);
229207
res.json(response);

0 commit comments

Comments
 (0)