Skip to content

Commit bda92c4

Browse files
Merge pull request #749 from Amksongs/feat/570-etag-markets-caching
feat(markets): add ETag/304 caching on all market GET endpoints
2 parents f79ed09 + 890999e commit bda92c4

13 files changed

Lines changed: 502 additions & 15 deletions

src/openapi/registry.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -655,10 +655,32 @@ registry.registerPath({
655655
path: "/api/markets",
656656
operationId: "listMarkets",
657657
tags: ["Markets"],
658-
summary: "List all markets",
658+
summary: "List all markets with cursor pagination",
659+
description:
660+
"Returns a cursor-paginated list of markets. Supports strong ETag / conditional GET: " +
661+
"send the ETag back as If-None-Match on subsequent requests; if the page is unchanged " +
662+
"the server responds 304 Not Modified (no body).",
663+
request: {
664+
headers: z.object({
665+
"if-none-match": z.string().optional().openapi({
666+
description: "ETag from a previous 200 response. Triggers 304 when the page is unchanged.",
667+
param: { name: "If-None-Match", in: "header" },
668+
}),
669+
}),
670+
},
659671
responses: {
660672
200: {
661673
description: "Array of markets",
674+
headers: {
675+
ETag: {
676+
description: "Strong ETag (SHA-256) of the response body.",
677+
schema: { type: "string" },
678+
},
679+
"Cache-Control": {
680+
description: "Always no-cache so clients revalidate before reuse.",
681+
schema: { type: "string", example: "no-cache" },
682+
},
683+
},
662684
content: {
663685
"application/json": {
664686
schema: z.object({ data: z.array(Market) }),
@@ -695,6 +717,13 @@ registry.registerPath({
695717
},
696718
},
697719
},
720+
304: {
721+
description: "Not Modified — page unchanged since the ETag in If-None-Match.",
722+
},
723+
400: {
724+
description: "Invalid query parameters",
725+
content: { "application/json": { schema: ErrorBody } },
726+
},
698727
},
699728
});
700729

@@ -704,17 +733,36 @@ registry.registerPath({
704733
operationId: "searchMarkets",
705734
tags: ["Markets"],
706735
summary: "Full-text search across markets",
736+
description:
737+
"Full-text search with fuzzy trigram fallback. Supports strong ETag / conditional GET: " +
738+
"send the ETag back as If-None-Match; if results are unchanged the server responds 304 Not Modified.",
707739
request: {
708740
query: z.object({
709741
q: z.string().min(1),
710742
limit: z.coerce.number().int().positive().default(20).optional(),
711743
offset: z.coerce.number().int().nonnegative().default(0).optional(),
712744
page: z.coerce.number().int().positive().optional(),
713745
}),
746+
headers: z.object({
747+
"if-none-match": z.string().optional().openapi({
748+
description: "ETag from a previous 200 response. Triggers 304 when results are unchanged.",
749+
param: { name: "If-None-Match", in: "header" },
750+
}),
751+
}),
714752
},
715753
responses: {
716754
200: {
717755
description: "Search results",
756+
headers: {
757+
ETag: {
758+
description: "Strong ETag (SHA-256) of the response body.",
759+
schema: { type: "string" },
760+
},
761+
"Cache-Control": {
762+
description: "Always no-cache so clients revalidate before reuse.",
763+
schema: { type: "string", example: "no-cache" },
764+
},
765+
},
718766
content: {
719767
"application/json": {
720768
schema: MarketSearchResult,
@@ -759,6 +807,9 @@ registry.registerPath({
759807
},
760808
},
761809
},
810+
304: {
811+
description: "Not Modified — search results unchanged since the ETag in If-None-Match.",
812+
},
762813
400: {
763814
description: "Missing query parameter",
764815
content: {
@@ -822,10 +873,31 @@ registry.registerPath({
822873
operationId: "getMarketById",
823874
tags: ["Markets"],
824875
summary: "Get a market by ID",
825-
request: { params: z.object({ id: z.string() }) },
876+
description:
877+
"Returns a single market by ID. Supports strong ETag / conditional GET: " +
878+
"send the ETag back as If-None-Match; if unchanged the server responds 304 Not Modified.",
879+
request: {
880+
params: z.object({ id: z.string() }),
881+
headers: z.object({
882+
"if-none-match": z.string().optional().openapi({
883+
description: "ETag from a previous 200 response. Triggers 304 when the market is unchanged.",
884+
param: { name: "If-None-Match", in: "header" },
885+
}),
886+
}),
887+
},
826888
responses: {
827889
200: {
828890
description: "Market",
891+
headers: {
892+
ETag: {
893+
description: "Strong ETag (SHA-256) of the response body.",
894+
schema: { type: "string" },
895+
},
896+
"Cache-Control": {
897+
description: "Always no-cache so clients revalidate before reuse.",
898+
schema: { type: "string", example: "no-cache" },
899+
},
900+
},
829901
content: {
830902
"application/json": {
831903
schema: z.object({ data: Market }),
@@ -854,6 +926,14 @@ registry.registerPath({
854926
content: { "application/json": { schema: ErrorBody } },
855927
},
856928
},
929+
304: {
930+
description: "Not Modified — market unchanged since the ETag in If-None-Match.",
931+
},
932+
404: {
933+
description: "Not found",
934+
content: { "application/json": { schema: ErrorBody } },
935+
},
936+
},
857937
});
858938

859939
const PatchMarketRequest = z

src/routes/markets/index.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, nex
8787

8888
const result = await searchMarkets({ query: q, limit, offset });
8989

90-
return res.status(200).json({
90+
const payload = {
9191
data: result.data,
9292
total: result.total,
9393
limit,
@@ -108,7 +108,13 @@ marketsRouter.get("/search", trackMarketsMetrics("search"), async (req, res, nex
108108
total: result.total,
109109
fallback: result.fallback,
110110
},
111-
});
111+
};
112+
113+
if (conditionalGet(payload, req, res)) {
114+
return;
115+
}
116+
117+
return res.status(200).json(payload);
112118
} catch (err) {
113119
logger.error({ reqId, correlationId: reqId, err }, "markets_search_failed");
114120
return next(err);
@@ -162,7 +168,13 @@ marketsRouter.get("/featured", trackMarketsMetrics("featured"), async (req, res,
162168

163169
const { limit } = parsed.data;
164170
const data = await listFeaturedMarkets(limit);
165-
return res.json({ data });
171+
const payload = { data };
172+
173+
if (conditionalGet(payload, req, res)) {
174+
return;
175+
}
176+
177+
return res.json(payload);
166178
} catch (e) {
167179
logger.error({ reqId, correlationId: reqId, err: e }, "markets_featured_failed");
168180
return next(e);
@@ -179,11 +191,17 @@ marketsRouter.get("/upcoming", trackMarketsMetrics("upcoming"), async (req, res,
179191

180192
const { limit } = parsed.data;
181193
const data = await listUpcomingMarkets({ limit });
194+
const payload = { data };
195+
196+
if (conditionalGet(payload, req, res)) {
197+
return;
198+
}
199+
182200
logger.info(
183201
{ reqId, correlationId: reqId, count: data.length },
184202
"markets_upcoming_listed",
185203
);
186-
return res.json({ data });
204+
return res.json(payload);
187205
} catch (err) {
188206
logger.error(
189207
{ reqId, correlationId: reqId, err },

src/routes/markets/prediction-count.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { Router } from "express";
2020
import { getPredictionCount } from "../../services/predictionCountService";
2121
import { NotFoundError } from "../../errors";
22+
import { conditionalGet } from "../../middleware/etag";
2223
import { logger } from "../../config/logger";
2324
import { getRequestId } from "../../lib/requestContext";
2425

@@ -44,13 +45,18 @@ predictionCountRouter.get("/", async (req, res, next) => {
4445
logger.debug({ reqId, marketId }, "prediction_count_request");
4546

4647
const result = await getPredictionCount(marketId);
48+
const payload = { data: result };
49+
50+
if (conditionalGet(payload, req, res)) {
51+
return;
52+
}
4753

4854
logger.info(
4955
{ reqId, marketId, count: result.count, cached: result.cached },
5056
"prediction_count_success",
5157
);
5258

53-
return res.status(200).json({ data: result });
59+
return res.status(200).json(payload);
5460
} catch (err) {
5561
if (err instanceof NotFoundError) {
5662
logger.warn({ reqId, marketId }, "prediction_count_not_found");

src/routes/markets/tags.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { Router } from "express";
33
import { getMarketTags } from "../../repositories/marketRepository";
44
import { rateLimitAnon } from "../../middleware/rateLimitAnon";
5+
import { conditionalGet } from "../../middleware/etag";
56
import { logger } from "../../config/logger";
67

78
export const tagsRouter = Router();
@@ -14,8 +15,14 @@ tagsRouter.get("/", async (req, res, next) => {
1415
try {
1516
logger.debug({ reqId, correlationId: reqId }, "Fetching market tags");
1617
const data = await getMarketTags();
18+
const payload = { data };
19+
20+
if (conditionalGet(payload, req, res)) {
21+
return;
22+
}
23+
1724
logger.info({ reqId, correlationId: reqId, count: data.length }, "Market tags fetched successfully");
18-
res.json({ data });
25+
res.json(payload);
1926
} catch (e) {
2027
logger.error({ reqId, correlationId: reqId, err: e }, "Failed to fetch market tags");
2128
next(e);

src/routes/markets/trending.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Router } from "express";
22
import { getTrending } from "../../services/trendingService";
33
import { rateLimitAnon } from "../../middleware/rateLimitAnon";
44
import { trendingQuerySchema } from "../../validators/markets";
5+
import { conditionalGet } from "../../middleware/etag";
56

67
export const trendingRouter = Router();
78

@@ -12,7 +13,13 @@ trendingRouter.get("/", async (req, res, next) => {
1213
try {
1314
const { limit, offset } = trendingQuerySchema.parse(req.query);
1415
const data = await getTrending(limit, offset);
15-
res.json({ data, meta: { limit, offset, count: data.length } });
16+
const payload = { data, meta: { limit, offset, count: data.length } };
17+
18+
if (conditionalGet(payload, req, res)) {
19+
return;
20+
}
21+
22+
res.json(payload);
1623
} catch (e) {
1724
next(e);
1825
}

src/routes/markets/watchers.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
import { Router } from "express";
17+
import { conditionalGet } from "../../middleware/etag";
1718
import { logger } from "../../config/logger";
1819
import { getRequestId } from "../../lib/requestContext";
1920
import { AuthenticatedRequest } from "../../middleware/auth";
@@ -23,6 +24,7 @@ import {
2324
addMarketWatcher,
2425
removeMarketWatcher,
2526
} from "../../services/marketWatcherService";
27+
import { NotFoundError } from "../../errors";
2628
import {
2729
marketParamsSchema,
2830
marketWatchersQuerySchema,
@@ -68,6 +70,15 @@ watchersRouter.get("/", async (req, res, next) => {
6870
logger.debug({ reqId, correlationId: reqId, marketId, limit, cursor }, "market_watchers_list_request");
6971

7072
const result = await listMarketWatchers(marketId, { limit, cursor });
73+
const payload = {
74+
data: result.data,
75+
nextCursor: result.nextCursor,
76+
total: result.total,
77+
};
78+
79+
if (conditionalGet(payload, req, res)) {
80+
return;
81+
}
7182

7283
logger.info(
7384
{
@@ -81,11 +92,7 @@ watchersRouter.get("/", async (req, res, next) => {
8192
"market_watchers_list_success",
8293
);
8394

84-
return res.status(200).json({
85-
data: result.data,
86-
nextCursor: result.nextCursor,
87-
total: result.total,
88-
});
95+
return res.status(200).json(payload);
8996
} catch (err) {
9097
if (err instanceof NotFoundError) {
9198
logger.warn({ reqId, correlationId: reqId, marketId: req.params.id }, "market_watchers_not_found");

tests/marketWatchers.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,53 @@ describe("GET /api/markets/:id/watchers", () => {
120120
expect(res.status).toBe(400);
121121
expect(res.body.error.code).toBe("validation_error");
122122
});
123+
124+
// ── ETag / conditional GET ─────────────────────────────────────────────
125+
126+
it("returns a strong ETag header on 200", async () => {
127+
mockListMarketWatchers.mockResolvedValueOnce({
128+
data: [{ id: "w-1", marketId: "mkt-1", userId: "u-1", stellarAddress: "GABC123", createdAt: "2026-07-25T10:00:00.000Z" }],
129+
nextCursor: null,
130+
total: 1,
131+
});
132+
133+
const res = await request(app).get("/mkt-1/watchers");
134+
expect(res.status).toBe(200);
135+
expect(res.headers["etag"]).toMatch(/^"[0-9a-f]{64}"$/);
136+
expect(res.headers["cache-control"]).toBe("no-cache");
137+
});
138+
139+
it("returns 304 when If-None-Match matches", async () => {
140+
mockListMarketWatchers.mockResolvedValue({
141+
data: [{ id: "w-1", marketId: "mkt-1", userId: "u-1", stellarAddress: "GABC123", createdAt: "2026-07-25T10:00:00.000Z" }],
142+
nextCursor: null,
143+
total: 1,
144+
});
145+
146+
const first = await request(app).get("/mkt-1/watchers");
147+
const etag = first.headers["etag"] as string;
148+
149+
const second = await request(app)
150+
.get("/mkt-1/watchers")
151+
.set("If-None-Match", etag);
152+
153+
expect(second.status).toBe(304);
154+
});
155+
156+
it("returns 200 for a stale ETag", async () => {
157+
mockListMarketWatchers.mockResolvedValueOnce({
158+
data: [{ id: "w-1", marketId: "mkt-1", userId: "u-1", stellarAddress: "GABC123", createdAt: "2026-07-25T10:00:00.000Z" }],
159+
nextCursor: null,
160+
total: 1,
161+
});
162+
163+
const res = await request(app)
164+
.get("/mkt-1/watchers")
165+
.set("If-None-Match", '"000000000000000000000000000000000000000000000000000000000000dead"');
166+
167+
expect(res.status).toBe(200);
168+
expect(res.body).toHaveProperty("data");
169+
});
123170
});
124171

125172
describe("POST /api/markets/:id/watchers", () => {

0 commit comments

Comments
 (0)