903 lines
32 KiB
TypeScript
903 lines
32 KiB
TypeScript
import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from 'cosmokit'
|
|
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
|
|
const kSchema = Symbol.for('schemastery')
|
|
const kValidationError = Symbol.for('ValidationError')
|
|
|
|
declare global {
|
|
namespace Schemastery {
|
|
/** Convert primitive constructors, constants, and existing schemas into a schema type. */
|
|
export type From<X> =
|
|
| X extends string | number | boolean ? Schema<X>
|
|
: X extends Schema ? X
|
|
: X extends typeof String ? Schema<string>
|
|
: X extends typeof Number ? Schema<number>
|
|
: X extends typeof Boolean ? Schema<boolean>
|
|
: X extends typeof Function ? Schema<Function, (...args: any[]) => any>
|
|
: X extends Constructor<infer S> ? Schema<S>
|
|
: never
|
|
|
|
type TypeS1<X> = X extends Schema<infer S, unknown> ? S : never
|
|
type Inverse<X> = X extends Schema<any, infer Y> ? (arg: Y) => void : never
|
|
|
|
/** Input type accepted by a schema-like value. */
|
|
export type TypeS<X> = TypeS1<From<X>>
|
|
/** Output type returned by a schema-like value after validation. */
|
|
export type TypeT<X> = ReturnType<From<X>>
|
|
/** Resolver callback used by custom schema types registered with `Schema.extend()`. */
|
|
export type Resolve = (data: any, schema: Schema, options: Options, strict?: boolean) => [any, any?]
|
|
|
|
/** Input type accepted by one schema in an intersection. */
|
|
export type IntersectS<X> = From<X> extends Schema<infer S, unknown> ? S : never
|
|
/** Output type returned by one schema in an intersection. */
|
|
export type IntersectT<X> = Inverse<From<X>> extends ((arg: infer T) => void) ? T : never
|
|
|
|
type TupleS<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [TypeS<L>?, ...TupleS<R>] : any[]
|
|
type TupleT<X extends readonly any[]> = X extends readonly [infer L, ...infer R] ? [TypeT<L>?, ...TupleT<R>] : any[]
|
|
type ObjectS<X extends Dict> = { [K in keyof X]?: TypeS<X[K]> | null } & Dict
|
|
type ObjectT<X extends Dict> = { [K in keyof X]: TypeT<X[K]> } & Dict
|
|
type Constructor<T = any> = new (...args: any[]) => T
|
|
|
|
/** Static constructor and factory methods exposed by the default `Schema` export. */
|
|
export interface Static {
|
|
<T = any>(options: Partial<Schema<T>>): Schema<T>
|
|
new <T = any>(options: Partial<Schema<T>>): Schema<T>
|
|
prototype: Schema
|
|
/** Validate a value against a schema node and return `[output, adaptedInput?]`. */
|
|
resolve: Resolve
|
|
/** Infer a schema from a primitive value, constructor, or existing schema. */
|
|
from<X = any>(source?: X): From<X>
|
|
/** Register a resolver for a custom schema `type`. */
|
|
extend(type: string, resolve: Resolve): void
|
|
/** Accept any value without validation. */
|
|
any<T = any>(): Schema<T>
|
|
/** Accept only nullable input. */
|
|
never(): Schema<never>
|
|
/** Accept exactly one constant value. */
|
|
const<const T>(value: T): Schema<T>
|
|
/** Accept strings, with optional metadata constraints added by instance methods. */
|
|
string(): Schema<string>
|
|
/** Accept numbers, with optional range and step constraints. */
|
|
number(): Schema<number>
|
|
/** Accept non-negative integer numbers. */
|
|
natural(): Schema<number>
|
|
/** Accept a number between 0 and 1 and mark it as a slider. */
|
|
percent(): Schema<number>
|
|
/** Accept booleans. */
|
|
boolean(): Schema<boolean>
|
|
/** Accept `Date` instances or parse datetime strings into `Date` objects. */
|
|
date(): Schema<string | Date, Date>
|
|
/** Accept `RegExp` instances or parse strings into regular expressions. */
|
|
regExp(flag?: string): Schema<string | RegExp, RegExp>
|
|
/** Accept binary sources and normalize them to `ArrayBufferLike`. */
|
|
arrayBuffer(): Schema<Binary.Source, ArrayBufferLike>
|
|
arrayBuffer(encoding: 'hex' | 'base64'): Schema<Binary.Source | string, ArrayBufferLike>
|
|
/** Accept a numeric bitset or string keys and normalize to a number. */
|
|
bitset<K extends string>(bits: Partial<Record<K, number>>): Schema<number | readonly K[], number>
|
|
/** Accept functions. */
|
|
function(): Schema<Function, (...args: any[]) => any>
|
|
/** Accept instances of a constructor or objects whose constructor name matches. */
|
|
is(constructor: string): Schema
|
|
is<T>(constructor: Constructor<T>): Schema<T>
|
|
/** Accept arrays whose elements match `inner`. */
|
|
array<X>(inner: X): Schema<TypeS<X>[], TypeT<X>[]>
|
|
/** Accept plain objects with values matching `inner` and optional key schema. */
|
|
dict<X, Y extends Schema<any, string> = Schema<string>>(inner: X, sKey?: Y): Schema<Dict<TypeS<X>, TypeS<Y>>, Dict<TypeT<X>, TypeT<Y>>>
|
|
/** Accept tuple arrays where each index matches the corresponding schema. */
|
|
tuple<const X extends readonly any[]>(list: X): Schema<TupleS<X>, TupleT<X>>
|
|
/** Accept plain objects whose declared properties match the schema dictionary. */
|
|
object<X extends Dict>(dict: X): Schema<ObjectS<X>, ObjectT<X>>
|
|
/** Accept values matching at least one schema in `list`. */
|
|
union<const X>(list: readonly X[]): Schema<TypeS<X>, TypeT<X>>
|
|
/** Accept values matching every schema in `list`, merging object outputs. */
|
|
intersect<const X>(list: readonly X[]): Schema<IntersectS<X>, IntersectT<X>>
|
|
/** Validate with `inner`, then convert the result with `callback`. */
|
|
transform<X, T>(inner: X, callback: (value: TypeS<X>, options: Schemastery.Options) => T, preserve?: boolean): Schema<TypeS<X>, T>
|
|
/** Defer construction of a recursive schema until validation or serialization. */
|
|
lazy<X extends Schema>(callback: () => X): X
|
|
ValidationError: typeof ValidationError
|
|
}
|
|
|
|
/** Runtime validation options shared by all schema calls. */
|
|
interface Options {
|
|
/** Remove invalid object properties instead of throwing when possible. */
|
|
autofix?: boolean
|
|
/** Skip validation for selected values and schema nodes. */
|
|
ignore?(data: any, schema: Schema): boolean
|
|
/** Path used to format nested validation errors. */
|
|
path?: (keyof any)[]
|
|
}
|
|
|
|
/** UI and validation metadata attached by schema builder methods. */
|
|
export interface Meta<T = any> {
|
|
default?: T extends {} ? Partial<T> : T
|
|
required?: boolean
|
|
disabled?: boolean
|
|
collapse?: boolean
|
|
badges?: { text: string; type: string }[]
|
|
hidden?: boolean
|
|
loose?: boolean
|
|
role?: string
|
|
extra?: any
|
|
link?: string
|
|
description?: string | Dict<string>
|
|
comment?: string
|
|
pattern?: { source: string; flags?: string }
|
|
max?: number
|
|
min?: number
|
|
step?: number
|
|
}
|
|
}
|
|
|
|
/** Callable schema instance that validates input and returns normalized output. */
|
|
interface Schemastery<S = any, T = S> {
|
|
(data?: S | null, options?: Schemastery.Options): T
|
|
new (data?: S | null, options?: Schemastery.Options): T
|
|
[kSchema]: true
|
|
uid: number
|
|
meta: Schemastery.Meta<T>
|
|
type: string
|
|
sKey?: Schema
|
|
inner?: Schema
|
|
list?: Schema[]
|
|
dict?: Dict<Schema>
|
|
bits?: Dict<number>
|
|
callback?: Function
|
|
constructor?: string | Function
|
|
builder?: Function
|
|
value?: T
|
|
refs?: Dict<Schema>
|
|
preserve?: boolean
|
|
'~standard': StandardSchemaV1.Props // <S, T>
|
|
/** Format this schema as a compact TypeScript-like type string. */
|
|
toString(inline?: boolean): string
|
|
/** Serialize this schema, preserving shared and recursive references. */
|
|
toJSON(): Schema<S, T>
|
|
/** Mark nullable input as invalid unless a default supplies a fallback. */
|
|
required(value?: boolean): Schema<S, T>
|
|
/** Hide this schema node from UI renderers. */
|
|
hidden(value?: boolean): Schema<S, T>
|
|
/** Return the default value instead of throwing when validation fails. */
|
|
loose(value?: boolean): Schema<S, T>
|
|
/** Attach a renderer role and optional role-specific metadata. */
|
|
role(text: string, extra?: any): Schema<S, T>
|
|
/** Attach an external documentation link. */
|
|
link(link: string): Schema<S, T>
|
|
/** Set the fallback value used for nullable input. */
|
|
default(value: T): Schema<S, T>
|
|
/** Attach an auxiliary comment for documentation or form UIs. */
|
|
comment(text: string): Schema<S, T>
|
|
/** Attach a localized or plain description for documentation or form UIs. */
|
|
description(text: string): Schema<S, T>
|
|
/** Mark this schema node as disabled for form UIs. */
|
|
disabled(value?: boolean): Schema<S, T>
|
|
/** Request collapsed rendering for nested form UIs. */
|
|
collapse(value?: boolean): Schema<S, T>
|
|
/** Add a deprecated badge to this schema node. */
|
|
deprecated(): Schema<S, T>
|
|
/** Add an experimental badge to this schema node. */
|
|
experimental(): Schema<S, T>
|
|
/** Require strings to match a regular expression. */
|
|
pattern(regexp: RegExp): Schema<S, T>
|
|
/** Set an inclusive maximum for numbers or collection lengths. */
|
|
max(value: number): Schema<S, T>
|
|
/** Set an inclusive minimum for numbers or collection lengths. */
|
|
min(value: number): Schema<S, T>
|
|
/** Set the numeric increment constraint. */
|
|
step(value: number): Schema<S, T>
|
|
/** Add or replace an object property schema. */
|
|
set(key: string, value: Schema): Schema<S, T>
|
|
/** Append a tuple, union, or intersection member schema. */
|
|
push(value: Schema): Schema<S, T>
|
|
/** Remove values equal to schema defaults from normalized output. */
|
|
simplify(value?: any): any
|
|
/** Return a schema clone with descriptions merged from locale messages. */
|
|
i18n(messages: Dict): Schema<S, T>
|
|
/** Attach arbitrary metadata consumed by form renderers and downstream tools. */
|
|
extra<K extends keyof Schemastery.Meta>(key: K, value: Schemastery.Meta[K]): Schema<S, T>
|
|
}
|
|
}
|
|
|
|
declare namespace globalThis {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
export let __schemastery_index__: number
|
|
export let __schemastery_refs__: Record<number, Schema> | undefined
|
|
}
|
|
|
|
globalThis.__schemastery_index__ ??= 0
|
|
globalThis.__schemastery_refs__ = undefined
|
|
|
|
class ValidationError extends TypeError {
|
|
name = 'ValidationError'
|
|
|
|
constructor(message: string, public options: Schemastery.Options) {
|
|
let prefix = '$'
|
|
for (const segment of options.path || []) {
|
|
if (typeof segment === 'string') {
|
|
prefix += '.' + segment
|
|
} else if (typeof segment === 'number') {
|
|
prefix += '[' + segment + ']'
|
|
} else if (typeof segment === 'symbol') {
|
|
prefix += `[Symbol(${segment.toString()})]`
|
|
}
|
|
}
|
|
if (prefix.startsWith('.')) prefix = prefix.slice(1)
|
|
super((prefix === '$' ? '' : `${prefix} `) + message)
|
|
}
|
|
|
|
static is(error: any): error is ValidationError {
|
|
return !!error?.[kValidationError]
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(ValidationError.prototype, kValidationError, {
|
|
value: true,
|
|
})
|
|
|
|
type Schema<S = any, T = S> = Schemastery<S, T>
|
|
|
|
const Schema = function (options: Schema) {
|
|
const schema = function (data: any, options: Schemastery.Options = {}) {
|
|
return Schema.resolve(data, schema, options)[0]
|
|
} as Schema
|
|
|
|
if (options.refs) {
|
|
const refs = valueMap(options.refs, options => new Schema(options))
|
|
const getRef = (uid: any) => refs[uid]!
|
|
for (const key in refs) {
|
|
const options = refs[key]!
|
|
options.sKey = getRef(options.sKey)
|
|
options.inner = getRef(options.inner)
|
|
options.list = options.list && options.list.map(getRef)
|
|
options.dict = options.dict && valueMap(options.dict, getRef)
|
|
}
|
|
return refs[options.uid!]
|
|
}
|
|
|
|
Object.assign(schema, options)
|
|
if (typeof schema.callback === 'string') {
|
|
try {
|
|
// eslint-disable-next-line no-new-func
|
|
schema.callback = new Function('return ' + schema.callback)()
|
|
} catch {}
|
|
}
|
|
Object.defineProperty(schema, 'uid', { value: globalThis.__schemastery_index__++ })
|
|
Object.setPrototypeOf(schema, Schema.prototype)
|
|
schema.meta ||= {}
|
|
schema.toString = schema.toString.bind(schema)
|
|
return schema
|
|
} as Schemastery.Static
|
|
|
|
Schema.prototype = Object.create(Function.prototype)
|
|
|
|
Schema.prototype[kSchema] = true
|
|
|
|
Object.defineProperty(Schema.prototype, '~standard', {
|
|
get(this: Schema) {
|
|
return {
|
|
version: 1,
|
|
vendor: 'schemastery',
|
|
validate: (value: unknown) => {
|
|
try {
|
|
return { value: Schema.resolve(value, this, {})[0] }
|
|
} catch (error) {
|
|
if (ValidationError.is(error)) {
|
|
return { issues: [{ message: error.message, path: error.options.path }] }
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
Schema.ValidationError = ValidationError
|
|
|
|
Schema.prototype.toJSON = function toJSON() {
|
|
if (globalThis.__schemastery_refs__) {
|
|
globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }))
|
|
return this.uid as any
|
|
}
|
|
|
|
globalThis.__schemastery_refs__ = { [this.uid]: { ...this } as Schema }
|
|
globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }))
|
|
const result = { uid: this.uid, refs: globalThis.__schemastery_refs__ }
|
|
globalThis.__schemastery_refs__ = undefined
|
|
return result
|
|
}
|
|
|
|
Schema.prototype.set = function set(key, value) {
|
|
this.dict![key] = value
|
|
return this
|
|
}
|
|
|
|
Schema.prototype.push = function push(value) {
|
|
this.list!.push(value)
|
|
return this
|
|
}
|
|
|
|
function mergeDesc(original: undefined | string | Dict<string>, messages: Dict) {
|
|
const result: Dict<string> = typeof original === 'string' ? { '': original } : { ...original }
|
|
for (const locale in messages) {
|
|
const value = messages[locale]
|
|
if (value?.$description || value?.$desc) {
|
|
result[locale] = value.$description || value.$desc
|
|
} else if (typeof value === 'string') {
|
|
result[locale] = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function getInner(value: any) {
|
|
return value?.$value ?? value?.$inner
|
|
}
|
|
|
|
function extractKeys(data: any) {
|
|
return filterKeys(data ?? {}, key => !key.startsWith('$'))
|
|
}
|
|
|
|
Schema.prototype.i18n = function i18n(messages) {
|
|
const schema = Schema(this)
|
|
const desc = mergeDesc(schema.meta.description, messages)
|
|
if (Object.keys(desc).length) schema.meta.description = desc
|
|
if (schema.dict) {
|
|
schema.dict = valueMap(schema.dict, (inner, key) => {
|
|
return inner.i18n(valueMap(messages, (data) => getInner(data)?.[key] ?? data?.[key]))
|
|
})
|
|
}
|
|
if (schema.list) {
|
|
schema.list = schema.list!.map((inner, index) => {
|
|
return inner.i18n(valueMap(messages, (data = {}) => {
|
|
if (Array.isArray(getInner(data))) return getInner(data)[index]
|
|
if (Array.isArray(data)) return data[index]
|
|
return extractKeys(data)
|
|
}))
|
|
})
|
|
}
|
|
if (schema.inner) {
|
|
schema.inner = schema.inner.i18n(valueMap(messages, (data) => {
|
|
if (getInner(data)) return getInner(data)
|
|
return extractKeys(data)
|
|
}))
|
|
}
|
|
if (schema.sKey) {
|
|
schema.sKey = schema.sKey.i18n(valueMap(messages, (data) => data?.$key))
|
|
}
|
|
return schema
|
|
}
|
|
|
|
Schema.prototype.extra = function extra(key, value) {
|
|
const schema = Schema(this)
|
|
schema.meta = { ...schema.meta, [key]: value }
|
|
return schema
|
|
}
|
|
|
|
for (const key of ['required', 'disabled', 'collapse', 'hidden', 'loose']) {
|
|
Object.assign(Schema.prototype, {
|
|
[key](this: Schema, value = true) {
|
|
const schema = Schema(this)
|
|
schema.meta = { ...schema.meta, [key]: value }
|
|
return schema
|
|
},
|
|
})
|
|
}
|
|
|
|
Schema.prototype.deprecated = function deprecated() {
|
|
const schema = Schema(this)
|
|
schema.meta.badges ||= []
|
|
schema.meta.badges.push({ text: 'deprecated', type: 'danger' })
|
|
return schema
|
|
}
|
|
|
|
Schema.prototype.experimental = function experimental() {
|
|
const schema = Schema(this)
|
|
schema.meta.badges ||= []
|
|
schema.meta.badges.push({ text: 'experimental', type: 'warning' })
|
|
return schema
|
|
}
|
|
|
|
Schema.prototype.pattern = function pattern(regexp) {
|
|
const schema = Schema(this)
|
|
const pattern = pick(regexp, ['source', 'flags'])
|
|
schema.meta = { ...schema.meta, pattern }
|
|
return schema
|
|
}
|
|
|
|
Schema.prototype.simplify = function simplify(this: Schema, value) {
|
|
if (deepEqual(value, this.meta.default, this.type === 'dict')) return null
|
|
if (isNullable(value)) return value
|
|
if (this.type === 'object' || this.type === 'dict') {
|
|
const result: Dict = {}
|
|
for (const key in value) {
|
|
const schema = this.type === 'object' ? this.dict![key] : this.inner
|
|
const item = schema?.simplify(value[key])
|
|
if (this.type === 'dict' || !isNullable(item)) result[key] = item
|
|
}
|
|
if (deepEqual(result, this.meta.default, this.type === 'dict')) return null
|
|
return result
|
|
} else if (this.type === 'array' || this.type === 'tuple') {
|
|
const result: any[] = []
|
|
;(value as any[]).forEach((value, index) => {
|
|
const schema = this.type === 'array' ? this.inner : this.list![index]
|
|
const item = schema ? schema.simplify(value) : value
|
|
result.push(item)
|
|
})
|
|
return result
|
|
} else if (this.type === 'intersect') {
|
|
const result: Dict = {}
|
|
for (const item of this.list!) {
|
|
Object.assign(result, item.simplify(value))
|
|
}
|
|
return result
|
|
} else if (this.type === 'union') {
|
|
for (const schema of this.list!) {
|
|
try {
|
|
Schema.resolve(value, schema, {})
|
|
return schema.simplify(value)
|
|
} catch {}
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
Schema.prototype.toString = function toString(inline?: boolean) {
|
|
return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`
|
|
}
|
|
|
|
Schema.prototype.role = function role(role, extra) {
|
|
const schema = Schema(this)
|
|
schema.meta = { ...schema.meta, role, extra }
|
|
return schema
|
|
}
|
|
|
|
for (const key of ['default', 'link', 'comment', 'description', 'max', 'min', 'step']) {
|
|
Object.assign(Schema.prototype, {
|
|
[key](this: Schema, value: any) {
|
|
const schema = Schema(this)
|
|
schema.meta = { ...schema.meta, [key]: value }
|
|
return schema
|
|
},
|
|
})
|
|
}
|
|
|
|
const resolvers: Dict<Schemastery.Resolve> = {}
|
|
|
|
Schema.extend = function extend(type, resolve) {
|
|
resolvers[type] = resolve
|
|
}
|
|
|
|
Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
|
|
if (!schema) return [data]
|
|
if (options.ignore?.(data, schema)) return [data]
|
|
|
|
if (isNullable(data) && schema.type !== 'lazy') {
|
|
if (schema.meta.required) throw new ValidationError(`missing required value`, options)
|
|
let current = schema
|
|
let fallback = schema.meta.default
|
|
while (current?.type === 'intersect' && isNullable(fallback)) {
|
|
current = current.list![0]
|
|
fallback = current?.meta.default
|
|
}
|
|
if (isNullable(fallback)) return [data]
|
|
data = clone(fallback)
|
|
}
|
|
|
|
const callback = resolvers[schema.type]
|
|
if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options)
|
|
|
|
try {
|
|
return callback(data, schema, options, strict)
|
|
} catch (error) {
|
|
if (!schema.meta.loose) throw error
|
|
return [schema.meta.default]
|
|
}
|
|
}
|
|
|
|
Schema.from = function from(source: any) {
|
|
if (isNullable(source)) {
|
|
return Schema.any()
|
|
} else if (['string', 'number', 'boolean'].includes(typeof source)) {
|
|
return Schema.const(source).required()
|
|
} else if (source[kSchema]) {
|
|
return source
|
|
} else if (typeof source === 'function') {
|
|
switch (source) {
|
|
case String: return Schema.string().required()
|
|
case Number: return Schema.number().required()
|
|
case Boolean: return Schema.boolean().required()
|
|
case Function: return Schema.function().required()
|
|
default: return Schema.is(source).required()
|
|
}
|
|
} else {
|
|
throw new TypeError(`cannot infer schema from ${source}`)
|
|
}
|
|
}
|
|
|
|
Schema.lazy = function lazy(builder) {
|
|
const toJSON = () => {
|
|
if (!schema.inner![kSchema]) {
|
|
schema.inner = schema.builder!()
|
|
schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
|
|
}
|
|
return schema.inner!.toJSON()
|
|
}
|
|
const schema = new Schema({ type: 'lazy', builder, inner: { toJSON } as any })
|
|
return schema as any
|
|
}
|
|
|
|
Schema.natural = function natural() {
|
|
return Schema.number().step(1).min(0)
|
|
}
|
|
|
|
Schema.percent = function percent() {
|
|
return Schema.number().step(0.01).min(0).max(1).role('slider')
|
|
}
|
|
|
|
Schema.date = function date() {
|
|
return Schema.union([
|
|
Schema.is(Date),
|
|
Schema.transform(Schema.string().role('datetime'), (value, options) => {
|
|
const date = new Date(value)
|
|
if (isNaN(+date)) throw new ValidationError(`invalid date "${value}"`, options)
|
|
return date
|
|
}, true),
|
|
])
|
|
}
|
|
|
|
Schema.regExp = function regExp(flag = '') {
|
|
return Schema.union([
|
|
Schema.is(RegExp),
|
|
Schema.transform(Schema.string().role('regexp', { flag }), (value, options) => {
|
|
try {
|
|
return new RegExp(value, flag)
|
|
} catch (e: any) {
|
|
throw new ValidationError(e.message, options)
|
|
}
|
|
}, true),
|
|
])
|
|
}
|
|
|
|
Schema.arrayBuffer = function arrayBuffer(encoding?: 'hex' | 'base64'): any {
|
|
return Schema.union([
|
|
Schema.is(ArrayBuffer),
|
|
Schema.is(SharedArrayBuffer),
|
|
Schema.transform(Schema.any<ArrayBufferView>(), (value, options) => {
|
|
if (Binary.isSource(value)) return Binary.fromSource(value)
|
|
throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options)
|
|
}, true),
|
|
...encoding ? [Schema.transform(Schema.string(), (value, options) => {
|
|
try {
|
|
return encoding === 'base64'
|
|
? Binary.fromBase64(value)
|
|
: Binary.fromHex(value)
|
|
} catch (e: any) {
|
|
throw new ValidationError(e.message, options)
|
|
}
|
|
}, true)] as const : [],
|
|
])
|
|
}
|
|
|
|
Schema.extend('lazy', (data, schema, options, strict) => {
|
|
if (!schema.inner![kSchema]) {
|
|
schema.inner = schema.builder!()
|
|
schema.inner!.meta = { ...schema.meta, ...schema.inner!.meta }
|
|
}
|
|
return Schema.resolve(data, schema.inner!, options, strict)
|
|
})
|
|
|
|
Schema.extend('any', (data) => {
|
|
return [data]
|
|
})
|
|
|
|
Schema.extend('never', (data, _, options) => {
|
|
throw new ValidationError(`expected nullable but got ${data}`, options)
|
|
})
|
|
|
|
Schema.extend('const', (data, { value }, options) => {
|
|
if (deepEqual(data, value)) return [value]
|
|
throw new ValidationError(`expected ${value} but got ${data}`, options)
|
|
})
|
|
|
|
function checkWithinRange(data: number, meta: Schemastery.Meta<any>, description: string, options: Schemastery.Options, skipMin = false) {
|
|
const { max = Infinity, min = -Infinity } = meta
|
|
if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options)
|
|
if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options)
|
|
}
|
|
|
|
Schema.extend('string', (data, { meta }, options) => {
|
|
if (typeof data !== 'string') throw new ValidationError(`expected string but got ${data}`, options)
|
|
if (meta.pattern) {
|
|
const regexp = new RegExp(meta.pattern.source, meta.pattern.flags)
|
|
if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options)
|
|
}
|
|
checkWithinRange(data.length, meta, 'string length', options)
|
|
return [data]
|
|
})
|
|
|
|
function decimalShift(data: number, digits: number) {
|
|
const str = data.toString()
|
|
if (str.includes('e')) return data * Math.pow(10, digits)
|
|
const index = str.indexOf('.')
|
|
if (index === -1) return data * Math.pow(10, digits)
|
|
const frac = str.slice(index + 1)
|
|
const integer = str.slice(0, index)
|
|
if (frac.length <= digits) return +(integer + frac.padEnd(digits, '0'))
|
|
return +(integer + frac.slice(0, digits) + '.' + frac.slice(digits))
|
|
}
|
|
|
|
function isMultipleOf(data: number, min: number, step: number) {
|
|
step = Math.abs(step)
|
|
if (!/^\d+\.\d+$/.test(step.toString())) {
|
|
return (data - min) % step === 0
|
|
}
|
|
const index = step.toString().indexOf('.')
|
|
const digits = step.toString().slice(index + 1).length
|
|
return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0
|
|
}
|
|
|
|
Schema.extend('number', (data, { meta }, options) => {
|
|
if (typeof data !== 'number') throw new ValidationError(`expected number but got ${data}`, options)
|
|
checkWithinRange(data, meta, 'number', options)
|
|
const { step } = meta
|
|
if (step && !isMultipleOf(data, meta.min ?? 0, step)) {
|
|
throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options)
|
|
}
|
|
return [data]
|
|
})
|
|
|
|
Schema.extend('boolean', (data, _, options) => {
|
|
if (typeof data === 'boolean') return [data]
|
|
throw new ValidationError(`expected boolean but got ${data}`, options)
|
|
})
|
|
|
|
Schema.extend('bitset', (data, { bits, meta }, options) => {
|
|
let value = 0, keys: string[] = []
|
|
if (typeof data === 'number') {
|
|
value = data
|
|
for (const key in bits!) {
|
|
if (data & bits![key]!) {
|
|
keys.push(key)
|
|
}
|
|
}
|
|
} else if (Array.isArray(data)) {
|
|
keys = data
|
|
for (const key of keys) {
|
|
if (typeof key !== 'string') throw new ValidationError(`expected string but got ${key}`, options)
|
|
if (key in bits!) value |= bits![key]!
|
|
}
|
|
} else {
|
|
throw new ValidationError(`expected number or array but got ${data}`, options)
|
|
}
|
|
if (value === meta.default) return [value]
|
|
return [value, keys]
|
|
})
|
|
|
|
Schema.extend('function', (data, _, options) => {
|
|
if (typeof data === 'function') return [data]
|
|
throw new ValidationError(`expected function but got ${data}`, options)
|
|
})
|
|
|
|
Schema.extend('is', (data, { constructor }, options) => {
|
|
if (typeof constructor === 'function') {
|
|
if (data instanceof constructor) return [data]
|
|
throw new ValidationError(`expected ${constructor.name} but got ${data}`, options)
|
|
} else {
|
|
if (isNullable(data)) {
|
|
throw new ValidationError(`expected ${constructor} but got ${data}`, options)
|
|
}
|
|
let prototype = Object.getPrototypeOf(data)
|
|
while (prototype) {
|
|
if (prototype.constructor?.name === constructor) return [data]
|
|
prototype = Object.getPrototypeOf(prototype)
|
|
}
|
|
throw new ValidationError(`expected ${constructor} but got ${data}`, options)
|
|
}
|
|
})
|
|
|
|
function property(data: any, key: keyof any, schema: Schema, options: Schemastery.Options) {
|
|
try {
|
|
const [value, adapted] = Schema.resolve(data[key], schema, {
|
|
...options,
|
|
path: [...options.path || [], key],
|
|
})
|
|
if (adapted !== undefined) data[key] = adapted
|
|
return value
|
|
} catch (e) {
|
|
if (!options?.autofix) throw e
|
|
delete data[key]
|
|
return schema.meta.default
|
|
}
|
|
}
|
|
|
|
Schema.extend('array', (data, { inner, meta }, options) => {
|
|
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options)
|
|
checkWithinRange(data.length, meta, 'array length', options, !isNullable(inner!.meta.default))
|
|
return [data.map((_, index) => property(data, index, inner!, options))]
|
|
})
|
|
|
|
Schema.extend('dict', (data, { inner, sKey }, options, strict) => {
|
|
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options)
|
|
const result: any = {}
|
|
for (const key in data) {
|
|
let rKey: string
|
|
try {
|
|
rKey = Schema.resolve(key, sKey!, options)[0]
|
|
} catch (error) {
|
|
if (strict) continue
|
|
throw error
|
|
}
|
|
result[rKey] = property(data, key, inner!, options)
|
|
data[rKey] = data[key]
|
|
if (key !== rKey) delete data[key]
|
|
}
|
|
return [result]
|
|
})
|
|
|
|
Schema.extend('tuple', (data, { list }, options, strict) => {
|
|
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options)
|
|
const result = list!.map((inner, index) => property(data, index, inner, options))
|
|
if (strict) return [result]
|
|
result.push(...data.slice(list!.length))
|
|
return [result]
|
|
})
|
|
|
|
function merge(result: any, data: any) {
|
|
for (const key in data) {
|
|
if (key in result) continue
|
|
result[key] = data[key]
|
|
}
|
|
}
|
|
|
|
Schema.extend('object', (data, { dict }, options, strict) => {
|
|
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options)
|
|
const result: any = {}
|
|
for (const key in dict) {
|
|
const value = property(data, key, dict![key]!, options)
|
|
if (!isNullable(value) || key in data) {
|
|
result[key] = value
|
|
}
|
|
}
|
|
if (!strict) merge(result, data)
|
|
return [result]
|
|
})
|
|
|
|
Schema.extend('union', (data, { list, toString }, options, strict) => {
|
|
const messages: any[] = []
|
|
for (const inner of list!) {
|
|
try {
|
|
return Schema.resolve(data, inner, options, strict)
|
|
} catch (error) {
|
|
messages.push(error)
|
|
}
|
|
}
|
|
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
|
|
})
|
|
|
|
Schema.extend('intersect', (data, { list, toString }, options, strict) => {
|
|
if (!list!.length) return [data]
|
|
let result
|
|
for (const inner of list!) {
|
|
const value: any = Schema.resolve(data, inner, options, true)[0]
|
|
if (isNullable(value)) continue
|
|
if (isNullable(result)) {
|
|
result = value
|
|
} else if (typeof result !== typeof value) {
|
|
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
|
|
} else if (typeof value === 'object') {
|
|
merge(result ??= {}, value)
|
|
} else if (result !== value) {
|
|
throw new ValidationError(`expected ${toString()} but got ${JSON.stringify(data)}`, options)
|
|
}
|
|
}
|
|
if (!strict && isPlainObject(data)) merge(result, data)
|
|
return [result]
|
|
})
|
|
|
|
Schema.extend('transform', (data, { inner, callback, preserve }, options) => {
|
|
const [result, adapted = data] = Schema.resolve(data, inner!, options, true)
|
|
if (preserve) {
|
|
return [callback!(result)]
|
|
// } else if (isPlainObject(data)) {
|
|
// const temp: any = {}
|
|
// for (const key in result) {
|
|
// if (!(key in data)) continue
|
|
// temp[key] = data[key]
|
|
// delete data[key]
|
|
// }
|
|
// Object.assign(data, callback!(temp))
|
|
// return [callback!(result)]
|
|
} else {
|
|
return [callback!(result), callback!(adapted)]
|
|
}
|
|
})
|
|
|
|
type Formatter = (schema: Schema, inline?: boolean) => string
|
|
const formatters: Dict<Formatter> = {}
|
|
|
|
function defineMethod(name: string, keys: (keyof Schema)[], format: Formatter) {
|
|
formatters[name] = format
|
|
Object.assign(Schema, {
|
|
[name](...args: any[]) {
|
|
const schema = new Schema({ type: name } as Schema)
|
|
keys.forEach((key, index) => {
|
|
switch (key) {
|
|
case 'sKey': schema.sKey = args[index] ?? Schema.string(); break
|
|
case 'inner': schema.inner = Schema.from(args[index]); break
|
|
case 'list': schema.list = args[index].map(Schema.from); break
|
|
case 'dict': schema.dict = valueMap(args[index], Schema.from); break
|
|
case 'bits': {
|
|
schema.bits = {}
|
|
for (const key in args[index]) {
|
|
if (typeof args[index][key] !== 'number') continue
|
|
schema.bits[key] = args[index][key]
|
|
}
|
|
break
|
|
}
|
|
case 'callback': {
|
|
const callback = schema.callback = args[index]
|
|
;callback['toJSON'] ||= () => callback.toString()
|
|
break
|
|
}
|
|
case 'constructor': {
|
|
const constructor = schema.constructor = args[index]
|
|
if (typeof constructor === 'function') {
|
|
;constructor['toJSON'] ||= () => constructor['name']
|
|
}
|
|
break
|
|
}
|
|
default: schema[key] = args[index] as never
|
|
}
|
|
})
|
|
if (name === 'object' || name === 'dict') {
|
|
schema.meta.default = {}
|
|
} else if (name === 'array' || name === 'tuple') {
|
|
schema.meta.default = []
|
|
} else if (name === 'bitset') {
|
|
schema.meta.default = 0
|
|
}
|
|
return schema
|
|
},
|
|
})
|
|
}
|
|
|
|
defineMethod('is', ['constructor'], ({ constructor }) => {
|
|
if (typeof constructor === 'function') {
|
|
return constructor.name
|
|
} else {
|
|
return constructor!
|
|
}
|
|
})
|
|
|
|
defineMethod('any', [], () => 'any')
|
|
defineMethod('never', [], () => 'never')
|
|
defineMethod('const', ['value'], ({ value }) => typeof value === 'string' ? JSON.stringify(value) : value)
|
|
defineMethod('string', [], () => 'string')
|
|
defineMethod('number', [], () => 'number')
|
|
defineMethod('boolean', [], () => 'boolean')
|
|
defineMethod('bitset', ['bits'], () => 'bitset')
|
|
defineMethod('function', [], () => 'function')
|
|
defineMethod('array', ['inner'], ({ inner }) => `${inner!.toString(true)}[]`)
|
|
defineMethod('dict', ['inner', 'sKey'], ({ inner, sKey }) => `{ [key: ${sKey!.toString()}]: ${inner!.toString()} }`)
|
|
defineMethod('tuple', ['list'], ({ list }) => `[${list!.map((inner) => inner.toString()).join(', ')}]`)
|
|
|
|
defineMethod('object', ['dict'], ({ dict }) => {
|
|
if (Object.keys(dict!).length === 0) return '{}'
|
|
return `{ ${Object.entries(dict!).map(([key, inner]) => {
|
|
return `${key}${inner!.meta.required ? '' : '?'}: ${inner!.toString()}`
|
|
}).join(', ')} }`
|
|
})
|
|
|
|
defineMethod('union', ['list'], ({ list }, inline) => {
|
|
const result = list!.map(({ toString: format }) => format()).join(' | ')
|
|
return inline ? `(${result})` : result
|
|
})
|
|
|
|
defineMethod('intersect', ['list'], ({ list }) => {
|
|
return `${list!.map((inner) => inner.toString(true)).join(' & ')}`
|
|
})
|
|
|
|
defineMethod('transform', ['inner', 'callback', 'preserve'], ({ inner }, isInner) => inner!.toString(isInner))
|
|
|
|
export default Schema
|