@exodus/schemasafe
Advanced tools
Comparing version 1.0.0-alpha.2 to 1.0.0-alpha.3
{ | ||
"name": "@exodus/schemasafe", | ||
"version": "1.0.0-alpha.2", | ||
"version": "1.0.0-alpha.3", | ||
"description": "JSON Safe Parser & Schema Validator", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
@@ -131,2 +131,3 @@ # `@exodus/schemasafe` | ||
* (function() { | ||
* 'use strict' | ||
* const format0 = (value) => /^0x[0-9A-Fa-f]*$/.test(value); | ||
@@ -136,9 +137,4 @@ * return (function validate(data) { | ||
* let errors = 0 | ||
* if (!(typeof data === "string")) { | ||
* return false | ||
* } else { | ||
* if (!format0(data)) { | ||
* return false | ||
* } | ||
* } | ||
* if (!(typeof data === "string")) return false | ||
* if (!format0(data)) return false | ||
* return errors === 0 | ||
@@ -145,0 +141,0 @@ * })})(); |
167
src/index.js
@@ -49,5 +49,7 @@ 'use strict' | ||
const noopRegExps = new Set(['^[\\s\\S]*$', '^[\\S\\s]*$', '^[^]*$', '', '.*']) | ||
// Helper methods for semi-structured paths | ||
const propvar = (name, key) => ({ parent: name, keyname: key }) // property by variable | ||
const propimm = (name, val) => ({ parent: name, keyval: val }) // property by immediate value | ||
const propvar = (parent, keyname, inKeys = false) => ({ parent, keyname, inKeys }) // property by variable | ||
const propimm = (parent, keyval) => ({ parent, keyval }) // property by immediate value | ||
const buildName = ({ name, parent, keyval, keyname }) => { | ||
@@ -75,2 +77,3 @@ if (name) { | ||
useDefaults = false, | ||
removeAdditional = false, // supports additionalProperties: false and additionalItems: false | ||
includeErrors: optIncludeErrors = false, | ||
@@ -84,2 +87,4 @@ allErrors: optAllErrors = false, | ||
complexityChecks = opts.mode === 'strong', | ||
isJSON: optIsJSON = false, // assume input to be JSON, which e.g. makes undefined impossible | ||
jsonCheck = false, // disabled by default, it's assumed that data is from JSON.parse | ||
$schemaDefault = null, | ||
@@ -106,2 +111,5 @@ formats: optFormats = {}, | ||
throw new Error('Strong mode forbids weakFormats and allowUnusedKeywords') | ||
if (optIsJSON && jsonCheck) | ||
throw new Error('Can not specify both isJSON and jsonCheck options, please choose one') | ||
const isJSON = optIsJSON || jsonCheck | ||
@@ -136,12 +144,17 @@ if (!scope[scopeCache]) | ||
const name = buildName(location) // also checks for sanity, do not remove | ||
const { parent, keyval, keyname } = location | ||
if (parent) { | ||
const { parent, keyval, keyname, inKeys } = location | ||
if (inKeys) { | ||
/* c8 ignore next */ | ||
if (isJSON) throw new Error('Unreachable: useless check, can not be undefined') | ||
return format('%s !== undefined', name) | ||
} | ||
if (parent && keyname) { | ||
scope.hasOwn = functions.hasOwn | ||
if (keyval) { | ||
return format('%s !== undefined && hasOwn(%s, %j)', name, parent, keyval) | ||
} else if (keyname) { | ||
return format('%s !== undefined && hasOwn(%s, %s)', name, parent, keyname) | ||
} | ||
return format('%s !== undefined && hasOwn(%s, %s)', name, parent, keyname) | ||
} else if (parent && keyval !== undefined) { | ||
scope.hasOwn = functions.hasOwn | ||
return format('%s !== undefined && hasOwn(%s, %j)', name, parent, keyval) | ||
} | ||
return format('%s !== undefined', name) | ||
/* c8 ignore next */ | ||
throw new Error('Unreachable: present() check without parent') | ||
} | ||
@@ -156,8 +169,11 @@ | ||
let jsonCheckPerformed = false | ||
const getMeta = () => rootMeta.get(root) || {} | ||
const basePathStack = basePathRoot ? [basePathRoot] : [] | ||
const visit = (allErrors, includeErrors, current, node, schemaPath) => { | ||
const visit = (allErrors, includeErrors, history, current, node, schemaPath) => { | ||
// e.g. top-level data and property names, OR already checked by present() in history, OR in keys and not undefined | ||
const definitelyPresent = | ||
!current.parent || history.includes(current) || (current.inKeys && isJSON) | ||
const name = buildName(current) | ||
const rule = (...args) => visit(allErrors, includeErrors, ...args) | ||
const subrule = (...args) => visit(true, false, ...args) | ||
const writeErrorObject = (error) => { | ||
@@ -175,9 +191,7 @@ if (allErrors) { | ||
if (includeErrors === true) { | ||
const leanError = { field: prop || name, message: msg } | ||
const errorObj = { field: prop || name, message: msg, schemaPath: toPointer(schemaPath) } | ||
if (verboseErrors) { | ||
const type = node.type || 'any' | ||
const fullError = { ...leanError, type, schemaPath: toPointer(schemaPath) } | ||
writeErrorObject(format('{ ...%j, value: %s }', fullError, value || name)) | ||
writeErrorObject(format('{ ...%j, value: %s }', errorObj, value || name)) | ||
} else { | ||
writeErrorObject(format('%j', leanError)) | ||
writeErrorObject(format('%j', errorObj)) | ||
} | ||
@@ -192,5 +206,15 @@ } | ||
const errorIf = (fmt, args, ...errorArgs) => { | ||
fun.write('if (%s) {', format(fmt, ...args)) | ||
error(...errorArgs) | ||
fun.write('}') | ||
const condition = format(fmt, ...args) | ||
if (includeErrors === false) { | ||
// in this case, we can fast-track and inline this to generate more readable code | ||
if (allErrors) { | ||
fun.write('if (%s) errors++', condition) | ||
} else { | ||
fun.write('if (%s) return false', condition) | ||
} | ||
} else { | ||
fun.write('if (%s) {', condition) | ||
error(...errorArgs) | ||
fun.write('}') | ||
} | ||
} | ||
@@ -206,2 +230,11 @@ | ||
// JSON check is once only for the top-level object, before everything else | ||
if (jsonCheck && !jsonCheckPerformed) { | ||
/* c8 ignore next */ | ||
if (`${name}` !== 'data') throw new Error('Unreachable: invalid json check') | ||
scope.deepEqual = functions.deepEqual | ||
errorIf('!deepEqual(%s, JSON.parse(JSON.stringify(%s)))', [name, name], 'not JSON compatible') | ||
jsonCheckPerformed = true | ||
} | ||
if (typeof node === 'boolean') { | ||
@@ -211,2 +244,5 @@ if (node === true) { | ||
enforceValidation('schema = true is not allowed') | ||
} else if (definitelyPresent) { | ||
// node === false always fails in this case | ||
error('is unexpected') | ||
} else { | ||
@@ -223,2 +259,7 @@ // node === false | ||
if (Object.keys(node).length === 0) { | ||
enforceValidation('empty rules node encountered') | ||
return // nothing to validate here, basically the same as node === true | ||
} | ||
const unused = new Set(Object.keys(node)) | ||
@@ -233,5 +274,4 @@ const consume = (prop, ...ruleTypes) => { | ||
const isTopLevel = !current.parent // e.g. top-level data and property names | ||
const finish = () => { | ||
if (!isTopLevel) fun.write('}') // undefined check | ||
if (!definitelyPresent) fun.write('}') // undefined check | ||
enforce(unused.size === 0 || allowUnusedKeywords, 'Unprocessed keywords:', [...unused]) | ||
@@ -283,7 +323,6 @@ } | ||
const defaultIsPresent = node.default !== undefined && useDefaults // will consume on use | ||
if (isTopLevel) { | ||
// top-level data is coerced to null above, or is an object key, it can't be undefined | ||
if (defaultIsPresent) fail('Can not apply default value at root') | ||
if (definitelyPresent) { | ||
if (defaultIsPresent) fail('Can not apply default value here (e.g. at root)') | ||
if (node.required === true || node.required === false) | ||
fail('Can not apply boolean required at root') | ||
fail('Can not apply boolean required here (e.g. at root)') | ||
} else if (defaultIsPresent || booleanRequired) { | ||
@@ -318,3 +357,4 @@ fun.write('if (!(%s)) {', present(current)) | ||
scope[n] = (...args) => fn(...args) | ||
fn = compile(sub, subRoot, { ...opts, includeErrors: false }, scope, path) | ||
const override = { includeErrors: false, jsonCheck: false, isJSON } | ||
fn = compile(sub, subRoot, { ...opts, ...override }, scope, path) | ||
scope[n] = fn | ||
@@ -367,2 +407,6 @@ } | ||
// Can not be used before undefined check above! The one performed by present() | ||
const rule = (...args) => visit(allErrors, includeErrors, [...history, current], ...args) | ||
const subrule = (...args) => visit(true, false, [...history, current], ...args) | ||
/* Checks inside blocks are independent, they are happening on the same code depth */ | ||
@@ -444,3 +488,3 @@ | ||
enforceRegex(node.pattern) | ||
if (node.pattern !== '^[\\s\\S]*$' && node.pattern !== '^[\\S\\s]*$') { | ||
if (!noopRegExps.has(node.pattern)) { | ||
const p = patterns(node.pattern) | ||
@@ -491,3 +535,8 @@ errorIf('!%s.test(%s)', [p, name], 'pattern mismatch') | ||
} else if (node.additionalItems === false) { | ||
errorIf('%s.length > %d', [name, node.items.length], 'has additional items') | ||
const limit = node.items.length | ||
if (removeAdditional) { | ||
fun.write('if (%s.length > %d) %s.length = %d', name, limit, name, limit) | ||
} else { | ||
errorIf('%s.length > %d', [name, limit], 'has additional items') | ||
} | ||
consume('additionalItems', 'boolean') | ||
@@ -513,11 +562,7 @@ } else if (node.additionalItems) { | ||
const i = genloop() | ||
fun.write('for (let %s = 0; %s < %s.length; %s++) {', i, i, name, i) | ||
fun.write('const %s = errors', prev) | ||
subrule(propvar(name, i), node.contains, subPath('contains')) | ||
fun.write('if (%s === errors) {', prev) | ||
fun.write('%s++', passes) | ||
fun.write('} else {') | ||
fun.write('errors = %s', prev) | ||
fun.write('}') | ||
fun.write('}') | ||
fun.block('for (let %s = 0; %s < %s.length; %s++) {', [i, i, name, i], '}', () => { | ||
fun.write('const %s = errors', prev) | ||
subrule(propvar(name, i), node.contains, subPath('contains')) | ||
fun.write('if (%s === errors) { %s++ } else errors = %s', prev, passes, prev) | ||
}) | ||
@@ -633,3 +678,4 @@ if (Number.isFinite(node.minContains)) { | ||
fun.block('if (%s.test(%s)) {', [patterns(p), key], '}', () => { | ||
rule(propvar(name, key), node.patternProperties[p], subPath('patternProperties', p)) | ||
const sub = propvar(name, key, true) // always own property, from Object.keys | ||
rule(sub, node.patternProperties[p], subPath('patternProperties', p)) | ||
}) | ||
@@ -652,5 +698,10 @@ } | ||
if (node.additionalProperties === false) { | ||
error('has additional properties', null, format('%j + %s', `${name}.`, key)) | ||
if (removeAdditional) { | ||
fun.write('delete %s[%s]', name, key) | ||
} else { | ||
error('has additional properties', null, format('%j + %s', `${name}.`, key)) | ||
} | ||
} else { | ||
rule(propvar(name, key), node.additionalProperties, subPath('additionalProperties')) | ||
const sub = propvar(name, key, true) // always own property, from Object.keys | ||
rule(sub, node.additionalProperties, subPath('additionalProperties')) | ||
} | ||
@@ -685,5 +736,3 @@ }) | ||
error('negative schema matches') | ||
fun.write('} else {') | ||
fun.write('errors = %s', prev) | ||
fun.write('}') | ||
fun.write('} else errors = %s', prev) | ||
consume('not', 'object', 'boolean') | ||
@@ -734,3 +783,3 @@ } | ||
node.anyOf.forEach((sch, i) => { | ||
if (i) fun.write('}') | ||
if (i > 0) fun.write('}') | ||
}) | ||
@@ -752,7 +801,3 @@ fun.write('if (%s !== errors) {', prev) | ||
subrule(current, sch, schemaPath) | ||
fun.write('if (%s === errors) {', prev) | ||
fun.write('%s++', passes) | ||
fun.write('} else {') | ||
fun.write('errors = %s', prev) | ||
fun.write('}') | ||
fun.write('if (%s === errors) { %s++ } else errors = %s', prev, passes, prev) | ||
} | ||
@@ -781,11 +826,7 @@ errorIf('%s !== 1', [passes], 'no (or more than one) schemas match') | ||
const needTypeValidation = `${typeValidate}` !== 'true' | ||
if (needTypeValidation) { | ||
fun.write('if (!(%s)) {', typeValidate) | ||
error('is the wrong type') | ||
} | ||
if (needTypeValidation) errorIf('!(%s)', [typeValidate], 'is the wrong type') | ||
if (type) consume('type', 'string', 'array') | ||
// If type validation was needed, we should wrap this inside an else clause. | ||
// No need to close, type validation would always close at the end if it's used. | ||
maybeWrap(needTypeValidation, '} else {', [], '', () => { | ||
// If type validation was needed and did not return early, wrap this inside an else clause. | ||
maybeWrap(needTypeValidation && allErrors, 'else {', [], '}', () => { | ||
typeWrap(checkNumbers, ['number', 'integer'], types.get('number')(name)) | ||
@@ -798,8 +839,6 @@ typeWrap(checkStrings, ['string'], types.get('string')(name)) | ||
if (needTypeValidation) fun.write('}') // type check | ||
finish() | ||
} | ||
visit(optAllErrors, optIncludeErrors, { name: safe('data') }, schema, []) | ||
visit(optAllErrors, optIncludeErrors, [], { name: safe('data') }, schema, []) | ||
@@ -821,3 +860,3 @@ fun.write('return errors === 0') | ||
// strong mode is default in parser | ||
const validate = validator(schema, { mode: 'strong', ...opts }) | ||
const validate = validator(schema, { mode: 'strong', ...opts, jsonCheck: false, isJSON: true }) | ||
const parse = (src) => { | ||
@@ -830,3 +869,5 @@ if (typeof src !== 'string') throw new Error('Invalid type!') | ||
: '' | ||
throw new Error(`JSON validation error${message ? `: ${message}` : ''}`) | ||
const error = new Error(`JSON validation error${message ? `: ${message}` : ''}`) | ||
error.errors = validate.errors | ||
throw error | ||
} | ||
@@ -833,0 +874,0 @@ parse.toModule = () => |
'use strict' | ||
const { format } = require('./safe-format') | ||
const isArrowFnWithParensRegex = /^\([^)]*\) *=>/ | ||
@@ -29,3 +31,3 @@ const isArrowFnWithoutParensRegex = /^[^=]*=>/ | ||
const proto = Object.getPrototypeOf(item) | ||
if (item instanceof RegExp && proto === RegExp.prototype) return String(item) | ||
if (item instanceof RegExp && proto === RegExp.prototype) return format('%r', item) | ||
throw new Error('Can not stringify an object with unexpected prototype') | ||
@@ -32,0 +34,0 @@ } |
'use strict' | ||
function toPointer(path) { | ||
if (path.length === 0) return '' | ||
if (path.length === 0) return '#' | ||
return `#/${path.map((part) => `${part}`.replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}` | ||
@@ -6,0 +6,0 @@ } |
@@ -6,5 +6,13 @@ 'use strict' | ||
const compares = new Set(['<', '>', '<=', '>=']) | ||
const escapeCode = (code) => `\\u${code.toString(16).padStart(4, '0')}` | ||
// Supports simple js variables only, i.e. constants and JSON-stringifiable | ||
const jsval = (val) => { | ||
if ([Infinity, -Infinity, NaN, undefined].includes(val)) return `${val}` | ||
// https://v8.dev/features/subsume-json#security, e.g. {'\u2028':0} on Node.js 8 | ||
return JSON.stringify(val).replace(/[\u2028\u2029]/g, (char) => escapeCode(char.charCodeAt(0))) | ||
} | ||
const format = (fmt, ...args) => { | ||
const res = fmt.replace(/%[%dscj]/g, (match) => { | ||
const res = fmt.replace(/%[%drscj]/g, (match) => { | ||
if (match === '%%') return '%' | ||
@@ -17,2 +25,6 @@ if (args.length === 0) throw new Error('Unexpected arguments count') | ||
throw new Error('Expected a number') | ||
case '%r': | ||
// String(regex) is not ok on Node.js 10 and below: console.log(String(new RegExp('\n'))) | ||
if (val instanceof RegExp) return format('new RegExp(%j, %j)', val.source, val.flags) | ||
throw new Error('Expected a RegExp instance') | ||
case '%s': | ||
@@ -25,4 +37,3 @@ if (val instanceof SafeString) return val | ||
case '%j': | ||
if ([Infinity, -Infinity, NaN, undefined].includes(val)) return `${val}` | ||
return JSON.stringify(val) | ||
return jsval(val) | ||
} | ||
@@ -29,0 +40,0 @@ /* c8 ignore next */ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
61647
1195
161