UNPKG

@naturalcycles/nodejs-lib

Version:
773 lines (772 loc) 29.3 kB
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'; }