From 48a87668e76f7413b29cd010e57a7ddc19647a9f Mon Sep 17 00:00:00 2001 From: Joel Olusegun Date: Fri, 31 Jul 2026 12:15:59 +0100 Subject: [PATCH] refactor: streamline exports query validation and improve integer coercion --- src/routes/exports.ts | 28 ++++------------------------ src/validators/export.ts | 26 +++++++++++++++++--------- 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/routes/exports.ts b/src/routes/exports.ts index 8aebe481..ee25fa21 100644 --- a/src/routes/exports.ts +++ b/src/routes/exports.ts @@ -2,8 +2,6 @@ import { Router } from 'express'; import { z } from 'zod'; import { requireAuth } from '../middleware/requireAuth.js'; import { ValidationError } from '../middleware/validate.js'; -import { requireAuth } from '../middleware/requireAuth.js'; -import { validate } from '../middleware/validate.js'; import { securityHeadersMiddleware } from '../middleware/securityHeaders.js'; import { createExportsCorsMiddleware } from '../middleware/cors.js'; import { ForbiddenError, UnauthorizedError } from '../errors/index.js'; @@ -11,31 +9,13 @@ import { encodeCursor, parseCursor } from '../lib/cursorPagination.js'; import { logger } from '../logger.js'; import type { ReportExporterService } from '../services/reportExporter.js'; import type { DeveloperRepository } from '../repositories/developerRepository.js'; - -const strictIntegerString = (field: string) => - z - .string() - .trim() - .regex(/^\d+$/, `${field} must be an integer`); +import { exportsQuerySchema } from '../validators/export.js'; /** - * Query parameters for listing exports + * Parse, validate, and normalize the export-list query string before the route + * executes business logic. This keeps the request boundary strict and keeps the + * route response contract stable for schema snapshot tests. */ -const exportsQuerySchema = z.object({ - limit: strictIntegerString('limit') - .optional() - .transform((val) => (val === undefined ? 20 : Number.parseInt(val, 10))) - .pipe(z.number().int().min(1).max(100)), - offset: strictIntegerString('offset') - .optional() - .transform((val) => (val === undefined ? 0 : Number.parseInt(val, 10))) - .pipe(z.number().int().min(0)), - cursor: z.string().trim().min(1).max(2048).optional(), - developerId: z.string().trim().min(1).max(255).optional(), - format: z.enum(['csv', 'json']).optional(), -}); -import { exportsQuerySchema } from '../validators/export.js'; - function parseExportsQuery(query: unknown): z.infer { const parsed = exportsQuerySchema.safeParse(query); if (parsed.success) { diff --git a/src/validators/export.ts b/src/validators/export.ts index 954e9b78..694e359b 100644 --- a/src/validators/export.ts +++ b/src/validators/export.ts @@ -1,19 +1,27 @@ import { z } from 'zod'; -export const exportsQuerySchema = z.object({ - limit: z +/** + * Strict integer-string validator for query-param coercions. + * Rejects non-digit characters so `limit=abc` fails with a clear message. + */ +const strictIntegerString = (field: string) => + z .string() + .trim() + .regex(/^\d+$/, `${field} must be an integer`); + +export const exportsQuerySchema = z.object({ + limit: strictIntegerString('limit') .optional() - .transform((val) => (val ? parseInt(val, 10) : 20)) - .pipe(z.number().int()) - .transform((val) => Math.min(Math.max(val, 1), 100)), - offset: z - .string() + .transform((val) => (val === undefined ? 20 : Number.parseInt(val, 10))) + .pipe(z.number().int().min(1).max(100)), + offset: strictIntegerString('offset') .optional() - .transform((val) => (val ? parseInt(val, 10) : 0)) + .transform((val) => (val === undefined ? 0 : Number.parseInt(val, 10))) .pipe(z.number().int().min(0)), + cursor: z.string().trim().min(1).max(2048).optional(), developerId: z.string().trim().min(1).max(255).optional(), format: z.enum(['csv', 'json']).optional(), }); -export type ExportsQueryInput = z.infer; \ No newline at end of file +export type ExportsQueryInput = z.infer;