From fcccf93772ad292808c6027c3891e41d08b2e5bb Mon Sep 17 00:00:00 2001 From: ton1c Date: Wed, 29 Jan 2025 01:50:13 +0300 Subject: [PATCH] working on validator --- server/src/lib/routerTree.ts | 1 + server/src/lib/test.ts | 711 +++++++++++++++++++++++++++++++++++ server/src/lib/validator.ts | 344 +++++++++++++++++ 3 files changed, 1056 insertions(+) create mode 100644 server/src/lib/test.ts create mode 100644 server/src/lib/validator.ts diff --git a/server/src/lib/routerTree.ts b/server/src/lib/routerTree.ts index 2df44db..f1bd958 100644 --- a/server/src/lib/routerTree.ts +++ b/server/src/lib/routerTree.ts @@ -88,6 +88,7 @@ class StaticNode implements Node { } } +// TODO: get rid of fixed param name class DynamicNode extends StaticNode implements Node { constructor( public readonly paramName: string, diff --git a/server/src/lib/test.ts b/server/src/lib/test.ts new file mode 100644 index 0000000..c09f957 --- /dev/null +++ b/server/src/lib/test.ts @@ -0,0 +1,711 @@ +import { err, ok, Result } from "@shared/utils/result.ts"; +import { none, Option, some } from "@shared/utils/option.ts"; + +class ParseError extends Error { + type = "ParseError"; + constructor( + public readonly input: any, + public readonly msg: string, + ) { + super(msg); + } +} + +function pe(input: unknown, msg: string) { + return new ParseError(input, msg); +} + +export interface Schema { + parse(input: unknown): Result; + checkIfValid(input: unknown): boolean; + nullable(): NullableSchema; + option(): OptionSchema; + or[]>(...schema: S): UnionSchema<[this, ...S]>; +} + +type CheckFunction = (input: T) => ParseError | void; + +export abstract class BaseSchema implements Schema { + protected checks: CheckFunction[] = []; + + public addCheck(check: CheckFunction): this { + this.checks.push(check); + return this; + } + + protected runChecks(input: T): Result { + for (const check of this.checks) { + const error = check(input); + if (error) { + return err(error); + } + } + return ok(input); + } + + checkIfValid(input: unknown): boolean { + return this.parse(input).isOk(); + } + + nullable(): NullableSchema { + return new NullableSchema(this); + } + + or[]>(...schema: S): UnionSchema<[this, ...S]> { + return new UnionSchema(this, ...schema); + } + + option(): OptionSchema { + return new OptionSchema(this); + } + + abstract parse(input: unknown): Result; +} + +export abstract class PrimitiveSchema< + T extends + | string + | number + | boolean + | bigint + | undefined + | null + | object + | void + | any + | unknown + | never, +> extends BaseSchema { + protected abstract initialCheck(input: unknown): Result; + + protected checkPrimitive( + input: unknown, + type: + | "string" + | "number" + | "boolean" + | "bigint" + | "undefined" + | "object" + | "symbol" + | "funciton", + ): Result { + const inputType = typeof input; + + if (inputType === type) { + return ok(input as U); + } + return err( + pe(input, `Expected '${type}', received '${inputType}'`), + ); + } + + public parse(input: unknown): Result { + return this.initialCheck(input).andThen((input) => { + for (const check of this.checks) { + const e = check(input); + + if (e) { + return err(e); + } + } + + return ok(input); + }); + } +} + +export class StringSchema extends PrimitiveSchema { + 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 initialCheck( + input: unknown, + ): Result { + return this.checkPrimitive(input, "string"); + } + + public max( + length: number, + msg: string = `String length must be at most ${length} characters long`, + ): this { + return this.addCheck((input) => + input.length < length ? pe(input, msg) : undefined + ); + } + + public min( + length: number, + msg: string = + `String length must be at least ${length} characters long`, + ): this { + return this.addCheck((input) => + input.length < length ? pe(input, msg) : undefined + ); + } + + public regex( + pattern: RegExp, + msg: string = `String must match the patter ${String(pattern)}`, + ): this { + return this.addCheck((input) => + pattern.test(input) ? undefined : pe(input, msg) + ); + } + + public email( + msg: string = `String must match a valid email address`, + ): this { + return this.regex(StringSchema.emailRegex, msg); + } + + public ip( + msg: string = `String must match a valid ip address`, + ): this { + return this.regex(StringSchema.ipRegex, msg); + } +} + +export class NumberSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + return this.checkPrimitive(input, "number"); + } + + gt(num: number, msg: string = `Number must be greater than ${num}`): this { + return this.addCheck((input) => + input <= num ? pe(input, msg) : undefined + ); + } + + gte( + num: number, + msg: string = `Number must be greater than or equal to ${num}`, + ): this { + return this.addCheck((input) => + input < num ? pe(input, msg) : undefined + ); + } + + lt(num: number, msg: string = `Number must be less than ${num}`): this { + return this.addCheck((input) => + input >= num ? pe(input, msg) : undefined + ); + } + + lte( + num: number, + msg: string = `Number must be less than or equal to ${num}`, + ): this { + return this.addCheck((input) => + input > num ? pe(input, msg) : undefined + ); + } + + int(msg: string = "Number must be an integer"): this { + return this.addCheck((input) => + Number.isInteger(input) ? pe(input, msg) : undefined + ); + } + + positive(msg: string = "Number must be positive"): this { + return this.gte(0, msg); + } + + nonnegative(msg: string = "Number must be nonnegative"): this { + return this.gt(0, msg); + } + + negative(msg: string = "Number must be negative"): this { + return this.lt(0, msg); + } + + nonpositive(msg: string = "Number must be nonpositive"): this { + return this.lte(0, msg); + } + + finite(msg: string = "Number must be finite"): this { + return this.addCheck((input) => + Number.isFinite(input) ? undefined : pe(input, msg) + ); + } + + safe(msg: string = "Number must be a safe integer"): this { + return this.addCheck((input) => + Number.isSafeInteger(input) ? undefined : pe(input, msg) + ); + } + + multipleOf( + num: number, + msg: string = `Number must be a multiple of ${num}`, + ): this { + return this.addCheck((input) => + input % num ? undefined : pe(input, msg) + ); + } +} + +export class BigintSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + return this.checkPrimitive(input, "bigint"); + } + + gt( + num: bigint | number, + msg: string = `Bigint must be greater than ${num}`, + ): this { + return this.addCheck((input) => + input <= num ? pe(input, msg) : undefined + ); + } + + gte( + num: bigint | number, + msg: string = `Bigint must be greater than or equal to ${num}`, + ): this { + return this.addCheck((input) => + input < num ? pe(input, msg) : undefined + ); + } + + lt( + num: bigint | number, + msg: string = `Bigint must be less than ${num}`, + ): this { + return this.addCheck((input) => + input >= num ? pe(input, msg) : undefined + ); + } + + lte( + num: bigint | number, + msg: string = `Bigint must be less than or equal to ${num}`, + ): this { + return this.addCheck((input) => + input > num ? pe(input, msg) : undefined + ); + } + + int(msg: string = "Bigint must be an integer"): this { + return this.addCheck((input) => + Number.isInteger(input) ? pe(input, msg) : undefined + ); + } + + positive(msg: string = "Bigint must be positive"): this { + return this.gte(0, msg); + } + + nonnegative(msg: string = "Bigint must be nonnegative"): this { + return this.gt(0, msg); + } + + negative(msg: string = "Bigint must be negative"): this { + return this.lt(0, msg); + } + + nonpositive(msg: string = "Bigint must be nonpositive"): this { + return this.lte(0, msg); + } + + finite(msg: string = "Bigint must be finite"): this { + return this.addCheck((input) => + Number.isFinite(input) ? undefined : pe(input, msg) + ); + } + + safe(msg: string = "Bigint must be a safe integer"): this { + return this.addCheck((input) => + Number.isSafeInteger(input) ? undefined : pe(input, msg) + ); + } + + multipleOf( + num: bigint, + msg: string = `Bigint must be a multiple of ${num}`, + ): this { + return this.addCheck((input) => + input % num ? undefined : pe(input, msg) + ); + } +} + +export class BooleanSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + return this.checkPrimitive(input, "boolean"); + } +} + +export class DateSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + return this.checkPrimitive(input, "object").andThen((obj) => { + if (obj instanceof Date) { + return ok(obj); + } + return err( + pe( + input, + `Expected instance of Date, received ${obj.constructor.name}`, + ), + ); + }); + } + + min( + date: Date, + msg: string = `Date must be after ${date.toLocaleString()}`, + ) { + return this.addCheck((input) => + input <= date ? pe(input, msg) : undefined + ); + } + + max( + date: Date, + msg: string = `Date must be before ${date.toLocaleString()}`, + ) { + return this.addCheck((input) => + input >= date ? pe(input, msg) : undefined + ); + } +} + +class UndefinedSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + return this.checkPrimitive(input, "undefined"); + } +} + +class NullSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + if (input === null) { + return ok(input); + } + return err(pe(input, "Expected 'null', received '${typeof input}'")); + } +} + +class VoidSchema extends PrimitiveSchema { + protected override initialCheck(input: unknown): Result { + if (input !== undefined && input !== null) { + return err( + pe(input, `Expected 'void', received '${typeof input}'`), + ); + } + + return ok(); + } +} + +class AnySchema extends PrimitiveSchema { + protected override initialCheck(input: unknown): Result { + return ok(input); + } +} + +class UnknownSchema extends PrimitiveSchema { + protected override initialCheck( + input: unknown, + ): Result { + return ok(input); + } +} + +type InferSchema = S extends Schema ? T : never; + +class ObjectSchema>> + extends PrimitiveSchema<{ [K in keyof O]: InferSchema }> { + private strict: boolean = false; + + constructor( + private readonly schema: O, + ) { + super(); + } + + protected override initialCheck( + input: unknown, + ): Result<{ [K in keyof O]: InferSchema }, ParseError> { + return this.checkPrimitive(input, "object").andThen( + (objPrimitive) => { + let obj = objPrimitive as Record; + let parsedObj: Record = {}; + + for (const [key, schema] of Object.entries(this.schema)) { + const value = obj[key]; + + const checkResult = schema.parse(value); + + if (checkResult.isErr()) { + return err( + pe( + input, + `Failed to parse '${key}' attribute: ${checkResult.error.msg}`, + ), + ); + } + + parsedObj[key] = checkResult.value; + } + + return ok(parsedObj as { [K in keyof O]: InferSchema }); + }, + ); + } +} + +class NullableSchema> + extends PrimitiveSchema | void> { + private static readonly voidSchema = new VoidSchema(); + + constructor( + private readonly schema: S, + ) { + super(); + } + + protected override initialCheck( + input: unknown, + ): Result, ParseError> { + if (NullableSchema.voidSchema.checkIfValid(input)) { + return ok(); + } + return this.schema.parse(input); + } +} + +class LiteralSchema extends PrimitiveSchema { + constructor( + private readonly literal: L, + ) { + super(); + } + + protected override initialCheck(input: unknown): Result { + if (input === this.literal) { + return ok(this.literal); + } + return err(pe(input, `Input must match literal '${this.literal}'`)); + } +} + +type InferSchemaUnion[]> = S[number] extends + Schema ? U : never; + +class UnionSchema[]> + extends PrimitiveSchema> { + private readonly schemas: S; + + constructor(...schemas: S) { + super(); + this.schemas = schemas; + } + + protected override initialCheck( + input: unknown, + ): Result, ParseError> { + const errors: string[] = []; + + for (const schema of this.schemas) { + const checkResult = schema.parse(input); + + if (checkResult.isOk()) { + return ok(checkResult.value); + } + + errors.push( + `${schema.constructor.name} - ${checkResult.error.msg}`, + ); + } + + return err( + pe( + input, + [ + "No matching schema found for a union:", + errors.join("\n"), + ].join("\n"), + ), + ); + } +} + +class ArraySchema> + extends PrimitiveSchema[]> { + constructor( + private readonly schema: S, + ) { + super(); + } + + protected override initialCheck( + input: unknown[], + ): Result[], ParseError> { + const parsed = []; + + for (let i = 0; i < input.length; i++) { + const r = this.schema.parse(input[i]); + + if (r.isErr()) { + return err( + pe( + input, + `Element at index ${i} does not conform to schema:\n${r.error.msg}`, + ), + ); + } + + parsed.push(r.value); + } + + return ok(parsed); + } +} + +class ResultSchema extends PrimitiveSchema> { + private schema; + + constructor( + private readonly valueSchema: Schema, + private readonly errorSchema: Schema, + ) { + super(); + + this.schema = new UnionSchema( + new ObjectSchema({ + tag: new LiteralSchema("ok"), + value: valueSchema, + }), + new ObjectSchema({ + tag: new LiteralSchema("err"), + error: errorSchema, + }), + ); + } + + protected override initialCheck( + input: unknown, + ): Result, ParseError> { + return this.schema.parse(input).map((result) => { + switch (result.tag) { + case "ok": + return ok(result.value); + case "err": + return err(result.error); + } + }); + } +} + +class OptionSchema extends PrimitiveSchema> { + private schema; + + constructor(private readonly valueSchema: Schema) { + super(); + + this.schema = new UnionSchema( + new ObjectSchema({ + tag: new LiteralSchema("some"), + value: valueSchema, + }), + new ObjectSchema({ + tag: new LiteralSchema("none"), + }), + ); + } + + protected override initialCheck( + input: unknown, + ): Result, ParseError> { + return this.schema.parse(input).map((option) => { + switch (option.tag) { + case "some": + return some(option.value); + case "none": + return none; + } + }); + } +} + +class Validator { + string(): StringSchema { + return new StringSchema(); + } + + literal(literal: L): LiteralSchema { + return new LiteralSchema(literal); + } + + number(): NumberSchema { + return new NumberSchema(); + } + + bigint(): BigintSchema { + return new BigintSchema(); + } + + boolean(): BooleanSchema { + return new BooleanSchema(); + } + + date(): DateSchema { + return new DateSchema(); + } + + undefined(): UndefinedSchema { + return new UndefinedSchema(); + } + + null(): NullSchema { + return new NullSchema(); + } + + void(): VoidSchema { + return new VoidSchema(); + } + + any(): AnySchema { + return new AnySchema(); + } + + unknown(): UnknownSchema { + return new UnknownSchema(); + } + + union[]>(...schemas: S): UnionSchema { + return new UnionSchema(...schemas); + } + + array>(elementSchema: S): ArraySchema { + return new ArraySchema(elementSchema); + } + + result( + valueSchema: Schema, + errorSchema: Schema, + ): ResultSchema { + return new ResultSchema(valueSchema, errorSchema); + } +} + +const v = new Validator(); + +const r = v.array(v.union(v.string(), v.number().gt(5))); + +console.log(r.parse(["5", true])); diff --git a/server/src/lib/validator.ts b/server/src/lib/validator.ts new file mode 100644 index 0000000..6687b49 --- /dev/null +++ b/server/src/lib/validator.ts @@ -0,0 +1,344 @@ +import { err, ok, Result } from "@shared/utils/result.ts"; +import console from "node:console"; + +class ParseError extends Error { + code = "ParseError"; + constructor( + public readonly input: any, + public readonly msg: string, + ) { + super(msg); + } +} + +export function pe(input: any, msg: string): ParseError { + return new ParseError(input, msg); +} + +type CheckFunction = ( + input: T, + msg?: string, +) => ParseError | void; + +export abstract class BaseSchema { + private checks: CheckFunction[] = []; + abstract initialCheck(input: unknown): Result; + public addCheck(check: CheckFunction) { + this.checks.push(check); + } + + parse(input: any): Result { + return this.initialCheck(input).andThen((input) => { + for (const check of this.checks) { + const e = check(input); + if (e) { + return err(e); + } + } + return ok(input); + }); + } +} + +export 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 + + private initialCheck(input: unknown): Result { + if (typeof input !== "string") { + return err(pe(input, `Expected string, received ${typeof input}`)); + } + return ok(input); + } + + max( + length: number, + msg: string = `String length must be at most ${length} characters long`, + ): this { + this.addCheck((input) => { + if (input.length > length) { + return pe( + input, + msg, + ); + } + }); + + return this; + } + + min( + length: number, + msg: string = + `String length must be at least ${length} characters long`, + ): this { + this.addCheck((input) => { + if (input.length < length) { + return pe( + input, + msg, + ); + } + }); + return this; + } + + regex( + pattern: RegExp, + msg: string = `String must match the pattern ${String(pattern)}`, + ): this { + this.addCheck((input) => { + if (!pattern.test(input)) { + return pe( + input, + msg, + ); + } + }); + return this; + } + + email(msg: string = `String must be a valid email address`): this { + return this.regex(StringSchema.emailRegex, msg); + } + ip(msg: string = "String must be a valid ip address"): this { + return this.regex(StringSchema.ipRegex, msg); + } +} + +export class NumberSchema extends BaseSchema { + private initialCheck(input: unknown): Result { + if (typeof input !== "number") { + return err(pe(input, `Expected number, recieved ${typeof input}`)); + } + return ok(input); + } + + gt(num: number, msg: string = `Number must be greater than ${num}`): this { + this.addCheck((input) => { + if (input <= num) return pe(input, msg); + }); + return this; + } + + gte( + num: number, + msg: string = `Number must be greater than or equal to ${num}`, + ): this { + this.addCheck((input) => { + if (input < num) return pe(input, msg); + }); + return this; + } + + lt(num: number, msg: string = `Number must be less than ${num}`): this { + this.addCheck((input) => { + if (input >= num) return pe(input, msg); + }); + return this; + } + + lte( + num: number, + msg: string = `Number must be less than or equal to ${num}`, + ): this { + this.addCheck((input) => { + if (input > num) return pe(input, msg); + }); + return this; + } + + int(msg: string = "Number must be an integer"): this { + this.addCheck((input) => { + if (!Number.isInteger(input)) return pe(input, msg); + }); + return this; + } + + positive(msg: string = "Number must be positive"): this { + return this.gte(0, msg); + } + + nonnegative(msg: string = "Number must be nonnegative"): this { + return this.gt(0, msg); + } + + negative(msg: string = "Number must be negative"): this { + return this.lt(0, msg); + } + + nonpositive(msg: string = "Number must be nonpositive"): this { + return this.lte(0, msg); + } +} + +export class BooleanSchema extends BaseSchema { + private initialCheck(input: unknown): Result { + if (typeof input !== "boolean") { + return err(pe(input, `Expected boolean, received ${typeof input}`)); + } + return ok(input); + } +} + +export class DateSchema extends BaseSchema { + override initialCheck(input: unknown): Result { + if (typeof input === "object" && input !== null) { + if (input instanceof Date) { + return ok(input); + } + // TODO: add check for an non Date instance. Right now lsp freaks out for some reason + } + + return err(pe(input, `Expected object, received ${typeof input}`)); + } + + min( + date: Date, + msg: string = `Date must come after ${date.toLocaleString()}`, + ) { + this.addCheck((input) => { + if (input < date) { + return pe(input, msg); + } + }); + return this; + } + + max( + date: Date, + msg: string = `Date must come before ${date.toLocaleString()}`, + ) { + this.addCheck((input) => { + if (input > date) { + return pe(input, msg); + } + }); + return this; + } +} + +const validatorsObjects = [ + StringSchema.name, + NumberSchema.name, + BooleanSchema.name, + DateSchema.name, +]; + +class UnionSchema { + private readonly schemas: Schema[]; + + constructor(...schemas: Schema[]) { + this.schemas = schemas; + } + + parse(input: unknown): Result, ParseError> { + for (const schema of this.schemas) { + const result = schema.parse(input); + + if (result.isOk()) { + return result; + } + } + + return err( + pe( + input, + `Failed to parse as one of the types: ${ + this.schemas.map((s) => s.constructor.name).join(", ") + }`, + ), + ); + } +} + +type Schema = + | NumberSchema + | StringSchema + | BooleanSchema + | DateSchema + | ObjectSchema> + | UnionSchema; + +export class ObjectSchema< + S extends Record, +> { + // TODO: rewrite this to be a static method returning result + constructor( + private readonly schema: S, + ) { + for (const [_, value] of Object.entries(schema)) { + if (validatorsObjects.indexOf(schema.constructor.name) === -1) { + throw "Not a valid ObjectSchema >:("; + } + } + + console.log("Yay! It is valid"); + } + + parse( + input: unknown, + ): Result<{ [K in keyof S]: InferTypeFromSchema }, ParseError> { + if (typeof input !== "object") { + return err(pe(input, `Expected object, received ${typeof input}`)); + } + + if (input === null) { + return err(pe(input, `Expected object, received null`)); + } + + const obj = input as Record; + const resultObj: Partial> = {}; + + for (const [key, schema] of Object.entries(this.schema)) { + const inputValue = obj[key]; + + if (!inputValue) { + return err(pe( + input, + `Object ${input} missing the '${key}' attribute`, + )); + } + + const result = schema.parse(inputValue); + + if (result.isErr()) { + return err(result.error); + } + + resultObj[key as keyof S] = result.value; + } + + return ok(resultObj); + } +} + +type InferTypeFromSchema | Schema[]> = + S extends Record + ? { [K in keyof S]: InferTypeFromSchema } + : S extends StringSchema ? string + : S extends NumberSchema ? number + : S extends DateSchema ? Date + : S extends BooleanSchema ? boolean + : S extends ObjectSchema ? InferTypeFromSchema + : S extends UnionSchema ? InferTypeFromSchema + : never; + +export const s = { + string: () => new StringSchema(), + number: () => new NumberSchema(), + date: () => new DateSchema(), +}; + +function createObjectSchema>( + schema: S, +): ObjectSchema { + return new ObjectSchema(schema); +} + +const union = new UnionSchema(s.string(), s.number()); + +console.log(union.parse({}));