mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
refactor!: migrate class-validator to zod (#26597)
This commit is contained in:
+191
-392
@@ -1,40 +1,62 @@
|
||||
import {
|
||||
ArgumentMetadata,
|
||||
BadRequestException,
|
||||
FileValidator,
|
||||
Injectable,
|
||||
ParseUUIDPipe,
|
||||
applyDecorators,
|
||||
} from '@nestjs/common';
|
||||
import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsEnum,
|
||||
IsHexColor,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Validate,
|
||||
ValidateBy,
|
||||
ValidateIf,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
buildMessage,
|
||||
isDateString,
|
||||
isDefined,
|
||||
} from 'class-validator';
|
||||
import { CronJob } from 'cron';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { Property, PropertyOptions } from 'src/decorators';
|
||||
import { isIP, isIPRange } from 'validator';
|
||||
import z from 'zod';
|
||||
|
||||
export type IsIPRangeOptions = { requireCIDR?: boolean };
|
||||
|
||||
function isIPOrRange(value: string, options?: IsIPRangeOptions): boolean {
|
||||
const { requireCIDR = true } = options ?? {};
|
||||
if (isIPRange(value)) {
|
||||
return true;
|
||||
}
|
||||
if (!requireCIDR && isIP(value)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema that validates an array of strings as IP addresses or IP/CIDR ranges.
|
||||
* When requireCIDR is true (default), plain IPs are rejected; only CIDR ranges are allowed.
|
||||
*
|
||||
* @example
|
||||
* z.string().optional().transform(...).pipe(IsIPRange())
|
||||
* @example
|
||||
* z.string().optional().transform(...).pipe(IsIPRange({ requireCIDR: false }))
|
||||
*/
|
||||
export function IsIPRange(options?: IsIPRangeOptions) {
|
||||
return z
|
||||
.array(z.string())
|
||||
.refine((arr) => arr.every((item) => isIPOrRange(item, options)), 'Must be an ip address or ip address range');
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema that validates sibling-exclusion for object schemas.
|
||||
* Validation passes when the target property is missing, or when none of the sibling properties are present.
|
||||
* Use with .pipe() like IsIPRange.
|
||||
*
|
||||
* @example
|
||||
* const Schema = z.object({ a: z.string().optional(), b: z.string().optional() });
|
||||
* Schema.pipe(IsNotSiblingOf(Schema, 'a', ['b']));
|
||||
*/
|
||||
export function IsNotSiblingOf<
|
||||
TSchema extends z.ZodObject<z.ZodRawShape>,
|
||||
TKey extends z.infer<ReturnType<TSchema['keyof']>> & keyof z.infer<TSchema>,
|
||||
>(_schema: TSchema, property: TKey, siblings: TKey[]) {
|
||||
type T = z.infer<TSchema>;
|
||||
const message = `${String(property)} cannot exist alongside ${siblings.join(' or ')}`;
|
||||
return z.custom<T>().refine(
|
||||
(data) => {
|
||||
if (data[property] === undefined) {
|
||||
return true;
|
||||
}
|
||||
return siblings.every((sibling) => data[sibling] === undefined);
|
||||
},
|
||||
{ message },
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ParseMeUUIDPipe extends ParseUUIDPipe {
|
||||
@@ -66,386 +88,163 @@ export class FileNotEmptyValidator extends FileValidator {
|
||||
}
|
||||
}
|
||||
|
||||
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
|
||||
export const ValidateUUID = (options?: UUIDOptions & PropertyOptions) => {
|
||||
const { optional, each, nullable, ...apiPropertyOptions } = {
|
||||
optional: false,
|
||||
each: false,
|
||||
nullable: false,
|
||||
...options,
|
||||
};
|
||||
return applyDecorators(
|
||||
IsUUID('4', { each }),
|
||||
Property({ format: 'uuid', ...apiPropertyOptions }),
|
||||
optional ? Optional({ nullable }) : IsNotEmpty(),
|
||||
each ? IsArray() : IsString(),
|
||||
);
|
||||
};
|
||||
const UUIDParamSchema = z.object({
|
||||
id: z.uuidv4(),
|
||||
});
|
||||
|
||||
export function IsAxisAlignedRotation() {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isAxisAlignedRotation',
|
||||
validator: {
|
||||
validate(value: any) {
|
||||
return [0, 90, 180, 270].includes(value);
|
||||
},
|
||||
defaultMessage: buildMessage(
|
||||
(eachPrefix) => eachPrefix + '$property must be one of the following values: 0, 90, 180, 270',
|
||||
{},
|
||||
),
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
export class UUIDParamDto extends createZodDto(UUIDParamSchema) {}
|
||||
|
||||
@ValidatorConstraint({ name: 'uniqueEditActions' })
|
||||
class UniqueEditActionsValidator implements ValidatorConstraintInterface {
|
||||
validate(edits: { action: string; parameters?: unknown }[]): boolean {
|
||||
if (!Array.isArray(edits)) {
|
||||
return true;
|
||||
}
|
||||
const UUIDAssetIDParamSchema = z.object({
|
||||
id: z.uuidv4(),
|
||||
assetId: z.uuidv4(),
|
||||
});
|
||||
|
||||
const actionSet = new Set<string>();
|
||||
for (const edit of edits) {
|
||||
const key = edit.action === 'mirror' ? `${edit.action}-${JSON.stringify(edit.parameters)}` : edit.action;
|
||||
if (actionSet.has(key)) {
|
||||
return false;
|
||||
}
|
||||
actionSet.add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export class UUIDAssetIDParamDto extends createZodDto(UUIDAssetIDParamSchema) {}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'Duplicate edit actions are not allowed';
|
||||
}
|
||||
}
|
||||
const FilenameParamSchema = z.object({
|
||||
filename: z.string().regex(/^[a-zA-Z0-9_\-.]+$/, {
|
||||
error: 'Filename contains invalid characters',
|
||||
}),
|
||||
});
|
||||
|
||||
export const IsUniqueEditActions = () => Validate(UniqueEditActionsValidator);
|
||||
|
||||
export class UUIDParamDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export class UUIDAssetIDParamDto {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class FilenameParamDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ format: 'string' })
|
||||
@Matches(/^[a-zA-Z0-9_\-.]+$/, {
|
||||
message: 'Filename contains invalid characters',
|
||||
})
|
||||
filename!: string;
|
||||
}
|
||||
|
||||
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
||||
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
|
||||
optional: false,
|
||||
nullable: false,
|
||||
emptyToNull: false,
|
||||
...options,
|
||||
};
|
||||
const decorators = [
|
||||
IsString(),
|
||||
IsNotEmpty(),
|
||||
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
|
||||
ApiProperty({ example: '123456', ...apiPropertyOptions }),
|
||||
];
|
||||
|
||||
if (optional) {
|
||||
decorators.push(Optional({ nullable, emptyToNull }));
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
export interface OptionalOptions {
|
||||
nullable?: boolean;
|
||||
/** convert empty strings to null */
|
||||
emptyToNull?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if value is missing and if so, ignores all validators.
|
||||
*
|
||||
* @param validationOptions {@link OptionalOptions}
|
||||
*
|
||||
* @see IsOptional exported from `class-validator.
|
||||
*/
|
||||
// https://stackoverflow.com/a/71353929
|
||||
export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) {
|
||||
const decorators: PropertyDecorator[] = [];
|
||||
|
||||
if (nullable === true) {
|
||||
decorators.push(IsOptional(validationOptions));
|
||||
} else {
|
||||
decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions));
|
||||
}
|
||||
|
||||
if (emptyToNull) {
|
||||
decorators.push(Transform(({ value }) => (value === '' ? null : value)));
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export function IsNotSiblingOf(siblings: string[], validationOptions?: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isNotSiblingOf',
|
||||
constraints: siblings,
|
||||
validator: {
|
||||
validate(value: any, args: ValidationArguments) {
|
||||
if (!isDefined(value)) {
|
||||
return true;
|
||||
}
|
||||
return args.constraints.filter((prop) => isDefined((args.object as any)[prop])).length === 0;
|
||||
},
|
||||
defaultMessage: (args: ValidationArguments) => {
|
||||
return `${args.property} cannot exist alongside any of the following properties: ${args.constraints.join(', ')}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export const ValidateHexColor = () => {
|
||||
const decorators = [
|
||||
IsHexColor(),
|
||||
Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)),
|
||||
];
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type DateOptions = OptionalOptions & { optional?: boolean; format?: 'date' | 'date-time' };
|
||||
export const ValidateDate = (options?: DateOptions & PropertyOptions) => {
|
||||
const {
|
||||
optional,
|
||||
nullable = false,
|
||||
emptyToNull = false,
|
||||
format = 'date-time',
|
||||
...apiPropertyOptions
|
||||
} = options || {};
|
||||
|
||||
return applyDecorators(
|
||||
Property({ format, ...apiPropertyOptions }),
|
||||
IsDate(),
|
||||
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
|
||||
Transform(({ key, value }) => {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!isDateString(value)) {
|
||||
throw new BadRequestException(`${key} must be a date string`);
|
||||
}
|
||||
|
||||
return new Date(value as string);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
type StringOptions = OptionalOptions & { optional?: boolean; trim?: boolean };
|
||||
export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, emptyToNull, trim, ...apiPropertyOptions } = options || {};
|
||||
const decorators = [
|
||||
ApiProperty(apiPropertyOptions),
|
||||
IsString(),
|
||||
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
|
||||
];
|
||||
|
||||
if (trim) {
|
||||
decorators.push(Transform(({ value }: { value: string }) => value?.trim()));
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type BooleanOptions = OptionalOptions & { optional?: boolean };
|
||||
export const ValidateBoolean = (options?: BooleanOptions & PropertyOptions) => {
|
||||
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = options || {};
|
||||
const decorators = [
|
||||
Property(apiPropertyOptions),
|
||||
IsBoolean(),
|
||||
Transform(({ value }) => {
|
||||
if (value == 'true') {
|
||||
return true;
|
||||
} else if (value == 'false') {
|
||||
return false;
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
optional ? Optional({ nullable, emptyToNull }) : IsNotEmpty(),
|
||||
];
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type EnumOptions<T> = {
|
||||
enum: T;
|
||||
name: string;
|
||||
each?: boolean;
|
||||
optional?: boolean;
|
||||
nullable?: boolean;
|
||||
default?: T[keyof T];
|
||||
description?: string;
|
||||
};
|
||||
export const ValidateEnum = <T extends object>({
|
||||
name,
|
||||
enum: value,
|
||||
each,
|
||||
optional,
|
||||
nullable,
|
||||
default: defaultValue,
|
||||
description,
|
||||
}: EnumOptions<T>) => {
|
||||
return applyDecorators(
|
||||
optional ? Optional({ nullable }) : IsNotEmpty(),
|
||||
IsEnum(value, { each }),
|
||||
ApiProperty({ enumName: name, enum: value, isArray: each, default: defaultValue, description }),
|
||||
);
|
||||
};
|
||||
|
||||
@ValidatorConstraint({ name: 'cronValidator' })
|
||||
class CronValidator implements ValidatorConstraintInterface {
|
||||
validate(expression: string): boolean {
|
||||
try {
|
||||
new CronJob(expression, () => {});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const IsCronExpression = () => Validate(CronValidator, { message: 'Invalid cron expression' });
|
||||
|
||||
type IValue = { value: unknown };
|
||||
|
||||
export const toEmail = ({ value }: IValue) => (typeof value === 'string' ? value.toLowerCase() : value);
|
||||
|
||||
export const toSanitized = ({ value }: IValue) => {
|
||||
const input = typeof value === 'string' ? value : '';
|
||||
return sanitize(input.replaceAll('.', ''));
|
||||
};
|
||||
export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}
|
||||
|
||||
export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
|
||||
const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
|
||||
return Number.isInteger(value) && value >= min && value <= max;
|
||||
};
|
||||
|
||||
export function isDateStringFormat(value: unknown, format: string) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return DateTime.fromFormat(value, format, { zone: 'utc' }).isValid;
|
||||
}
|
||||
/**
|
||||
* Unified email validation
|
||||
* Converts email strings to lowercase and validates against HTML5 email regex
|
||||
* @docs https://zod.dev/api?id=email
|
||||
*/
|
||||
export const toEmail = z
|
||||
.email({
|
||||
pattern: z.regexes.html5Email,
|
||||
error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`,
|
||||
})
|
||||
.transform((val) => val.toLowerCase());
|
||||
|
||||
export function IsDateStringFormat(format: string, validationOptions?: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
/**
|
||||
* Parse ISO 8601 datetime strings to Date objects
|
||||
* @docs https://zod.dev/api?id=codec
|
||||
*/
|
||||
export const isoDatetimeToDate = z
|
||||
.codec(
|
||||
z.iso.datetime({
|
||||
error: (iss) => `Invalid input: expected ISO 8601 datetime string, received ${typeof iss.input}`,
|
||||
}),
|
||||
z.date(),
|
||||
{
|
||||
name: 'isDateStringFormat',
|
||||
constraints: [format],
|
||||
validator: {
|
||||
validate(value: unknown) {
|
||||
return isDateStringFormat(value, format);
|
||||
},
|
||||
defaultMessage: () => `$property must be a string in the format ${format}`,
|
||||
},
|
||||
decode: (isoString) => new Date(isoString),
|
||||
encode: (date) => date.toISOString(),
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
)
|
||||
.meta({ example: '2024-01-01T00:00:00.000Z' });
|
||||
|
||||
function maxDate(date: DateTime, maxDate: DateTime | (() => DateTime)) {
|
||||
return date <= (maxDate instanceof DateTime ? maxDate : maxDate());
|
||||
}
|
||||
|
||||
export function MaxDateString(
|
||||
date: DateTime | (() => DateTime),
|
||||
validationOptions?: ValidationOptions,
|
||||
): PropertyDecorator {
|
||||
return ValidateBy(
|
||||
/**
|
||||
* Parse ISO date strings to Date objects
|
||||
* @docs https://zod.dev/api?id=codec
|
||||
*/
|
||||
export const isoDateToDate = z
|
||||
.codec(
|
||||
z.iso.date({
|
||||
error: (iss) => `Invalid input: expected ISO date string (YYYY-MM-DD), received ${typeof iss.input}`,
|
||||
}),
|
||||
z.date(),
|
||||
{
|
||||
name: 'maxDateString',
|
||||
constraints: [date],
|
||||
validator: {
|
||||
validate: (value, args) => {
|
||||
const date = DateTime.fromISO(value, { zone: 'utc' });
|
||||
return maxDate(date, args?.constraints[0]);
|
||||
},
|
||||
defaultMessage: buildMessage(
|
||||
(eachPrefix) => 'maximal allowed date for ' + eachPrefix + '$property is $constraint1',
|
||||
validationOptions,
|
||||
),
|
||||
},
|
||||
decode: (isoString) => new Date(isoString),
|
||||
encode: (date) => date.toISOString().slice(0, 10),
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
)
|
||||
.meta({ example: '2024-01-01' });
|
||||
|
||||
type IsIPRangeOptions = { requireCIDR?: boolean };
|
||||
export function IsIPRange(options: IsIPRangeOptions, validationOptions?: ValidationOptions): PropertyDecorator {
|
||||
const { requireCIDR } = { requireCIDR: true, ...options };
|
||||
export const isValidTime = z
|
||||
.string()
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Invalid input: expected string in HH:mm format, received string');
|
||||
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isIPRange',
|
||||
validator: {
|
||||
validate: (value): boolean => {
|
||||
if (isIPRange(value)) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Latitude in range [-90, 90]. Reuse for body or query params.
|
||||
*
|
||||
* @example
|
||||
* // Regular (body): optional coordinates
|
||||
* latitudeSchema.optional().describe('Latitude coordinate')
|
||||
*
|
||||
* @example
|
||||
* // Pipe (query): coerce string to number then validate range
|
||||
* z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)')
|
||||
*/
|
||||
export const latitudeSchema = z.number().min(-90).max(90);
|
||||
|
||||
if (!requireCIDR && isIP(value)) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Longitude in range [-180, 180]. Reuse for body or query params.
|
||||
*
|
||||
* @example
|
||||
* // Regular (body): optional coordinates
|
||||
* longitudeSchema.optional().describe('Longitude coordinate')
|
||||
*
|
||||
* @example
|
||||
* // Pipe (query): coerce string to number then validate range
|
||||
* z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)')
|
||||
*/
|
||||
export const longitudeSchema = z.number().min(-180).max(180);
|
||||
|
||||
return false;
|
||||
},
|
||||
defaultMessage: buildMessage(
|
||||
(eachPrefix) => eachPrefix + '$property must be an ip address, or ip address range',
|
||||
validationOptions,
|
||||
),
|
||||
},
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Parse string to boolean
|
||||
* This should be used for boolean query parameters and path parameters, but not for boolean request body parameters, as the first are always string.
|
||||
* We don't use z.coerce.boolean() as any truthy value is considered true
|
||||
* z.stringbool() is a more robust way to parse strings to booleans as it lets you specify the truthy and falsy values and the case sensitivity.
|
||||
* @docs https://zod.dev/api?id=coercion
|
||||
* @docs https://zod.dev/api?id=stringbool
|
||||
*/
|
||||
export const stringToBool = z
|
||||
.stringbool({ truthy: ['true'], falsy: ['false'], case: 'sensitive' })
|
||||
.meta({ type: 'boolean' });
|
||||
|
||||
@ValidatorConstraint({ name: 'isGreaterThanOrEqualTo' })
|
||||
export class IsGreaterThanOrEqualToConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments) {
|
||||
const relatedPropertyName = args.constraints?.[0] as string;
|
||||
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
|
||||
if (!Number.isFinite(value) || !Number.isFinite(relatedValue)) {
|
||||
return true;
|
||||
/**
|
||||
* Parse JSON strings from multipart/form-data
|
||||
*/
|
||||
export const JsonParsed = z.transform((val, ctx) => {
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch {
|
||||
ctx.issues.push({
|
||||
code: 'custom',
|
||||
message: `Invalid input: expected JSON string, received ${typeof val}`,
|
||||
input: val,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
return Number(value) >= Number(relatedValue);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
const relatedPropertyName = args.constraints?.[0] as string;
|
||||
return `${args.property} must be greater than or equal to ${relatedPropertyName}`;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hex color validation and normalization.
|
||||
* Accepts formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without # prefix).
|
||||
* Normalizes output to always include the # prefix.
|
||||
*
|
||||
* @example
|
||||
* hexColor.optional()
|
||||
*/
|
||||
const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
||||
export const hexColor = z
|
||||
.string()
|
||||
.regex(hexColorRegex)
|
||||
.transform((val) => (val.startsWith('#') ? val : `#${val}`));
|
||||
|
||||
export const IsGreaterThanOrEqualTo = (property: string, validationOptions?: ValidationOptions) => {
|
||||
return Validate(IsGreaterThanOrEqualToConstraint, [property], validationOptions);
|
||||
};
|
||||
/**
|
||||
* Transform empty strings to null. Inner schema passed to this function must accept null.
|
||||
* @docs https://zod.dev/api?id=preprocess
|
||||
* @example emptyStringToNull(z.string().nullable()).optional() // [encouraged] final schema is optional
|
||||
* @example emptyStringToNull(z.string().nullable()) // [encouraged] same as the one above, but final schema is not optional
|
||||
* @example emptyStringToNull(z.string().nullish()) // [discouraged] same as the one above, might be confusing
|
||||
* @example emptyStringToNull(z.string().optional()) // fails: string schema rejects null
|
||||
* @example emptyStringToNull(z.string().nullable()).nullish() // [discouraged] passes, null is duplicated. use the first example instead
|
||||
*/
|
||||
export const emptyStringToNull = <T extends z.ZodTypeAny>(schema: T) =>
|
||||
z.preprocess((val) => (val === '' ? null : val), schema);
|
||||
|
||||
export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));
|
||||
|
||||
Reference in New Issue
Block a user