@naturalcycles/nodejs-lib
Version:
Standard library for Node.js
773 lines (772 loc) • 29.3 kB
JavaScript
import { _isBetween, _lazyValue, _round } from '@naturalcycles/js-lib';
import { _assert } from '@naturalcycles/js-lib/error';
import { _deepCopy, _mapObject, Set2 } from '@naturalcycles/js-lib/object';
import { _substringAfterLast } from '@naturalcycles/js-lib/string';
import { Ajv2020 } from 'ajv/dist/2020.js';
import { validTLDs } from '../tlds.js';
// oxlint-disable unicorn/prefer-code-point
const AJV_OPTIONS = {
removeAdditional: true,
allErrors: true,
// https://ajv.js.org/options.html#usedefaults
useDefaults: 'empty', // this will mutate your input!
// these are important and kept same as default:
// https://ajv.js.org/options.html#coercetypes
coerceTypes: false, // while `false` - it won't mutate your input
strictTypes: true,
strictTuples: true,
allowUnionTypes: true, // supports oneOf/anyOf schemas
};
const AJV_NON_MUTATING_OPTIONS = {
...AJV_OPTIONS,
removeAdditional: false,
useDefaults: false,
};
const AJV_MUTATING_COERCING_OPTIONS = {
...AJV_OPTIONS,
coerceTypes: true,
};
/**
* Return cached instance of Ajv with default (recommended) options.
*
* This function should be used as much as possible,
* to benefit from cached Ajv instance.
*/
export const getAjv = _lazyValue(createAjv);
/**
* Returns cached instance of Ajv, which is non-mutating.
*
* To be used in places where we only need to know if an item is valid or not,
* and are not interested in transforming the data.
*/
export const getNonMutatingAjv = _lazyValue(() => createAjv(AJV_NON_MUTATING_OPTIONS));
/**
* Returns cached instance of Ajv, which is coercing data.
*
* To be used in places where we know that we are going to receive data with the wrong type,
* typically: request path params and request query params.
*/
export const getCoercingAjv = _lazyValue(() => createAjv(AJV_MUTATING_COERCING_OPTIONS));
/**
* Create Ajv with modified defaults.
*
* !!! Please note that this function is EXPENSIVE computationally !!!
*
* https://ajv.js.org/options.html
*/
export function createAjv(opt) {
const ajv = new Ajv2020({
...AJV_OPTIONS,
...opt,
});
// Adds $merge, $patch keywords
// https://github.com/ajv-validator/ajv-merge-patch
// Kirill: temporarily disabled, as it creates a noise of CVE warnings
// require('ajv-merge-patch')(ajv)
ajv.addKeyword({
keyword: 'transform',
type: 'string',
modifying: true,
schemaType: 'object',
errors: true,
validate: function validate(transform, data, _schema, ctx) {
if (!data)
return true;
let transformedData = data;
if (transform.trim) {
transformedData = transformedData.trim();
}
if (transform.toLowerCase) {
transformedData = transformedData.toLocaleLowerCase();
}
if (transform.toUpperCase) {
transformedData = transformedData.toLocaleUpperCase();
}
if (typeof transform.truncate === 'number' && transform.truncate >= 0) {
transformedData = transformedData.slice(0, transform.truncate);
if (transform.trim) {
transformedData = transformedData.trim();
}
}
// Explicit check for `undefined` because parentDataProperty can be `0` when it comes to arrays.
if (ctx?.parentData && typeof ctx.parentDataProperty !== 'undefined') {
ctx.parentData[ctx.parentDataProperty] = transformedData;
}
return true;
},
});
ajv.addKeyword({
keyword: 'instanceof',
modifying: false,
schemaType: 'string',
validate(instanceOf, data, _schema, _ctx) {
if (typeof data !== 'object')
return false;
if (data === null)
return false;
let proto = Object.getPrototypeOf(data);
while (proto) {
if (proto.constructor?.name === instanceOf)
return true;
proto = Object.getPrototypeOf(proto);
}
return false;
},
});
ajv.addKeyword({
keyword: 'Set2',
type: ['array', 'object'],
modifying: true,
errors: true,
schemaType: 'object',
compile(innerSchema, _parentSchema, _it) {
const validateItem = ajv.compile(innerSchema);
function validateSet(data, ctx) {
let set;
const isIterable = data === null || typeof data[Symbol.iterator] === 'function';
if (data instanceof Set2) {
set = data;
}
else if (isIterable && ctx?.parentData) {
set = new Set2(data);
}
else if (isIterable && !ctx?.parentData) {
;
validateSet.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: 'can only transform an Iterable into a Set2 when the schema is in an object or an array schema. This is an Ajv limitation.',
},
];
return false;
}
else {
;
validateSet.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: 'must be a Set2 object (or optionally an Iterable in some cases)',
},
];
return false;
}
let idx = 0;
for (const value of set.values()) {
if (!validateItem(value)) {
;
validateSet.errors = [
{
instancePath: (ctx?.instancePath ?? '') + '/' + idx,
message: `invalid set item at index ${idx}`,
params: { errors: validateItem.errors },
},
];
return false;
}
idx++;
}
if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
ctx.parentData[ctx.parentDataProperty] = set;
}
return true;
}
return validateSet;
},
});
ajv.addKeyword({
keyword: 'Buffer',
modifying: true,
errors: true,
schemaType: 'boolean',
compile(_innerSchema, _parentSchema, _it) {
function validateBuffer(data, ctx) {
let buffer;
if (data === null)
return false;
const isValid = data instanceof Buffer ||
data instanceof ArrayBuffer ||
Array.isArray(data) ||
typeof data === 'string';
if (!isValid)
return false;
if (data instanceof Buffer) {
buffer = data;
}
else if (isValid && ctx?.parentData) {
buffer = Buffer.from(data);
}
else if (isValid && !ctx?.parentData) {
;
validateBuffer.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: 'can only transform data into a Buffer when the schema is in an object or an array schema. This is an Ajv limitation.',
},
];
return false;
}
else {
;
validateBuffer.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: 'must be a Buffer object (or optionally an Array-like object or ArrayBuffer in some cases)',
},
];
return false;
}
if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
ctx.parentData[ctx.parentDataProperty] = buffer;
}
return true;
}
return validateBuffer;
},
});
ajv.addKeyword({
keyword: 'email',
type: 'string',
modifying: true,
errors: true,
schemaType: 'object',
validate: function validate(opt, data, _schema, ctx) {
const { checkTLD } = opt;
const cleanData = data.trim();
// from `ajv-formats`
const EMAIL_REGEX = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
const result = cleanData.match(EMAIL_REGEX);
if (!result) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `is not a valid email address`,
},
];
return false;
}
if (checkTLD) {
const tld = _substringAfterLast(cleanData, '.');
if (!validTLDs.has(tld)) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `has an invalid TLD`,
},
];
return false;
}
}
if (ctx?.parentData && ctx.parentDataProperty !== undefined) {
ctx.parentData[ctx.parentDataProperty] = cleanData;
}
return true;
},
});
ajv.addKeyword({
keyword: 'IsoDate',
type: 'string',
modifying: false,
errors: true,
schemaType: 'object',
validate: function validate(opt, data, _schema, ctx) {
const hasOptions = Object.keys(opt).length > 0;
const isValid = isIsoDateValid(data);
if (!isValid) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `is an invalid IsoDate`,
},
];
return false;
}
if (!hasOptions)
return true;
const { before, sameOrBefore, after, sameOrAfter } = opt;
const errors = [];
if (before) {
const isParamValid = isIsoDateValid(before);
const isRuleValid = isParamValid && before > data;
if (!isRuleValid) {
errors.push({
instancePath: ctx?.instancePath ?? '',
message: `is not before ${before}`,
});
}
}
if (sameOrBefore) {
const isParamValid = isIsoDateValid(sameOrBefore);
const isRuleValid = isParamValid && sameOrBefore >= data;
if (!isRuleValid) {
errors.push({
instancePath: ctx?.instancePath ?? '',
message: `is not the same or before ${sameOrBefore}`,
});
}
}
if (after) {
const isParamValid = isIsoDateValid(after);
const isRuleValid = isParamValid && after < data;
if (!isRuleValid) {
errors.push({
instancePath: ctx?.instancePath ?? '',
message: `is not after ${after}`,
});
}
}
if (sameOrAfter) {
const isParamValid = isIsoDateValid(sameOrAfter);
const isRuleValid = isParamValid && sameOrAfter <= data;
if (!isRuleValid) {
errors.push({
instancePath: ctx?.instancePath ?? '',
message: `is not the same or after ${sameOrAfter}`,
});
}
}
if (errors.length === 0)
return true;
validate.errors = errors;
return false;
},
});
ajv.addKeyword({
keyword: 'IsoDateTime',
type: 'string',
modifying: false,
errors: true,
schemaType: 'boolean',
validate: function validate(_opt, data, _schema, ctx) {
const isValid = isIsoDateTimeValid(data);
if (isValid)
return true;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `is an invalid IsoDateTime`,
},
];
return false;
},
});
ajv.addKeyword({
keyword: 'IsoMonth',
type: 'string',
modifying: false,
errors: true,
schemaType: 'object',
validate: function validate(_opt, data, _schema, ctx) {
const isValid = isIsoMonthValid(data);
if (isValid)
return true;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `is an invalid IsoMonth`,
},
];
return false;
},
});
ajv.addKeyword({
keyword: 'errorMessages',
schemaType: 'object',
});
ajv.addKeyword({
keyword: 'hasIsOfTypeCheck',
schemaType: 'boolean',
});
ajv.addKeyword({
keyword: 'optionalValues',
type: ['string', 'number', 'boolean', 'null'],
modifying: true,
errors: false,
schemaType: 'array',
validate: function validate(optionalValues, data, _schema, ctx) {
if (!optionalValues)
return true;
_assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `optional([x, y, z])` on a property of an object, or on an element of an array due to Ajv mutation issues.');
if (!optionalValues.includes(data))
return true;
ctx.parentData[ctx.parentDataProperty] = undefined;
return true;
},
});
ajv.addKeyword({
keyword: 'keySchema',
type: 'object',
modifying: true,
errors: false,
schemaType: 'object',
compile(innerSchema, _parentSchema, _it) {
const isValidKeyFn = ajv.compile(innerSchema);
function validate(data, _ctx) {
if (typeof data !== 'object' || data === null)
return true;
for (const key of Object.keys(data)) {
if (!isValidKeyFn(key)) {
delete data[key];
}
}
return true;
}
return validate;
},
});
// Validates that the value is undefined. Used in record/stringMap with optional value schemas
// to allow undefined values in patternProperties via anyOf.
ajv.addKeyword({
keyword: 'isUndefined',
modifying: false,
errors: false,
schemaType: 'boolean',
validate: (_schema, data) => data === undefined,
});
// This is added because Ajv validates the `min/maxProperties` before validating the properties.
// So, in case of `minProperties(1)` and `{ foo: 'bar' }` Ajv will let it pass, even
// if the property validation would strip `foo` from the data.
// And Ajv would return `{}` as a successful validation.
// Since the keyword validation runs after JSON Schema validation,
// here we can make sure the number of properties are right ex-post property validation.
// It's named with the `2` suffix, because `minProperties` is reserved.
// And `maxProperties` does not suffer from this error due to the nature of how maximum works.
ajv.addKeyword({
keyword: 'minProperties2',
type: 'object',
modifying: false,
errors: true,
schemaType: 'number',
validate: function validate(minProperties, data, _schema, ctx) {
if (typeof data !== 'object')
return true;
const numberOfProperties = Object.entries(data).filter(([, v]) => v !== undefined).length;
const isValid = numberOfProperties >= minProperties;
if (!isValid) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `must NOT have fewer than ${minProperties} properties`,
},
];
}
return isValid;
},
});
ajv.addKeyword({
keyword: 'exclusiveProperties',
type: 'object',
modifying: false,
errors: true,
schemaType: 'array',
validate: function validate(exclusiveProperties, data, _schema, ctx) {
if (typeof data !== 'object')
return true;
for (const props of exclusiveProperties) {
let numberOfDefinedProperties = 0;
for (const prop of props) {
if (data[prop] !== undefined)
numberOfDefinedProperties++;
if (numberOfDefinedProperties > 1) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `must have only one of the "${props.join(', ')}" properties`,
},
];
return false;
}
}
}
return true;
},
});
ajv.addKeyword({
keyword: 'anyOfBy',
type: 'object',
modifying: true,
errors: true,
schemaType: 'object',
compile(config, _parentSchema, _it) {
const { propertyName, schemaDictionary } = config;
const isValidFnByKey = _mapObject(schemaDictionary, (key, value) => {
return [key, ajv.compile(value)];
});
function validate(data, ctx) {
if (typeof data !== 'object' || data === null)
return true;
const determinant = data[propertyName];
const isValidFn = isValidFnByKey[determinant];
if (!isValidFn) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `could not find a suitable schema to validate against based on "${propertyName}"`,
},
];
return false;
}
const result = isValidFn(data);
if (!result) {
;
validate.errors = isValidFn.errors;
}
return result;
}
return validate;
},
});
ajv.addKeyword({
keyword: 'anyOfThese',
modifying: true,
errors: true,
schemaType: 'array',
compile(schemas, _parentSchema, _it) {
const validators = schemas.map(schema => ajv.compile(schema));
function validate(data, ctx) {
let correctValidator;
let result = false;
let clonedData;
// Try each validator until we find one that works!
for (const validator of validators) {
clonedData = isPrimitive(data) ? _deepCopy(data) : data;
result = validator(clonedData);
if (result) {
correctValidator = validator;
break;
}
}
if (result && ctx?.parentData && ctx.parentDataProperty !== undefined) {
// If we found a validator and the data is valid and we are validating a property inside an object,
// then we can inject our result and be done with it.
ctx.parentData[ctx.parentDataProperty] = clonedData;
}
else if (result) {
// If we found a validator but we are not validating a property inside an object,
// then we must re-run the validation so that the mutations caused by Ajv
// will be done on the input data, not only on the clone.
result = correctValidator(data);
}
else {
// If we didn't find a fitting schema,
// we add our own error.
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: `could not find a suitable schema to validate against`,
},
];
}
return result;
}
return validate;
},
});
ajv.addKeyword({
keyword: 'precision',
type: ['number'],
modifying: true,
errors: false,
schemaType: 'number',
validate: function validate(numberOfDigits, data, _schema, ctx) {
if (!numberOfDigits)
return true;
_assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `precision(n)` on a property of an object, or on an element of an array due to Ajv mutation issues.');
ctx.parentData[ctx.parentDataProperty] = _round(data, 10 ** (-1 * numberOfDigits));
return true;
},
});
ajv.addKeyword({
keyword: 'customValidations',
modifying: false,
errors: true,
schemaType: 'array',
validate: function validate(customValidations, data, _schema, ctx) {
if (!customValidations?.length)
return true;
for (const validator of customValidations) {
const error = validator(data);
if (error) {
;
validate.errors = [
{
instancePath: ctx?.instancePath ?? '',
message: error,
},
];
return false;
}
}
return true;
},
});
ajv.addKeyword({
keyword: 'customConversions',
modifying: true,
errors: false,
schemaType: 'array',
validate: function validate(customConversions, data, _schema, ctx) {
if (!customConversions?.length)
return true;
_assert(ctx?.parentData && ctx.parentDataProperty !== undefined, 'You should only use `convert()` on a property of an object, or on an element of an array due to Ajv mutation issues.');
for (const converter of customConversions) {
data = converter(data);
}
ctx.parentData[ctx.parentDataProperty] = data;
return true;
},
});
// postValidation is handled in AjvSchema.getValidationResult, not by Ajv itself.
// We register it here so Ajv's strict mode doesn't reject the keyword.
ajv.addKeyword({
keyword: 'postValidation',
valid: true,
});
return ajv;
}
const monthLengths = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const DASH_CODE = '-'.charCodeAt(0);
const ZERO_CODE = '0'.charCodeAt(0);
const PLUS_CODE = '+'.charCodeAt(0);
const COLON_CODE = ':'.charCodeAt(0);
/**
* This is a performance optimized correct validation
* for ISO dates formatted as YYYY-MM-DD.
*
* - Slightly more performant than using `localDate`.
* - More performant than string splitting and `Number()` conversions
* - Less performant than regex, but it does not allow invalid dates.
*/
function isIsoDateValid(s) {
// must be exactly "YYYY-MM-DD"
if (s.length !== 10)
return false;
if (s.charCodeAt(4) !== DASH_CODE || s.charCodeAt(7) !== DASH_CODE)
return false;
// fast parse numbers without substrings/Number()
const year = (s.charCodeAt(0) - ZERO_CODE) * 1000 +
(s.charCodeAt(1) - ZERO_CODE) * 100 +
(s.charCodeAt(2) - ZERO_CODE) * 10 +
(s.charCodeAt(3) - ZERO_CODE);
const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE);
const day = (s.charCodeAt(8) - ZERO_CODE) * 10 + (s.charCodeAt(9) - ZERO_CODE);
if (month < 1 || month > 12 || day < 1)
return false;
if (month !== 2) {
return day <= monthLengths[month];
}
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
return day <= (isLeap ? 29 : 28);
}
/**
* This is a performance optimized correct validation
* for ISO datetimes formatted as "YYYY-MM-DDTHH:MM:SS" followed by
* nothing, "Z" or "+hh:mm" or "-hh:mm".
*
* - Slightly more performant than using `localTime`.
* - More performant than string splitting and `Number()` conversions
* - Less performant than regex, but it does not allow invalid dates.
*/
function isIsoDateTimeValid(s) {
if (s.length < 19 || s.length > 25)
return false;
if (s.charCodeAt(10) !== 84)
return false; // 'T'
const datePart = s.slice(0, 10); // YYYY-MM-DD
if (!isIsoDateValid(datePart))
return false;
const timePart = s.slice(11, 19); // HH:MM:SS
if (!isIsoTimeValid(timePart))
return false;
const zonePart = s.slice(19); // nothing or Z or +/-hh:mm
if (!isIsoTimezoneValid(zonePart))
return false;
return true;
}
/**
* This is a performance optimized correct validation
* for ISO times formatted as "HH:MM:SS".
*
* - Slightly more performant than using `localTime`.
* - More performant than string splitting and `Number()` conversions
* - Less performant than regex, but it does not allow invalid dates.
*/
function isIsoTimeValid(s) {
if (s.length !== 8)
return false;
if (s.charCodeAt(2) !== COLON_CODE || s.charCodeAt(5) !== COLON_CODE)
return false;
const hour = (s.charCodeAt(0) - ZERO_CODE) * 10 + (s.charCodeAt(1) - ZERO_CODE);
if (hour < 0 || hour > 23)
return false;
const minute = (s.charCodeAt(3) - ZERO_CODE) * 10 + (s.charCodeAt(4) - ZERO_CODE);
if (minute < 0 || minute > 59)
return false;
const second = (s.charCodeAt(6) - ZERO_CODE) * 10 + (s.charCodeAt(7) - ZERO_CODE);
if (second < 0 || second > 59)
return false;
return true;
}
/**
* This is a performance optimized correct validation
* for the timezone suffix of ISO times
* formatted as "Z" or "+HH:MM" or "-HH:MM".
*
* It also accepts an empty string.
*/
function isIsoTimezoneValid(s) {
if (s === '')
return true;
if (s === 'Z')
return true;
if (s.length !== 6)
return false;
if (s.charCodeAt(0) !== PLUS_CODE && s.charCodeAt(0) !== DASH_CODE)
return false;
if (s.charCodeAt(3) !== COLON_CODE)
return false;
const isWestern = s[0] === '-';
const isEastern = s[0] === '+';
const hour = (s.charCodeAt(1) - ZERO_CODE) * 10 + (s.charCodeAt(2) - ZERO_CODE);
if (hour < 0)
return false;
if (isWestern && hour > 12)
return false;
if (isEastern && hour > 14)
return false;
const minute = (s.charCodeAt(4) - ZERO_CODE) * 10 + (s.charCodeAt(5) - ZERO_CODE);
if (minute < 0 || minute > 59)
return false;
if (isEastern && hour === 14 && minute > 0)
return false; // max is +14:00
if (isWestern && hour === 12 && minute > 0)
return false; // min is -12:00
return true;
}
/**
* This is a performance optimized correct validation
* for ISO month formatted as "YYYY-MM".
*/
function isIsoMonthValid(s) {
// must be exactly "YYYY-MM"
if (s.length !== 7)
return false;
if (s.charCodeAt(4) !== DASH_CODE)
return false;
// fast parse numbers without substrings/Number()
const year = (s.charCodeAt(0) - ZERO_CODE) * 1000 +
(s.charCodeAt(1) - ZERO_CODE) * 100 +
(s.charCodeAt(2) - ZERO_CODE) * 10 +
(s.charCodeAt(3) - ZERO_CODE);
const month = (s.charCodeAt(5) - ZERO_CODE) * 10 + (s.charCodeAt(6) - ZERO_CODE);
return _isBetween(year, 1900, 2500, '[]') && _isBetween(month, 1, 12, '[]');
}
function isPrimitive(data) {
return data !== null && typeof data === 'object';
}