diff --git a/shared/utils/api.ts b/shared/utils/api.ts index 54e9f12..2c15141 100644 --- a/shared/utils/api.ts +++ b/shared/utils/api.ts @@ -1,12 +1,5 @@ import { Result } from "@shared/utils/result.ts"; -class ValidationError extends BaseError { - code = "ValidationError"; - constructor(msg: string) { - super(msg); - } -} - class ClientApi { constructor(path: string, method: string) {} validate(res: Response): ResultAsync { diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 3956bca..94da3cc 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -1,3 +1,4 @@ export * from "@shared/utils/option.ts"; export * from "@shared/utils/result.ts"; export * from "@shared/utils/resultasync.ts"; +//export * from "@shared/utils/validator.ts"; diff --git a/shared/utils/validator.ts b/shared/utils/validator.ts new file mode 100644 index 0000000..569b5f2 --- /dev/null +++ b/shared/utils/validator.ts @@ -0,0 +1,1035 @@ +import { err, Result } from "@shared/utils/result.ts"; +import { ok } from "@shared/utils/index.ts"; + +// ── Error Types ───────────────────────────────────────────────────── +type ValidationErrorDetail = + | { + kind: "typeMismatch"; + expected: string; + received: string; + msg?: string; + } + | { + kind: "propertyValidation"; + property: string; + detail: ValidationErrorDetail; + msg?: string; + } + | { + kind: "missingProperties" | "unexpectedProperties"; + keys: string[]; + msg?: string; + } + | { + kind: "unionValidation"; + details: ValidationErrorDetail[]; + msg?: string; + } + | { + kind: "arrayElement"; + index: number; + detail: ValidationErrorDetail; + msg?: string; + } + | { kind: "general"; mark?: string; msg: string }; + +class SchemaValidationError extends Error { + public readonly type = "SchemaValidationError"; + + constructor( + public readonly input: unknown, + public readonly detail: ValidationErrorDetail, + ) { + super(detail.msg || "Schema validation error"); + } + + public format(): Record { + return { + input: this.input, + error: SchemaValidationError.formatDetail(this.detail), + }; + } + + get msg(): string { + return SchemaValidationError.getBestMsg(this.detail); + } + + private static getBestMsg(detail: ValidationErrorDetail): string { + switch (detail.kind) { + case "typeMismatch": + case "unexpectedProperties": + case "missingProperties": + case "general": + case "unionValidation": + return SchemaValidationError.formatMsg(detail); + case "propertyValidation": + case "arrayElement": + return detail.msg || + SchemaValidationError.getBestMsg(detail.detail); + default: + return "Unknown error"; + } + } + + private static formatDetail(detail: ValidationErrorDetail): any { + switch (detail.kind) { + case "general": + case "typeMismatch": + return SchemaValidationError.formatMsg(detail); + case "propertyValidation": + return { + [detail.property]: detail.msg || + this.formatDetail(detail.detail!), + }; + case "unexpectedProperties": + case "missingProperties": { + const msg = detail.msg || + (detail.kind === "unexpectedProperties" + ? "Property is not allowed in a strict schema object" + : "Property is required, but missing"); + + return detail.keys.reduce>( + (acc, key) => { + acc[key] = msg; + return acc; + }, + {}, + ); + } + case "arrayElement": { + const detailObj: Record = {}; + if (detail.msg) { + detailObj["msg"] = detail.msg; + } + detailObj[`index_${detail.index}`] = this.formatDetail( + detail.detail, + ); + return detailObj; + } + case "unionValidation": { + const arr: unknown[] = detail.details?.map( + (err): unknown => this.formatDetail(err), + ); + if (detail.msg) { + arr.unshift("Msg: " + detail.msg); + } + return arr; + } + default: + return "Unknown error type"; + } + } + + private static formatMsg(detail: ValidationErrorDetail): string { + if (detail.msg || detail.kind === "general") { + return detail.msg || "Unknown error"; + } + switch (detail.kind) { + case "typeMismatch": + return `Expected ${detail.expected}, but received ${detail.received}`; + case "unexpectedProperties": + return `Properties not allowed: ${detail.keys.join(", ")}`; + case "missingProperties": + return `Missing required properties: ${detail.keys.join(", ")}`; + case "unionValidation": + return `Input did not match any union member`; + default: + return "Unknown error"; + } + } +} + +function createValidationError( + input: unknown, + error: ValidationErrorDetail, +) { + return new SchemaValidationError(input, error); +} + +type PrimitiveTypeName = + | "string" + | "number" + | "bigint" + | "boolean" + | "symbol" + | "undefined" + | "object" + | "function"; + +// ── Core Schema Types ─────────────────────────────────────────────── +export interface Schema { + parse(input: unknown): Result; +} + +type ValidationCheck = (value: T) => SchemaValidationError | void; + +export abstract class BaseSchema implements Schema { + protected checks: ValidationCheck[] = []; + + constructor(public readonly msg?: string) {} + + public parse(input: unknown): Result { + return this.validateInput(input).andThen((value) => + this.applyValidationChecks(value) + ); + } + + protected abstract validateInput( + input: unknown, + ): Result; + + protected static validatePrimitive( + input: unknown, + expectedType: PrimitiveTypeName, + msg?: string, + ): Result { + const receivedType = typeof input; + return receivedType === expectedType ? ok(input as U) : err( + createValidationError(input, { + kind: "typeMismatch", + expected: expectedType, + received: receivedType, + msg: msg, + }), + ); + } + + public addCheck(check: ValidationCheck): this { + this.checks.push(check); + return this; + } + + protected applyValidationChecks( + value: T, + ): Result { + for (const check of this.checks) { + const error = check(value); + if (error) { + return err(error); + } + } + return ok(value); + } + + protected static isNullishSchema(schema: Schema): boolean { + return schema.parse(null).isOk() || schema.parse(undefined).isOk(); + } +} + +class StringSchema extends BaseSchema { + private static readonly emailRegex = + /^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i; // https://stackoverflow.com/questions/46155/how-can-i-validate-an-email-address-in-javascript + + private static readonly ipRegex = + /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; // https://stackoverflow.com/questions/4460586/javascript-regular-expression-to-check-for-ip-addresses + + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "string", + this.msg, + ); + } + + public max(len: number, msg?: string): this { + return this.addCheck((value) => { + if (value.length > len) { + return createValidationError(value, { + kind: "general", + msg: msg || `String must be at most ${len} characters long`, + }); + } + }); + } + + public min(len: number, msg?: string): this { + return this.addCheck((value) => { + if (value.length < len) { + return createValidationError(value, { + kind: "general", + msg: msg || `String must be at most ${len} characters long`, + }); + } + }); + } + + public regex(pattern: RegExp, msg?: string): this { + return this.addCheck((value) => { + if (!pattern.test(value)) { + return createValidationError(value, { + kind: "general", + msg: msg || `String must match pattern ${String(pattern)}`, + }); + } + }); + } + + public email(msg?: string): this { + return this.regex( + StringSchema.emailRegex, + msg || "String must be a valid email address", + ); + } + + public ip(msg?: string): this { + return this.regex( + StringSchema.ipRegex, + msg || "String must be a valid ip address", + ); + } +} + +class LiteralSchema extends BaseSchema { + constructor( + public readonly literal: L, + msg?: string, + ) { + super(msg); + } + + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "string", + this.msg, + ) + .andThen((str) => + str === this.literal ? ok(str as L) : err( + createValidationError( + input, + { + kind: "typeMismatch", + expected: this.literal, + received: str, + msg: this.msg, + }, + ), + ) + ); + } +} + +class NumberSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "number", + this.msg, + ); + } + + public gt( + num: number, + msg?: string, + ): this { + return this.addCheck((value) => { + if (value <= num) { + return createValidationError( + value, + { + kind: "general", + msg: msg || `Number must be greater than ${num}`, + }, + ); + } + }); + } + + public gte( + num: number, + msg?: string, + ): this { + return this.addCheck((value) => { + if (value < num) { + return createValidationError( + value, + { + kind: "general", + msg: msg || + `Number must be greater than or equal to ${num}`, + }, + ); + } + }); + } + + public lt( + num: number, + msg?: string, + ): this { + return this.addCheck((value) => { + if (value >= num) { + return createValidationError( + value, + { + kind: "general", + msg: msg || `Number must be less than ${num}`, + }, + ); + } + }); + } + + public lte( + num: number, + msg?: string, + ): this { + return this.addCheck((value) => { + if (value > num) { + return createValidationError( + value, + { + kind: "general", + msg: msg || + `Number must be less than or equal to ${num}`, + }, + ); + } + }); + } + + public int( + msg?: string, + ): this { + return this.addCheck((value) => { + if (!Number.isInteger(value)) { + return createValidationError( + value, + { + kind: "general", + msg: msg || + `Number must be an integer`, + }, + ); + } + }); + } + + public positive( + msg?: string, + ): this { + return this.gt(0, msg || "Number must be positive"); + } + + public nonnegative( + msg?: string, + ) { + return this.gte(0, msg || "Number must be nonnegative"); + } + + public negative( + msg?: string, + ): this { + return this.lt(0, msg || "Number must be negative"); + } + + public nonpositive( + msg?: string, + ) { + return this.lte(0, msg || "Number must be nonpositive"); + } + + public finite( + msg?: string, + ): this { + return this.addCheck((value) => { + if (!Number.isFinite(value)) { + return createValidationError( + value, + { + kind: "general", + msg: msg || + `Number must be an integer`, + }, + ); + } + }); + } + + public safe( + msg?: string, + ): this { + return this.addCheck((value) => { + if (!Number.isSafeInteger(value)) { + return createValidationError( + value, + { + kind: "general", + msg: msg || + `Number must be an integer`, + }, + ); + } + }); + } + + public multipleOf( + num: number, + msg?: string, + ): this { + return this.addCheck((value) => { + if (value % num !== 0) { + return createValidationError( + value, + { + kind: "general", + msg: msg || `Number must be a multiple of ${num}`, + }, + ); + } + }); + } +} + +class BigintSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "bigint", + this.msg, + ); + } +} + +class BooleanSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "boolean", + this.msg, + ); + } +} + +class DateSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "object", + this.msg, + ) + .andThen((o) => { + if (o instanceof Date) return ok(o as Date); + + const received = o?.constructor?.name ?? "unknown"; + return err(createValidationError( + input, + { + kind: "typeMismatch", + expected: "Date instance", + received, + msg: this.msg, + }, + )); + }); + } +} + +class SymbolSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "symbol", + this.msg, + ); + } +} + +class UndefinedSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return BaseSchema.validatePrimitive( + input, + "undefined", + this.msg, + ); + } +} + +class NullSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + if (input === null) return ok(input); + + const received = typeof input === "object" + ? input?.constructor?.name ?? "unknown" + : typeof input; + + return err( + createValidationError( + input, + { + kind: "typeMismatch", + expected: "null", + received, + msg: this.msg, + }, + ), + ); + } +} + +class VoidSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + if (input === undefined || input === null) return ok(); + + const received = typeof input === "object" + ? input?.constructor?.name ?? "unknown" + : typeof input; + + return err(createValidationError( + input, + { + kind: "typeMismatch", + expected: "void (undefined/null)", + received, + msg: this.msg, + }, + )); + } +} + +class AnySchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return ok(input); + } +} + +class UnknownSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return ok(input); + } +} + +class NeverSchema extends BaseSchema { + protected override validateInput( + input: unknown, + ): Result { + return err( + createValidationError( + input, + { + kind: "typeMismatch", + expected: "never", + received: typeof input, + msg: this.msg, + }, + ), + ); + } +} + +type InferSchemaType = S extends Schema ? T : never; + +class ObjectSchema>> + extends BaseSchema<{ [K in keyof S]: InferSchemaType }> { + private strictMode: boolean = false; + private objectMsg?; + + constructor( + public readonly shape: S, + msg?: { + mismatch?: string; + nullObject?: string; + unexpectedProperty?: string; + propertyValidation?: string; + missingProperty?: string; + } | string, + ) { + let mismatchMsg: string | undefined; + let objectMsg; + + if (typeof msg === "string") { + mismatchMsg = msg; + } else if (typeof msg === "object") { + objectMsg = msg; + } + + super(mismatchMsg); + + this.objectMsg = objectMsg; + } + + // TODO: Simplify it a bit + protected override validateInput( + input: unknown, + ): Result< + { [K in keyof S]: InferSchemaType }, + SchemaValidationError + > { + return BaseSchema.validatePrimitive( + input, + "object", + this.msg || this.objectMsg?.mismatch, + ) + .andThen((obj) => { + if (obj === null) { + return err( + createValidationError(input, { + kind: "typeMismatch", + expected: "Non-null object", + received: "null", + msg: this.msg || this.objectMsg?.nullObject, + }), + ); + } + + const resultObj: Record = {}; + const expectedKeys = new Set(Object.keys(this.shape)); + + for (const key of Object.keys(obj)) { + const schema = this.shape[key]; + if (schema === undefined) { + if (this.strictMode) { + const keys = new Set(Object.keys(obj)).difference( + new Set(Object.keys(this.shape)), + ).keys().toArray(); + + return err( + createValidationError( + input, + { + kind: "unexpectedProperties", + keys, + msg: this.msg || + this.objectMsg + ?.unexpectedProperty, + }, + ), + ); + } + continue; + } + + const result = schema.parse((obj as any)[key]); + if (result.isErr()) { + return err( + createValidationError( + input, + { + kind: "propertyValidation", + property: key, + detail: result.error.detail, + msg: this.msg || + this.objectMsg?.propertyValidation, + }, + ), + ); + } + expectedKeys.delete(key); + resultObj[key] = result.value; + } + + const missingProperties = expectedKeys.keys().filter( + (key) => !BaseSchema.isNullishSchema(this.shape[key]), + ).toArray(); + + if (missingProperties.length > 0) { + return err( + createValidationError( + input, + { + kind: "missingProperties", + keys: missingProperties, + msg: this.msg || + this.objectMsg?.missingProperty, + }, + ), + ); + } + + return ok( + resultObj as { [K in keyof S]: InferSchemaType }, + ); + }); + } + + strict(): this { + this.strictMode = true; + return this; + } +} + +type InferUnionSchemaType[]> = U[number] extends + Schema ? T : never; + +class UnionSchema[]> + extends BaseSchema> { + private unionMsg?; + + constructor( + public readonly schemas: U, + msg?: { + mismatch?: string; + unionValidation?: string; + } | string, + ) { + let mismatchMsg: string | undefined; + let unionMsg; + + if (typeof msg === "string") { + mismatchMsg = msg; + } else if (typeof msg === "object") { + unionMsg = msg; + } + + super(mismatchMsg); + this.unionMsg = unionMsg; + } + + private static getTypeFromSchemaName(name: string): string { + switch (name) { + case "StringSchema": + case "LiteralSchema": + return "string"; + case "NumberSchema": + return "number"; + case "BigintSchema": + return "bigint"; + case "BooleanSchema": + return "boolean"; + case "UndefinedSchema": + return "undefined"; + case "SymbolSchema": + return "symbol"; + default: + return "object"; + } + } + + protected validateInput( + input: unknown, + ): Result, SchemaValidationError> { + const errors: ValidationErrorDetail[] = []; + let allTypeMismatch = true; + + for (const schema of this.schemas) { + const result = schema.parse(input); + if (result.isOk()) { + return ok(result.value as InferUnionSchemaType); + } + allTypeMismatch = result.error.detail?.kind === "typeMismatch" && + allTypeMismatch; + errors.push(result.error.detail); + } + + if (allTypeMismatch) { + return err(createValidationError(input, { + kind: "typeMismatch", + expected: this.schemas.map((s) => + UnionSchema.getTypeFromSchemaName( + s.constructor.name, + ) + ).join(" | "), + received: typeof input, + msg: this.msg || this.unionMsg?.mismatch, + })); + } + + return err( + createValidationError(input, { + kind: "unionValidation", + msg: this.msg || + this.unionMsg?.unionValidation || + "Input did not match any union member", + details: errors, + }), + ); + } +} + +class ArraySchema> + extends BaseSchema[]> { + private arrayMsg?; + + constructor( + private readonly schema: S, + msg?: { + mismatch?: string; + element?: string; + } | string, + ) { + let mismatchMsg: string | undefined; + let arrayMsg; + + if (typeof msg === "string") { + mismatchMsg = msg; + } else if (typeof msg === "object") { + arrayMsg = msg; + } + + super(mismatchMsg); + // TODO: abstract complex schemas in a separate type with thos messages + this.arrayMsg = arrayMsg; + } + + protected override validateInput( + input: unknown, + ): Result[], SchemaValidationError> { + if (!Array.isArray(input)) { + return err(createValidationError(input, { + kind: "typeMismatch", + expected: "Array", + received: "Non-array", + msg: this.msg || this.arrayMsg?.mismatch, + })); + } + + for (let i = 0; i < input.length; i++) { + const result = this.schema.parse(input[i]); + + if (result.isErr()) { + return err( + createValidationError(input, { + kind: "arrayElement", + index: i, + detail: result.error.detail, + msg: this.msg || this.arrayMsg?.element, + }), + ); + } + } + + return ok(input as InferSchemaType[]); + } +} + +class OptionalSchema> + extends BaseSchema | undefined> { + constructor( + public readonly schema: S, + msg?: string, + ) { + super(msg); + } + + protected override validateInput( + input: unknown, + ): Result | undefined, SchemaValidationError> { + if (input === undefined) { + return ok(input); + } + return this.schema.parse(input); + } +} + +class NullableSchema> + extends BaseSchema | null> { + constructor( + public readonly schema: S, + msg?: string, + ) { + super(msg); + } + + protected override validateInput( + input: unknown, + ): Result | null, SchemaValidationError> { + if (input === null) { + return ok(input); + } + return this.schema.parse(input); + } +} + +class NullishSchema> + extends BaseSchema | undefined | null> { + constructor( + public readonly schema: S, + msg?: string, + ) { + super(msg); + } + + protected override validateInput( + input: unknown, + ): Result | undefined | null, SchemaValidationError> { + if (input === undefined || input === null) { + return ok(input); + } + return this.schema.parse(input); + } +} + +/* ── Helper Object for Schema Creation (z) ───────────────────────────────────── */ + +export const z = { + string: (msg?: string) => new StringSchema(msg), + literal: (lit: L, msg?: string) => + new LiteralSchema(lit, msg), + number: (msg?: string) => new NumberSchema(msg), + bigint: (msg?: string) => new BigintSchema(msg), + boolean: (msg?: string) => new BooleanSchema(msg), + date: (msg?: string) => new DateSchema(msg), + symbol: (msg?: string) => new SymbolSchema(msg), + undefined: (msg?: string) => new UndefinedSchema(msg), + null: (msg?: string) => new NullSchema(msg), + void: (msg?: string) => new VoidSchema(msg), + any: (msg?: string) => new AnySchema(msg), + unknown: (msg?: string) => new UnknownSchema(msg), + never: (msg?: string) => new NeverSchema(msg), + obj: >>( + schema: S, + msg?: string | { + mismatch?: string; + nullObject?: string; + unexpectedProperty?: string; + propertyValidation?: string; + missingProperty?: string; + }, + ) => new ObjectSchema(schema, msg), + union: []>( + schemas: U, + msg?: string | { + mismatch?: string; + unionValidation?: string; + }, + ) => new UnionSchema(schemas, msg), + array: >( + schema: S, + msg?: string | { mismatch?: string; element?: string }, + ) => new ArraySchema(schema, msg), + optional: >( + schema: S, + msg?: string, + ) => new OptionalSchema(schema, msg), + nullable: >( + schema: S, + msg?: string, + ) => new NullableSchema(schema, msg), + nullish: >( + schema: S, + msg?: string, + ) => new NullishSchema(schema, msg), +}; + +const schema = z.obj({ + string: z.string(), + number: z.number(), +}); + +type schemaType = typeof schema; + +type test = InferSchemaType;