UNPKG

@naturalcycles/nodejs-lib

Version:
1,676 lines (1,441 loc) 66.6 kB
import type { ValidationFunction, ValidationFunctionResult } from '@naturalcycles/js-lib' import { _isObject, _isUndefined, _numberEnumValues, _stringEnumValues, getEnumType, } from '@naturalcycles/js-lib' import { _uniq } from '@naturalcycles/js-lib/array' import { _assert, _try } from '@naturalcycles/js-lib/error' import type { Set2 } from '@naturalcycles/js-lib/object' import { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-lib/object' import { _substringBefore } from '@naturalcycles/js-lib/string' import type { AnyObject, BaseDBEntity, IANATimezone, Inclusiveness, IsoDate, IsoDateTime, IsoMonth, NumberEnum, StringEnum, StringMap, UnixTimestamp, UnixTimestampMillis, } from '@naturalcycles/js-lib/types' import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types' import type { StandardJSONSchemaV1, StandardSchemaV1 } from '@standard-schema/spec' import type { Ajv, ErrorObject } from 'ajv' import { _inspect } from '../../string/inspect.js' import { BASE64URL_REGEX, COUNTRY_CODE_REGEX, CURRENCY_REGEX, IPV4_REGEX, IPV6_REGEX, LANGUAGE_TAG_REGEX, SEMVER_REGEX, SLUG_REGEX, URL_REGEX, UUID_REGEX, } from '../regexes.js' import { TIMEZONES } from '../timezones.js' import { AjvValidationError } from './ajvValidationError.js' import { getAjv } from './getAjv.js' import { isEveryItemNumber, isEveryItemPrimitive, isEveryItemString, JSON_SCHEMA_ORDER, mergeJsonSchemaObjects, } from './jsonSchemaBuilder.util.js' // ==== j (factory object) ==== export const j = { /** * Matches literally any value - equivalent to TypeScript's `any` type. * Use sparingly, as it bypasses type validation entirely. */ any(): JBuilder<any, false> { return new JBuilder({}) }, string(): JString<string, false> { return new JString() }, number(): JNumber<number, false> { return new JNumber() }, boolean(): JBoolean<boolean, false> { return new JBoolean() }, object: Object.assign(object, { dbEntity: objectDbEntity, infer: objectInfer, any() { return j.object<AnyObject>({}).allowAdditionalProperties() }, stringMap<S extends JSchema<any, any>>(schema: S): JObject<StringMap<SchemaOut<S>>> { const isValueOptional = schema.getSchema().optionalField const builtSchema = schema.build() const finalValueSchema: JsonSchema = isValueOptional ? { anyOf: [{ isUndefined: true }, builtSchema] } : builtSchema return new JObject<StringMap<SchemaOut<S>>>( {}, { hasIsOfTypeCheck: false, patternProperties: { '^.+$': finalValueSchema, }, }, ) }, /** * @experimental Look around, maybe you find a rule that is better for your use-case. * * For Record<K, V> type of validations. * ```ts * const schema = j.object * .record( * j * .string() * .regex(/^\d{3,4}$/) * .branded<B>(), * j.number().nullable(), * ) * .isOfType<Record<B, number | null>>() * ``` * * When the keys of the Record are values from an Enum, prefer `j.object.withEnumKeys`! * * Non-matching keys will be stripped from the object, i.e. they will not cause an error. * * Caveat: This rule first validates values of every properties of the object, and only then validates the keys. * A consequence of that is that the validation will throw when there is an unexpected property with a value not matching the value schema. */ record, /** * For Record<ENUM, V> type of validations. * * When the keys of the Record are values from an Enum, * this helper is more performant and behaves in a more conventional manner than `j.object.record` would. * * */ withEnumKeys, withRegexKeys, /** * Validates that the value is an instance of the given class/constructor. * * ```ts * j.object.instanceOf(Date) // typed as Date * j.object.instanceOf(Date).optional() // typed as Date | undefined * ``` */ instanceOf<T>(ctor: new (...args: any[]) => T): JBuilder<T, false> { return new JBuilder<T, false>({ type: 'object', instanceof: ctor.name, hasIsOfTypeCheck: true, }) }, }), array<OUT, Opt>(itemSchema: JSchema<OUT, Opt>): JArray<OUT, Opt> { return new JArray(itemSchema) }, tuple<const S extends JSchema<any, any>[]>(items: S): JTuple<S> { return new JTuple<S>(items) }, set<OUT, Opt>(itemSchema: JSchema<OUT, Opt>): JSet2Builder<OUT, Opt> { return new JSet2Builder(itemSchema) }, buffer(): JBuilder<Buffer, false> { return new JBuilder<Buffer, false>({ Buffer: true, }) }, enum<const T extends readonly (string | number | boolean | null)[] | StringEnum | NumberEnum>( input: T, opt?: JsonBuilderRuleOpt, ): JEnum< T extends readonly (infer U)[] ? U : T extends StringEnum ? T[keyof T] : T extends NumberEnum ? T[keyof T] : never > { let enumValues: readonly (string | number | boolean | null)[] | undefined let baseType: EnumBaseType = 'other' if (Array.isArray(input)) { enumValues = input if (isEveryItemNumber(input)) { baseType = 'number' } else if (isEveryItemString(input)) { baseType = 'string' } } else if (typeof input === 'object') { const enumType = getEnumType(input) if (enumType === 'NumberEnum') { enumValues = _numberEnumValues(input as NumberEnum) baseType = 'number' } else if (enumType === 'StringEnum') { enumValues = _stringEnumValues(input as StringEnum) baseType = 'string' } } _assert(enumValues, 'Unsupported enum input') return new JEnum(enumValues as any, baseType, opt) }, /** * Use only with primitive values, otherwise this function will throw to avoid bugs. * To validate objects, use `anyOfBy`. * * Our Ajv is configured to strip unexpected properties from objects, * and since Ajv is mutating the input, this means that it cannot * properly validate the same data over multiple schemas. * * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format). * Use `oneOf` when schemas are mutually exclusive. */ oneOf<B extends readonly JSchema<any, boolean>[]>( items: [...B], ): JBuilder<BuilderOutUnion<B>, false> { const schemas = items.map(b => b.build()) _assert( schemas.every(hasNoObjectSchemas), 'Do not use `oneOf` validation with non-primitive types!', ) return new JBuilder<BuilderOutUnion<B>, false>({ oneOf: schemas, }) }, /** * Use only with primitive values, otherwise this function will throw to avoid bugs. * To validate objects, use `anyOfBy` or `anyOfThese`. * * Our Ajv is configured to strip unexpected properties from objects, * and since Ajv is mutating the input, this means that it cannot * properly validate the same data over multiple schemas. * * Use `anyOf` when schemas may overlap (e.g., AccountId | PartnerId with same format). * Use `oneOf` when schemas are mutually exclusive. */ anyOf<B extends readonly JSchema<any, boolean>[]>( items: [...B], ): JBuilder<BuilderOutUnion<B>, false> { const schemas = items.map(b => b.build()) _assert( schemas.every(hasNoObjectSchemas), 'Do not use `anyOf` validation with non-primitive types!', ) return new JBuilder<BuilderOutUnion<B>, false>({ anyOf: schemas, }) }, /** * Pick validation schema for an object based on the value of a specific property. * * ``` * const schemaMap = { * true: successSchema, * false: errorSchema * } * * const schema = j.anyOfBy('success', schemaMap) * ``` */ anyOfBy<D extends Record<PropertyKey, JSchema<any, any>>>( propertyName: string, schemaDictionary: D, ): JBuilder<AnyOfByOut<D>, false> { const builtSchemaDictionary: Record<string, JsonSchema> = {} for (const [key, schema] of Object.entries(schemaDictionary)) { builtSchemaDictionary[key] = schema.build() } return new JBuilder<AnyOfByOut<D>, false>({ type: 'object', hasIsOfTypeCheck: true, anyOfBy: { propertyName, schemaDictionary: builtSchemaDictionary, }, }) }, /** * Custom version of `anyOf` which - in contrast to the original function - does not mutate the input. * This comes with a performance penalty, so do not use it where performance matters. * * ``` * const schema = j.anyOfThese([successSchema, errorSchema]) * ``` */ anyOfThese<B extends readonly JSchema<any, boolean>[]>( items: [...B], ): JBuilder<BuilderOutUnion<B>, false> { return new JBuilder<BuilderOutUnion<B>, false>({ anyOfThese: items.map(b => b.build()), }) }, and() { return { silentBob: () => { throw new Error('...strike back!') }, } }, literal<const V extends string | number | boolean | null>(v: V) { let baseType: EnumBaseType = 'other' if (typeof v === 'string') baseType = 'string' if (typeof v === 'number') baseType = 'number' return new JEnum<V>([v], baseType) }, /** * Create a JSchema from a plain JsonSchema object. * Useful when the schema is loaded from a JSON file or generated externally. * * Optionally accepts a custom Ajv instance and/or inputName for error messages. */ fromSchema<OUT>( schema: JsonSchema<OUT>, cfg?: { ajv?: Ajv; inputName?: string }, ): JSchema<OUT, false> { return new JSchema<OUT, false>(schema, cfg) }, } // ==== Symbol for caching compiled AjvSchema ==== export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA') export type WithCachedAjvSchema<Base, OUT> = Base & { [HIDDEN_AJV_SCHEMA]: AjvSchema<OUT> } // ==== JSchema (locked base) ==== /* Notes for future reference Q: Why do we need `Opt` - when `IN` and `OUT` already carries the `| undefined`? A: Because of objects. Without `Opt`, an optional field would be inferred as `{ foo: string | undefined }`, which means that the `foo` property would be mandatory, it's just that its value can be `undefined` as well. With `Opt`, we can infer it as `{ foo?: string | undefined }`. */ export class JSchema<OUT, Opt> implements StandardSchemaV1<unknown, OUT>, StandardJSONSchemaV1<unknown, OUT> { protected [HIDDEN_AJV_SCHEMA]: AjvSchema<any> | undefined protected schema: JsonSchema private _cfg?: { ajv?: Ajv; inputName?: string } constructor(schema: JsonSchema, cfg?: { ajv?: Ajv; inputName?: string }) { this.schema = schema this._cfg = cfg } private _builtSchema?: JsonSchema private _compiledFns?: WeakMap<Ajv, any> private _getBuiltSchema(): JsonSchema { if (!this._builtSchema) { const builtSchema = this.build() if (this instanceof JBuilder) { _assert( builtSchema.type !== 'object' || builtSchema.hasIsOfTypeCheck, 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.', ) } delete builtSchema.optionalField this._builtSchema = builtSchema } return this._builtSchema } private _getCompiled(overrideAjv?: Ajv): { fn: any; builtSchema: JsonSchema } { const builtSchema = this._getBuiltSchema() const ajv = overrideAjv ?? this._cfg?.ajv ?? getAjv() this._compiledFns ??= new WeakMap() let fn = this._compiledFns.get(ajv) if (!fn) { fn = ajv.compile(builtSchema as any) this._compiledFns.set(ajv, fn) // Cache AjvSchema wrapper for HIDDEN_AJV_SCHEMA backward compat (default ajv only) if (!overrideAjv) { this[HIDDEN_AJV_SCHEMA] = AjvSchema._wrap<any>(builtSchema, fn) } } return { fn, builtSchema } } getSchema(): JsonSchema { return this.schema } /** * @deprecated * The usage of this function is discouraged as it defeats the purpose of having type-safe validation. */ castAs<T>(): JSchema<T, Opt> { return this as unknown as JSchema<T, Opt> } /** * A helper function that takes a type parameter and compares it with the type inferred from the schema. * * When the type inferred from the schema differs from the passed-in type, * the schema becomes unusable, by turning its type into `never`. */ isOfType<ExpectedType>(): ExactMatch<ExpectedType, OUT> extends true ? this : never { return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }) as any } /** * Produces a "clean schema object" without methods. * Same as if it would be JSON.stringified. */ build(): JsonSchema<OUT> { _assert( !(this.schema.optionalField && this.schema.default !== undefined), '.optional() and .default() should not be used together - the default value makes .optional() redundant and causes incorrect type inference', ) const jsonSchema = _sortObject( deepCopyPreservingFunctions(this.schema) as AnyObject, JSON_SCHEMA_ORDER, ) as JsonSchema<OUT> delete jsonSchema.optionalField return jsonSchema } clone(): this { const cloned = Object.create(Object.getPrototypeOf(this)) cloned.schema = deepCopyPreservingFunctions(this.schema) cloned._cfg = this._cfg return cloned } cloneAndUpdateSchema(schema: Partial<JsonSchema>): this { const clone = this.clone() _objectAssign(clone.schema, schema) return clone } get ['~standard'](): StandardSchemaV1.Props<unknown, OUT> & StandardJSONSchemaV1.Props<unknown, OUT> { const value: StandardSchemaV1.Props<unknown, OUT> & StandardJSONSchemaV1.Props<unknown, OUT> = { version: 1, vendor: 'j', validate: v => { const [err, output] = this.getValidationResult(v) if (err) { // todo: make getValidationResult return issues with path, so we can pass the path here too return { issues: [{ message: err.message }] } } return { value: output } }, jsonSchema: { input: () => this.build() as Record<string, unknown>, output: () => this.build() as Record<string, unknown>, }, } Object.defineProperty(this, '~standard', { value }) return value } validate(input: unknown, opt?: AjvValidationOptions): OUT { const [err, output] = this.getValidationResult(input, opt) if (err) throw err return output } isValid(input: unknown, opt?: AjvValidationOptions): boolean { const [err] = this.getValidationResult(input, opt) return !err } getValidationResult( input: unknown, opt: AjvValidationOptions = {}, ): ValidationFunctionResult<OUT, AjvValidationError> { const { fn, builtSchema } = this._getCompiled(opt.ajv) const inputName = this._cfg?.inputName || (builtSchema.$id ? _substringBefore(builtSchema.$id, '.') : undefined) return executeValidation<OUT>(fn, builtSchema, input, opt, inputName) } getValidationFunction( opt: AjvValidationOptions = {}, ): ValidationFunction<OUT, AjvValidationError> { return (input, opt2) => { return this.getValidationResult(input, { ajv: opt.ajv, mutateInput: opt2?.mutateInput ?? opt.mutateInput, inputName: opt2?.inputName ?? opt.inputName, inputId: opt2?.inputId ?? opt.inputId, }) } } /** * Specify a function to be called after the normal validation is finished. * * This function will receive the validated, type-safe data, and you can use it * to do further validations, e.g. conditional validations based on certain property values, * or to do data modifications either by mutating the input or returning a new value. * * If you throw an error from this function, it will show up as an error in the validation. */ postValidation<OUT2 = OUT>(fn: PostValidatonFn<OUT, OUT2>): JSchema<OUT2, Opt> { const clone = this.cloneAndUpdateSchema({ postValidation: fn, }) return clone as unknown as JSchema<OUT2, Opt> } /** * @experimental */ out!: OUT opt!: Opt /** Forces OUT to be invariant (prevents covariant subtype matching in object property constraints). */ declare protected _invariantOut: (x: OUT) => void } // ==== JBuilder (chainable base) ==== export class JBuilder<OUT, Opt> extends JSchema<OUT, Opt> { protected setErrorMessage(ruleName: string, errorMessage: string | undefined): void { if (_isUndefined(errorMessage)) return this.schema.errorMessages ||= {} this.schema.errorMessages[ruleName] = errorMessage } /** * @deprecated * The usage of this function is discouraged as it defeats the purpose of having type-safe validation. */ override castAs<T>(): JBuilder<T, Opt> { return this as unknown as JBuilder<T, Opt> } $schema($schema: string): this { return this.cloneAndUpdateSchema({ $schema }) } $schemaDraft7(): this { return this.$schema('http://json-schema.org/draft-07/schema#') } $id($id: string): this { return this.cloneAndUpdateSchema({ $id }) } title(title: string): this { return this.cloneAndUpdateSchema({ title }) } description(description: string): this { return this.cloneAndUpdateSchema({ description }) } deprecated(deprecated = true): this { return this.cloneAndUpdateSchema({ deprecated }) } type(type: string): this { return this.cloneAndUpdateSchema({ type }) } default(v: any): this { return this.cloneAndUpdateSchema({ default: v }) } instanceof(of: string): this { return this.cloneAndUpdateSchema({ type: 'object', instanceof: of }) } /** * @param optionalValues List of values that should be considered/converted as `undefined`. * * This `optionalValues` feature only works when the current schema is nested in an object or array schema, * due to how mutability works in Ajv. * * Make sure this `optional()` call is at the end of your call chain. * * When `null` is included in optionalValues, the return type becomes `JSchema` * (no further chaining allowed) because the schema is wrapped in an anyOf structure. */ optional<T extends readonly (string | number | boolean | null)[] | undefined = undefined>( optionalValues?: T, ): T extends readonly (string | number | boolean | null)[] ? JSchema<OUT | undefined, true> : JBuilder<OUT | undefined, true> { if (!optionalValues?.length) { const clone = this.cloneAndUpdateSchema({ optionalField: true }) return clone as any } const builtSchema = this.build() // When optionalValues is just [null], use a simple null-wrapping structure. // If the schema already has anyOf with a null branch (from nullable()), // inject optionalValues directly into it. if (optionalValues.length === 1 && optionalValues[0] === null) { if (builtSchema.anyOf) { const nullBranch = builtSchema.anyOf.find(b => b.type === 'null') if (nullBranch) { nullBranch.optionalValues = [null] return new JSchema({ ...builtSchema, optionalField: true }) as any } } // Wrap with null type branch return new JSchema({ anyOf: [{ type: 'null', optionalValues: [null] }, builtSchema], optionalField: true, }) as any } // General case: create anyOf with current schema + alternatives. // Preserve the original type for Ajv strict mode (optionalValues keyword requires a type). const alternativesSchema = j.enum(optionalValues).build() const innerSchema: JsonSchema = { ...(builtSchema.type ? { type: builtSchema.type } : {}), anyOf: [builtSchema, alternativesSchema], optionalValues: [...optionalValues], } // When `null` is specified, we want `null` to be stripped and the value to become `undefined`, // so we must allow `null` values to be parsed by Ajv, // but the typing should not reflect that. if (optionalValues.includes(null)) { return new JSchema({ anyOf: [{ type: 'null', optionalValues: [...optionalValues] }, innerSchema], optionalField: true, }) as any } return new JSchema({ ...innerSchema, optionalField: true }) as any } nullable(): JBuilder<OUT | null, Opt> { return new JBuilder({ anyOf: [this.build(), { type: 'null' }], }) } /** * Locks the given schema chain and no other modification can be done to it. */ final(): JSchema<OUT, Opt> { return new JSchema<OUT, Opt>(this.schema) } /** * * @param validator A validator function that returns an error message or undefined. * * You may add multiple custom validators and they will be executed in the order you added them. */ custom<OUT2 = OUT>(validator: CustomValidatorFn): JBuilder<OUT2, Opt> { const { customValidations = [] } = this.schema return this.cloneAndUpdateSchema({ customValidations: [...customValidations, validator], }) as unknown as JBuilder<OUT2, Opt> } /** * * @param converter A converter function that returns a new value. * * You may add multiple converters and they will be executed in the order you added them, * each converter receiving the result from the previous one. * * This feature only works when the current schema is nested in an object or array schema, * due to how mutability works in Ajv. */ convert<OUT2>(converter: CustomConverterFn<OUT2>): JBuilder<OUT2, Opt> { const { customConversions = [] } = this.schema return this.cloneAndUpdateSchema({ customConversions: [...customConversions, converter], }) as unknown as JBuilder<OUT2, Opt> } } // ==== Consts const TS_2500 = 16725225600 // 2500-01-01 const TS_2500_MILLIS = TS_2500 * 1000 const TS_2000 = 946684800 // 2000-01-01 const TS_2000_MILLIS = TS_2000 * 1000 // ==== Type-specific builders ==== export class JString< OUT extends string | undefined = string, Opt extends boolean = false, > extends JBuilder<OUT, Opt> { constructor() { super({ type: 'string', }) } regex(pattern: RegExp, opt?: JsonBuilderRuleOpt): this { _assert( !pattern.flags, `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`, ) return this.pattern(pattern.source, opt) } pattern(pattern: string, opt?: JsonBuilderRuleOpt): this { const clone = this.cloneAndUpdateSchema({ pattern }) if (opt?.name) clone.setErrorMessage('pattern', `is not a valid ${opt.name}`) if (opt?.msg) clone.setErrorMessage('pattern', opt.msg) return clone } minLength(minLength: number): this { return this.cloneAndUpdateSchema({ minLength }) } maxLength(maxLength: number): this { return this.cloneAndUpdateSchema({ maxLength }) } length(exactLength: number): this length(minLength: number, maxLength: number): this length(minLengthOrExactLength: number, maxLength?: number): this { const maxLengthActual = maxLength ?? minLengthOrExactLength return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual) } email(opt?: Partial<JsonSchemaStringEmailOptions>): this { const defaultOptions: JsonSchemaStringEmailOptions = { checkTLD: true } return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } }) .trim() .toLowerCase() } trim(): this { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } }) } toLowerCase(): this { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, toLowerCase: true }, }) } toUpperCase(): this { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, toUpperCase: true }, }) } truncate(toLength: number): this { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, truncate: toLength }, }) } branded<B extends string>(): JString<B, Opt> { return this as unknown as JString<B, Opt> } /** * Validates that the input is a fully-specified YYYY-MM-DD formatted valid IsoDate value. * * All previous expectations in the schema chain are dropped - including `.optional()` - * because this call effectively starts a new schema chain. */ isoDate(): JIsoDate { return new JIsoDate() } isoDateTime(): JString<IsoDateTime, Opt> { return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded<IsoDateTime>() } isoMonth(): JBuilder<IsoMonth, false> { return new JBuilder<IsoMonth, false>({ type: 'string', IsoMonth: {}, }) } /** * Validates the string format to be JWT. * Expects the JWT to be signed! */ jwt(): this { return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' }) } url(): this { return this.regex(URL_REGEX, { msg: 'is not a valid URL format' }) } ipv4(): this { return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' }) } ipv6(): this { return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' }) } slug(): this { return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' }) } semVer(): this { return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' }) } languageTag(): this { return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' }) } countryCode(): this { return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' }) } currency(): this { return this.regex(CURRENCY_REGEX, { msg: 'is not a valid currency format' }) } /** * Validates that the input is a valid IANATimzone value. * * All previous expectations in the schema chain are dropped - including `.optional()` - * because this call effectively starts a new schema chain as an `enum` validation. */ ianaTimezone(): JEnum<IANATimezone, false> { // UTC is added to assist unit-testing, which uses UTC by default (not technically a valid Iana timezone identifier) return j.enum(TIMEZONES, { msg: 'is an invalid IANA timezone' }).branded<IANATimezone>() } base64Url(): this { return this.regex(BASE64URL_REGEX, { msg: 'contains characters not allowed in Base64 URL characterset', }) } uuid(): this { return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' }) } } export interface JsonSchemaStringEmailOptions { checkTLD: boolean } export class JIsoDate<Opt extends boolean = false> extends JBuilder<IsoDate, Opt> { constructor() { super({ type: 'string', IsoDate: {}, }) } before(date: string): this { return this.cloneAndUpdateSchema({ IsoDate: { before: date } }) } sameOrBefore(date: string): this { return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } }) } after(date: string): this { return this.cloneAndUpdateSchema({ IsoDate: { after: date } }) } sameOrAfter(date: string): this { return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } }) } between(fromDate: string, toDate: string, incl: Inclusiveness): this { let schemaPatch: Partial<JsonSchema> = {} if (incl === '[)') { schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } } } else if (incl === '[]') { schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } } } return this.cloneAndUpdateSchema(schemaPatch) } } export interface JsonSchemaIsoDateOptions { before?: string sameOrBefore?: string after?: string sameOrAfter?: string } export interface JsonSchemaIsoMonthOptions {} export class JNumber< OUT extends number | undefined = number, Opt extends boolean = false, > extends JBuilder<OUT, Opt> { constructor() { super({ type: 'number', }) } integer(): this { return this.cloneAndUpdateSchema({ type: 'integer' }) } branded<B extends number>(): JNumber<B, Opt> { return this as unknown as JNumber<B, Opt> } multipleOf(multipleOf: number): this { return this.cloneAndUpdateSchema({ multipleOf }) } min(minimum: number): this { return this.cloneAndUpdateSchema({ minimum }) } exclusiveMin(exclusiveMinimum: number): this { return this.cloneAndUpdateSchema({ exclusiveMinimum }) } max(maximum: number): this { return this.cloneAndUpdateSchema({ maximum }) } exclusiveMax(exclusiveMaximum: number): this { return this.cloneAndUpdateSchema({ exclusiveMaximum }) } lessThan(value: number): this { return this.exclusiveMax(value) } lessThanOrEqual(value: number): this { return this.max(value) } moreThan(value: number): this { return this.exclusiveMin(value) } moreThanOrEqual(value: number): this { return this.min(value) } equal(value: number): this { return this.min(value).max(value) } range(minimum: number, maximum: number, incl: Inclusiveness): this { if (incl === '[)') { return this.moreThanOrEqual(minimum).lessThan(maximum) } return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum) } int32(): this { const MIN_INT32 = -(2 ** 31) const MAX_INT32 = 2 ** 31 - 1 const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER const newMin = Math.max(MIN_INT32, currentMin) const newMax = Math.min(MAX_INT32, currentMax) return this.integer().min(newMin).max(newMax) } int64(): this { const currentMin = this.schema.minimum ?? Number.MIN_SAFE_INTEGER const currentMax = this.schema.maximum ?? Number.MAX_SAFE_INTEGER const newMin = Math.max(Number.MIN_SAFE_INTEGER, currentMin) const newMax = Math.min(Number.MAX_SAFE_INTEGER, currentMax) return this.integer().min(newMin).max(newMax) } float(): this { return this } double(): this { return this } unixTimestamp(): JNumber<UnixTimestamp, Opt> { return this.integer().min(0).max(TS_2500).branded<UnixTimestamp>() } unixTimestamp2000(): JNumber<UnixTimestamp, Opt> { return this.integer().min(TS_2000).max(TS_2500).branded<UnixTimestamp>() } unixTimestampMillis(): JNumber<UnixTimestampMillis, Opt> { return this.integer().min(0).max(TS_2500_MILLIS).branded<UnixTimestampMillis>() } unixTimestamp2000Millis(): JNumber<UnixTimestampMillis, Opt> { return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded<UnixTimestampMillis>() } utcOffset(): this { return this.integer() .multipleOf(15) .min(-12 * 60) .max(14 * 60) } utcOffsetHour(): this { return this.integer().min(-12).max(14) } /** * Specify the precision of the floating point numbers by the number of digits after the ".". * Excess digits will be cut-off when the current schema is nested in an object or array schema, * due to how mutability works in Ajv. */ precision(numberOfDigits: number): this { return this.cloneAndUpdateSchema({ precision: numberOfDigits }) } } export class JBoolean< OUT extends boolean | undefined = boolean, Opt extends boolean = false, > extends JBuilder<OUT, Opt> { constructor() { super({ type: 'boolean', }) } } export class JObject<OUT extends AnyObject, Opt extends boolean = false> extends JBuilder< OUT, Opt > { constructor(props?: AnyObject, opt?: JObjectOpts) { super({ type: 'object', properties: {}, required: [], additionalProperties: false, hasIsOfTypeCheck: opt?.hasIsOfTypeCheck ?? true, patternProperties: opt?.patternProperties ?? undefined, keySchema: opt?.keySchema ?? undefined, }) if (props) addPropertiesToSchema(this.schema, props) } /** * When set, the validation will not strip away properties that are not specified explicitly in the schema. */ allowAdditionalProperties(): this { return this.cloneAndUpdateSchema({ additionalProperties: true }) } extend<P extends Record<string, JSchema<any, any>>>( props: P, ): JObject< Expand< Override< OUT, { // required keys [K in keyof P as P[K] extends JSchema<any, infer IsOpt> ? IsOpt extends true ? never : K : never]: P[K] extends JSchema<infer OUT2, any> ? OUT2 : never } & { // optional keys [K in keyof P as P[K] extends JSchema<any, infer IsOpt> ? IsOpt extends true ? K : never : never]?: P[K] extends JSchema<infer OUT2, any> ? OUT2 : never } > >, false > { const newBuilder = new JObject() _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema)) const incomingSchemaBuilder = new JObject(props) mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any) _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false }) return newBuilder as any } /** * Concatenates another schema to the current schema. * * It expects you to use `isOfType<T>()` in the chain, * otherwise the validation will throw. This is to ensure * that the schemas you concatenated match the intended final type. */ concat<OUT2 extends AnyObject>(other: JObject<OUT2, any>): JObject<OUT & OUT2, false> { const clone = this.clone() mergeJsonSchemaObjects(clone.schema as any, other.schema as any) _objectAssign(clone.schema, { hasIsOfTypeCheck: false }) return clone as unknown as JObject<OUT & OUT2, false> } /** * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions. */ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type dbEntity() { return this.extend({ id: j.string(), created: j.number().unixTimestamp2000(), updated: j.number().unixTimestamp2000(), }) } minProperties(minProperties: number): this { return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties }) } maxProperties(maxProperties: number): this { return this.cloneAndUpdateSchema({ maxProperties }) } exclusiveProperties(propNames: readonly (keyof OUT & string)[]): this { const exclusiveProperties = this.schema.exclusiveProperties ?? [] return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] }) } } interface JObjectOpts { hasIsOfTypeCheck?: false patternProperties?: StringMap<JsonSchema<any>> keySchema?: JsonSchema } export class JObjectInfer< PROPS extends Record<string, JSchema<any, any>>, Opt extends boolean = false, > extends JBuilder< Expand< { [K in keyof PROPS as PROPS[K] extends JSchema<any, infer IsOpt> ? IsOpt extends true ? never : K : never]: PROPS[K] extends JSchema<infer OUT, any> ? OUT : never } & { [K in keyof PROPS as PROPS[K] extends JSchema<any, infer IsOpt> ? IsOpt extends true ? K : never : never]?: PROPS[K] extends JSchema<infer OUT, any> ? OUT : never } >, Opt > { constructor(props?: PROPS) { super({ type: 'object', properties: {}, required: [], additionalProperties: false, }) if (props) addPropertiesToSchema(this.schema, props) } /** * When set, the validation will not strip away properties that are not specified explicitly in the schema. */ allowAdditionalProperties(): this { return this.cloneAndUpdateSchema({ additionalProperties: true }) } extend<NEW_PROPS extends Record<string, JSchema<any, any>>>( props: NEW_PROPS, ): JObjectInfer< { [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS ? NEW_PROPS[K] : K extends keyof PROPS ? PROPS[K] : never }, Opt > { const newBuilder = new JObjectInfer<PROPS, Opt>() _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema)) const incomingSchemaBuilder = new JObjectInfer<NEW_PROPS, false>(props) mergeJsonSchemaObjects(newBuilder.schema as any, incomingSchemaBuilder.schema as any) // This extend function is not type-safe as it is inferring, // so even if the base schema was already type-checked, // the new schema loses that quality. _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false }) return newBuilder as unknown as JObjectInfer< { [K in keyof PROPS | keyof NEW_PROPS]: K extends keyof NEW_PROPS ? NEW_PROPS[K] : K extends keyof PROPS ? PROPS[K] : never }, Opt > } /** * Extends the current schema with `id`, `created` and `updated` according to NC DB conventions. */ // oxlint-disable-next-line @typescript-eslint/explicit-function-return-type dbEntity() { return this.extend({ id: j.string(), created: j.number().unixTimestamp2000(), updated: j.number().unixTimestamp2000(), }) } } export class JArray<OUT, Opt> extends JBuilder<OUT[], Opt> { constructor(itemsSchema: JSchema<OUT, Opt>) { super({ type: 'array', items: itemsSchema.build(), }) } minLength(minItems: number): this { return this.cloneAndUpdateSchema({ minItems }) } maxLength(maxItems: number): this { return this.cloneAndUpdateSchema({ maxItems }) } length(exactLength: number): this length(minItems: number, maxItems: number): this length(minItemsOrExact: number, maxItems?: number): this { const maxItemsActual = maxItems ?? minItemsOrExact return this.minLength(minItemsOrExact).maxLength(maxItemsActual) } exactLength(length: number): this { return this.minLength(length).maxLength(length) } unique(): this { return this.cloneAndUpdateSchema({ uniqueItems: true }) } } class JSet2Builder<OUT, Opt> extends JBuilder<Set2<OUT>, Opt> { constructor(itemsSchema: JSchema<OUT, Opt>) { super({ type: ['array', 'object'], Set2: itemsSchema.build(), }) } min(minItems: number): this { return this.cloneAndUpdateSchema({ minItems }) } max(maxItems: number): this { return this.cloneAndUpdateSchema({ maxItems }) } } export class JEnum< OUT extends string | number | boolean | null, Opt extends boolean = false, > extends JBuilder<OUT, Opt> { constructor(enumValues: readonly OUT[], baseType: EnumBaseType, opt?: JsonBuilderRuleOpt) { const jsonSchema: JsonSchema = { enum: enumValues } // Specifying the base type helps in cases when we ask Ajv to coerce the types. // Having only the `enum` in the schema does not trigger a coercion in Ajv. if (baseType === 'string') jsonSchema.type = 'string' if (baseType === 'number') jsonSchema.type = 'number' super(jsonSchema) if (opt?.name) this.setErrorMessage('pattern', `is not a valid ${opt.name}`) if (opt?.msg) this.setErrorMessage('enum', opt.msg) } branded<B extends OUT>(): JEnum<B, Opt> { return this as unknown as JEnum<B, Opt> } } export class JTuple<ITEMS extends JSchema<any, any>[]> extends JBuilder<TupleOut<ITEMS>, false> { constructor(items: ITEMS) { super({ type: 'array', prefixItems: items.map(i => i.build()), minItems: items.length, maxItems: items.length, }) } } // ==== Standalone functions for j.object ==== function object(props: AnyObject): never function object<OUT extends AnyObject>( props: [keyof OUT] extends [never] ? Record<string, never> : { [K in keyof Required<OUT>]-?: JSchema<OUT[K], any> }, ): [keyof OUT] extends [never] ? never : JObject<OUT, false> function object<OUT extends AnyObject>(props: { [key in keyof OUT]: JSchema<OUT[key], any> }): JObject<OUT, false> { return new JObject<OUT, false>(props) } function objectInfer<P extends Record<string, JSchema<any, any>>>( props: P, ): JObjectInfer<P, false> { return new JObjectInfer<P, false>(props) } function objectDbEntity(props: AnyObject): never function objectDbEntity< OUT extends BaseDBEntity, EXTRA_KEYS extends Exclude<keyof OUT, keyof BaseDBEntity> = Exclude< keyof OUT, keyof BaseDBEntity >, >( props: { // ✅ all non-system fields must be explicitly provided [K in EXTRA_KEYS]-?: BuilderFor<OUT[K]> } & // ✅ if `id` differs, it's required (ExactMatch<OUT['id'], BaseDBEntity['id']> extends true ? { id?: BuilderFor<BaseDBEntity['id']> } : { id: BuilderFor<OUT['id']> }) & (ExactMatch<OUT['created'], BaseDBEntity['created']> extends true ? { created?: BuilderFor<BaseDBEntity['created']> } : { created: BuilderFor<OUT['created']> }) & (ExactMatch<OUT['updated'], BaseDBEntity['updated']> extends true ? { updated?: BuilderFor<BaseDBEntity['updated']> } : { updated: BuilderFor<OUT['updated']> }), ): JObject<OUT, false> function objectDbEntity(props: AnyObject): any { return j.object({ id: j.string(), created: j.number().unixTimestamp2000(), updated: j.number().unixTimestamp2000(), ...props, }) } function record< KS extends JSchema<any, any>, VS extends JSchema<any, any>, Opt extends boolean = SchemaOpt<VS>, >( keySchema: KS, valueSchema: VS, ): SchemaOut<KS> extends string ? JObject< Opt extends true ? Partial<Record<SchemaOut<KS>, SchemaOut<VS>>> : Record<SchemaOut<KS>, SchemaOut<VS>>, false > : never { const keyJsonSchema = keySchema.build() _assert( keyJsonSchema.type !== 'number' && keyJsonSchema.type !== 'integer', 'record() key schema must validate strings, not numbers. JSON object keys are always strings.', ) // Check if value schema is optional before build() strips the optionalField flag const isValueOptional = (valueSchema as JSchema<any, any>).getSchema().optionalField const valueJsonSchema = valueSchema.build() // When value schema is optional, wrap in anyOf to allow undefined values const finalValueSchema: JsonSchema = isValueOptional ? { anyOf: [{ isUndefined: true }, valueJsonSchema] } : valueJsonSchema return new JObject([], { hasIsOfTypeCheck: false, keySchema: keyJsonSchema, patternProperties: { ['^.*$']: finalValueSchema, }, }) as any } function withRegexKeys<S extends JSchema<any, any>>( keyRegex: RegExp | string, schema: S, ): JObject<StringMap<SchemaOut<S>>, false> { if (keyRegex instanceof RegExp) { _assert( !keyRegex.flags, `Regex flags are not supported by JSON Schema. Received: /${keyRegex.source}/${keyRegex.flags}`, ) } const pattern = keyRegex instanceof RegExp ? keyRegex.source : keyRegex const jsonSchema = schema.build() return new JObject<StringMap<SchemaOut<S>>, false>([], { hasIsOfTypeCheck: false, patternProperties: { [pattern]: jsonSchema, }, }) } /** * Builds the object schema with the indicated `keys` and uses the `schema` for their validation. */ function withEnumKeys< const T extends readonly (string | number)[] | StringEnum | NumberEnum, S extends JSchema<any, any>, K extends string | number = EnumKeyUnion<T>, Opt extends boolean = SchemaOpt<S>, >( keys: T, schema: S, ): JObject<Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> }, false> { let enumValues: readonly (string | number)[] | undefined if (Array.isArray(keys)) { _assert( isEveryItemPrimitive(keys), 'Every item in the key list should be string, number or symbol', ) enumValues = keys } else if (typeof keys === 'object') { const enumType = getEnumType(keys) _assert( enumType === 'NumberEnum' || enumType === 'StringEnum', 'The key list should be StringEnum or NumberEnum', ) if (enumType === 'NumberEnum') { enumValues = _numberEnumValues(keys as NumberEnum) } else if (enumType === 'StringEnum') { enumValues = _stringEnumValues(keys as StringEnum) } } _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum') const typedValues = enumValues as readonly K[] const props = Object.fromEntries(typedValues.map(key => [key, schema])) as any return new JObject< Opt extends true ? { [P in K]?: SchemaOut<S> } : { [P in K]: SchemaOut<S> }, false >(props, { hasIsOfTypeCheck: false }) } // ==== AjvSchema compat wrapper ==== /** * On creation - compiles ajv validation function. * Provides convenient methods, error reporting, etc. */ export class AjvSchema<OUT> { private constructor( public schema: JsonSchema<OUT>, cfg: Partial<AjvSchemaCfg> = {}, preCompiledFn?: any, ) { this.cfg = { lazy: false, ...cfg, ajv: cfg.ajv || getAjv(), // Auto-detecting "InputName" from $id of the schema (e.g "Address.schema.json") inputName: cfg.inputName || (schema.$id ? _substringBefore(schema.$id, '.') : undefined), } if (preCompiledFn) { this._compiledFn = preCompiledFn } else if (!cfg.lazy) { this._getValidateFn() // compile eagerly } } /** * Shortcut for AjvSchema.create(schema, { lazy: true }) */ static createLazy<OUT>( schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>, ): AjvSchema<OUT> { return AjvSchema.create(schema, { lazy: true, ...cfg, }) } /** * Conveniently allows to pass either JsonSchema or JSchema builder, or existing AjvSchema. * If it's already an AjvSchema - it'll just return it without any processing. * If it's a Builder - will call `build` before proceeding. * Otherwise - will construct AjvSchema instance ready to be used. */ static create<OUT>(schema: SchemaHandledByAjv<OUT>, cfg?: Partial<AjvSchemaCfg>): AjvSchema<OUT> { if (schema instanceof AjvSchema) return schema if (AjvSchema.isSchemaWithCachedAjvSchema<typeof schema, OUT>(schema)) { return AjvSchema.requireCachedAjvSchema<typeof schema, OUT>(schema) } let jsonSchema: JsonSchema<OUT> if (schema instanceof JSchema) { jsonSchema = schema.build() AjvSchema.requireValidJsonSchema(jsonSchema) } else { jsonSchema = schema } // This is our own helper which marks a schema as optional // in case it is going to be used in an object schema, // where we need to mark the given property as not-required. // But once all compilation is done, the presence of this field // really upsets Ajv. delete jsonSchema.optionalField const ajvSchema = new AjvSchema<OUT>(jsonSchema, cfg) AjvSchema.cacheAjvSchema(schema, ajvSchema) return ajvSchema } /** * Creates a minimal AjvSchema wrapper from a pre-compiled validate function. * Used internally by JSchema to cache a compatible AjvSchema instance. */ static _wrap<OUT>(schema: JsonSchema<OUT>, compiledFn: any): AjvSchema<OUT> { return new AjvSchema<OUT>(schema, {}, compiledFn) } static isSchemaWithCachedAjvSchema<Base, OUT>( schema: Base, ): schema is WithCachedAjvSchema<Base, OUT> { return !!(schema as any)?.[HIDDEN_AJV_SCHEMA] } static cacheAjvSchema<Base extends AnyObject, OUT>( schema: Base, ajvSchema: AjvSchema<OUT>, ): WithCachedAjvSchema<Base, OUT> { return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema }) } static requireCachedAjvSchema<Base, OUT>(schema: WithCachedAjvSchema<Base, OUT>): AjvSchema<OUT> { return schema[HIDDEN_AJV_SCHEMA] } readonly cfg: AjvSchemaCfg private _compiledFn: any private _getValidateFn(): any { if (!this._compiledFn) { this._compiledFn = this.cfg.ajv.compile(this.schema as any) } return this._compiledFn } /** * It returns the original object just for convenience. */ validate(input: unknown, opt: AjvValidationOptions = {}): OUT { const [err, output] = this.getValidationResult(input, opt) if (err) throw err return output } isValid(input: unknown, opt?: AjvValidationOptions): boolean { const [err] = this.getValidationResult(input, opt) return !err } getValidationResult( input: unknown, opt: AjvValidationOptions = {}, ): ValidationFunctionResult<OUT, AjvValidationError> { const fn = this._getValidateFn() return executeValidation<OUT>(fn, this.schema, input, opt, this.cfg.inputName) } getValidationFunction(): ValidationFunction<OUT, AjvValidationError> { return (input, opt) => { return this.getValidationResult(input, { mutateInput: opt?.mutateInput, inputName: opt?.inputName, inputId: opt?.inputId, }) } } private static requireValidJsonSchema(schema: JsonSchema): void { // For object schemas we require that it is type checked against an external type, e.g.: // interface Foo { name: string } // const schema = j.object({ name: j.string() }).ofType<Foo>() _assert( schema.type !== 'object' || schema.hasIsOfTypeCheck, 'The schema must be type checked against a type or interface, using the `.isOfType()` helper in `j`.', ) } } // ==== Shared validation logic ==== const separator = '\n' function executeValidation<OUT>( fn: any, builtSchema: JsonSchema, input: unknown, opt: AjvValidationOptions = {}, defaultInputName?: string, ): ValidationFunctionResult<OUT, AjvValidationError> { const item = opt.mutateInput !== false || typeof input !== 'object' ? input // mutate : _deepCopy(input) // not mutate let valid = fn(item) // mutates item, but not input _typeCast<OUT>(item) let output: OUT = item if (valid && builtSchema.postValidation) { const [err, result] = _try(() => builtSchema.postValidation!(output)) if (err) { valid = false fn.errors = [ {