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
36 changes: 36 additions & 0 deletions src/modules/creators/creators.boolean-query.parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { strict as assert } from 'assert';
import { CreatorListQuerySchema } from './creators.schemas';
import { parsePublicQuery } from '../../utils/public-query-parse.utils';

function run() {
const trueParsed = CreatorListQuerySchema.parse({
verified: 'true',
});
assert.equal(trueParsed.verified, true);

const falseParsed = CreatorListQuerySchema.parse({
verified: '0',
});
assert.equal(falseParsed.verified, false);

const invalid = parsePublicQuery(CreatorListQuerySchema, {
verified: 'yes',
});

assert.equal(invalid.ok, false);
if (invalid.ok) {
throw new Error('Expected invalid boolean query flag to fail validation');
}

assert.deepEqual(invalid.details, [
{
field: 'verified',
message:
'Invalid boolean value for query parameter "verified": received "yes". Accepted values: "true", "false", "1", "0".',
},
]);

console.log('creators.boolean-query.parse tests passed');
}

run();
8 changes: 8 additions & 0 deletions src/modules/creators/creators.filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,15 @@ function run() {

assert.deepEqual(parseCreatorFilters({ verified: 'true' }), { verified: true });
assert.deepEqual(parseCreatorFilters({ verified: 'false' }), { verified: false });
assert.deepEqual(parseCreatorFilters({ verified: '1' }), { verified: true });
assert.deepEqual(parseCreatorFilters({ verified: '0' }), { verified: false });
assert.deepEqual(parseCreatorFilters({ verified: true }), { verified: true });
assert.deepEqual(parseCreatorFilters({ verified: false }), { verified: false });

assert.throws(
() => parseCreatorFilters({ verified: 'yes' }),
/Accepted values: "true", "false", "1", "0"\./
);

// --- combined ---

Expand Down
12 changes: 10 additions & 2 deletions src/modules/creators/creators.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Parser for creator list filter input. Reusable across list handlers.

import { rejectUnknownKeys } from '../../utils/filter-whitelist.utils';
import { parseBoolean } from '../../utils/parseBoolean.utils';

/**
* Supported filter keys for creator list requests.
Expand All @@ -22,7 +23,7 @@ export interface CreatorFilterInput {
* Parse and validate raw query filter input for creator list requests.
*
* - Accepts only supported filter keys; rejects unknown ones with an error
* - Coerces `verified` string to boolean
* - Parses `verified` using the shared boolean query flag helper
* - Trims `search` string
* - Repeated calls with the same input return the same result
*
Expand All @@ -46,7 +47,14 @@ export function parseCreatorFilters(
const result: CreatorFilterInput = {};

if (raw.verified !== undefined) {
result.verified = raw.verified === 'true' || raw.verified === true;
const verified = parseBoolean(
'verified',
raw.verified as string | string[] | boolean | null | undefined
);

if (verified !== null) {
result.verified = verified;
}
}

if (typeof raw.search === 'string') {
Expand Down
15 changes: 5 additions & 10 deletions src/modules/creators/creators.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from 'zod';
import { creatorListSortDirectionQueryParam } from './creators.sort-direction.parse';
import { creatorListIncludeQueryParam } from './creators.include.parse';
import { withCreatorListQueryStringNormalization } from './creators.query-string.utils';
import { safeIntParam } from '../../utils/query.utils';
import { safeBooleanQueryParam, safeIntParam } from '../../utils/query.utils';
import {
MIN_PAGE_SIZE,
MAX_PAGE_SIZE,
Expand Down Expand Up @@ -57,14 +57,9 @@ export const CreatorListQuerySchema = z
include: creatorListIncludeQueryParam(),

// Filters
verified: withCreatorListQueryStringNormalization(
z
.string()
.optional()
.transform((val: string | undefined) =>
val === undefined ? undefined : val === 'true'
)
),
verified: safeBooleanQueryParam({
paramName: 'verified',
}),

search: withCreatorListQueryStringNormalization(
z
Expand Down Expand Up @@ -101,4 +96,4 @@ export const UpdateCreatorProfileSchema = z.object({
perks: z.array(CreatorPerkSchema).optional(),
}).strict();

export type UpdateCreatorProfileType = z.infer<typeof UpdateCreatorProfileSchema>;
export type UpdateCreatorProfileType = z.infer<typeof UpdateCreatorProfileSchema>;
96 changes: 37 additions & 59 deletions src/utils/parseBoolean.utils.ts
Original file line number Diff line number Diff line change
@@ -1,102 +1,80 @@
/**
* parseBoolean.utils.ts
* Reusable parser for boolean-like query flags.
*
* Reusable parser for boolean query parameters across creator endpoints.
* Query strings arrive as strings, so this utility normalizes accepted flag
* forms into a real boolean and rejects everything else.
*
* HTTP query strings are always strings, so a value of `true` arrives as
* the literal string "true". This utility normalises common truthy/falsy
* variants into a proper boolean (or null when the value is absent) and
* rejects anything that cannot be unambiguously interpreted.
*
* Supported true variants : "true" | "1" | "yes" | "on"
* Supported false variants : "false" | "0" | "no" | "off"
* Absent value (undefined) : returns null — caller decides the default
* Any other string : throws ParseBooleanError (400-safe)
* Supported true variants: "true" | "1"
* Supported false variants: "false" | "0"
* Absent value (undefined/null): returns null
* Any other value: throws ParseBooleanError
*/

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const TRUE_VALUES = new Set(["true", "1", "yes", "on"]);
const FALSE_VALUES = new Set(["false", "0", "no", "off"]);

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
const TRUE_VALUES = new Set(['true', '1']);
const FALSE_VALUES = new Set(['false', '0']);

export class ParseBooleanError extends Error {
/** The raw value that could not be parsed */
public readonly rawValue: string;
/** The query parameter name, for clearer error messages */
public readonly paramName: string;

constructor(paramName: string, rawValue: string) {
super(
`Invalid boolean value for query parameter "${paramName}": received "${rawValue}". ` +
`Accepted values: "true", "false", "1", "0", "yes", "no", "on", "off".`
`Accepted values: "true", "false", "1", "0".`
);
this.name = "ParseBooleanError";
this.name = 'ParseBooleanError';
this.rawValue = rawValue;
this.paramName = paramName;
}
}

// ---------------------------------------------------------------------------
// Core parser
// ---------------------------------------------------------------------------

/**
* Parses a raw query string value into a boolean.
*
* @param paramName - Name of the query parameter (used in error messages)
* @param raw - The raw value from `req.query[paramName]`
* @param raw - The raw value from `req.query[paramName]`
* @returns `true`, `false`, or `null` when the parameter is absent
* @throws {ParseBooleanError} when the value is present but unrecognised
*
* @example
* // ?isVerified=true → true
* // ?isVerified=0 → false
* // ?isVerified=yes → true
* // ?isVerified= → throws ParseBooleanError
* // (param absent) → null
* @throws {ParseBooleanError} when the value is present but unrecognized
*/
export function parseBoolean(
paramName: string,
raw: string | string[] | undefined
raw: string | string[] | boolean | null | undefined
): boolean | null {
// Absent parameter — caller applies its own default
if (raw === undefined) return null;
if (raw === undefined || raw === null) {
return null;
}

// Always work with a single string; ignore arrays (take the first value)
const value = (Array.isArray(raw) ? raw[0] : raw).trim().toLowerCase();
if (typeof raw === 'boolean') {
return raw;
}

if (TRUE_VALUES.has(value)) return true;
if (FALSE_VALUES.has(value)) return false;
const rawValue = Array.isArray(raw) ? raw[0] : raw;
if (typeof rawValue !== 'string') {
throw new ParseBooleanError(paramName, String(rawValue));
}

throw new ParseBooleanError(paramName, Array.isArray(raw) ? raw[0] : raw);
}
const value = rawValue.trim().toLowerCase();

// ---------------------------------------------------------------------------
// Convenience wrapper with a fallback default
// ---------------------------------------------------------------------------
if (TRUE_VALUES.has(value)) {
return true;
}

if (FALSE_VALUES.has(value)) {
return false;
}

throw new ParseBooleanError(paramName, rawValue);
}

/**
* Same as `parseBoolean` but returns a caller-supplied default when the
* parameter is absent instead of null.
*
* @param paramName - Name of the query parameter
* @param raw - The raw value from `req.query[paramName]`
* @param defaultValue - Value to return when the parameter is absent
*
* @example
* const isVerified = parseBooleanWithDefault("isVerified", req.query.isVerified, false);
*/
export function parseBooleanWithDefault(
paramName: string,
raw: string | string[] | undefined,
raw: string | string[] | boolean | null | undefined,
defaultValue: boolean
): boolean {
const result = parseBoolean(paramName, raw);
return result === null ? defaultValue : result;
}
}
31 changes: 31 additions & 0 deletions src/utils/query.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { parseBoolean, ParseBooleanError } from './parseBoolean.utils';

/**
* Creates a Zod schema for safely parsing an integer query parameter.
Expand All @@ -24,3 +25,33 @@ export function safeIntParam(options: {
message: `${label} must be an integer between ${min} and ${max}`,
});
}

/**
* Creates a Zod schema for safely parsing a boolean-like query parameter.
*
* Accepts the common string flag forms supported by `parseBoolean` and maps
* invalid values into a stable validation error message.
*/
export function safeBooleanQueryParam(options: {
paramName: string;
defaultValue?: boolean;
}) {
const { paramName, defaultValue } = options;

return z.any().optional().transform((raw, ctx) => {
try {
const parsed = parseBoolean(paramName, raw);
return parsed === null ? defaultValue : parsed;
} catch (error) {
if (error instanceof ParseBooleanError) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: error.message,
});
return z.NEVER;
}

throw error;
}
});
}
Loading
Loading