fix: update

Signed-off-by: Avior <git@avior.me>
This commit is contained in:
2024-10-09 17:47:31 +02:00
parent 7fa18d682d
commit e38bc9b0b2
69 changed files with 3482 additions and 2071 deletions

View File

@ -0,0 +1,23 @@
import SchemaItem, { type JSONSchemaItem } from '../SchemaItem'
export default class DzeioLiteral<T> extends SchemaItem<T> {
public constructor(private readonly value: T) {
super()
this.validations.push({
fn(input) {
return input === value
}
})
}
public override isOfType(input: unknown): input is T {
return typeof input === typeof this.value
}
public override toJSON(): JSONSchemaItem {
return {
type: 'literal',
params: [this.value as string]
}
}
}

View File

@ -0,0 +1,93 @@
import type { ValidationError, ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
export default class SchemaArray<A> extends SchemaItem<Array<A>> {
public constructor(
private readonly values: SchemaItem<A>
) {
super()
}
public override parse(input: unknown): A[] | unknown {
// let master handle the first pass is desired
input = super.parse(input)
if (!Array.isArray(input)) {
return input
}
const clone = []
for (const item of input) {
clone.push(this.values.parse(item))
}
return clone
}
public override validate(input: A[], fast = false): ValidationResult<A[]> {
const tmp = super.validate(input, fast)
if (tmp.error) {
return tmp
}
const clone: Array<A> = []
const errs: Array<ValidationError> = []
for (let idx = 0; idx < tmp.object.length; idx++) {
const item = tmp.object[idx];
const res = this.values.validate(item as A)
if (res.error) {
const errors = res.error.map((it) => ({
message: it.message,
field: it.field ? `${idx}.${it.field}` : idx.toString()
}))
if (fast) {
return {
error: errors
}
}
errs.push(...errors)
} else {
clone.push(res.object as A)
}
}
if (errs.length > 0) {
return {
error: errs
}
}
return {
object: clone
}
}
public override transform(input: A[]): A[] {
const clone = []
for (const item of super.transform(input)) {
clone.push(this.values.transform(item))
}
return clone
}
/**
* transform the array so it only contains one of each elements
*/
public unique(): this {
this.transforms.push((input) => input.filter((it, idx) => input.indexOf(it) === idx))
return this
}
public override isOfType(input: unknown): input is Array<A> {
return Array.isArray(input)
}
// public override toJSON(): JSONSchemaItem {
// return {
// type: 'array',
// childs: this.values
// }
// }
}

View File

@ -0,0 +1,8 @@
import SchemaItem from '../SchemaItem'
export default class SchemaBoolean extends SchemaItem<boolean> {
public override isOfType(input: unknown): input is boolean {
return typeof input === 'boolean'
}
}

View File

@ -0,0 +1,62 @@
import SchemaItem from '../SchemaItem'
export default class SchemaDate extends SchemaItem<Date> {
public parseString(): this {
this.parseActions.push((input) => typeof input === 'string' ? new Date(input) : input)
return this
}
public parseFrenchDate(): this {
this.parseActions.push((input) => {
if (typeof input !== 'string') {
return input
}
const splitted = input.split('/').map((it) => Number.parseInt(it, 10))
if (splitted.length !== 3) {
return input
}
return new Date(Date.UTC(splitted[2]!, splitted[1], splitted[0]))
})
return this
}
public min(value: Date, message?: string): this {
this.validations.push({
fn(input) {
return input >= value
},
message: message
})
return this
}
public parseFromExcelString(): this {
this.parseActions.push((input) => {
if (typeof input !== 'string') {
return input
}
const days = parseFloat(input)
const millis = days * 24 * 60 * 60 * 1000
const date = new Date('1900-01-01')
date.setTime(date.getTime() + millis)
return date
})
return this
}
public max(value: Date, message?: string): this {
this.validations.push({
fn(input) {
return input <= value
},
message: message
})
return this
}
public override isOfType(input: unknown): input is Date {
return input instanceof Date && !isNaN(input.getTime())
}
}

View File

@ -0,0 +1,21 @@
import SchemaItem from '../SchemaItem'
export default class SchemaFile extends SchemaItem<File> {
constructor () {
super()
this.parseActions.push((input) => this.isOfType(input) && input.size > 0 ? input : undefined)
}
public extension(ext: string, message?: string): this {
this.validations.push({
fn: (input) => input.name.endsWith(ext),
message
})
return this
}
public override isOfType(input: unknown): input is File {
return input instanceof File
}
}

View File

@ -0,0 +1,65 @@
import type { ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
import { isNull } from '../utils'
export default class SchemaNullable<A> extends SchemaItem<A | undefined | null> {
public constructor(private readonly item: SchemaItem<A>) {
super()
}
public emptyAsNull(): this {
this.parseActions.push((input) => {
if (typeof input === 'string' && input === '') {
return null
}
return input
})
return this
}
public falthyAsNull(): this {
this.parseActions.push((input) => {
if (!input) {
return null
}
return input
})
return this
}
public override transform(input: A | null | undefined): A | null | undefined {
const transformed = super.transform(input)
if (isNull(transformed) || isNull(input)) {
return transformed
}
return this.item.transform(input)
}
public override validate(input: A | null | undefined): ValidationResult<A | null | undefined> {
if (isNull(input)) {
return {
object: input
}
}
return this.item.validate(input)
}
public override parse(input: unknown): (A | null | undefined) | unknown {
const parsed = super.parse(input)
if (isNull(parsed) || isNull(input)) {
return parsed
}
return this.item.parse(input)
}
public override isOfType(input: unknown): input is A | undefined | null {
return isNull(input) || this.item.isOfType(input)
}
}

View File

@ -0,0 +1,89 @@
import SchemaItem from '../SchemaItem'
export default class SchemaNumber extends SchemaItem<number> {
public min(...params: Parameters<SchemaNumber['gte']>): this {
return this.gte(...params)
}
public max(...params: Parameters<SchemaNumber['lte']>): this {
return this.lte(...params)
}
/**
* validate that the number is less or equal than {@link value}
* @param value the maxumum value (inclusive)
* @param message the message sent if not valid
*/
public lte(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input <= value
},
message: message
})
return this
}
/**
* validate that the number is more or equal than {@link value}
* @param value the minimum value (inclusive)
* @param message the message sent if not valid
*/
public gte(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input >= value
},
message: message
})
return this
}
/**
* validate that the number is less than {@link value}
* @param value the maxumum value (exclusive)
* @param message the message sent if not valid
*/
public lt(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input < value
},
message: message
})
return this
}
/**
* validate that the number is more than {@link value}
* @param value the minimum value (exclusive)
* @param message the message sent if not valid
*/
public gt(value: number, message?: string): this {
this.validations.push({
fn(input) {
return input > value
},
message: message
})
return this
}
/**
* Try to parse strings before validating
*/
public parseString(): this {
this.parseActions.push((input) =>
typeof input === 'string' ? Number.parseFloat(input) : input
)
return this
}
public override isOfType(input: unknown): input is number {
return typeof input === 'number' && !Number.isNaN(input)
}
}

View File

@ -0,0 +1,88 @@
import { isObject, objectLoop, objectRemap } from '@dzeio/object-util'
import type { ValidationError, ValidationResult } from '..'
import SchemaItem from '../SchemaItem'
export default class SchemaRecord<A extends string | number | symbol, B> extends SchemaItem<Record<A, B>> {
public constructor(
private readonly key: SchemaItem<A>,
private readonly values: SchemaItem<B>
) {
super()
}
public override parse(input: unknown): unknown {
input = super.parse(input)
if (!this.isOfType(input)) {
return input
}
const finalObj: Record<A, B> = {} as Record<A, B>
const error = objectLoop(input, (value, key) => {
const res1 = this.key.parse(key)
const res2 = this.values.parse(value)
if (typeof res1 !== 'string' && typeof res1 !== 'number') {
return false
}
// @ts-expect-error normal behavior
finalObj[res1] = res2
return true
})
if (error) {
return input
}
return finalObj
}
public override transform(input: Record<A, B>): Record<A, B> {
return objectRemap(super.transform(input), (value, key) => {
return {
key: this.key.transform(key),
value: this.values.transform(value)
}
})
}
public override validate(input: Record<A, B>, fast = false): ValidationResult<Record<A, B>> {
const tmp = super.validate(input)
if (tmp.error) {
return tmp
}
const errs: Array<ValidationError> = []
const finalObj: Record<A, B> = {} as Record<A, B>
objectLoop(tmp.object, (value, key) => {
const res1 = this.key.validate(key)
const res2 = this.values.validate(value)
const localErrs = (res1.error ?? []).concat(...(res2.error ?? []))
if (localErrs.length > 0) {
errs.push(...localErrs.map((it) => ({
message: it.message,
field: it.field ? `${key as string}.${it.field}` : key.toString()
})))
return !fast
} else {
// @ts-expect-error the check in the if assure the typing below
finalObj[res1.object] = res2.object
}
return true
})
if (errs.length > 0) {
return {
error: errs
}
}
return {
object: finalObj
}
}
public override isOfType(input: unknown): input is Record<A, B> {
return isObject(input) && Object.prototype.toString.call(input) === '[object Object]'
}
}

View File

@ -0,0 +1,76 @@
import SchemaItem from '../SchemaItem'
import SchemaNullable from './SchemaNullable'
export default class SchemaString extends SchemaItem<string> {
/**
* force the input text to be a minimum of `value` size
* @param value the minimum length of the text
* @param message the message to display on an error
*/
public min(value: number, message?: string): SchemaString {
this.validations.push({
fn(input) {
return input.length >= value
},
message: message
})
return this
}
/**
* force the input text to be a maximum of `value` size
* @param value the maximum length of the text
* @param message the message to display on an error
*/
public max(value: number, message?: string): SchemaString {
this.validations.push({
fn(input) {
return input.length <= value
},
message: message
})
return this
}
/**
* the value must not be empty (`''`)
* @param message
* @returns
*/
public notEmpty(message?: string): this {
this.validations.push({
fn(input) {
return input !== ''
},
message: message
})
return this
}
/**
* note: this nullable MUST be used last as it change the type of the returned function
*/
public nullable() {
return new SchemaNullable(this)
}
/**
* force the input text to respect a Regexp
* @param regex the regex to validate against
* @param message the message to display on an error
*/
public regex(regex: RegExp, message?: string): SchemaString {
this.validations.push({
fn(input) {
return regex.test(input)
},
message
})
return this
}
public override isOfType(input: unknown): input is string {
return typeof input === 'string'
}
}

25
src/Schema/README.md Normal file
View File

@ -0,0 +1,25 @@
a Full featured and lightweight Schema validation/parsing library
it is meant to be used for input validation
example :
```ts
import Schema, { s, type SchemaInfer } from 'libs/Schema'
const schema = new Schema({
test: s.record(s.string(), s.object({
a: s.number().parseString().min(3, 'a is too small')
}))
})
const t = {
test: {
b: {a: '34'}
}
}
// validate that `t` is coherant with the schema above
const { object, error } = schema.validate(t)
console.log(object, error)
```

169
src/Schema/SchemaItem.ts Normal file
View File

@ -0,0 +1,169 @@
import type { ValidationResult } from '.'
import Schema from '.'
import { isNull } from './utils'
export interface Messages {
globalInvalid: string
}
/**
* An element of a schema
*/
export default abstract class SchemaItem<T> {
/**
* get additionnal attributes used to make the Schema work with outside libs
*/
public attributes: Array<string> = []
/**
* the list of validations
*/
protected validations: Array<{
fn: (input: T) => boolean
message?: string | undefined
}> = []
protected parseActions: Array<(input: unknown) => T | unknown> = []
protected transforms: Array<(input: T) => T> = []
/**
* set the list of attributes for the item of the schema
* @param attributes the attributes
*/
public attr(...attributes: Array<string>) {
this.attributes = attributes
return this
}
/**
* set the default value of the schema element
* @param value the default value
* @param strict if strict, it will use it for null/undefined, else it will check for falthy values
*/
public defaultValue(value: T, strict = true) {
this.parseActions.push((input) => {
if (strict && isNull(input)) {
return value
}
if (!value) {
return input
}
return input
})
return this
}
/**
*
* @param values the possible values the field can be
* @param message the message returned if it does not respect the value
*/
public in(values: Array<T>, message?: string) {
this.validations.push({
fn: (input) => values.includes(input),
message
})
return this
}
/**
* Try to parse the input from another format
*
* @param input the input to transform, it is done before validation, so the value can be anything
* @returns the transformed value
*/
public parse(input: unknown): T | unknown {
for (const transform of this.parseActions) {
const tmp = transform(input)
if (this.isOfType(tmp)) {
return tmp
}
}
return input
}
/**
* transform a valid value
*
* @param input the input to transform, it MUST be validated beforehand
* @returns the transformed value
*/
public transform(input: T): T {
let res = input
for (const action of this.transforms) {
res = action(res)
}
return res
}
/**
* validate that the input is valid or not
* @param input the input to validate
* @param fast if true the validation stops at the first error
* @returns a string if it's not valid, else null
*/
public validate(input: T, fast = false): ValidationResult<T> {
for (const validation of this.validations) {
if (!validation.fn(input as T)) {
return {
error: [{
message: validation.message ?? Schema.messages.globalInvalid
}]
}
}
}
return {
object: input as T
}
}
/**
* validate that the input value is of the type of the schema item
*
* it makes others functions easier to works with
* @param input the input to validate
*/
public abstract isOfType(input: unknown): input is T
// public abstract toJSON(): JSONSchemaItem
}
type Parseable = string | number | boolean
export interface ValidatorJSON {
/**
* the function name (ex: `min`, `max`)
*/
name: string
/**
* the function parameters
*/
params?: Array<Parseable>
}
export interface JSONSchemaItem {
/**
* Schema item
*
* ex: `string`, `number`, `boolean`, ...
*/
type: string
/**
* constructor params
*/
params?: Array<Parseable>
/**
* list of attributes
*/
attributes?: Array<string>
actions?: Array<ValidatorJSON>
}
export type JSONSchema = {
[a: string]: JSONSchemaItem
}

216
src/Schema/index.ts Normal file
View File

@ -0,0 +1,216 @@
import { isObject, objectLoop } from '@dzeio/object-util'
import DzeioLiteral from './Items/DzeioLiteral'
import SchemaArray from './Items/SchemaArray'
import SchemaBoolean from './Items/SchemaBoolean'
import SchemaDate from './Items/SchemaDate'
import SchemaFile from './Items/SchemaFile'
import SchemaNullable from './Items/SchemaNullable'
import SchemaNumber from './Items/SchemaNumber'
import SchemaRecord from './Items/SchemaRecord'
import SchemaString from './Items/SchemaString'
import SchemaItem from './SchemaItem'
export interface ValidationError {
message: string
field?: string
value?: unknown
}
export type ValidationResult<T> = {
object: T
error?: undefined
} | {
object?: undefined
error: Array<ValidationError>
}
export type Model = Record<string, SchemaItem<any>>
export type SchemaInfer<S extends Schema> = ModelInfer<S['model']>
export type ModelInfer<M extends Model> = {
[key in keyof M]: ReturnType<M[key]['transform']>
}
/**
* A schema to validate input or external datas
*/
export default class Schema<M extends Model = Model> extends SchemaItem<ModelInfer<Model>> {
public static messages = {
typeInvalid: 'Type of field is not valid',
notAnObject: 'the data submitted is not valid',
globalInvalid: 'the field is invalid'
}
public constructor(public readonly model: M) {
super()
}
/**
* See {@link SchemaString}
*/
public static string(
...inputs: ConstructorParameters<typeof SchemaString>
): SchemaString {
return new SchemaString(...inputs)
}
public static file(
...inputs: ConstructorParameters<typeof SchemaFile>
): SchemaFile {
return new SchemaFile(...inputs)
}
public static number(
...inputs: ConstructorParameters<typeof SchemaNumber>
): SchemaNumber {
return new SchemaNumber(...inputs)
}
public static date(
...inputs: ConstructorParameters<typeof SchemaDate>
): SchemaDate {
return new SchemaDate(...inputs)
}
public static literal<T>(
...inputs: ConstructorParameters<typeof DzeioLiteral<T>>
): DzeioLiteral<T> {
return new DzeioLiteral<T>(...inputs)
}
public static object<T extends Model>(
...inputs: ConstructorParameters<typeof Schema<T>>
): Schema<T> {
return new Schema(...inputs)
}
public static record<A extends string | number, B>(
...inputs: ConstructorParameters<typeof SchemaRecord<A, B>>
): SchemaRecord<A, B> {
return new SchemaRecord<A, B>(...inputs)
}
public static array<A>(
...inputs: ConstructorParameters<typeof SchemaArray<A>>
): SchemaArray<A> {
return new SchemaArray<A>(...inputs)
}
public static nullable<A>(
...inputs: ConstructorParameters<typeof SchemaNullable<A>>
): SchemaNullable<A> {
return new SchemaNullable<A>(...inputs)
}
public static boolean(
...inputs: ConstructorParameters<typeof SchemaBoolean>
): SchemaBoolean {
return new SchemaBoolean(...inputs)
}
/**
* @param query the URL params to validate
* @returns
*/
public validateQuery(query: URLSearchParams, fast = false): ReturnType<Schema<M>['validate']> {
const record: Record<string, unknown> = {}
for (const [key, value] of query) {
record[key] = value
}
return this.validate(record, fast)
}
/**
* @param form the form to validate
*/
public validateForm(form: HTMLFormElement, fast = false): ReturnType<Schema<M>['validate']> {
const data = new FormData(form)
return this.validateFormData(data, fast)
}
/**
* @param data the FormData to validate
* @returns
*/
public validateFormData(data: FormData, fast = false): ReturnType<Schema<M>['validate']> {
const record: Record<string, unknown> = {}
for (const [key, value] of data) {
const isArray = this.model[key]?.isOfType([]) ?? false
record[key] = isArray ? data.getAll(key) : value
}
return this.validate(record, fast)
}
/**
*
* @param input the data to validate
* @param options additionnal validation options
* @returns blablabla
*/
public override validate(input: unknown, fast = false): ValidationResult<SchemaInfer<this>> {
if (!isObject(input)) {
return {
error: [{
message: Schema.messages.notAnObject
}]
}
}
const errors: Array<ValidationError> = []
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const res: ModelInfer<M> = {} as any
objectLoop(this.model, (v, k) => {
// parse value from other formats
const value = v.parse(input[k])
// validate that the value is of type
if (!v.isOfType(value)) {
errors.push({
message: Schema.messages.typeInvalid,
field: k,
value: value
})
return !fast
}
// run validations
const invalid = v.validate(value)
if (invalid.error) {
errors.push(...invalid.error.map((it) => ({
message: it.message,
field: it.field ? `${k}.${it.field}` : k
})))
return !fast
}
// transform and assign final value
// @ts-expect-error normal behavior
res[k] = v.transform(value)
return true
})
if (errors.length > 0) {
return {
error: errors
}
}
return {
object: res
}
}
public override isOfType(input: unknown): input is ModelInfer<Model> {
return isObject(input)
}
}
/**
* alias of {@link Schema}
*/
export const s = Schema

3
src/Schema/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export function isNull(value: unknown): value is undefined | null {
return typeof value === 'undefined' || value === null
}