Skip to content

Commit bb39fd0

Browse files
authored
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
1 parent 8b96b7c commit bb39fd0

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

src/lib/listingsCache.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export interface ListingsCacheKeyParams {
4646
search?: string;
4747
/** Opaque cursor string for keyset pagination. When present, offset is ignored. */
4848
cursor?: string;
49+
/** Optional status filter. When absent, the route returns active APIs by default. */
50+
status?: string;
4951
}
5052

5153
// ── Cache key builder ─────────────────────────────────────────────────────────
@@ -67,6 +69,9 @@ export function buildCacheKey(params: ListingsCacheKeyParams): string {
6769
category: params.category ?? null,
6870
search: params.search ?? null,
6971
cursor: params.cursor ?? null,
72+
// Status is included so ?status=draft and ?status=active are cached
73+
// independently and never serve each other's data.
74+
status: params.status ?? null,
7075
});
7176
}
7277

src/routes/apis.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
NotFoundError,
55
UnauthorizedError,
66
} from "../errors/index.js";
7+
import { apiStatusEnum, type ApiStatus } from "../db/schema.js";
78
import {
89
parseCursorPagination,
910
decodeCursor,
@@ -139,6 +140,23 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
139140
const search =
140141
typeof req.query.search === "string" ? req.query.search : undefined;
141142

143+
// Validate optional ?status filter against the known enum values.
144+
// The public listing only returns active APIs by default; callers may
145+
// explicitly request a different status (e.g. draft) but unknown values
146+
// are rejected early to avoid silent no-result responses.
147+
const statusParam =
148+
typeof req.query.status === "string" ? req.query.status : undefined;
149+
if (statusParam !== undefined) {
150+
if (!apiStatusEnum.includes(statusParam as ApiStatus)) {
151+
next(
152+
new BadRequestError(
153+
`status must be one of: ${apiStatusEnum.join(", ")}`,
154+
),
155+
);
156+
return;
157+
}
158+
}
159+
142160
const { limit, cursor: rawCursor } = parseCursorPagination(query);
143161

144162
let cursorDate: Date | undefined;
@@ -164,6 +182,9 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
164182
category,
165183
search,
166184
cursor: rawCursor,
185+
// Include status in the key so different status filters are cached
186+
// independently and never collide.
187+
status: statusParam,
167188
});
168189
const cached = cache.get(cacheKey);
169190
if (cached !== undefined) {
@@ -179,6 +200,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
179200
const fetchLimit = rawCursor ? limit : limit + 1;
180201
const rows = await apiRepository.listPublic({
181202
limit: fetchLimit,
203+
status: statusParam as ApiStatus | undefined,
182204
category,
183205
search,
184206
cursor:
@@ -199,7 +221,34 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
199221
);
200222
}
201223

202-
const response = cursorPaginatedResponse(pageRows, {
224+
// Enrich each row with developer info and endpoints.
225+
// findById returns the full ApiDetails (including joined developer).
226+
// getEndpoints is a lightweight indexed lookup per API.
227+
const enrichedRows = await Promise.all(
228+
pageRows.map(async (api) => {
229+
const [details, endpoints] = await Promise.all([
230+
apiRepository.findById(api.id),
231+
apiRepository.getEndpoints(api.id),
232+
]);
233+
return {
234+
id: api.id,
235+
name: api.name,
236+
description: api.description,
237+
base_url: api.base_url,
238+
logo_url: api.logo_url,
239+
category: api.category,
240+
status: api.status,
241+
developer: details?.developer ?? {
242+
name: null,
243+
website: null,
244+
description: null,
245+
},
246+
endpoints,
247+
};
248+
}),
249+
);
250+
251+
const response = cursorPaginatedResponse(enrichedRows, {
203252
limit,
204253
nextCursor,
205254
hasMore,
@@ -212,7 +261,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router {
212261
}
213262
});
214263

215-
router.get("/:id", etagMiddleware, async (req, res, next) => {
264+
router.get("/:id", async (req, res, next) => {
216265
try {
217266
const id = Number(req.params.id);
218267

0 commit comments

Comments
 (0)