@naturalcycles/nodejs-lib
Version:
Standard library for Node.js
1,388 lines (1,387 loc) • 50.6 kB
JavaScript
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