From 9a50a6d871290032025be4040dc57ab38d6c3029 Mon Sep 17 00:00:00 2001 From: Okoli Johnpaul Sochimaobi <132228270+Johnpii1@users.noreply.github.com> Date: Sun, 31 May 2026 14:47:46 +0100 Subject: [PATCH] Normalize empty public query params --- src/utils/empty-query-param.utils.test.ts | 44 +++++++++++++++++++++++ src/utils/empty-query-param.utils.ts | 37 +++++++++++++++++++ src/utils/public-query-parse.utils.ts | 26 ++++++++------ 3 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 src/utils/empty-query-param.utils.test.ts create mode 100644 src/utils/empty-query-param.utils.ts diff --git a/src/utils/empty-query-param.utils.test.ts b/src/utils/empty-query-param.utils.test.ts new file mode 100644 index 0000000..b7f427f --- /dev/null +++ b/src/utils/empty-query-param.utils.test.ts @@ -0,0 +1,44 @@ +import { strict as assert } from 'assert'; +import { CreatorListQuerySchema } from '../modules/creators/creators.schemas'; +import { coerceEmptyStringQueryParamsToUndefined } from './empty-query-param.utils'; +import { parsePublicQuery } from './public-query-parse.utils'; + +function assertOk( + result: { ok: true; data: T } | { ok: false; details: unknown } +): T { + if (!result.ok) { + throw new Error('Expected query parsing to succeed'); + } + + return result.data; +} + +describe('empty query param normalization', () => { + it('treats empty string and omitted creator params the same after validation', () => { + const omitted = assertOk(parsePublicQuery(CreatorListQuerySchema, {})); + const emptyStrings = assertOk( + parsePublicQuery(CreatorListQuerySchema, { + search: '', + verified: '', + cursor: '', + }) + ); + + assert.deepEqual(emptyStrings, omitted); + }); + + it('leaves non-empty string values unchanged', () => { + assert.deepEqual( + coerceEmptyStringQueryParamsToUndefined({ + search: 'jazz', + verified: 'false', + cursor: 'cursor-123', + }), + { + search: 'jazz', + verified: 'false', + cursor: 'cursor-123', + } + ); + }); +}); diff --git a/src/utils/empty-query-param.utils.ts b/src/utils/empty-query-param.utils.ts new file mode 100644 index 0000000..a64adb5 --- /dev/null +++ b/src/utils/empty-query-param.utils.ts @@ -0,0 +1,37 @@ +/** + * Normalize empty query-string values before schema validation. + * + * Express represents `?search=` as `{ search: '' }`, while an omitted query + * parameter is absent from `req.query`. Public query schemas should receive + * both forms identically so optional defaults and filters behave consistently. + */ +export function coerceEmptyStringQueryParamsToUndefined( + value: unknown +): unknown { + if (value === '') { + return undefined; + } + + if (Array.isArray(value)) { + const normalizedItems = value + .map(item => coerceEmptyStringQueryParamsToUndefined(item)) + .filter(item => item !== undefined); + + return normalizedItems.length > 0 ? normalizedItems : undefined; + } + + if (isQueryParamRecord(value)) { + const normalizedEntries = Object.entries(value).flatMap(([key, item]) => { + const normalizedValue = coerceEmptyStringQueryParamsToUndefined(item); + return normalizedValue === undefined ? [] : [[key, normalizedValue]]; + }); + + return Object.fromEntries(normalizedEntries); + } + + return value; +} + +function isQueryParamRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/utils/public-query-parse.utils.ts b/src/utils/public-query-parse.utils.ts index 59ac771..6b50c80 100644 --- a/src/utils/public-query-parse.utils.ts +++ b/src/utils/public-query-parse.utils.ts @@ -1,5 +1,6 @@ import { z, ZodError, ZodTypeAny } from 'zod'; import { emitQueryNormalizationDebug } from './query-normalization-debug.utils'; +import { coerceEmptyStringQueryParamsToUndefined } from './empty-query-param.utils'; export type PublicQueryValidationDetail = { field: string; @@ -24,7 +25,7 @@ export interface ParsePublicQueryOptions { * * This helper is intentionally small and focused: * - maps `ZodError` into `{ field, message }[]` for API validation responses - * - does not add runtime behavior beyond schema parsing and error shaping + * - coerces empty-string query params to omitted values before validation * - optionally emits debug snapshots when debugContext is provided (debug level only) */ export function parsePublicQuery( @@ -32,9 +33,11 @@ export function parsePublicQuery( rawQuery: unknown, options?: ParsePublicQueryOptions ): PublicQueryParseResult> { + const normalizedQuery = coerceEmptyStringQueryParamsToUndefined(rawQuery); + try { - const data = schema.parse(rawQuery); - + const data = schema.parse(normalizedQuery); + // Emit debug snapshot if context is provided if (options?.debugContext) { emitQueryNormalizationDebug({ @@ -44,15 +47,17 @@ export function parsePublicQuery( context: options.debugContext, }); } - + return { ok: true, data }; } catch (error) { if (error instanceof ZodError) { - const details: PublicQueryValidationDetail[] = error.errors.map(err => ({ - field: err.path.join('.'), - message: err.message, - })); - + const details: PublicQueryValidationDetail[] = error.errors.map( + err => ({ + field: err.path.join('.'), + message: err.message, + }) + ); + // Emit debug snapshot if context is provided if (options?.debugContext) { emitQueryNormalizationDebug({ @@ -63,10 +68,9 @@ export function parsePublicQuery( context: options.debugContext, }); } - + return { ok: false, details }; } throw error; } } -