@exodus/schemasafe
Advanced tools
Comparing version 1.0.0-beta.4 to 1.0.0-beta.5
{ | ||
"name": "@exodus/schemasafe", | ||
"version": "1.0.0-beta.4", | ||
"version": "1.0.0-beta.5", | ||
"description": "JSON Safe Parser & Schema Validator", | ||
@@ -33,3 +33,3 @@ "license": "MIT", | ||
"coverage:lcov": "c8 --reporter=lcovonly npm run test", | ||
"test": "npm run test:normal | tap-spec && npm run test:module | tap-spec", | ||
"test": "npm run test:raw | tap-spec", | ||
"test:raw": "npm run test:normal && npm run test:module", | ||
@@ -36,0 +36,0 @@ "test:module": "tape -r ./test/tools/test-module.js test/*.js test/regressions/*.js", |
'use strict' | ||
const { format, safe, safeand, safeor, safenot } = require('./safe-format') | ||
const { format, safe, safeand, safenot, safenotor } = require('./safe-format') | ||
const genfun = require('./generate-function') | ||
@@ -37,2 +37,8 @@ const { resolveReference, joinPath } = require('./pointer') | ||
const constantValue = (schema) => { | ||
if (typeof schema === 'boolean') return schema | ||
if (schemaTypes.get('object')(schema) && Object.keys(schema).length === 0) return true | ||
return undefined | ||
} | ||
const rootMeta = new WeakMap() | ||
@@ -46,4 +52,6 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { | ||
allErrors = false, | ||
dryRun = false, | ||
dryRun, // unused, just for rest siblings | ||
allowUnusedKeywords = opts.mode === 'lax', | ||
allowUnreachable = opts.mode === 'lax', | ||
requireSchema = opts.mode === 'strong', | ||
requireValidation = opts.mode === 'strong', | ||
@@ -76,2 +84,4 @@ requireStringValidation = opts.mode === 'strong', | ||
if (!includeErrors && allErrors) throw new Error('allErrors requires includeErrors to be enabled') | ||
if (requireSchema && $schemaDefault) throw new Error('requireSchema forbids $schemaDefault') | ||
if (mode === 'strong' && !requireSchema) throw new Error('Strong mode demands requireSchema') | ||
@@ -153,5 +163,3 @@ const { gensym, getref, genref, genformat } = scopeMethods(scope) | ||
fun.write('validate.errors.push(...%s.map(e => errorMerge(e, %j, %s)))', ...args) | ||
} else { | ||
fun.write('validate.errors = [errorMerge(%s[0], %j, %s)]', ...args) | ||
} | ||
} else fun.write('validate.errors = [errorMerge(%s[0], %j, %s)]', ...args) | ||
} else if (includeErrors === true && errors) { | ||
@@ -162,6 +170,3 @@ const errorJS = format('{ keywordLocation: %j, instanceLocation: %s }', schemaP, dataP) | ||
fun.write('%s.push(%s)', errors, errorJS) | ||
} else { | ||
// Array assignment is significantly faster, do not refactor the two branches | ||
fun.write('%s = [%s]', errors, errorJS) | ||
} | ||
} else fun.write('%s = [%s]', errors, errorJS) // Array assignment is significantly faster, do not refactor the two branches | ||
} | ||
@@ -172,6 +177,3 @@ if (suberr) mergeerror(suberr) // can only happen in allErrors | ||
} | ||
const errorIf = (condition, errorArgs) => { | ||
if (includeErrors === true && errors) fun.if(condition, () => error(errorArgs)) | ||
else fun.write('if (%s) return false', condition) // fast-track and inline for more readable code | ||
} | ||
const errorIf = (condition, errorArgs) => fun.if(condition, () => error(errorArgs)) | ||
@@ -185,5 +187,8 @@ const fail = (msg, value) => { | ||
const enforceMinMax = (a, b) => laxMode(!(node[b] < node[a]), `Invalid ${a} / ${b} combination`) | ||
const enforceValidation = (msg, suffix = 'must be specified') => | ||
const enforceValidation = (msg, suffix = 'should be specified') => | ||
enforce(!requireValidation, `[requireValidation] ${msg} ${suffix}`) | ||
const subPath = (...args) => [...schemaPath, ...args] | ||
const uncertain = (msg) => | ||
enforce(!removeAdditional && !useDefaults, `[removeAdditional/useDefaults] uncertain: ${msg}`) | ||
const complex = (msg, arg) => enforce(!complexityChecks, `[complexityChecks] ${msg}`, arg) | ||
@@ -196,13 +201,7 @@ // evaluated tracing | ||
if (node === true) { | ||
// any is valid | ||
enforceValidation('schema = true', 'is not allowed') | ||
enforceValidation('schema = true', 'is not allowed') // any is valid here | ||
return { stat } // nothing is evaluated for true | ||
} else if (definitelyPresent) { | ||
// node === false always fails in this case | ||
error({}) | ||
} else { | ||
// node === false | ||
errorIf(present(current), {}) | ||
} | ||
evaluateDelta({ properties: [true], items: Infinity, type: [] }) // everything is evaluated for false | ||
errorIf(definitelyPresent || current.inKeys ? true : present(current), {}) // node === false | ||
evaluateDelta({ type: [] }) // everything is evaluated for false | ||
return { stat } | ||
@@ -216,3 +215,3 @@ } | ||
if (Object.keys(node).length === 0) { | ||
enforceValidation('empty rules node', 'encountered') | ||
enforceValidation('empty rules node', 'is not allowed') | ||
return { stat } // nothing to validate here, basically the same as node === true | ||
@@ -244,8 +243,2 @@ } | ||
const finish = (local) => { | ||
if (!definitelyPresent) fun.write('}') // undefined check | ||
enforce(unused.size === 0 || allowUnusedKeywords, 'Unprocessed keywords:', [...unused]) | ||
return { stat, local } // return statically evaluated | ||
} | ||
if (node === root) { | ||
@@ -262,3 +255,3 @@ const $schema = get('$schema', 'string') || $schemaDefault | ||
}) | ||
} | ||
} else enforce(!requireSchema, '[requireSchema] $schema is required') | ||
handle('$vocabulary', ['object'], ($vocabulary) => { | ||
@@ -291,12 +284,2 @@ for (const [vocab, flag] of Object.entries($vocabulary)) { | ||
if (node.default !== undefined && useDefaults) { | ||
if (definitelyPresent) fail('Can not apply default value here (e.g. at root)') | ||
fun.write('if (%s) {', safenot(present(current))) | ||
fun.write('%s = %j', name, get('default', 'jsonval')) | ||
fun.write('} else {') | ||
} else { | ||
handle('default', ['jsonval'], null) // unused | ||
if (!definitelyPresent) fun.write('if (%s) {', present(current)) | ||
} | ||
// evaluated: declare dynamic | ||
@@ -309,4 +292,2 @@ const needUnevaluated = (rule) => | ||
}) | ||
if (local.items) fun.write('const %s = [0]', local.items) | ||
if (local.props) fun.write('const %s = [[], []]', local.props) | ||
const dyn = { items: local.items || trace.items, props: local.props || trace.props } | ||
@@ -318,12 +299,10 @@ const canSkipDynamic = () => | ||
if (dyn.items && delta.items > stat.items) fun.write('%s.push(%d)', dyn.items, delta.items) | ||
if (dyn.props) { | ||
if (dyn.props && delta.properties.includes(true) && !stat.properties.includes(true)) { | ||
fun.write('%s[0].push(true)', dyn.props) | ||
} else if (dyn.props) { | ||
const inStat = (properties, patterns) => inProperties(stat, { properties, patterns }) | ||
const properties = delta.properties.filter((x) => !inStat([x], [])) | ||
const patterns = delta.patterns.filter((x) => !inStat([], [x])) | ||
if (properties.includes(true)) { | ||
fun.write('%s[0].push(true)', dyn.props) | ||
} else { | ||
if (properties.length > 0) fun.write('%s[0].push(...%j)', dyn.props, properties) | ||
if (patterns.length > 0) fun.write('%s[1].push(...%s)', dyn.props, patterns) | ||
} | ||
if (properties.length > 0) fun.write('%s[0].push(...%j)', dyn.props, properties) | ||
if (patterns.length > 0) fun.write('%s[1].push(...%s)', dyn.props, patterns) | ||
} | ||
@@ -364,19 +343,2 @@ } | ||
} | ||
handle('$ref', ['string'], ($ref) => { | ||
const resolved = resolveReference(root, schemas, node.$ref, basePath()) | ||
const [sub, subRoot, path] = resolved[0] || [] | ||
if (!sub && sub !== false) fail('failed to resolve $ref:', node.$ref) | ||
const n = getref(sub) || compileSchema(sub, subRoot, opts, scope, path) | ||
return applyRef(n, { path: ['$ref'] }) | ||
}) | ||
if (node.$ref && getMeta().exclusiveRefs) { | ||
enforce(!opts[optDynamic], 'unevaluated* is supported only on draft2019-09 schemas and above') | ||
return finish() // ref overrides any sibling keywords for older schemas | ||
} | ||
handle('$recursiveRef', ['string'], ($recursiveRef) => { | ||
enforce($recursiveRef === '#', 'Behavior of $recursiveRef is defined only for "#"') | ||
// Apply deep recursion from here only if $recursiveAnchor is true, else just run self | ||
const n = recursiveAnchor ? format('(recursive || validate)') : format('validate') | ||
return applyRef(n, { path: ['$recursiveRef'] }) | ||
}) | ||
@@ -406,4 +368,4 @@ /* Preparation and methods, post-$ref validation will begin at the end of the function */ | ||
enforce(/^\^.*\$$/.test(source), 'Should start with ^ and end with $:', source) | ||
if (complexityChecks && ((source.match(/[{+*]/g) || []).length > 1 || /\)[{+*]/.test(source))) | ||
enforce(target.maxLength !== undefined, 'maxLength should be specified for:', source) | ||
if ((/[{+*].*[{+*]/.test(source) || /\)[{+*]/.test(source)) && target.maxLength === undefined) | ||
complex('maxLength should be specified for pattern:', source) | ||
} | ||
@@ -417,9 +379,15 @@ | ||
// Can not be used before undefined check above! The one performed by present() | ||
const rule = (...args) => visit(errors, [...history, { stat, prop: current }], ...args).stat | ||
const nexthistory = () => [...history, { stat, prop: current }] | ||
// Can not be used before undefined check! The one performed by present() | ||
const rule = (...args) => visit(errors, nexthistory(), ...args).stat | ||
const subrule = (suberr, ...args) => { | ||
if (args[0] === current) { | ||
const constval = constantValue(args[1]) | ||
if (constval === true) return { sub: format('true'), delta: {} } | ||
if (constval === false) return { sub: format('false'), delta: { type: [] } } | ||
} | ||
const sub = gensym('sub') | ||
fun.write('const %s = (() => {', sub) | ||
if (allErrors) fun.write('let errorCount = 0') // scoped error counter | ||
const { stat: delta } = visit(suberr, [...history, { stat, prop: current }], ...args) | ||
const { stat: delta } = visit(suberr, nexthistory(), ...args) | ||
if (allErrors) { | ||
@@ -439,3 +407,3 @@ fun.write('return errorCount === 0') | ||
// suberror can be null e.g. on failed empty contains | ||
if (suberr !== null) fun.write('if (%s) %s.push(...%s)', suberr, errors, suberr) | ||
if (suberr !== null) fun.if(suberr, () => fun.write('%s.push(...%s)', errors, suberr)) | ||
} | ||
@@ -470,4 +438,4 @@ | ||
safeand( | ||
...[...new Set(properties)].map((p) => format('%s !== %j', key, p)), | ||
...[...new Set(patternProperties)].map((p) => safenot(patternTest(p, key))) | ||
...properties.map((p) => format('%s !== %j', key, p)), | ||
...patternProperties.map((p) => safenot(patternTest(p, key))) | ||
) | ||
@@ -528,9 +496,8 @@ | ||
enforce(valid, 'Invalid format used:', fmtname) | ||
const n = genformat(formatImpl) | ||
if (formatImpl instanceof RegExp) { | ||
// built-in formats are fine, check only ones from options | ||
if (functions.hasOwn(optFormats, fmtname)) enforceRegex(formatImpl.source) | ||
return format('!%s.test(%s)', n, target) | ||
return format('!%s.test(%s)', genformat(formatImpl), target) | ||
} | ||
return format('!%s(%s)', n, target) | ||
return format('!%s(%s)', genformat(formatImpl), target) | ||
} | ||
@@ -546,4 +513,3 @@ | ||
evaluateDelta({ fullstring: true }) | ||
if (noopRegExps.has(pattern)) return null | ||
return safenot(patternTest(pattern, name)) | ||
return noopRegExps.has(pattern) ? null : safenot(patternTest(pattern, name)) | ||
}) | ||
@@ -622,2 +588,3 @@ | ||
handle('contains', ['object', 'boolean'], () => { | ||
uncertain('contains') | ||
const passes = gensym('passes') | ||
@@ -629,3 +596,3 @@ fun.write('let %s = 0', passes) | ||
const { sub } = subrule(suberr, prop, node.contains, subPath('contains')) | ||
fun.write('if (%s) %s++', sub, passes) | ||
fun.if(sub, () => fun.write('%s++', passes)) | ||
// evaluateDelta({ unknown: true }) // draft2020: contains counts towards evaluatedItems | ||
@@ -643,3 +610,3 @@ }) | ||
const uniqueIsSimple = () => { | ||
const uniqueSimple = () => { | ||
if (node.maxItems !== undefined) return true | ||
@@ -659,6 +626,4 @@ if (typeof node.items === 'object') { | ||
if (uniqueItems === false) return null | ||
if (complexityChecks) | ||
enforce(uniqueIsSimple(), 'maxItems should be specified for non-primitive uniqueItems') | ||
scope.unique = functions.unique | ||
scope.deepEqual = functions.deepEqual | ||
if (!uniqueSimple()) complex('maxItems should be specified for non-primitive uniqueItems') | ||
Object.assign(scope, { unique: functions.unique, deepEqual: functions.deepEqual }) | ||
return format('!unique(%s)', name) | ||
@@ -669,2 +634,7 @@ }) | ||
// if allErrors is false, we can skip present check for required properties validated before | ||
const checked = (p) => | ||
!allErrors && | ||
(stat.required.includes(p) || queryCurrent().some((h) => h.stat.required.includes(p))) | ||
const checkObjects = () => { | ||
@@ -685,7 +655,2 @@ const propertiesCount = format('Object.keys(%s).length', name) | ||
// if allErrors is false, we can skip present check for required properties validated before | ||
const checked = (p) => | ||
!allErrors && | ||
(stat.required.includes(p) || queryCurrent().some((h) => h.stat.required.includes(p))) | ||
handle('required', ['array'], (required) => { | ||
@@ -722,8 +687,8 @@ for (const req of required) { | ||
) { | ||
const body = () => { | ||
uncertain(dependencies) | ||
fun.if(item.checked ? true : present(item), () => { | ||
const delta = rule(current, deps, subPath(dependencies, key), dyn) | ||
evaluateDelta(orDelta({}, delta)) | ||
evaluateDeltaDynamic(delta) | ||
} | ||
fun.if(item.checked ? true : present(item), body) | ||
}) | ||
} else fail(`Unexpected ${dependencies} entry`) | ||
@@ -769,3 +734,3 @@ } | ||
const primitive = vals.filter((value) => !(value && typeof value === 'object')) | ||
return safenot(safeor(...[...primitive, ...objects].map((value) => compare(name, value)))) | ||
return safenotor(...[...primitive, ...objects].map((value) => compare(name, value))) | ||
}) | ||
@@ -776,33 +741,101 @@ } | ||
handle('not', ['object', 'boolean'], (not) => subrule(null, current, not, subPath('not')).sub) | ||
if (node.not) uncertain('not') | ||
const thenOrElse = node.then || node.then === false || node.else || node.else === false | ||
if ((node.if || node.if === false) && thenOrElse) { | ||
const { sub, delta: deltaIf } = subrule(null, current, node.if, subPath('if'), dyn) | ||
let deltaElse, deltaThen | ||
fun.write('if (%s) {', safenot(sub)) | ||
if (node.else || node.else === false) { | ||
deltaElse = rule(current, node.else, subPath('else'), dyn) | ||
evaluateDeltaDynamic(deltaElse) | ||
consume('else', 'object', 'boolean') | ||
} else deltaElse = {} | ||
if (node.then || node.then === false) { | ||
fun.write('} else {') | ||
deltaThen = rule(current, node.then, subPath('then'), dyn) | ||
evaluateDeltaDynamic(andDelta(deltaIf, deltaThen)) | ||
consume('then', 'object', 'boolean') | ||
} else deltaThen = {} | ||
fun.write('}') | ||
evaluateDelta(orDelta(deltaElse, andDelta(deltaIf, deltaThen))) | ||
consume('if', 'object', 'boolean') | ||
} | ||
if (thenOrElse) | ||
handle('if', ['object', 'boolean'], (ifS) => { | ||
uncertain('if/then/else') | ||
const { sub, delta: deltaIf } = subrule(null, current, ifS, subPath('if'), dyn) | ||
let handleElse, handleThen, deltaElse, deltaThen | ||
handle('else', ['object', 'boolean'], (elseS) => { | ||
handleElse = () => { | ||
deltaElse = rule(current, elseS, subPath('else'), dyn) | ||
evaluateDeltaDynamic(deltaElse) | ||
} | ||
return null | ||
}) | ||
handle('then', ['object', 'boolean'], (thenS) => { | ||
handleThen = () => { | ||
deltaThen = rule(current, thenS, subPath('then'), dyn) | ||
evaluateDeltaDynamic(andDelta(deltaIf, deltaThen)) | ||
} | ||
return null | ||
}) | ||
fun.if(sub, handleThen, handleElse) | ||
evaluateDelta(orDelta(deltaElse || {}, andDelta(deltaIf, deltaThen || {}))) | ||
return null | ||
}) | ||
handle('allOf', ['array'], (allOf) => { | ||
const performAllOf = (allOf, rulePath = 'allOf') => { | ||
enforce(allOf.length > 0, `${rulePath} cannot be empty`) | ||
for (const [key, sch] of Object.entries(allOf)) | ||
evaluateDelta(rule(current, sch, subPath('allOf', key), dyn)) | ||
evaluateDelta(rule(current, sch, subPath(rulePath, key), dyn)) | ||
return null | ||
} | ||
handle('allOf', ['array'], (allOf) => performAllOf(allOf)) | ||
let handleDiscriminator = null | ||
handle('discriminator', ['object'], (discriminator) => { | ||
const seen = new Set() | ||
const fix = (check, message, arg) => enforce(check, `[discriminator]: ${message}`, arg) | ||
const { propertyName: pname, mapping: map, ...e0 } = discriminator | ||
const prop = currPropImm(pname) | ||
fix(pname && !node.oneOf !== !node.anyOf, 'need propertyName, oneOf OR anyOf') | ||
fix(Object.keys(e0).length === 0, 'only "propertyName" and "mapping" are supported') | ||
const keylen = (obj) => (schemaTypes.get('object')(obj) ? Object.keys(obj).length : null) | ||
handleDiscriminator = (branches, ruleName) => { | ||
fix(map === undefined || keylen(map) === branches.length, 'mismatching mapping size') | ||
const runDiscriminator = () => { | ||
fun.write('switch (%s) {', buildName(prop)) // we could also have used ifs for complex types | ||
let delta | ||
for (const [i, { properties, ...branch }] of Object.entries(branches)) { | ||
const { [pname]: { const: ownval, ...e1 } = {}, ...props } = properties || {} | ||
let val = ownval | ||
if (!val && branch.$ref) { | ||
const [sub] = resolveReference(root, schemas, branch.$ref, basePath())[0] || [] | ||
enforce(schemaTypes.get('object')(sub), 'failed to resolve $ref:', branch.$ref) | ||
val = ((sub.properties || {})[pname] || {}).const | ||
} | ||
const ok = typeof val === 'string' && !seen.has(val) && Object.keys(e1).length === 0 | ||
fix(ok, 'branches need unique string const values for [propertyName]') | ||
seen.add(val) | ||
const okMapping = !map || (functions.hasOwn(map, val) && map[val] === branch.$ref) | ||
fix(okMapping, 'mismatching mapping for', val) | ||
fun.write('case %j: {', val) | ||
const subdelta = rule(current, { properties: props, ...branch }, subPath(ruleName, i)) | ||
evaluateDeltaDynamic(subdelta) | ||
delta = delta ? orDelta(delta, subdelta) : subdelta | ||
fun.write('}') | ||
fun.write('break') | ||
} | ||
evaluateDelta(delta) | ||
fun.write('default:') | ||
error({ path: [ruleName] }) | ||
fun.write('}') | ||
} | ||
const propCheck = () => { | ||
if (!checked(pname)) { | ||
const errorPath = ['discriminator', 'propertyName'] | ||
fun.if(present(prop), runDiscriminator, () => error({ path: errorPath, prop })) | ||
} else runDiscriminator() | ||
} | ||
if (allErrors || !functions.deepEqual(stat.type, ['object'])) { | ||
fun.if(types.get('object')(name), propCheck, () => error({ path: ['discriminator'] })) | ||
} else propCheck() | ||
// can't evaluateDelta on type and required to not break the checks below, but discriminator | ||
// is usually used with refs anyway so those won't be of much use | ||
fix(functions.deepEqual(stat.type, ['object']), 'has to be checked for type:', 'object') | ||
fix(stat.required.includes(pname), 'propertyName should be placed in required:', pname) | ||
return null | ||
} | ||
return null | ||
}) | ||
handle('anyOf', ['array'], (anyOf) => { | ||
enforce(anyOf.length > 0, 'anyOf cannot be empty') | ||
if (anyOf.length === 1) return performAllOf(anyOf) | ||
if (handleDiscriminator) return handleDiscriminator(anyOf, 'anyOf') | ||
uncertain('anyOf, use discriminator to make it certain') | ||
const suberr = suberror() | ||
if (anyOf.length > 0 && !canSkipDynamic()) { | ||
if (!canSkipDynamic()) { | ||
// In this case, all have to be checked to gather evaluated properties | ||
@@ -813,3 +846,3 @@ const entries = Object.entries(anyOf).map(([key, sch]) => | ||
evaluateDelta(entries.reduce((acc, cur) => orDelta(acc, cur.delta), {})) | ||
const condition = safenot(safeor(...entries.map(({ sub }) => sub))) | ||
const condition = safenotor(...entries.map(({ sub }) => sub)) | ||
errorIf(condition, { path: ['anyOf'], suberr }) | ||
@@ -820,10 +853,13 @@ for (const { delta, sub } of entries) fun.if(sub, () => evaluateDeltaDynamic(delta)) | ||
let delta | ||
for (const [key, sch] of Object.entries(anyOf)) { | ||
const { sub, delta: deltaVariant } = subrule(suberr, current, sch, subPath('anyOf', key)) | ||
fun.write('if (%s) {', safenot(sub)) | ||
delta = delta ? orDelta(delta, deltaVariant) : deltaVariant | ||
let body = () => error({ path: ['anyOf'], suberr }) | ||
for (const [key, sch] of Object.entries(anyOf).reverse()) { | ||
const oldBody = body | ||
body = () => { | ||
const { sub, delta: deltaVar } = subrule(suberr, current, sch, subPath('anyOf', key)) | ||
fun.if(safenot(sub), oldBody) | ||
delta = delta ? orDelta(delta, deltaVar) : deltaVar | ||
} | ||
} | ||
if (anyOf.length > 0) evaluateDelta(delta) | ||
error({ path: ['anyOf'], suberr }) | ||
anyOf.forEach(() => fun.write('}')) | ||
body() | ||
evaluateDelta(delta) | ||
return null | ||
@@ -833,2 +869,6 @@ }) | ||
handle('oneOf', ['array'], (oneOf) => { | ||
enforce(oneOf.length > 0, 'oneOf cannot be empty') | ||
if (oneOf.length === 1) return performAllOf(oneOf) | ||
if (handleDiscriminator) return handleDiscriminator(oneOf, 'oneOf') | ||
uncertain('oneOf, use discriminator to make it certain') | ||
const passes = gensym('passes') | ||
@@ -842,7 +882,7 @@ fun.write('let %s = 0', passes) | ||
const entry = subrule(suberr, current, sch, subPath('oneOf', key), dyn) | ||
fun.write('if (%s) %s++', entry.sub, passes) | ||
fun.if(entry.sub, () => fun.write('%s++', passes)) | ||
delta = delta ? orDelta(delta, entry.delta) : entry.delta | ||
return entry | ||
}) | ||
if (oneOf.length > 0) evaluateDelta(delta) | ||
evaluateDelta(delta) | ||
errorIf(format('%s !== 1', passes), { path: ['oneOf'] }) | ||
@@ -897,4 +937,2 @@ fun.if(format('%s === 0', passes), () => mergeerror(suberr)) // if none matched, dump all errors | ||
/* Actual post-$ref validation happens below */ | ||
const performValidation = () => { | ||
@@ -925,26 +963,80 @@ if (prev !== null) fun.write('const %s = errorCount', prev) | ||
let typeIfAdded = false | ||
handle('type', ['string', 'array'], (type) => { | ||
const typearr = Array.isArray(type) ? type : [type] | ||
for (const t of typearr) enforce(typeof t === 'string' && types.has(t), 'Unknown type:', t) | ||
if (current.type) { | ||
enforce(functions.deepEqual(typearr, [current.type]), 'One type is allowed:', current.type) | ||
evaluateDelta({ type: [current.type] }) | ||
// main post-presence check validation function | ||
const writeMain = () => { | ||
if (local.items) fun.write('const %s = [0]', local.items) | ||
if (local.props) fun.write('const %s = [[], []]', local.props) | ||
// refs | ||
handle('$ref', ['string'], ($ref) => { | ||
const resolved = resolveReference(root, schemas, $ref, basePath()) | ||
const [sub, subRoot, path] = resolved[0] || [] | ||
if (!sub && sub !== false) fail('failed to resolve $ref:', $ref) | ||
const n = getref(sub) || compileSchema(sub, subRoot, opts, scope, path) | ||
return applyRef(n, { path: ['$ref'] }) | ||
}) | ||
if (node.$ref && getMeta().exclusiveRefs) { | ||
enforce(!opts[optDynamic], 'unevaluated* is supported only on draft2019-09 and above') | ||
return // ref overrides any sibling keywords for older schemas | ||
} | ||
handle('$recursiveRef', ['string'], ($recursiveRef) => { | ||
enforce($recursiveRef === '#', 'Behavior of $recursiveRef is defined only for "#"') | ||
// Apply deep recursion from here only if $recursiveAnchor is true, else just run self | ||
const n = recursiveAnchor ? format('(recursive || validate)') : format('validate') | ||
return applyRef(n, { path: ['$recursiveRef'] }) | ||
}) | ||
// typecheck | ||
let typeCheck = null | ||
handle('type', ['string', 'array'], (type) => { | ||
const typearr = Array.isArray(type) ? type : [type] | ||
for (const t of typearr) enforce(typeof t === 'string' && types.has(t), 'Unknown type:', t) | ||
if (current.type) { | ||
enforce(functions.deepEqual(typearr, [current.type]), 'One type allowed:', current.type) | ||
evaluateDelta({ type: [current.type] }) | ||
return null | ||
} | ||
if (parentCheckedType(...typearr)) return null | ||
const filteredTypes = typearr.filter((t) => typeApplicable(t)) | ||
if (filteredTypes.length === 0) fail('No valid types possible') | ||
evaluateDelta({ type: typearr }) // can be safely done here, filteredTypes already prepared | ||
typeCheck = safenotor(...filteredTypes.map((t) => types.get(t)(name))) | ||
return null | ||
}) | ||
// main validation block | ||
// if type validation was needed and did not return early, wrap this inside an else clause. | ||
if (typeCheck && allErrors) { | ||
fun.if(typeCheck, () => error({ path: ['type'] }), performValidation) | ||
} else { | ||
if (typeCheck) errorIf(typeCheck, { path: ['type'] }) | ||
performValidation() | ||
} | ||
if (parentCheckedType(...typearr)) return null | ||
const filteredTypes = typearr.filter((t) => typeApplicable(t)) | ||
if (filteredTypes.length === 0) fail('No valid types possible') | ||
evaluateDelta({ type: typearr }) // can be safely done here, filteredTypes already prepared | ||
typeIfAdded = true | ||
return safenot(safeor(...filteredTypes.map((t) => types.get(t)(name)))) | ||
}) | ||
// If type validation was needed and did not return early, wrap this inside an else clause. | ||
if (typeIfAdded && allErrors) fun.block('else {', [], '}', performValidation) | ||
else performValidation() | ||
// account for maxItems to recheck if they limit items. TODO: perhaps we could keep track of this in stat? | ||
if (stat.items < Infinity && node.maxItems <= stat.items) evaluateDelta({ items: Infinity }) | ||
} | ||
if (!isSub) { | ||
// presence check and call main validation block | ||
if (node.default !== undefined && useDefaults) { | ||
if (definitelyPresent) fail('Can not apply default value here (e.g. at root)') | ||
const defvalue = get('default', 'jsonval') | ||
fun.if(present(current), writeMain, () => fun.write('%s = %j', name, defvalue)) | ||
} else { | ||
handle('default', ['jsonval'], null) // unused | ||
fun.if(definitelyPresent ? true : present(current), writeMain) | ||
} | ||
// Checks related to static schema analysis | ||
if (!allowUnreachable) enforce(!fun.optimizedOut, 'some checks are never reachable') | ||
if (isSub) { | ||
const logicalOp = ['not', 'if', 'then', 'else'].includes(schemaPath[schemaPath.length - 1]) | ||
const branchOp = ['oneOf', 'anyOf', 'allOf'].includes(schemaPath[schemaPath.length - 2]) | ||
const depOp = ['dependencies', 'dependentSchemas'].includes(schemaPath[schemaPath.length - 2]) | ||
// Coherence check, unreachable, double-check that we came from expected path | ||
enforce(logicalOp || branchOp || depOp, 'Unexpected') | ||
} else if (!schemaPath.includes('not')) { | ||
// 'not' does not mark anything as evaluated (unlike even if/then/else), so it's safe to exclude from these | ||
// checks, as we are sure that everything will be checked without it. It can be viewed as a pure add-on. | ||
if (!stat.type) enforceValidation('type') | ||
if (typeApplicable('array') && stat.items !== Infinity && !(node.maxItems <= stat.items)) | ||
if (typeApplicable('array') && stat.items !== Infinity) | ||
enforceValidation(node.items ? 'additionalItems or unevaluatedItems' : 'items rule') | ||
@@ -957,15 +1049,10 @@ if (typeApplicable('object') && !stat.properties.includes(true)) | ||
if (!stat.fullstring && requireStringValidation) { | ||
const stringWarning = 'pattern, format or contentSchema must be specified for strings' | ||
const stringWarning = 'pattern, format or contentSchema should be specified for strings' | ||
fail(`[requireStringValidation] ${stringWarning}, use pattern: ^[\\s\\S]*$ to opt-out`) | ||
} | ||
} else { | ||
const n0 = schemaPath[schemaPath.length - 1] | ||
const n1 = schemaPath[schemaPath.length - 2] | ||
const allowed0 = ['not', 'if', 'then', 'else'] | ||
const allowed1 = ['oneOf', 'anyOf', 'allOf', 'dependencies', 'dependentSchemas'] | ||
// Sanity check, unreachable, double-check that we came from expected path | ||
enforce(allowed0.includes(n0) || allowed1.includes(n1), 'Unexpected') | ||
} | ||
if (node.properties && !node.required) enforceValidation('if properties is used, required') | ||
enforce(unused.size === 0 || allowUnusedKeywords, 'Unprocessed keywords:', [...unused]) | ||
return finish(local) | ||
return { stat, local } // return statically evaluated | ||
} | ||
@@ -981,9 +1068,7 @@ | ||
if (allErrors) { | ||
fun.write('return errorCount === 0') | ||
} else fun.write('return true') | ||
if (allErrors) fun.write('return errorCount === 0') | ||
else fun.write('return true') | ||
fun.write('}') | ||
if (dryRun) return | ||
validate = fun.makeFunction(scope) | ||
@@ -1003,6 +1088,4 @@ validate[evaluatedStatic] = stat | ||
// it enabled if needed. Enabling it without need can give up to about 40% performance drop. | ||
if (e.message === 'Dynamic unevaluated tracing is not enabled') { | ||
const scope = Object.create(null) | ||
return { scope, ref: compileSchema(schema, schema, { ...opts, [optDynamic]: true }, scope) } | ||
} | ||
if (!opts[optDynamic] && e.message === 'Dynamic unevaluated tracing is not enabled') | ||
return compile(schema, { ...opts, [optDynamic]: true }) | ||
throw e | ||
@@ -1009,0 +1092,0 @@ } |
'use strict' | ||
const { format, safe } = require('./safe-format') | ||
const { format, safe, safenot } = require('./safe-format') | ||
const { jaystring } = require('./javascript') | ||
@@ -15,3 +15,3 @@ | ||
if (INDENT_END.test(line.trim()[0])) indent-- | ||
lines.push(`${' '.repeat(indent * 2)}${line}`) | ||
lines.push({ indent, code: line }) | ||
if (INDENT_START.test(line[line.length - 1])) indent++ | ||
@@ -22,3 +22,3 @@ } | ||
if (indent !== 0) throw new Error('Unexpected indent at build()') | ||
const joined = lines.join('\n') | ||
const joined = lines.map((line) => `${' '.repeat(line.indent * 2)}${line.code}`).join('\n') | ||
return /^[a-z][a-z0-9]*$/i.test(joined) ? `return ${joined}` : `return (${joined})` | ||
@@ -38,2 +38,3 @@ } | ||
return { | ||
optimizedOut: false, // some branch of code has been optimized out | ||
size: () => lines.length, | ||
@@ -44,8 +45,9 @@ | ||
if (fmt.includes('\n')) throw new Error('Only single lines are supported') | ||
pushLine(args.length > 0 ? format(fmt, ...args) : fmt) | ||
pushLine(format(fmt, ...args)) | ||
return true // code was written | ||
}, | ||
block(fmt, args, close, writeBody) { | ||
block(prefix, writeBody, noInline = false) { | ||
const oldIndent = indent | ||
this.write(fmt, ...args) | ||
this.write('%s {', prefix) | ||
const length = lines.length | ||
@@ -57,12 +59,28 @@ writeBody() | ||
indent = oldIndent | ||
return | ||
return false // nothing written | ||
} else if (length === lines.length - 1 && !noInline) { | ||
// a single line has been written, inline it if opt-in allows | ||
const { code } = lines[lines.length - 1] | ||
// check below is just for generating more readable code, it's safe to inline all !noInline | ||
if (!/^(if|for) /.test(code)) { | ||
lines.length -= 2 | ||
indent = oldIndent | ||
return this.write('%s %s', prefix, code) | ||
} | ||
} | ||
this.write(close) | ||
return this.write('}') | ||
}, | ||
if(condition, writeBody) { | ||
/* c8 ignore next */ | ||
if (`${condition}` === 'false') throw new Error('Unexpected: false if condition') | ||
if (`${condition}` === 'true') return writeBody() | ||
this.block('if (%s) {', [condition], '}', writeBody) | ||
if(condition, writeBody, writeElse) { | ||
if (`${condition}` === 'false') { | ||
if (writeElse) writeElse() | ||
if (writeBody) this.optimizedOut = true | ||
} else if (`${condition}` === 'true') { | ||
if (writeBody) writeBody() | ||
if (writeElse) this.optimizedOut = true | ||
} else if (writeBody && this.block(format('if (%s)', condition), writeBody, !!writeElse)) { | ||
if (writeElse) this.block(format('else'), writeElse) // !!writeElse above ensures {} wrapping before `else` | ||
} else if (writeElse) { | ||
this.if(safenot(condition), writeElse) | ||
} | ||
}, | ||
@@ -69,0 +87,0 @@ |
@@ -49,3 +49,3 @@ 'use strict' | ||
const present = (obj) => { | ||
const name = buildName(obj) // also checks for sanity, do not remove | ||
const name = buildName(obj) // also checks for coherence, do not remove | ||
const { parent, keyval, keyname, inKeys, checked } = obj | ||
@@ -71,3 +71,3 @@ /* c8 ignore next */ | ||
const key = gensym('key') | ||
fun.block('for (const %s of Object.keys(%s)) {', [key, buildName(obj)], '}', () => { | ||
fun.block(format('for (const %s of Object.keys(%s))', key, buildName(obj)), () => { | ||
writeBody(propvar(obj, key, true), key) // always own property here | ||
@@ -80,3 +80,3 @@ }) | ||
const name = buildName(obj) | ||
fun.block('for (let %s = %s; %s < %s.length; %s++) {', [i, start, i, name, i], '}', () => { | ||
fun.block(format('for (let %s = %s; %s < %s.length; %s++)', i, start, i, name, i), () => { | ||
writeBody(propvar(obj, i, unmodifiedPrototypes, true), i) // own property in Array if proto not mangled | ||
@@ -83,0 +83,0 @@ }) |
@@ -20,2 +20,3 @@ 'use strict' | ||
...['deprecated', 'description', 'title', 'examples', '$comment'], // unused | ||
'discriminator', // optimization hint and error filtering only, does not affect validation result | ||
] | ||
@@ -22,0 +23,0 @@ |
@@ -78,2 +78,5 @@ 'use strict' | ||
const safepriority = (arg) => | ||
// simple expression and single brackets can not break priority | ||
/^[a-z][a-z0-9_().]*$/i.test(arg) || /^\([^()]+\)$/i.test(arg) ? arg : format('(%s)', arg) | ||
const safeor = safewrap( | ||
@@ -88,7 +91,7 @@ (...args) => (args.some((arg) => `${arg}` === 'true') ? 'true' : args.join(' || ') || 'false') | ||
if (`${arg}` === 'false') return safe('true') | ||
// simple expression and single brackets can not break priority | ||
if (/^[a-z][a-z0-9_().]*$/i.test(arg) || /^\([^()]+\)$/i.test(arg)) return format('!%s', arg) | ||
return format('!(%s)', arg) | ||
return format('!%s', safepriority(arg)) | ||
} | ||
// this function is priority-safe, unlike safeor, hence it's exported and safeor is not atm | ||
const safenotor = (...args) => safenot(safeor(...args)) | ||
module.exports = { format, safe, safeor, safeand, safenot } | ||
module.exports = { format, safe, safeand, safenot, safenotor } |
@@ -9,3 +9,3 @@ 'use strict' | ||
// A isMultipleOf B: shortest decimal denoted as A % shortest decimal denoted as B === 0 | ||
// Optimized, sanity checks and precomputation are outside of this method | ||
// Optimized, coherence checks and precomputation are outside of this method | ||
const isMultipleOf = (value, divisor, factor, factorMultiple) => { | ||
@@ -12,0 +12,0 @@ if (value % divisor === 0) return true |
@@ -12,27 +12,68 @@ 'use strict' | ||
* for items and properties, for use with unevaluatedItems and unevaluatedProperties. | ||
* | ||
* WARNING: it is important that this doesn't produce invalid information. i.e.: | ||
* * Extra properties or patterns, too high items | ||
* * Missing dyn.properties or dyn.patterns, too low dyn.items | ||
* * Extra fullstring flag or required entries | ||
* * Missing types, if type is present | ||
* * Missing unknown | ||
* | ||
* The other way around is non-optimal but safe. | ||
* | ||
* null means any type (i.e. any type is possible, not validated) | ||
* true in properties means any property (i.e. all properties were evaluated) | ||
* fullstring means that the object is not an unvalidated string (i.e. is either validated or not a string) | ||
* unknown means that there could be evaluated items or properties unknown to both top-level or dyn | ||
* | ||
* For normalization: | ||
* 1. If type is applicable: | ||
* * dyn.items >= items, | ||
* * dyn.properties includes properties | ||
* * dyn.patterns includes patterns. | ||
* 2. If type is not applicable, the following rules apply: | ||
* * `fullstring = true` if `string` type is not applicable | ||
* * `items = Infinity`, `dyn.items = 0` if `array` type is not applicable | ||
* * `properties = [true]`, `dyn.properties = []` if `object` type is not applicable | ||
* * `patterns = dyn.patterns = []` if `object` type is not applicable | ||
* * `required = []` if `object` type is not applicable | ||
* | ||
* That allows to simplify the `or` operation. | ||
*/ | ||
const initTracing = () => ({ | ||
...{ properties: [], patterns: [], required: [], items: 0, type: null, fullstring: false }, | ||
dyn: { properties: [], patterns: [], items: 0 }, | ||
unknown: false, | ||
const merge = (a, b) => [...new Set([...a, ...b])].sort() | ||
const intersect = (a, b) => a.filter((x) => b.includes(x)) | ||
const wrapArgs = (f) => (...args) => f(...args.map(normalize)) | ||
const wrapFull = (f) => (...args) => normalize(f(...args.map(normalize))) | ||
const typeIsNot = (type, t) => type && !type.includes(t) // type=null means any and includes anything | ||
const normalize = ({ type = null, dyn: d = {}, ...A }) => ({ | ||
type: type ? [...type].sort() : type, | ||
items: typeIsNot(type, 'array') ? Infinity : A.items || 0, | ||
properties: typeIsNot(type, 'object') ? [true] : [...(A.properties || [])].sort(), | ||
patterns: typeIsNot(type, 'object') ? [] : [...(A.patterns || [])].sort(), | ||
required: typeIsNot(type, 'object') ? [] : [...(A.required || [])].sort(), | ||
fullstring: typeIsNot(type, 'string') || A.fullstring || false, | ||
dyn: { | ||
items: typeIsNot(type, 'array') ? 0 : Math.max(A.items || 0, d.items || 0), | ||
properties: typeIsNot(type, 'object') ? [] : merge(A.properties || [], d.properties || []), | ||
patterns: typeIsNot(type, 'object') ? [] : merge(A.patterns || [], d.patterns || []), | ||
}, | ||
unknown: (A.unknown && !(typeIsNot(type, 'object') && typeIsNot(type, 'array'))) || false, | ||
}) | ||
const wrap = (A) => ({ ...initTracing(), ...A }) // sets default empty values | ||
const wrapFun = (f) => (...args) => f(...args.map(wrap)) | ||
const stringValidated = (A) => A.fullstring || (A.type && !A.type.includes('string')) | ||
const initTracing = () => normalize({}) | ||
// Result means that both sets A and B are correct | ||
// type is intersected, lists of known properties are merged | ||
const andDelta = wrapFun((A, B) => ({ | ||
const andDelta = wrapFull((A, B) => ({ | ||
type: A.type && B.type ? intersect(A.type, B.type) : A.type || B.type || null, | ||
items: Math.max(A.items, B.items), | ||
properties: [...A.properties, ...B.properties], | ||
patterns: [...A.patterns, ...B.patterns], | ||
required: [...A.required, ...B.required], | ||
type: A.type && B.type ? A.type.filter((x) => B.type.includes(x)) : A.type || B.type || null, | ||
fullstring: stringValidated(A) || stringValidated(B), | ||
properties: merge(A.properties, B.properties), | ||
patterns: merge(A.patterns, B.patterns), | ||
required: merge(A.required, B.required), | ||
fullstring: A.fullstring || B.fullstring, | ||
dyn: { | ||
items: Math.max(A.dyn.items, B.dyn.items), | ||
properties: [...A.dyn.properties, ...B.dyn.properties], | ||
patterns: [...A.dyn.patterns, ...B.dyn.patterns], | ||
properties: merge(A.dyn.properties, B.dyn.properties), | ||
patterns: merge(A.dyn.patterns, B.dyn.patterns), | ||
}, | ||
@@ -42,30 +83,33 @@ unknown: A.unknown || B.unknown, | ||
const regtest = (pattern, value) => new RegExp(pattern, 'u').test(value) | ||
const regtest = (pattern, value) => value !== true && new RegExp(pattern, 'u').test(value) | ||
const orProperties = ({ properties: a, patterns: rega }, { properties: b, patterns: regb }) => { | ||
if (a.includes(true)) return b | ||
if (b.includes(true)) return a | ||
const afiltered = a.filter((x) => b.includes(x) || regb.some((p) => regtest(p, x))) | ||
const bfiltered = b.filter((x) => rega.some((p) => regtest(p, x))) | ||
return [...afiltered, ...bfiltered] | ||
const intersectProps = ({ properties: a, patterns: rega }, { properties: b, patterns: regb }) => { | ||
// properties | ||
const af = a.filter((x) => b.includes(x) || b.includes(true) || regb.some((p) => regtest(p, x))) | ||
const bf = b.filter((x) => a.includes(x) || a.includes(true) || rega.some((p) => regtest(p, x))) | ||
// patterns | ||
const ar = rega.filter((x) => regb.includes(x) || b.includes(true)) | ||
const br = regb.filter((x) => rega.includes(x) || a.includes(true)) | ||
return { properties: merge(af, bf), patterns: merge(ar, br) } | ||
} | ||
const inProperties = ({ properties: a, patterns: rega }, { properties: b, patterns: regb }) => | ||
a.includes(true) || | ||
(regb.every((x) => rega.includes(x)) && | ||
b.every((x) => a.includes(x) || rega.some((p) => regtest(p, x)))) | ||
b.every((x) => a.includes(x) || a.includes(true) || rega.some((p) => regtest(p, x))) && | ||
regb.every((x) => rega.includes(x) || a.includes(true)) | ||
// Result means that at least one of sets A and B is correct | ||
// type is merged, lists of known properties are intersected | ||
const orDelta = wrapFun((A, B) => ({ | ||
// type is merged, lists of known properties are intersected, lists of dynamic properties are merged | ||
const orDelta = wrapFull((A, B) => ({ | ||
type: A.type && B.type ? merge(A.type, B.type) : null, | ||
items: Math.min(A.items, B.items), | ||
properties: orProperties(A, B), | ||
patterns: A.patterns.filter((x) => B.patterns.includes(x)), | ||
required: A.required.filter((x) => B.required.includes(x)), | ||
type: A.type && B.type ? [...new Set([...A.type, ...B.type])] : null, | ||
fullstring: stringValidated(A) && stringValidated(B), | ||
...intersectProps(A, B), | ||
required: | ||
(typeIsNot(A.type, 'object') && B.required) || | ||
(typeIsNot(B.type, 'object') && A.required) || | ||
intersect(A.required, B.required), | ||
fullstring: A.fullstring && B.fullstring, | ||
dyn: { | ||
items: Math.max(A.items, B.items, A.dyn.items, B.dyn.items), | ||
properties: [...A.properties, ...B.properties, ...A.dyn.properties, ...B.dyn.properties], | ||
patterns: [...A.patterns, ...B.patterns, ...A.dyn.patterns, ...B.dyn.patterns], | ||
items: Math.max(A.dyn.items, B.dyn.items), | ||
properties: merge(A.dyn.properties, B.dyn.properties), | ||
patterns: merge(A.dyn.patterns, B.dyn.patterns), | ||
}, | ||
@@ -75,17 +119,5 @@ unknown: A.unknown || B.unknown, | ||
const applyDelta = (stat, delta) => { | ||
if (delta.items) stat.items = Math.max(stat.items, delta.items) | ||
if (delta.properties) stat.properties.push(...delta.properties) | ||
if (delta.patterns) stat.patterns.push(...delta.patterns) | ||
if (delta.required) stat.required.push(...delta.required) | ||
if (delta.type) | ||
stat.type = stat.type ? stat.type.filter((x) => delta.type.includes(x)) : delta.type | ||
if (delta.fullstring || (stat.type && !stat.type.includes('string'))) stat.fullstring = true | ||
if (delta.dyn) stat.dyn.items = Math.max(stat.dyn.items, delta.dyn.items) | ||
if (delta.dyn) stat.dyn.properties.push(...delta.dyn.properties) | ||
if (delta.dyn) stat.dyn.patterns.push(...delta.dyn.patterns) | ||
if (delta.unknown) stat.unknown = true | ||
} | ||
const applyDelta = (stat, delta) => Object.assign(stat, andDelta(stat, delta)) | ||
const isDynamic = wrapFun(({ unknown, items, dyn, ...stat }) => ({ | ||
const isDynamic = wrapArgs(({ unknown, items, dyn, ...stat }) => ({ | ||
items: items !== Infinity && (unknown || dyn.items > items), | ||
@@ -92,0 +124,0 @@ properties: !stat.properties.includes(true) && (unknown || !inProperties(stat, dyn)), |
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
100385
1839