Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express {
app.use("/api/leaderboard", leaderboardRouter);
app.use("/api/leaderboard/global", globalLeaderboardRouter);
app.use("/api/rate-limit", rateLimitRouter);
app.use("/api/search", searchRouter);
app.use("/api/quota/requests", quotaRequestsRouter);
app.use("/api/notifications", notificationsRouter);
app.use("/api/webhooks", webhooksRouter);
Expand Down
172 changes: 171 additions & 1 deletion src/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"
import { z } from "zod";
import { logger } from "../config/logger";
import { getRequestId } from "../lib/requestContext";
import { idempotency } from "../middleware/idempotency";

export const searchRouter = Router();

Expand Down Expand Up @@ -42,7 +43,7 @@ const searchSchema = z.object({
.trim()
.min(1, "Search query must not be empty")
.max(200, "Search query is too long")
.refine((s) => !/[\x00-\x1F]/.test(s), "Control characters are not allowed in query"),
.refine((s) => !s || s.split("").every((c) => c >= " "), "Control characters are not allowed in query"),
limit: z.coerce
.number()
.int()
Expand Down Expand Up @@ -101,3 +102,172 @@ searchRouter.get("/", async (req: Request, res: Response, next: NextFunction): P
inFlightSearchRequests--;
}
});

// ── Mutation endpoints with idempotency-key support ──────────────────────

const saveSearchBodySchema = z.object({
query: z
.string()
.trim()
.min(1, "Search query must not be empty")
.max(200, "Search query is too long")
.refine((s) => !s || s.split("").every((c) => c >= " "), "Control characters are not allowed in query"),
label: z
.string()
.trim()
.max(100, "Label is too long")
.optional(),
}).strict();

const updateSearchBodySchema = z.object({
label: z
.string()
.trim()
.min(1, "Label must not be empty when provided")
.max(100, "Label is too long"),
}).strict();

/**
* POST /api/search
*
* Save a search query to the user's search history. Idempotency-key header
* enables safe retries — the same request with the same key returns the
* same saved search record.
*
* Request body (JSON):
* ```json
* {
* "query": "prediction markets",
* "label": "My Search" // optional display label
* }
* ```
*
* Response (201):
* ```json
* {
* "data": {
* "id": "search-uuid",
* "query": "prediction markets",
* "label": "My Search",
* "createdAt": "2026-07-28T12:00:00.000Z"
* }
* }
* ```
*/
searchRouter.post("/", idempotency, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
inFlightSearchRequests++;
try {
const parseResult = saveSearchBodySchema.safeParse(req.body);
const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown";

if (!parseResult.success) {
res.status(400).json({
error: {
code: "validation_error",
message: parseResult.error.issues[0]?.message ?? "Invalid request body",
requestId: reqId,
},
});
return;
}

const { query, label } = parseResult.data;

logger.info(
{ query, label, correlationId: reqId },
"Saving search query",
);

// Mock save operation — in production this would persist to the database
const savedSearch = {
id: `search-${Date.now()}`,
query,
label: label ?? null,
createdAt: new Date().toISOString(),
};

res.status(201).json({
data: savedSearch,
});
} catch (e) {
next(e);
} finally {
inFlightSearchRequests--;
}
});

/**
* PATCH /api/search/:id
*
* Update an existing saved search (e.g., rename the label). Idempotency-key
* header enables safe retries.
*
* Request body (JSON):
* ```json
* {
* "label": "Updated Label"
* }
* ```
*
* Response (200):
* ```json
* {
* "data": {
* "id": "search-uuid",
* "query": "prediction markets",
* "label": "Updated Label",
* "updatedAt": "2026-07-28T12:00:00.000Z"
* }
* }
* ```
*/
searchRouter.patch("/:id", idempotency, async (req: Request, res: Response, next: NextFunction): Promise<void> => {
inFlightSearchRequests++;
try {
const parseResult = updateSearchBodySchema.safeParse(req.body);
const reqId = getRequestId() ?? (req as { id?: string }).id ?? "unknown";
const { id } = req.params;

if (!parseResult.success) {
res.status(400).json({
error: {
code: "validation_error",
message: parseResult.error.issues[0]?.message ?? "Invalid request body",
requestId: reqId,
},
});
return;
}

if (!id || typeof id !== "string" || id.length > 128) {
res.status(400).json({
error: {
code: "validation_error",
message: "Invalid search ID",
requestId: reqId,
},
});
return;
}

const { label } = parseResult.data;

logger.info(
{ searchId: id, label, correlationId: reqId },
"Updating saved search",
);

// Mock update operation — in production this would update the database
res.json({
data: {
id,
label,
updatedAt: new Date().toISOString(),
},
});
} catch (e) {
next(e);
} finally {
inFlightSearchRequests--;
}
});
Loading