gentleschema
Version:
A compact, production-friendly JS schema validator.
1,072 lines (1,012 loc) • 58.5 kB
JavaScript
'use strict';
const DEFAULT_OPTIONS = {
failFast: false,
removeUnknown: false,
strict: false,
valueOnly: false,
coerceTypes: false,
removeEmpty: false,
nullable: false,
strictEnum: false,
maxRegexLength: 1000,
conditionalsCacheSize: Infinity
};
function _isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function _getType(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v;
}
function _arrayPath(prefix, index) {
return prefix ? `${prefix}[${index}]` : `[${index}]`;
}
function _getByPath(obj, path) {
if (!path) return obj;
const parts = [];
let cur = '';
for (let i = 0; i < path.length; i++) {
const ch = path[i];
if (ch === '.') {
if (cur.length) { parts.push(cur); cur = ''; }
} else if (ch === '[') {
if (cur.length) { parts.push(cur); cur = ''; }
let j = i + 1, idx = '';
while (j < path.length && path[j] !== ']') { idx += path[j]; j++; }
if (idx.length) parts.push(Number(idx));
i = j;
} else cur += ch;
}
if (cur.length) parts.push(cur);
let out = obj;
for (let i = 0; i < parts.length; i++) {
if (out == null) return undefined;
out = out[parts[i]];
}
return out;
}
function _shallowCloneSchema(val) {
if (val === null || typeof val !== 'object') return val;
if (Array.isArray(val)) {
const out = new Array(val.length);
for (let i = 0; i < val.length; i++) out[i] = _shallowCloneSchema(val[i]);
return out;
}
const out = {};
const ks = Object.keys(val);
for (let i = 0; i < ks.length; i++) {
const k = ks[i];
out[k] = _shallowCloneSchema(val[k]);
}
return out;
}
function _makeErrorObj(fieldName, reason, code, rawErr) {
return {
path: fieldName || '',
message: `Invalid ${fieldName} field. ${reason}`,
code: code || 'ERR_VALIDATION',
rawError: rawErr || null
};
}
function _enumMatch(enumArr, v, strictEnum) {
if (!Array.isArray(enumArr)) return true;
if (enumArr.length === 0) return true;
for (let i = 0; i < enumArr.length; i++) {
const e = enumArr[i];
if (typeof e === 'function') {
if (strictEnum) continue;
try { if (e(v)) return true; } catch (_) { /* ignore */ }
} else {
if (e === v) return true;
}
}
return false;
}
function _coerceToType(val, type) {
try {
const tname = typeof type === 'string' ? type : (typeof type === 'function' ? (type.name || '') : '');
if (tname === 'number' || type === Number) {
if (typeof val === 'number') return { coercedValue: val, coerced: true };
if (val === '' || val == null) return { coercedValue: val, coerced: false, error: new Error('cannot coerce empty/null to number') };
const n = Number(val);
if (!Number.isNaN(n)) return { coercedValue: n, coerced: true };
return { coercedValue: val, coerced: false, error: new Error('invalid number') };
}
if (tname === 'string' || type === String) {
if (typeof val === 'string') return { coercedValue: val, coerced: true };
return { coercedValue: String(val), coerced: true };
}
if (tname === 'boolean' || type === Boolean) {
if (typeof val === 'boolean') return { coercedValue: val, coerced: true };
if (val === 'true' || val === '1' || val === 1) return { coercedValue: true, coerced: true };
if (val === 'false' || val === '0' || val === 0) return { coercedValue: false, coerced: true };
return { coercedValue: val, coerced: false, error: new Error('invalid boolean') };
}
if (tname === 'array' || type === Array) {
if (Array.isArray(val)) return { coercedValue: val, coerced: true };
if (typeof val === 'string') {
try {
const p = JSON.parse(val);
if (Array.isArray(p)) return { coercedValue: p, coerced: true };
} catch (e) { return { coercedValue: val, coerced: false, error: e }; }
}
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to array') };
}
if (tname === 'object' || type === Object) {
if (_isPlainObject(val)) return { coercedValue: val, coerced: true };
if (typeof val === 'string') {
try {
const p = JSON.parse(val);
if (_isPlainObject(p)) return { coercedValue: p, coerced: true };
} catch (e) { return { coercedValue: val, coerced: false, error: e }; }
}
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to object') };
}
if (typeof type === 'function') {
if (type === Date) {
if (val instanceof Date) return { coercedValue: val, coerced: true };
if (typeof val === 'string' || typeof val === 'number') {
const d = new Date(val);
if (!isNaN(d.getTime())) return { coercedValue: d, coerced: true };
return { coercedValue: val, coerced: false, error: new Error('invalid Date') };
}
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to Date') };
}
if (type === RegExp) {
if (val instanceof RegExp) return { coercedValue: val, coerced: true };
if (typeof val === 'string') {
try { return { coercedValue: new RegExp(val), coerced: true }; } catch (e) { return { coercedValue: val, coerced: false, error: e }; }
}
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to RegExp') };
}
if (type === Map) {
if (val instanceof Map) return { coercedValue: val, coerced: true };
if (_isPlainObject(val)) return { coercedValue: new Map(Object.entries(val)), coerced: true };
if (Array.isArray(val)) return { coercedValue: new Map(val), coerced: true };
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to Map') };
}
if (type === Set) {
if (val instanceof Set) return { coercedValue: val, coerced: true };
if (Array.isArray(val)) return { coercedValue: new Set(val), coerced: true };
if (_isPlainObject(val)) return { coercedValue: new Set(Object.values(val)), coerced: true };
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to Set') };
}
if (typeof URL !== 'undefined' && type === URL) {
if (val instanceof URL) return { coercedValue: val, coerced: true };
if (typeof val === 'string') {
try { return { coercedValue: new URL(val), coerced: true }; } catch (e) { return { coercedValue: val, coerced: false, error: e }; }
}
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to URL') };
}
if (type === BigInt) {
if (typeof val === 'bigint') return { coercedValue: val, coerced: true };
if (typeof val === 'number' || typeof val === 'string') {
try { return { coercedValue: BigInt(val), coerced: true }; } catch (e) { return { coercedValue: val, coerced: false, error: e }; }
}
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to BigInt') };
}
if (typeof Buffer !== 'undefined' && type === Buffer) {
if (Buffer.isBuffer(val)) return { coercedValue: val, coerced: true };
if (typeof val === 'string') return { coercedValue: Buffer.from(val), coerced: true };
if (Array.isArray(val)) return { coercedValue: Buffer.from(val), coerced: true };
return { coercedValue: val, coerced: false, error: new Error('cannot coerce to Buffer') };
}
if (val instanceof type) return { coercedValue: val, coerced: true };
return { coercedValue: val, coerced: false, error: new Error('no safe coercion for constructor') };
}
return { coercedValue: val, coerced: false, error: new Error('no coercion rule') };
} catch (e) {
return { coercedValue: val, coerced: false, error: e };
}
}
function _matchesType(expectedType, value) {
if (expectedType === 'any') return true;
if (typeof expectedType === 'string') {
if (expectedType === 'date') return value instanceof Date;
if (expectedType === 'regexp' || expectedType === 'regex') return value instanceof RegExp;
if (expectedType === 'map') return value instanceof Map;
if (expectedType === 'set') return value instanceof Set;
if (expectedType === 'url') return (typeof URL !== 'undefined') ? value instanceof URL : false;
if (expectedType === 'bigint') return typeof value === 'bigint';
if (expectedType === 'buffer') return (typeof Buffer !== 'undefined') ? Buffer.isBuffer(value) : false;
return _getType(value) === expectedType;
}
if (typeof expectedType === 'function') {
try {
if (expectedType === Date) return value instanceof Date;
if (expectedType === RegExp) return value instanceof RegExp;
if (expectedType === Map) return value instanceof Map;
if (expectedType === Set) return value instanceof Set;
if (typeof URL !== 'undefined' && expectedType === URL) return value instanceof URL;
if (expectedType === BigInt) return typeof value === 'bigint';
if (typeof Buffer !== 'undefined' && expectedType === Buffer) return Buffer.isBuffer(value);
return value instanceof expectedType;
} catch (e) { return false; }
}
return false;
}
function _expectedLabelForType(type) {
if (typeof type === 'function') return `a valid instance of the ${type.name || 'Constructor'} class`;
return `type ${type}`;
}
class SchemaError extends Error {
constructor(errors) {
super('Schema error');
this.name = 'SchemaError';
this.errors = errors || [];
if (Error.captureStackTrace) Error.captureStackTrace(this, SchemaError);
}
}
class ValidationError extends Error {
constructor(errors = [], partialValue = undefined) {
super('Validation failed');
this.name = 'ValidationError';
this.errors = errors;
this.partialValue = partialValue;
if (Error.captureStackTrace) Error.captureStackTrace(this, ValidationError);
}
}
function normalizeEntry(raw, opts, keyName, pathForRegex) {
if (typeof raw === 'string') return { type: raw };
if (typeof raw === 'function') return { validator: raw };
if (Array.isArray(raw)) {
const a = raw[0], b = raw[1];
const base = normalizeEntry(a, opts, keyName, pathForRegex);
if (_isPlainObject(b)) {
const out = Object.assign({}, base);
const ks = Object.keys(b);
for (let i = 0; i < ks.length; i++) out[ks[i]] = b[ks[i]];
return out;
}
return base;
}
if (_isPlainObject(raw)) {
if (typeof raw.$ref === 'string') return { __isRef: true, $ref: raw.$ref };
const out = {};
if ('type' in raw) out.type = raw.type;
if ('required' in raw) out.required = !!raw.required;
if ('nullable' in raw) out.nullable = !!raw.nullable;
if ('default' in raw) out.default = raw.default;
if ('validator' in raw) out.validator = raw.validator;
if ('coerce' in raw) out.coerce = !!raw.coerce;
out.strictType = !!(raw.forceType || raw.strictType);
if ('enum' in raw) out.enum = raw.enum;
if ('items' in raw) out.items = normalizeEntry(raw.items, opts, keyName, pathForRegex ? `${pathForRegex}.items` : `${keyName}.items`);
if ('properties' in raw) {
const props = {};
const pks = Object.keys(raw.properties || {});
for (let i = 0; i < pks.length; i++) props[pks[i]] = normalizeEntry(raw.properties[pks[i]], opts, pks[i], pathForRegex ? `${pathForRegex}.properties.${pks[i]}` : `${keyName}.properties.${pks[i]}`);
out.properties = props;
}
if ('regex' in raw) {
if (raw.regex instanceof RegExp) out.regex = raw.regex;
else if (typeof raw.regex === 'string' && raw.regex.length) {
if (opts && typeof opts.maxRegexLength === 'number' && raw.regex.length > opts.maxRegexLength) {
throw new SchemaError([{
path: pathForRegex || keyName,
message: `Regex string too long (${raw.regex.length} > ${opts.maxRegexLength}). Pass a RegExp instance instead.`,
code: 'ERR_REGEX_TOO_LONG'
}]);
}
out.regex = new RegExp(raw.regex);
}
}
if ('min' in raw) out.min = raw.min;
if ('max' in raw) out.max = raw.max;
if ('throw' in raw) out.throw = !!raw.throw;
if ('errorMessage' in raw) out.errorMessage = raw.errorMessage;
const skip = { type: 1, required: 1, nullable: 1, default: 1, validator: 1, coerce: 1, forceType: 1, strictType: 1, enum: 1, items: 1, properties: 1, regex: 1, min: 1, max: 1, throw: 1, errorMessage: 1, $ref: 1 };
const keys = Object.keys(raw);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (!skip[k]) out[k] = raw[k];
}
return out;
}
return { validator: raw };
}
function normalizeSchema(schemaDef, opts = {}) {
const out = {};
const localDefs = {};
const inlineMap = new Map();
if (_isPlainObject(schemaDef) && _isPlainObject(schemaDef.$defs)) {
const defKeys = Object.keys(schemaDef.$defs);
for (let i = 0; i < defKeys.length; i++) {
const k = defKeys[i];
localDefs[k] = schemaDef.$defs[k];
}
}
function resolveEntry(raw, keyName, pathForRegex) {
if (_isPlainObject(raw) && typeof raw.$ref === 'string') {
return { __isRef: true, $ref: raw.$ref };
}
if (_isPlainObject(raw) && inlineMap.has(raw)) return inlineMap.get(raw);
const normalized = normalizeEntry(raw, opts, keyName, pathForRegex);
if (_isPlainObject(raw)) inlineMap.set(raw, normalized);
return normalized;
}
const keys = Object.keys(schemaDef || {});
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (k === '$defs') continue;
out[k] = resolveEntry(schemaDef[k], k, k);
if (!('required' in out[k])) out[k].required = false;
if (!('nullable' in out[k])) out[k].nullable = undefined;
if (!('coerce' in out[k])) out[k].coerce = undefined;
if (!('strictType' in out[k])) out[k].strictType = undefined;
if (!('throw' in out[k])) out[k].throw = undefined;
}
return { schema: out, inlineMap, localDefs };
}
function makeChecker(fieldName, entry, globalOptions) {
const type = entry.type;
const required = !!entry.required;
const nullable = !!entry.nullable;
const def = entry.default;
const enumVals = entry.enum;
const regex = entry.regex;
const min = entry.min;
const max = entry.max;
const items = entry.items;
const properties = entry.properties;
const validator = entry.validator;
const coerceField = !!entry.coerce;
const strictType = !!entry.strictType;
const throwField = !!entry.throw;
const errorMessage = typeof entry.errorMessage === 'string' ? entry.errorMessage : null;
let itemsChecker = null;
if (items) itemsChecker = makeChecker(`${fieldName}[]`, items, globalOptions);
let propertiesCheck = null;
if (properties) {
const pkeys = Object.keys(properties);
const props = {};
for (let i = 0; i < pkeys.length; i++) {
const pk = pkeys[i];
props[pk] = makeChecker(pk, properties[pk], globalOptions);
}
propertiesCheck = { keys: pkeys, props };
}
function makeErr(path, reason, code, raw) {
const reasonMessage = errorMessage ? errorMessage : reason;
return { path, message: `Invalid ${path} field. ${reasonMessage}`, code: code || 'ERR_VALIDATION', rawError: raw || null };
}
return function checker(value, ctx) {
const path = ctx.path;
const options = ctx.options;
const errs = [];
let out = value;
const hasValue = !(typeof value === 'undefined');
if (!hasValue) {
if (typeof def !== 'undefined') out = (typeof def === 'function') ? def() : def;
else if (required) {
const e = makeErr(path, 'is required', 'ERR_REQUIRED', null);
if (throwField) throw new ValidationError([e], undefined);
errs.push(e);
return [errs, out];
} else return [errs, out];
}
if (out === null) {
if (nullable || options.nullable || (type === 'null')) return [errs, out];
const e = makeErr(path, 'must not be null', 'ERR_NULL', null);
if (throwField) throw new ValidationError([e], undefined);
errs.push(e);
return [errs, out];
}
if (typeof validator === 'function' && typeof type === 'undefined') {
try {
const r = validator(out, { path });
if (r === true) return [errs, out];
if (r === false) {
const e = makeErr(path, 'custom validator failed', 'ERR_CUSTOM', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
if (typeof r === 'string') {
const e = makeErr(path, r, 'ERR_CUSTOM', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
if (Array.isArray(r) && r.length) {
for (let i = 0; i < r.length; i++) errs.push(makeErr(path, String(r[i]), 'ERR_CUSTOM', null));
if (throwField) throw new ValidationError(errs, out);
return [errs, out];
}
if (_isPlainObject(r)) {
if (r.valid === true) return [errs, out];
if (Array.isArray(r.errors)) {
for (let i = 0; i < r.errors.length; i++) errs.push(makeErr(path, String(r.errors[i]), 'ERR_CUSTOM', null));
if (throwField) throw new ValidationError(errs, out);
return [errs, out];
}
}
return [errs, out];
} catch (err) {
const e = makeErr(path, String(err), 'ERR_CUSTOM', err);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
const shouldCoerce = !!(coerceField || options.coerceTypes);
if (shouldCoerce && typeof type !== 'undefined') {
const c = _coerceToType(out, type);
if (c.coerced) out = c.coercedValue;
else {
if (typeof type === 'string' && ['number', 'string', 'boolean', 'array', 'object'].indexOf(type) !== -1) {
const e = makeErr(path, `coercion failed (${c.error ? c.error.message : 'unknown'})`, 'ERR_COERCE', c.error || null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
}
if (typeof type !== 'undefined') {
if (type !== 'any') {
if (!_matchesType(type, out)) {
const expectedLabel = _expectedLabelForType(type);
const e = makeErr(path, `expected ${expectedLabel}, received ${_getType(out)}`, 'ERR_TYPE', null);
if (strictType || options.strictType) {
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
} else {
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
}
}
if (Array.isArray(enumVals) && enumVals.length > 0) {
if (!_enumMatch(enumVals, out, options.strictEnum)) {
const friendly = enumVals.map(function (v) { if (typeof v === 'function') return `<fn:${v.name || 'anon'}>`; try { return JSON.stringify(v); } catch (e) { return String(v); } }).join(', ');
const e = makeErr(path, `must be one of [${friendly}]`, 'ERR_ENUM', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
if (regex) {
if (!(regex instanceof RegExp)) {
const e = makeErr(path, 'regex must be a RegExp instance', 'ERR_REGEX', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
if (typeof out !== 'string' || !regex.test(out)) {
const e = makeErr(path, `String should follow regex pattern ${regex.toString()}`, 'ERR_REGEX', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
if (typeof out === 'number') {
if (typeof min === 'number' && out < min) {
const e = makeErr(path, `must be >= ${min}`, 'ERR_MIN', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
if (typeof max === 'number' && out > max) {
const e = makeErr(path, `must be <= ${max}`, 'ERR_MAX', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
} else if (typeof out === 'string' || Array.isArray(out)) {
if (typeof min === 'number' && out.length < min) {
const e = makeErr(path, `length must be >= ${min}`, 'ERR_MIN', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
if (typeof max === 'number' && out.length > max) {
const e = makeErr(path, `length must be <= ${max}`, 'ERR_MAX', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
if (Array.isArray(out) && itemsChecker) {
const newArr = [];
let hadErr = false;
for (let i = 0; i < out.length; i++) {
const childPath = _arrayPath(path, i);
const [childErrs, childVal] = itemsChecker(out[i], { path: childPath, options });
if (childErrs.length) {
hadErr = true;
for (let j = 0; j < childErrs.length; j++) errs.push(childErrs[j]);
if (options.failFast) {
if (throwField) throw new ValidationError(errs, undefined);
return [errs, out];
}
} else newArr.push(childVal);
}
if (!hadErr) out = newArr;
}
if (_isPlainObject(out) && propertiesCheck) {
const nk = propertiesCheck.keys;
const props = propertiesCheck.props;
const nestedResult = {};
for (let i = 0; i < nk.length; i++) {
const pk = nk[i];
const ck = props[pk];
const childPath = path ? `${path}.${pk}` : pk;
const [childErrs, childVal] = ck(out[pk], { path: childPath, options });
if (childErrs.length) {
for (let j = 0; j < childErrs.length; j++) errs.push(childErrs[j]);
if (options.failFast) {
if (throwField) throw new ValidationError(errs, undefined);
return [errs, out];
}
} else nestedResult[pk] = childVal;
}
if (options.strict) {
const objKs = Object.keys(out);
for (let i = 0; i < objKs.length; i++) {
const k2 = objKs[i];
let found = false;
for (let j = 0; j < nk.length; j++) if (nk[j] === k2) { found = true; break; }
if (!found) {
if (options.removeUnknown) continue;
const e = makeErr(path ? `${path}.${k2}` : k2, 'unknown field', 'ERR_UNKNOWN', null);
errs.push(e);
if (options.failFast) {
if (throwField) throw new ValidationError(errs, undefined);
return [errs, out];
}
}
}
} else {
const objKs = Object.keys(out);
for (let i = 0; i < objKs.length; i++) {
const k2 = objKs[i];
let found = false;
for (let j = 0; j < nk.length; j++) if (nk[j] === k2) { found = true; break; }
if (!found) {
if (options.removeUnknown) continue;
nestedResult[k2] = out[k2];
}
}
}
out = nestedResult;
}
if (typeof validator === 'function') {
try {
const r = validator(out, { path });
if (r === true) {
} else if (r === false) {
const e = makeErr(path, 'custom validator failed', 'ERR_CUSTOM', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
} else if (typeof r === 'string') {
const e = makeErr(path, r, 'ERR_CUSTOM', null);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
} else if (Array.isArray(r) && r.length) {
for (let i = 0; i < r.length; i++) errs.push(makeErr(path, String(r[i]), 'ERR_CUSTOM', null));
if (throwField) throw new ValidationError(errs, out);
return [errs, out];
} else if (_isPlainObject(r)) {
if (r.valid === true) {
} else if (Array.isArray(r.errors)) {
for (let i = 0; i < r.errors.length; i++) errs.push(makeErr(path, String(r.errors[i]), 'ERR_CUSTOM', null));
if (throwField) throw new ValidationError(errs, out);
return [errs, out];
}
}
} catch (err) {
const e = makeErr(path, String(err), 'ERR_CUSTOM', err);
if (throwField) throw new ValidationError([e], out);
errs.push(e);
return [errs, out];
}
}
return [errs, out];
};
}
function mergeNormalizedEntry(base, frag) {
if (!base) return frag;
if (!frag) return base;
const out = Object.assign({}, base, frag);
if (base.items || frag.items) out.items = mergeNormalizedEntry(base.items || {}, frag.items || {});
if (base.properties || frag.properties) {
const bp = base.properties || {};
const fp = frag.properties || {};
const mergedProps = {};
const keys = Object.keys(bp);
for (let i = 0; i < keys.length; i++) mergedProps[keys[i]] = bp[keys[i]];
const fk = Object.keys(fp);
for (let i = 0; i < fk.length; i++) {
const k = fk[i];
mergedProps[k] = mergeNormalizedEntry(bp[k], fp[k]);
}
out.properties = mergedProps;
}
return out;
}
class Schema {
constructor(schemaDef = {}, options = {}) {
if (!_isPlainObject(schemaDef)) throw new TypeError('schemaDef must be an object');
// options
this.options = Object.assign({}, DEFAULT_OPTIONS, options || {});
// refs storage (external refs) and caches MUST be initialized BEFORE any _loadExternalRefs call
this.externalRefs = {};
this._refCheckerCache = new Map();
this._overrideCheckerCache = new Map();
// pre-init stats
this._stats = { counts: {}, totalNs: {} };
// load external refs (if provided in options)
if (this.options.refs) this._loadExternalRefs(this.options.refs);
// raw schema + normalized
this.rawSchema = _shallowCloneSchema(schemaDef);
const normalized = normalizeSchema(schemaDef, { maxRegexLength: this.options.maxRegexLength });
this._normalizedSchema = normalized.schema;
this._inlineMap = normalized.inlineMap;
this._localDefs = normalized.localDefs || {};
// conditionals
this._conditionals = [];
// prebuilt checkers (or lazy wrappers for $ref placeholders)
this._checkers = {};
const keys = Object.keys(this._normalizedSchema);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const ent = this._normalizedSchema[k];
if (ent && ent.__isRef) {
this._checkers[k] = (value, ctx) => {
const checker = this._resolveRefChecker(ent.$ref, k);
return checker(value, ctx);
};
} else {
this._checkers[k] = makeChecker(k, ent, this.options);
}
}
// lint schema now and throw SchemaError on issues
const lint = this._lintSchema();
if (lint.length) throw new SchemaError(lint);
}
_loadExternalRefs(input) {
if (!input) return;
if (Array.isArray(input)) {
for (let i = 0; i < input.length; i++) {
const it = input[i];
if (_isPlainObject(it)) {
const ks = Object.keys(it);
for (let j = 0; j < ks.length; j++) this.externalRefs[ks[j]] = it[ks[j]];
}
}
} else if (_isPlainObject(input)) {
const ks = Object.keys(input);
for (let i = 0; i < ks.length; i++) this.externalRefs[ks[i]] = input[ks[i]];
} else {
throw new TypeError('refs must be an object map or array of maps');
}
// clear cache
if (this._refCheckerCache && typeof this._refCheckerCache.clear === 'function') this._refCheckerCache.clear();
}
addRef(name, def) {
if (typeof name !== 'string' || !name.length) throw new TypeError('ref name must be a non-empty string');
if (Object.prototype.hasOwnProperty.call(this.externalRefs, name)) {
throw new SchemaError([{ path: name, message: `Ref '${name}' already exists` }]);
}
this.externalRefs[name] = def;
this._refCheckerCache.delete(name);
return undefined;
}
addRefs(refs) {
if (!refs) return undefined;
const incoming = {};
if (Array.isArray(refs)) {
for (let i = 0; i < refs.length; i++) {
const it = refs[i];
if (!_isPlainObject(it)) continue;
const ks = Object.keys(it);
for (let j = 0; j < ks.length; j++) incoming[ks[j]] = it[ks[j]];
}
} else if (_isPlainObject(refs)) {
const ks = Object.keys(refs);
for (let i = 0; i < ks.length; i++) incoming[ks[i]] = refs[ks[i]];
} else {
throw new TypeError('refs must be an object map or array of maps');
}
// detect duplicates
const dupes = [];
const inKeys = Object.keys(incoming);
for (let i = 0; i < inKeys.length; i++) {
const k = inKeys[i];
if (Object.prototype.hasOwnProperty.call(this.externalRefs, k)) dupes.push(k);
}
if (dupes.length) {
const errs = dupes.map(d => ({ path: d, message: `Ref '${d}' already exists` }));
throw new SchemaError(errs);
}
// merge
for (let i = 0; i < inKeys.length; i++) {
const k = inKeys[i];
this.externalRefs[k] = incoming[k];
this._refCheckerCache.delete(k);
}
return undefined;
}
overrideRef(name, def) {
if (typeof name !== 'string' || !name.length) throw new TypeError('ref name must be a non-empty string');
this.externalRefs[name] = def;
this._refCheckerCache.delete(name);
return undefined;
}
removeRef(name) {
if (typeof name !== 'string' || !name.length) return false;
const had = Object.prototype.hasOwnProperty.call(this.externalRefs, name);
if (had) delete this.externalRefs[name];
this._refCheckerCache.delete(name);
return had;
}
resolveRef(name) {
if (!name) return undefined;
if (this.externalRefs && Object.prototype.hasOwnProperty.call(this.externalRefs, name)) {
try {
return normalizeEntry(this.externalRefs[name], { maxRegexLength: this.options.maxRegexLength }, name, name);
} catch (e) { return undefined; }
}
if (this._localDefs && Object.prototype.hasOwnProperty.call(this._localDefs, name)) {
try { return normalizeEntry(this._localDefs[name], { maxRegexLength: this.options.maxRegexLength }, name, name); } catch (e) { return undefined; }
}
const looked = _getByPath(this.rawSchema, name);
if (typeof looked !== 'undefined') {
try { return normalizeEntry(looked, { maxRegexLength: this.options.maxRegexLength }, name, name); } catch (e) { return undefined; }
}
return undefined;
}
_resolveRefChecker(refName, fieldNameForErrors) {
if (this._refCheckerCache.has(refName)) return this._refCheckerCache.get(refName);
// external refs
if (this.externalRefs && Object.prototype.hasOwnProperty.call(this.externalRefs, refName)) {
const raw = this.externalRefs[refName];
try {
const normalized = normalizeEntry(raw, { maxRegexLength: this.options.maxRegexLength }, refName, refName);
const chk = makeChecker(fieldNameForErrors || refName, normalized, this.options);
this._refCheckerCache.set(refName, chk);
return chk;
} catch (e) {
const throwingChecker = (v, ctx) => {
const path = ctx.path;
const err = _makeErrorObj(path, `failed to normalize external ref '${refName}': ${String(e.message || e)}`, 'ERR_REF_NORMALIZE', e);
return [[err], v];
};
this._refCheckerCache.set(refName, throwingChecker);
return throwingChecker;
}
}
// local defs
if (this._localDefs && Object.prototype.hasOwnProperty.call(this._localDefs, refName)) {
try {
const normalized = normalizeEntry(this._localDefs[refName], { maxRegexLength: this.options.maxRegexLength }, refName, refName);
const chk = makeChecker(fieldNameForErrors || refName, normalized, this.options);
this._refCheckerCache.set(refName, chk);
return chk;
} catch (e) {
const throwingChecker = (v, ctx) => {
const path = ctx.path;
const err = _makeErrorObj(path, `failed to normalize local $defs ref '${refName}': ${String(e.message || e)}`, 'ERR_REF_NORMALIZE', e);
return [[err], v];
};
this._refCheckerCache.set(refName, throwingChecker);
return throwingChecker;
}
}
// rawSchema path-ref
const looked = _getByPath(this.rawSchema, refName);
if (typeof looked !== 'undefined') {
try {
const normalized = normalizeEntry(looked, { maxRegexLength: this.options.maxRegexLength }, refName, refName);
const chk = makeChecker(fieldNameForErrors || refName, normalized, this.options);
this._refCheckerCache.set(refName, chk);
return chk;
} catch (e) {
const throwingChecker = (v, ctx) => {
const path = ctx.path;
const err = _makeErrorObj(path, `failed to normalize path-ref '${refName}': ${String(e.message || e)}`, 'ERR_REF_NORMALIZE', e);
return [[err], v];
};
this._refCheckerCache.set(refName, throwingChecker);
return throwingChecker;
}
}
// unresolved
const unresolvedChecker = (v, ctx) => {
const path = ctx.path;
const err = _makeErrorObj(path, `unresolved ref '${refName}'`, 'ERR_REF_UNRESOLVED', null);
return [[err], v];
};
this._refCheckerCache.set(refName, unresolvedChecker);
return unresolvedChecker;
}
withOptions(opts = {}) {
const merged = Object.assign({}, this.options, opts || {});
const clone = new Schema(this.rawSchema, merged);
clone.externalRefs = Object.assign({}, this.externalRefs);
clone._refCheckerCache = new Map();
return clone;
}
updateOptions(opts = {}) {
Object.assign(this.options, opts || {});
// clear caches if relevant options changed
if (this._refCheckerCache && typeof this._refCheckerCache.clear === 'function') this._refCheckerCache.clear();
this._invalidateOverrideCache();
return this;
}
when(pathOrPredicate) {
const self = this;
const holder = { pathOrPredicate };
return {
is(isValOrFn) {
holder.is = isValOrFn;
return {
do(fragment) {
if (!_isPlainObject(fragment)) throw new TypeError('fragment must be an object');
const normalizedFragment = {};
const fk = Object.keys(fragment);
for (let i = 0; i < fk.length; i++) {
const key = fk[i];
normalizedFragment[key] = normalizeEntry(fragment[key], { maxRegexLength: self.options.maxRegexLength }, key, key);
}
self._conditionals.push({
pathOrPredicate: holder.pathOrPredicate,
is: holder.is,
fragment: normalizedFragment
});
self._invalidateOverrideCache();
return self;
}
};
}
};
}
_invalidateOverrideCache() {
this._overrideCheckerCache = new Map();
}
_lintSchema() {
const issues = [];
const keys = Object.keys(this._normalizedSchema);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const e = this._normalizedSchema[k];
if (typeof e.type !== 'undefined') {
const t = e.type;
if (typeof t === 'string') {
const valid = ['string', 'number', 'object', 'array', 'boolean', 'bigint', 'function', 'symbol', 'null', 'undefined', 'any', 'date', 'regexp', 'regex', 'map', 'set', 'url', 'buffer'];
if (valid.indexOf(t) === -1) issues.push({ path: k, message: `Invalid type '${t}' for field '${k}'`, code: 'ERR_SCHEMA_TYPE' });
} else if (typeof t !== 'function') {
issues.push({ path: k, message: `Type for field '${k}' must be a string or constructor`, code: 'ERR_SCHEMA_TYPE' });
}
}
if (e.regex && !(e.regex instanceof RegExp)) issues.push({ path: k, message: `Field '${k}' has invalid regex; must be RegExp`, code: 'ERR_SCHEMA_REGEX' });
if (e.enum && !Array.isArray(e.enum)) issues.push({ path: k, message: `Field '${k}' enum must be an array`, code: 'ERR_SCHEMA_ENUM' });
if (e.properties && !_isPlainObject(e.properties)) issues.push({ path: k, message: `Field '${k}' properties must be an object`, code: 'ERR_SCHEMA_PROPERTIES' });
}
return issues;
}
_evaluateConditionals(obj) {
const overrides = {};
const conds = this._conditionals;
for (let i = 0; i < conds.length; i++) {
const rule = conds[i];
let condResult = false;
try {
if (typeof rule.pathOrPredicate === 'function') condResult = !!rule.pathOrPredicate(obj);
else {
const valueAtPath = _getByPath(obj, rule.pathOrPredicate);
if (typeof rule.is === 'function') condResult = !!rule.is(valueAtPath);
else condResult = (valueAtPath === rule.is);
}
} catch (_) { condResult = false; }
if (condResult) {
const fk = Object.keys(rule.fragment || {});
for (let j = 0; j < fk.length; j++) {
const field = fk[j];
overrides[field] = rule.fragment[field];
}
}
}
return overrides;
}
_getOverrideCheckerFor(fieldName, fragmentEntry) {
if (!this._overrideCheckerCache.has(fieldName)) this._overrideCheckerCache.set(fieldName, new WeakMap());
const wmap = this._overrideCheckerCache.get(fieldName);
if (fragmentEntry && typeof fragmentEntry === 'object' && wmap.has(fragmentEntry)) return wmap.get(fragmentEntry);
const baseEntry = this._normalizedSchema[fieldName] || {};
const merged = mergeNormalizedEntry(baseEntry, fragmentEntry);
const checker = makeChecker(fieldName, merged, this.options);
if (fragmentEntry && typeof fragmentEntry === 'object') {
try { wmap.set(fragmentEntry, checker); } catch (e) { /* ignore */ }
}
return checker;
}
getStats() { return Object.assign({}, this._stats); }
resetStats() { this._stats = { counts: {}, totalNs: {} }; }
benchmark(obj, opts = {}) {
const iterations = typeof opts.iterations === 'number' ? opts.iterations : 1000;
const action = opts.action || 'validate';
const start = process.hrtime.bigint();
let last;
for (let i = 0; i < iterations; i++) {
if (action === 'validate') last = this.validate(obj, opts);
else if (action === 'check') last = this.check(obj, opts);
else if (action === 'assertTypes') last = this.assertTypes(obj, opts);
else last = this.validate(obj, opts);
}
const end = process.hrtime.bigint();
const totalNs = Number(end - start);
const totalMs = totalNs / 1e6;
return { iterations, totalMs, avgMs: totalMs / iterations, lastResult: last };
}
profile(items = [], opts = {}) {
const action = opts.action || 'validate';
const warmup = typeof opts.warmup === 'number' ? opts.warmup : 10;
for (let i = 0; i < Math.min(warmup, items.length); i++) {
if (action === 'validate') this.validate(items[i], opts);
else if (action === 'check') this.check(items[i], opts);
else if (action === 'assertTypes') this.assertTypes(items[i], opts);
else this.validate(items[i], opts);
}
const results = [];
for (let i = 0; i < items.length; i++) {
const t0 = process.hrtime.bigint();
let res;
if (action === 'validate') res = this.validate(items[i], opts);
else if (action === 'check') res = this.check(items[i], opts);
else if (action === 'assertTypes') res = this.assertTypes(items[i], opts);
else res = this.validate(items[i], opts);
const t1 = process.hrtime.bigint();
results.push({ index: i, ns: Number(t1 - t0), ok: res && res.valid });
}
let sum = 0;
for (let i = 0; i < results.length; i++) sum += results[i].ns;
const totalMs = sum / 1e6;
return { items: items.length, totalMs, avgMs: totalMs / (items.length || 1), results };
}
validate(obj = {}, callOptions = {}) {
if (!_isPlainObject(obj) && !Array.isArray(obj)) throw new TypeError('object must be an object or array');
const opts = Object.assign({}, this.options, callOptions || {});
const errors = [];
const result = Array.isArray(obj) ? [] : {};
const overrides = this._evaluateConditionals(obj);
const schema = this._normalizedSchema;
const keys = Object.keys(schema);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const path = opts.pathPrefix ? `${opts.pathPrefix}.${k}` : k;
let checker = this._checkers[k];
if (Object.prototype.hasOwnProperty.call(overrides, k)) checker = this._getOverrideCheckerFor(k, overrides[k]);
const t0 = process.hrtime.bigint();
const [entryErrors, coerced] = checker(obj[k], { path, options: opts });
const t1 = process.hrtime.bigint();
const ns = Number(t1 - t0);
this._stats.counts[k] = (this._stats.counts[k] || 0) + 1;
this._stats.totalNs[k] = (this._stats.totalNs[k] || 0) + ns;
if (entryErrors.length) {
for (let j = 0; j < entryErrors.length; j++) errors.push(entryErrors[j]);
if (opts.failFast) {
const partial = result;
if (opts.valueOnly) throw new ValidationError(errors, partial);
return { valid: false, errors, value: partial };
}
}
if (typeof coerced !== 'undefined') result[k] = coerced;
else if (Object.prototype.hasOwnProperty.call(obj, k)) result[k] = obj[k];
}
const objectKeys = Object.keys(obj || {});
if (opts.strict) {
for (let i = 0; i < objectKeys.length; i++) {
const k = objectKeys[i];
if (!Object.prototype.hasOwnProperty.call(schema, k)) {
if (opts.removeUnknown) continue;
const path = opts.pathPrefix ? `${opts.pathPrefix}.${k}` : k;
errors.push(_makeErrorObj(path, 'unknown field', 'ERR_UNKNOWN', null));
if (opts.failFast) {
const partial = result;
if (opts.valueOnly) throw new ValidationError(errors, partial);
return { valid: false, errors, value: partial };
}
}
}
} else {
for (let i = 0; i < objectKeys.length; i++) {
const k = objectKeys[i];
if (!Object.prototype.hasOwnProperty.call(schema, k)) {
if (opts.removeUnknown) continue;
result[k] = obj[k];
}
}
}
if (opts.removeEmpty) {
function clean(v) {
if (v === '' || v === null || (Array.isArray(v) && v.length === 0) || (_isPlainObject(v) && Object.keys(v).length === 0)) return undefined;
if (Array.isArray(v)) {
const tmp = [];
for (let i = 0; i < v.length; i++) {