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
44 changes: 44 additions & 0 deletions src/utils/empty-query-param.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
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',
}
);
});
});
37 changes: 37 additions & 0 deletions src/utils/empty-query-param.utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
26 changes: 15 additions & 11 deletions src/utils/public-query-parse.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,17 +25,19 @@ 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<S extends ZodTypeAny>(
schema: S,
rawQuery: unknown,
options?: ParsePublicQueryOptions
): PublicQueryParseResult<z.infer<S>> {
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({
Expand All @@ -44,15 +47,17 @@ export function parsePublicQuery<S extends ZodTypeAny>(
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({
Expand All @@ -63,10 +68,9 @@ export function parsePublicQuery<S extends ZodTypeAny>(
context: options.debugContext,
});
}

return { ok: false, details };
}
throw error;
}
}

Loading