UNPKG

@naturalcycles/nodejs-lib

Version:
1,388 lines (1,387 loc) 50.6 kB
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 { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-lib/object'; import { _substringBefore } from '@naturalcycles/js-lib/string'; import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types'; 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() { return new JBuilder({}); }, string() { return new JString(); }, number() { return new JNumber(); }, boolean() { return new JBoolean(); }, object: Object.assign(object, { dbEntity: objectDbEntity, infer: objectInfer, any() { return j.object({}).allowAdditionalProperties(); }, stringMap(schema) { const isValueOptional = schema.getSchema().optionalField; const builtSchema = schema.build(); const finalValueSchema = isValueOptional ? { anyOf: [{ isUndefined: true }, builtSchema] } : builtSchema; return new JObject({}, { 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(ctor) { return new JBuilder({ type: 'object', instanceof: ctor.name, hasIsOfTypeCheck: true, }); }, }), array(itemSchema) { return new JArray(itemSchema); }, tuple(items) { return new JTuple(items); }, set(itemSchema) { return new JSet2Builder(itemSchema); }, buffer() { return new JBuilder({ Buffer: true, }); }, enum(input, opt) { let enumValues; let baseType = '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); baseType = 'number'; } else if (enumType === 'StringEnum') { enumValues = _stringEnumValues(input); baseType = 'string'; } } _assert(enumValues, 'Unsupported enum input'); return new JEnum(enumValues, 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(items) { const schemas = items.map(b => b.build()); _assert(schemas.every(hasNoObjectSchemas), 'Do not use `oneOf` validation with non-primitive types!'); return new JBuilder({ 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(items) { const schemas = items.map(b => b.build()); _assert(schemas.every(hasNoObjectSchemas), 'Do not use `anyOf` validation with non-primitive types!'); return new JBuilder({ 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(propertyName, schemaDictionary) { const builtSchemaDictionary = {}; for (const [key, schema] of Object.entries(schemaDictionary)) { builtSchemaDictionary[key] = schema.build(); } return new JBuilder({ 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(items) { return new JBuilder({ anyOfThese: items.map(b => b.build()), }); }, and() { return { silentBob: () => { throw new Error('...strike back!'); }, }; }, literal(v) { let baseType = 'other'; if (typeof v === 'string') baseType = 'string'; if (typeof v === 'number') baseType = 'number'; return new JEnum([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(schema, cfg) { return new JSchema(schema, cfg); }, }; // ==== Symbol for caching compiled AjvSchema ==== export const HIDDEN_AJV_SCHEMA = Symbol('HIDDEN_AJV_SCHEMA'); // ==== 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 { [HIDDEN_AJV_SCHEMA]; schema; _cfg; constructor(schema, cfg) { this.schema = schema; this._cfg = cfg; } _builtSchema; _compiledFns; _getBuiltSchema() { 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; } _getCompiled(overrideAjv) { 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); 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(builtSchema, fn); } } return { fn, builtSchema }; } getSchema() { return this.schema; } /** * @deprecated * The usage of this function is discouraged as it defeats the purpose of having type-safe validation. */ castAs() { return this; } /** * 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() { return this.cloneAndUpdateSchema({ hasIsOfTypeCheck: true }); } /** * Produces a "clean schema object" without methods. * Same as if it would be JSON.stringified. */ build() { _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), JSON_SCHEMA_ORDER); delete jsonSchema.optionalField; return jsonSchema; } clone() { const cloned = Object.create(Object.getPrototypeOf(this)); cloned.schema = deepCopyPreservingFunctions(this.schema); cloned._cfg = this._cfg; return cloned; } cloneAndUpdateSchema(schema) { const clone = this.clone(); _objectAssign(clone.schema, schema); return clone; } get ['~standard']() { const value = { 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(), output: () => this.build(), }, }; Object.defineProperty(this, '~standard', { value }); return value; } validate(input, opt) { const [err, output] = this.getValidationResult(input, opt); if (err) throw err; return output; } isValid(input, opt) { const [err] = this.getValidationResult(input, opt); return !err; } getValidationResult(input, opt = {}) { const { fn, builtSchema } = this._getCompiled(opt.ajv); const inputName = this._cfg?.inputName || (builtSchema.$id ? _substringBefore(builtSchema.$id, '.') : undefined); return executeValidation(fn, builtSchema, input, opt, inputName); } getValidationFunction(opt = {}) { 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(fn) { const clone = this.cloneAndUpdateSchema({ postValidation: fn, }); return clone; } /** * @experimental */ out; opt; } // ==== JBuilder (chainable base) ==== export class JBuilder extends JSchema { setErrorMessage(ruleName, errorMessage) { 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. */ castAs() { return this; } $schema($schema) { return this.cloneAndUpdateSchema({ $schema }); } $schemaDraft7() { return this.$schema('http://json-schema.org/draft-07/schema#'); } $id($id) { return this.cloneAndUpdateSchema({ $id }); } title(title) { return this.cloneAndUpdateSchema({ title }); } description(description) { return this.cloneAndUpdateSchema({ description }); } deprecated(deprecated = true) { return this.cloneAndUpdateSchema({ deprecated }); } type(type) { return this.cloneAndUpdateSchema({ type }); } default(v) { return this.cloneAndUpdateSchema({ default: v }); } instanceof(of) { 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(optionalValues) { if (!optionalValues?.length) { const clone = this.cloneAndUpdateSchema({ optionalField: true }); return clone; } 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 }); } } // Wrap with null type branch return new JSchema({ anyOf: [{ type: 'null', optionalValues: [null] }, builtSchema], optionalField: true, }); } // 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 = { ...(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, }); } return new JSchema({ ...innerSchema, optionalField: true }); } nullable() { return new JBuilder({ anyOf: [this.build(), { type: 'null' }], }); } /** * Locks the given schema chain and no other modification can be done to it. */ final() { return new JSchema(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(validator) { const { customValidations = [] } = this.schema; return this.cloneAndUpdateSchema({ customValidations: [...customValidations, validator], }); } /** * * @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(converter) { const { customConversions = [] } = this.schema; return this.cloneAndUpdateSchema({ customConversions: [...customConversions, converter], }); } } // ==== 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 extends JBuilder { constructor() { super({ type: 'string', }); } regex(pattern, opt) { _assert(!pattern.flags, `Regex flags are not supported by JSON Schema. Received: /${pattern.source}/${pattern.flags}`); return this.pattern(pattern.source, opt); } pattern(pattern, opt) { 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) { return this.cloneAndUpdateSchema({ minLength }); } maxLength(maxLength) { return this.cloneAndUpdateSchema({ maxLength }); } length(minLengthOrExactLength, maxLength) { const maxLengthActual = maxLength ?? minLengthOrExactLength; return this.minLength(minLengthOrExactLength).maxLength(maxLengthActual); } email(opt) { const defaultOptions = { checkTLD: true }; return this.cloneAndUpdateSchema({ email: { ...defaultOptions, ...opt } }) .trim() .toLowerCase(); } trim() { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, trim: true } }); } toLowerCase() { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, toLowerCase: true }, }); } toUpperCase() { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, toUpperCase: true }, }); } truncate(toLength) { return this.cloneAndUpdateSchema({ transform: { ...this.schema.transform, truncate: toLength }, }); } branded() { return this; } /** * 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() { return new JIsoDate(); } isoDateTime() { return this.cloneAndUpdateSchema({ IsoDateTime: true }).branded(); } isoMonth() { return new JBuilder({ type: 'string', IsoMonth: {}, }); } /** * Validates the string format to be JWT. * Expects the JWT to be signed! */ jwt() { return this.regex(JWT_REGEX, { msg: 'is not a valid JWT format' }); } url() { return this.regex(URL_REGEX, { msg: 'is not a valid URL format' }); } ipv4() { return this.regex(IPV4_REGEX, { msg: 'is not a valid IPv4 format' }); } ipv6() { return this.regex(IPV6_REGEX, { msg: 'is not a valid IPv6 format' }); } slug() { return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' }); } semVer() { return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' }); } languageTag() { return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' }); } countryCode() { return this.regex(COUNTRY_CODE_REGEX, { msg: 'is not a valid country code format' }); } currency() { 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() { // 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(); } base64Url() { return this.regex(BASE64URL_REGEX, { msg: 'contains characters not allowed in Base64 URL characterset', }); } uuid() { return this.regex(UUID_REGEX, { msg: 'is an invalid UUID' }); } } export class JIsoDate extends JBuilder { constructor() { super({ type: 'string', IsoDate: {}, }); } before(date) { return this.cloneAndUpdateSchema({ IsoDate: { before: date } }); } sameOrBefore(date) { return this.cloneAndUpdateSchema({ IsoDate: { sameOrBefore: date } }); } after(date) { return this.cloneAndUpdateSchema({ IsoDate: { after: date } }); } sameOrAfter(date) { return this.cloneAndUpdateSchema({ IsoDate: { sameOrAfter: date } }); } between(fromDate, toDate, incl) { let schemaPatch = {}; if (incl === '[)') { schemaPatch = { IsoDate: { sameOrAfter: fromDate, before: toDate } }; } else if (incl === '[]') { schemaPatch = { IsoDate: { sameOrAfter: fromDate, sameOrBefore: toDate } }; } return this.cloneAndUpdateSchema(schemaPatch); } } export class JNumber extends JBuilder { constructor() { super({ type: 'number', }); } integer() { return this.cloneAndUpdateSchema({ type: 'integer' }); } branded() { return this; } multipleOf(multipleOf) { return this.cloneAndUpdateSchema({ multipleOf }); } min(minimum) { return this.cloneAndUpdateSchema({ minimum }); } exclusiveMin(exclusiveMinimum) { return this.cloneAndUpdateSchema({ exclusiveMinimum }); } max(maximum) { return this.cloneAndUpdateSchema({ maximum }); } exclusiveMax(exclusiveMaximum) { return this.cloneAndUpdateSchema({ exclusiveMaximum }); } lessThan(value) { return this.exclusiveMax(value); } lessThanOrEqual(value) { return this.max(value); } moreThan(value) { return this.exclusiveMin(value); } moreThanOrEqual(value) { return this.min(value); } equal(value) { return this.min(value).max(value); } range(minimum, maximum, incl) { if (incl === '[)') { return this.moreThanOrEqual(minimum).lessThan(maximum); } return this.moreThanOrEqual(minimum).lessThanOrEqual(maximum); } int32() { 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() { 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() { return this; } double() { return this; } unixTimestamp() { return this.integer().min(0).max(TS_2500).branded(); } unixTimestamp2000() { return this.integer().min(TS_2000).max(TS_2500).branded(); } unixTimestampMillis() { return this.integer().min(0).max(TS_2500_MILLIS).branded(); } unixTimestamp2000Millis() { return this.integer().min(TS_2000_MILLIS).max(TS_2500_MILLIS).branded(); } utcOffset() { return this.integer() .multipleOf(15) .min(-12 * 60) .max(14 * 60); } utcOffsetHour() { 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) { return this.cloneAndUpdateSchema({ precision: numberOfDigits }); } } export class JBoolean extends JBuilder { constructor() { super({ type: 'boolean', }); } } export class JObject extends JBuilder { constructor(props, opt) { 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() { return this.cloneAndUpdateSchema({ additionalProperties: true }); } extend(props) { const newBuilder = new JObject(); _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema)); const incomingSchemaBuilder = new JObject(props); mergeJsonSchemaObjects(newBuilder.schema, incomingSchemaBuilder.schema); _objectAssign(newBuilder.schema, { hasIsOfTypeCheck: false }); return newBuilder; } /** * 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(other) { const clone = this.clone(); mergeJsonSchemaObjects(clone.schema, other.schema); _objectAssign(clone.schema, { hasIsOfTypeCheck: false }); return clone; } /** * 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) { return this.cloneAndUpdateSchema({ minProperties, minProperties2: minProperties }); } maxProperties(maxProperties) { return this.cloneAndUpdateSchema({ maxProperties }); } exclusiveProperties(propNames) { const exclusiveProperties = this.schema.exclusiveProperties ?? []; return this.cloneAndUpdateSchema({ exclusiveProperties: [...exclusiveProperties, propNames] }); } } export class JObjectInfer extends JBuilder { constructor(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() { return this.cloneAndUpdateSchema({ additionalProperties: true }); } extend(props) { const newBuilder = new JObjectInfer(); _objectAssign(newBuilder.schema, deepCopyPreservingFunctions(this.schema)); const incomingSchemaBuilder = new JObjectInfer(props); mergeJsonSchemaObjects(newBuilder.schema, incomingSchemaBuilder.schema); // 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; } /** * 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 extends JBuilder { constructor(itemsSchema) { super({ type: 'array', items: itemsSchema.build(), }); } minLength(minItems) { return this.cloneAndUpdateSchema({ minItems }); } maxLength(maxItems) { return this.cloneAndUpdateSchema({ maxItems }); } length(minItemsOrExact, maxItems) { const maxItemsActual = maxItems ?? minItemsOrExact; return this.minLength(minItemsOrExact).maxLength(maxItemsActual); } exactLength(length) { return this.minLength(length).maxLength(length); } unique() { return this.cloneAndUpdateSchema({ uniqueItems: true }); } } class JSet2Builder extends JBuilder { constructor(itemsSchema) { super({ type: ['array', 'object'], Set2: itemsSchema.build(), }); } min(minItems) { return this.cloneAndUpdateSchema({ minItems }); } max(maxItems) { return this.cloneAndUpdateSchema({ maxItems }); } } export class JEnum extends JBuilder { constructor(enumValues, baseType, opt) { const 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() { return this; } } export class JTuple extends JBuilder { constructor(items) { super({ type: 'array', prefixItems: items.map(i => i.build()), minItems: items.length, maxItems: items.length, }); } } function object(props) { return new JObject(props); } function objectInfer(props) { return new JObjectInfer(props); } function objectDbEntity(props) { return j.object({ id: j.string(), created: j.number().unixTimestamp2000(), updated: j.number().unixTimestamp2000(), ...props, }); } function record(keySchema, valueSchema) { 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.getSchema().optionalField; const valueJsonSchema = valueSchema.build(); // When value schema is optional, wrap in anyOf to allow undefined values const finalValueSchema = isValueOptional ? { anyOf: [{ isUndefined: true }, valueJsonSchema] } : valueJsonSchema; return new JObject([], { hasIsOfTypeCheck: false, keySchema: keyJsonSchema, patternProperties: { ['^.*$']: finalValueSchema, }, }); } function withRegexKeys(keyRegex, schema) { 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([], { hasIsOfTypeCheck: false, patternProperties: { [pattern]: jsonSchema, }, }); } /** * Builds the object schema with the indicated `keys` and uses the `schema` for their validation. */ function withEnumKeys(keys, schema) { let enumValues; 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); } else if (enumType === 'StringEnum') { enumValues = _stringEnumValues(keys); } } _assert(enumValues, 'The key list should be an array of values, NumberEnum or a StringEnum'); const typedValues = enumValues; const props = Object.fromEntries(typedValues.map(key => [key, schema])); return new JObject(props, { hasIsOfTypeCheck: false }); } // ==== AjvSchema compat wrapper ==== /** * On creation - compiles ajv validation function. * Provides convenient methods, error reporting, etc. */ export class AjvSchema { schema; constructor(schema, cfg = {}, preCompiledFn) { this.schema = schema; 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(schema, cfg) { 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(schema, cfg) { if (schema instanceof AjvSchema) return schema; if (AjvSchema.isSchemaWithCachedAjvSchema(schema)) { return AjvSchema.requireCachedAjvSchema(schema); } let jsonSchema; 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(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(schema, compiledFn) { return new AjvSchema(schema, {}, compiledFn); } static isSchemaWithCachedAjvSchema(schema) { return !!schema?.[HIDDEN_AJV_SCHEMA]; } static cacheAjvSchema(schema, ajvSchema) { return Object.assign(schema, { [HIDDEN_AJV_SCHEMA]: ajvSchema }); } static requireCachedAjvSchema(schema) { return schema[HIDDEN_AJV_SCHEMA]; } cfg; _compiledFn; _getValidateFn() { if (!this._compiledFn) { this._compiledFn = this.cfg.ajv.compile(this.schema); } return this._compiledFn; } /** * It returns the original object just for convenience. */ validate(input, opt = {}) { const [err, output] = this.getValidationResult(input, opt); if (err) throw err; return output; } isValid(input, opt) { const [err] = this.getValidationResult(input, opt); return !err; } getValidationResult(input, opt = {}) { const fn = this._getValidateFn(); return executeValidation(fn, this.schema, input, opt, this.cfg.inputName); } getValidationFunction() { return (input, opt) => { return this.getValidationResult(input, { mutateInput: opt?.mutateInput, inputName: opt?.inputName, inputId: opt?.inputId, }); }; } static requireValidJsonSchema(schema) { // 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(fn, builtSchema, input, opt = {}, defaultInputName) { const item = opt.mutateInput !== false || typeof input !== 'object' ? input // mutate : _deepCopy(input); // not mutate let valid = fn(item); // mutates item, but not input _typeCast(item); let output = item; if (valid && builtSchema.postValidation) { const [err, result] = _try(() => builtSchema.postValidation(output)); if (err) { valid = false; fn.errors = [ { instancePath: '', message: err.message, }, ]; } else { output = result; } } if (valid) return [null, output]; const errors = fn.errors; const { inputId = _isObject(input) ? input['id'] : undefined, inputName = defaultInputName || 'Object', } = opt; const dataVar = [inputName, inputId].filter(Boolean).join('.'); // Build fingerprint before applyImprovementsOnErrorMessages: after it, /items/0/name becomes // .items[0].name, embedding the index into the segment and making it harder to strip without regex const fingerprint = buildAjvErrorFingerprint(errors[0], inputName); applyImprovementsOnErrorMessages(errors, builtSchema); let message = getAjv().errorsText(errors, { dataVar, separator, }); // Note: if we mutated the input already, e.g stripped unknown properties, // the error message Input would contain already mutated object print, such as Input: {} // Unless `getOriginalInput` function is provided - then it will be used to preserve the Input pureness. const inputStringified = _inspect(opt.getOriginalInput?.() || input, { maxLen: 4000 }); message = [message, 'Input: ' + inputStringified].join(separator); const err = new AjvValidationError(message, _filterNullishValues({ errors, inputName, inputId, fingerprint, })); return [err, output]; } // ==== Error formatting helpers ==== function applyImprovementsOnErrorMessages(errors, schema) { if (!errors) return; filterNullableAnyOfErrors(errors, schema); const { errorMessages } = schema; for (const error of errors) { const errorMessage = getErrorMessageForInstancePath(schema, error.instancePath, error.keyword); if (errorMessage) { error.message = errorMessage; } else if (errorMessages?.[error.keyword]) { error.message = errorMessages[error.keyword]; } else { const unwrapped = unwrapNullableAnyOf(schema); if (unwrapped?.errorMessages?.[error.keyword]) { error.message = unwrapped.errorMessages[error.keyword]; } } error.instancePath = error.instancePath.replaceAll(/\/(\d+)/g, `[$1]`).replaceAll('/', '.'); } } /** * Groups repeated validation errors by rule rather than by unique request content. * Excludes instance-specific data like record IDs and array indices. */ function buildAjvErrorFingerprint(e, inputName) { const value = Object.values(e.params || {})[0]; let rule = e.keyword; if (value !== undefined) rule += `:${value}`; const path = e.instancePath .split('/') .filter(s => s && isNaN(Number(s))) .join('.'); const location = [inputName, path].filter(Boolean).join('.'); return [location, rule].join(' '); } /** * Filters out noisy errors produced by nullable anyOf patterns. * When `nullable()` wraps a schema in `anyOf: [realSchema, { type: 'null' }]`, * AJV produces "must be null" and "must match a schema in anyOf" errors * that are confusing. This method splices them out, keeping only the real errors. */ function filterNullableAnyOfErrors(errors, schema) { // Collect exact schemaPaths to remove (anyOf aggregates) and prefixes (null branches) const exactPaths = []; const nullBranchPrefixes = []; for (const error of errors) { if (error.keyword !== 'anyOf') continue; const parentSchema = resolveSchemaPath(schema, error.schemaPath); if (!parentSchema) continue; const nullIndex = unwrapNullableAnyOfIndex(parentSchema); if (nullIndex === -1) continue; exactPaths.push(error.schemaPath); // e.g. "#/anyOf" const anyOfBase = error.schemaPath.slice(0, -'anyOf'.length); nullBranchPrefixes.push(`${anyOfBase}anyOf/${nullIndex}/`); // e.g. "#/anyOf/1/" } if (!exactPaths.length) return; for (let i = errors.length - 1; i >= 0; i--) { const sp = errors[i].schemaPath; if (exactPaths.includes(sp) || nullBranchPrefixes.some(p => sp.startsWith(p))) { errors.splice(i, 1); } } } /** * Navigates the schema tree using an AJV schemaPath (e.g. "#/properties/foo/anyOf") * and returns the parent schema containing the last keyword. */ function resolveSchemaPath(schema, schemaPath) { // schemaPath looks like "#/properties/foo/anyOf" or "#/anyOf" // We want the schema that contains the final keyword (e.g. "anyOf") const segments = schemaPath.replace(/^#\//, '').split('/'); // Remove the last segment (the keyword itself, e.g. "anyOf") segments.pop(); let current = schema; for (const segment of segments) { if (!current || typeof current !== 'object') return undefined; current = current[segment]; } return current; } function getErrorMessageForInstancePath(schema, instancePath, keyword) { if (!schema || !instancePath) return undefined; const segments = instancePath.split('/').filter(Boolean); return traverseSchemaPath(schema, segments, keyword); } function traverseSchemaPath(schema, segments, keyword) { if (!segments.length) return undefined; const [currentSegment, ...remainingSegments] = segments; const nextSchema = getChildSchema(schema, currentSegment); if (!nextSchema) return undefined; if (nextSchema.errorMessages?.[keyword]) { return nextSchema.errorMessages[keyword]; } // Check through nullable wrapper const unwrapped = unwrapNullableAnyOf(nextSchema); if (unwrapped?.errorMessages?.[keyword]) { return unwrapped.errorMessages[keyword]; } if (remainingSegments.length) { return traverseSchemaPath(nextSchema, remainingSegments, keyword); } return undefined; } function getChildSchema(schema, segment) { if (!segment) return undefined; // Unwrap nullable anyOf to find properties/items through nullable wrappers const effectiveSchema = unwrapNullableAnyOf(schema) ?? schema; if (/^\d+$/.test(segment) && effectiveSchema.items) { return getArrayItemSchema(effectiveSchema, segment); } return getObjectPropertySchema(effectiveSchema, segment); } function getArrayItemSchema(schema, indexSegment) { if (!schema.items) return undefined; if (Array.isArray(schema.items)) { return schema.items[Number(indexSegment)]; } return schema.items; } function getObjectPropertySchema(schema, segment) { return schema.properties?.[segment]; } function unwrapNullableAnyOf(schema) { const nullIndex = unwrapNullableAnyOfIndex(schema); if (nullIndex === -1) return undefined; return schema.anyOf[1 - nullIndex]; } function unwrapNullableAnyOfIndex(schema) { if (schema.anyOf?.length !== 2) return -1; const nullIndex = schema.anyOf.findIndex(s => s.type === 'null'); return nullIndex; } // ==== Utility helpers ==== function addPropertiesToSchema(schema, props) { const properties = {}; const required = []; for (const [key, builder] of Object.entries(props)) { const isOptional = builder.getSchema().optionalField; if (!isOptional) { required.push(key); } const builtSchema = builder.build(); properties[key] = builtSchema; } schema.properties = properties; schema.required = _uniq(required).sort(); } function hasNoObjectSchemas(schema) { if (Array.isArray(schema.type)) { return schema.type.every(type => ['string', 'number', 'integer', 'boolean', 'null'].includes(type)); } else if (schema.anyOf) { return schema.anyOf.every(hasNoObjectSchemas); } else if (schema.oneOf) { return schema.oneOf.every(hasNoObjectSchemas); } else if (schema.enum) { return true; } else if (schema.type === 'array') { return !schema.items || hasNoObjectSchemas(schema.items); } else { return !!schema.type && ['string', 'number', 'integer', 'boolean', 'null'].includes(schema.type); } return false; } /** * Deep copy that preserves functions in customValidations/customConversions. * Unlike structuredClone, this handles function references (whi