@exodus/schemasafe
Advanced tools
Comparing version 1.0.0-alpha.4 to 1.0.0-beta.1
{ | ||
"name": "@exodus/schemasafe", | ||
"version": "1.0.0-alpha.4", | ||
"version": "1.0.0-beta.1", | ||
"description": "JSON Safe Parser & Schema Validator", | ||
@@ -16,10 +16,11 @@ "license": "MIT", | ||
"files": [ | ||
"src/compile.js", | ||
"src/formats.js", | ||
"src/generate-function.js", | ||
"src/safe-format.js", | ||
"src/index.js", | ||
"src/jaystring.js", | ||
"src/formats.js", | ||
"src/scope-functions.js", | ||
"src/known-keywords.js", | ||
"src/pointer.js", | ||
"src/index.js" | ||
"src/safe-format.js", | ||
"src/scope-functions.js" | ||
], | ||
@@ -30,2 +31,3 @@ "scripts": { | ||
"coverage": "c8 --reporter=lcov --reporter=text npm run test", | ||
"coverage:lcov": "c8 --reporter=lcovonly npm run test", | ||
"test": "npm run test:normal | tap-spec && npm run test:module | tap-spec", | ||
@@ -32,0 +34,0 @@ "test:raw": "npm run test:normal && npm run test:module", |
# `@exodus/schemasafe` | ||
A [JSONSchema](https://json-schema.org/) validator that uses code generation to be extremely fast. | ||
A code-generating [JSON Schema](https://json-schema.org/) validator that attempts to be reasonably secure. | ||
Supports [draft-04/06/07](doc/Specification-support.md). | ||
[![Node CI Status](https://github.com/ExodusMovement/schemasafe/workflows/Node%20CI/badge.svg)](https://github.com/ExodusMovement/schemasafe/actions) | ||
[![npm](https://img.shields.io/npm/v/@exodus/schemasafe.svg)](https://www.npmjs.com/package/@exodus/schemasafe) | ||
[![codecov](https://codecov.io/gh/ExodusMovement/schemasafe/branch/master/graph/badge.svg)](https://codecov.io/gh/ExodusMovement/schemasafe) | ||
@@ -32,6 +36,2 @@ ## Installation | ||
console.log('should not be valid', validate({})) | ||
// get the last list of errors by checking validate.errors | ||
// the following will print [{field: 'data.hello', message: 'is required'}] | ||
console.log(validate.errors) | ||
``` | ||
@@ -78,8 +78,10 @@ | ||
## Verbose mode shows more information about the source of the error | ||
## Enabling errors shows information about the source of the error | ||
When the `verbose` options is set to `true`, `@exodus/schemasafe` also outputs: | ||
When the `includeErrors` option is set to `true`, `@exodus/schemasafe` also outputs: | ||
- `value`: The data value that caused the error | ||
- `schemaPath`: a JSON pointer string as an URI fragment indicating which sub-schema failed, e.g. `#/type` | ||
- `schemaPath`: a JSON pointer string as an URI fragment indicating which sub-schema failed, e.g. | ||
`#/properties/item/type` | ||
- `dataPath`: a JSON pointer string as an URI fragment indicating which property of the object | ||
failed validation, e.g. `#/item` | ||
@@ -96,17 +98,12 @@ ```js | ||
} | ||
const validate = validator(schema, { | ||
includeErrors: true, | ||
verboseErrors: true | ||
}) | ||
const validate = validator(schema, { includeErrors: true }) | ||
validate({ hello: 100 }); | ||
console.log(validate.errors) | ||
// [ { field: 'data["hello"]', | ||
// message: 'is the wrong type', | ||
// type: 'string', | ||
// schemaPath: '#/properties/hello', | ||
// value: 100 } ] | ||
// [ { schemaPath: '#/properties/hello/type', | ||
// dataPath: '#/hello' } ] | ||
``` | ||
See [Error handling](./doc/Error-handling.md) for more information. | ||
## Generate Modules | ||
@@ -138,6 +135,5 @@ | ||
* if (data === undefined) data = null | ||
* let errors = 0 | ||
* if (!(typeof data === "string")) return false | ||
* if (!format0(data)) return false | ||
* return errors === 0 | ||
* return true | ||
* })})(); | ||
@@ -149,4 +145,8 @@ */ | ||
`@exodus/schemasafe` uses code generation to turn a JSON schema into javascript code that is easily optimizeable by v8. | ||
`@exodus/schemasafe` uses code generation to turn a JSON schema into javascript code that is easily | ||
optimizeable by v8. | ||
See [Performance](./doc/Performance.md) for information on options that might affect performace | ||
both ways. | ||
## Previous work | ||
@@ -153,0 +153,0 @@ |
@@ -11,7 +11,8 @@ 'use strict' | ||
if (name[0] === '.' || name.endsWith('.') || name.includes('..')) return false | ||
if (!/^[a-z0-9.-]+$/i.test(host) || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+/.test(name)) return false | ||
if (!/^[a-z0-9.-]+$/i.test(host) || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+$/i.test(name)) return false | ||
return host.split('.').every((part) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(part)) | ||
}, | ||
// matches ajv + length checks | ||
hostname: (host) => { | ||
hostname: (input) => { | ||
const host = input.endsWith('.') ? input.slice(0, input.length - 1) : input | ||
if (host.length > 253) return false | ||
@@ -64,9 +65,18 @@ if (!/^[a-z0-9.-]+$/i.test(host)) return false | ||
// matches ajv + length checks | ||
// matches ajv + unwrap nested group | ||
// uuid: http://tools.ietf.org/html/rfc4122 | ||
uuid: (input) => | ||
input.length <= 36 + 9 && | ||
/^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(input), | ||
uuid: /^(?:urn:uuid:)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, | ||
// TODO: iri, iri-reference, idn-email, idn-hostname, duration | ||
// length restriction is an arbitrary safeguard | ||
// first regex verifies structure | ||
// second regex verifies no more than one fraction, and that at least 1 block is present | ||
duration: (input) => | ||
input.length > 1 && | ||
input.length < 80 && | ||
/^P(?:[.,\d]+Y)?(?:[.,\d]+M)?(?:[.,\d]+W)?(?:[.,\d]+D)?(?:T(?:[.,\d]+H)?(?:[.,\d]+M)?(?:[.,\d]+S)?)?$/.test( | ||
input | ||
) && | ||
/^P[\dYMWDTHMS]*(?:\d[.,]\d+)?[YMWDTHMS]$/.test(input), | ||
// TODO: iri, iri-reference, idn-email, idn-hostname | ||
} | ||
@@ -87,12 +97,2 @@ | ||
// other | ||
'utc-millisec': /^[0-9]{1,15}\.?[0-9]{0,15}$/, | ||
phone: (input) => { | ||
if (input.length > 30) return false | ||
if (!/^\+[0-9][0-9 ]{5,27}[0-9]$/.test(input)) return false | ||
if (/ {2}/.test(input)) return false | ||
const digits = input.substring(1).replace(/ /g, '').length | ||
return digits >= 7 && digits <= 15 | ||
}, | ||
// ajv has /^#(?:\/(?:[a-z0-9_\-.!$&'()*+,;:=@]|%[0-9a-f]{2}|~0|~1)*)*$/i, this is equivalent | ||
@@ -107,3 +107,3 @@ // uri fragment: https://tools.ietf.org/html/rfc3986#appendix-A | ||
// manually cleaned up from is-my-json-valid, CSS 2.1 colors only per draft03 spec | ||
color: /^(?:#[0-9A-Fa-f]{3,6}|aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow|rgb\(\s*(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*,\s*(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*,\s*(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*\)|rgb\(\s*(?:\d?\d%|100%)+\s*,\s*(?:\d?\d%|100%)+\s*,\s*(?:\d?\d%|100%)+\s*\))$/, | ||
color: /^(?:#[0-9A-Fa-f]{3,6}|aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|white|yellow|rgb\(\s*(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*,\s*(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*,\s*(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\s*\)|rgb\(\s*(?:\d?\d%|100%)\s*,\s*(?:\d?\d%|100%)\s*,\s*(?:\d?\d%|100%)\s*\))$/, | ||
@@ -110,0 +110,0 @@ // style is deliberately unsupported, don't accept untrusted styles |
'use strict' | ||
const { format } = require('./safe-format') | ||
const { format, safe } = require('./safe-format') | ||
const jaystring = require('./jaystring') | ||
@@ -58,3 +58,5 @@ | ||
makeModule(scope = {}) { | ||
const scopeDefs = processScope(scope).map(([key, val]) => `const ${key} = ${jaystring(val)};`) | ||
const scopeDefs = processScope(scope).map( | ||
([key, val]) => `const ${safe(key)} = ${jaystring(val)};` | ||
) | ||
return `(function() {\n'use strict'\n${scopeDefs.join('\n')}\n${build()}})();` | ||
@@ -61,0 +63,0 @@ }, |
859
src/index.js
'use strict' | ||
const { format, safe, safeand, safeor } = require('./safe-format') | ||
const genfun = require('./generate-function') | ||
const { toPointer, resolveReference, joinPath } = require('./pointer') | ||
const formats = require('./formats') | ||
const { buildSchemas } = require('./pointer') | ||
const { compile } = require('./compile') | ||
const functions = require('./scope-functions') | ||
const KNOWN_KEYWORDS = require('./known-keywords') | ||
// for building into the validation function | ||
const types = new Map( | ||
Object.entries({ | ||
null: (name) => format('%s === null', name), | ||
boolean: (name) => format('typeof %s === "boolean"', name), | ||
array: (name) => format('Array.isArray(%s)', name), | ||
object: (n) => format('typeof %s === "object" && %s && !Array.isArray(%s)', n, n, n), | ||
number: (name) => format('typeof %s === "number"', name), | ||
integer: (name) => format('Number.isInteger(%s)', name), | ||
string: (name) => format('typeof %s === "string"', name), | ||
}) | ||
) | ||
const validator = (schema, { jsonCheck = false, isJSON = false, schemas, ...opts } = {}) => { | ||
if (jsonCheck && isJSON) throw new Error('Can not specify both isJSON and jsonCheck options') | ||
const options = { ...opts, schemas: buildSchemas(schemas || []), isJSON: isJSON || jsonCheck } | ||
const scope = Object.create(null) | ||
const actualValidate = compile(schema, schema, options, scope) | ||
if (!jsonCheck || opts.dryRun) return actualValidate | ||
// for checking schema parts in consume() | ||
const schemaTypes = new Map( | ||
Object.entries({ | ||
boolean: (arg) => typeof arg === 'boolean', | ||
array: (arg) => Array.isArray(arg), | ||
object: (arg) => typeof arg === 'object' && arg && !Array.isArray(arg), | ||
finite: (arg) => Number.isFinite(arg), | ||
integer: (arg) => Number.isInteger(arg), | ||
natural: (arg) => Number.isInteger(arg) && arg >= 0, | ||
string: (arg) => typeof arg === 'string', | ||
jsonval: (arg) => functions.deepEqual(arg, JSON.parse(JSON.stringify(arg))), | ||
}) | ||
) | ||
const scopeCache = Symbol('cache') | ||
// Order is important, newer at the top! | ||
const schemaVersions = [ | ||
'https://json-schema.org/draft/2019-09/schema', | ||
'https://json-schema.org/draft-07/schema', | ||
'https://json-schema.org/draft-06/schema', | ||
'https://json-schema.org/draft-04/schema', | ||
'https://json-schema.org/draft-03/schema', | ||
] | ||
const noopRegExps = new Set(['^[\\s\\S]*$', '^[\\S\\s]*$', '^[^]*$', '', '.*']) | ||
// Helper methods for semi-structured paths | ||
const propvar = (parent, keyname, inKeys = false) => Object.freeze({ parent, keyname, inKeys }) // property by variable | ||
const propimm = (parent, keyval) => Object.freeze({ parent, keyval }) // property by immediate value | ||
const buildName = ({ name, parent, keyval, keyname }) => { | ||
if (name) { | ||
if (parent || keyval || keyname) throw new Error('name can be used only stand-alone') | ||
return name // top-level | ||
} | ||
if (keyval && keyname) throw new Error('Can not use key value and name at the same time') | ||
if (!parent) throw new Error('Can not use property of undefined parent!') | ||
if (parent && keyval !== undefined) { | ||
if (!['string', 'number'].includes(typeof keyval)) throw new Error('Invalid property path') | ||
return format('%s[%j]', buildName(parent), keyval) | ||
} else if (parent && keyname) { | ||
return format('%s[%s]', buildName(parent), keyname) | ||
} | ||
/* c8 ignore next */ | ||
throw new Error('Unreachable') | ||
} | ||
const rootMeta = new WeakMap() | ||
const compile = (schema, root, opts, scope, basePathRoot) => { | ||
const { | ||
mode = 'default', | ||
useDefaults = false, | ||
removeAdditional = false, // supports additionalProperties: false and additionalItems: false | ||
includeErrors: optIncludeErrors = false, | ||
allErrors: optAllErrors = false, | ||
verboseErrors = false, | ||
dryRun = false, | ||
allowUnusedKeywords = opts.mode === 'lax', | ||
requireValidation = opts.mode === 'strong', | ||
requireStringValidation = opts.mode === 'strong', | ||
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, | ||
formats: optFormats = {}, | ||
weakFormats = opts.mode !== 'strong', | ||
extraFormats = false, | ||
schemas = {}, | ||
...unknown | ||
} = opts | ||
const fmts = { | ||
...formats.core, | ||
...(weakFormats ? formats.weak : {}), | ||
...(extraFormats ? formats.extra : {}), | ||
...optFormats, | ||
} | ||
if (Object.keys(unknown).length !== 0) | ||
throw new Error(`Unknown options: ${Object.keys(unknown).join(', ')}`) | ||
if (!['strong', 'lax', 'default'].includes(mode)) throw new Error(`Invalid mode: ${mode}`) | ||
if (mode === 'strong' && (!requireValidation || !requireStringValidation || !complexityChecks)) | ||
throw new Error('Strong mode demands require(String)Validation and complexityChecks') | ||
if (mode === 'strong' && (weakFormats || allowUnusedKeywords)) | ||
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 | ||
if (!scope[scopeCache]) | ||
scope[scopeCache] = { sym: new Map(), ref: new Map(), format: new Map(), pattern: new Map() } | ||
const cache = scope[scopeCache] // cache meta info for known scope variables, per meta type | ||
const gensym = (name) => { | ||
if (!cache.sym.get(name)) cache.sym.set(name, 0) | ||
const index = cache.sym.get(name) | ||
cache.sym.set(name, index + 1) | ||
return safe(`${name}${index}`) | ||
} | ||
const patterns = (p) => { | ||
if (cache.pattern.has(p)) return cache.pattern.get(p) | ||
const n = gensym('pattern') | ||
scope[n] = new RegExp(p, 'u') | ||
cache.pattern.set(p, n) | ||
return n | ||
} | ||
const vars = 'ijklmnopqrstuvxyz'.split('') | ||
const genloop = () => { | ||
const v = vars.shift() | ||
vars.push(v + v[0]) | ||
return safe(v) | ||
} | ||
const present = (location) => { | ||
const name = buildName(location) // also checks for sanity, do not remove | ||
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 | ||
return format('%s !== undefined && hasOwn(%s, %s)', name, buildName(parent), keyname) | ||
} else if (parent && keyval !== undefined) { | ||
scope.hasOwn = functions.hasOwn | ||
return format('%s !== undefined && hasOwn(%s, %j)', name, buildName(parent), keyval) | ||
} | ||
/* c8 ignore next */ | ||
throw new Error('Unreachable: present() check without parent') | ||
} | ||
// jsonCheck wrapper implementation below | ||
scope.deepEqual = functions.deepEqual | ||
scope.actualValidate = actualValidate | ||
const fun = genfun() | ||
fun.write('function validate(data) {') | ||
// Since undefined is not a valid JSON value, we coerce to null and other checks will catch this | ||
fun.write('if (data === undefined) data = null') | ||
if (optIncludeErrors) fun.write('validate.errors = null') | ||
fun.write('let errors = 0') | ||
let jsonCheckPerformed = false | ||
const getMeta = () => rootMeta.get(root) || {} | ||
const basePathStack = basePathRoot ? [basePathRoot] : [] | ||
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 currPropVar = (...args) => propvar(current, ...args) | ||
const currPropImm = (...args) => propimm(current, ...args) | ||
const error = (msg, prop, value) => { | ||
if (includeErrors === true) { | ||
const errorObj = { field: prop || name, message: msg, schemaPath: toPointer(schemaPath) } | ||
const errorJS = verboseErrors | ||
? format('{ ...%j, value: %s }', errorObj, value || name) | ||
: format('%j', errorObj) | ||
if (allErrors) { | ||
fun.write('if (validate.errors === null) validate.errors = []') | ||
fun.write('validate.errors.push(%s)', errorJS) | ||
} else { | ||
// Array assignment is significantly faster, do not refactor the two branches | ||
fun.write('validate.errors = [%s]', errorJS) | ||
} | ||
} | ||
if (allErrors) { | ||
fun.write('errors++') | ||
} else { | ||
fun.write('return false') | ||
} | ||
} | ||
const errorIf = (fmt, args, ...errorArgs) => { | ||
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('}') | ||
} | ||
} | ||
const fail = (msg, value) => { | ||
const comment = value !== undefined ? ` ${JSON.stringify(value)}` : '' | ||
throw new Error(`${msg}${comment} at ${toPointer(schemaPath)}`) | ||
} | ||
const enforce = (ok, ...args) => ok || fail(...args) | ||
const enforceValidation = (msg) => enforce(!requireValidation, `[requireValidation] ${msg}`) | ||
const subPath = (...args) => [...schemaPath, ...args] | ||
// 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') { | ||
if (node === true) { | ||
// any is valid | ||
enforceValidation('schema = true is not allowed') | ||
} else if (definitelyPresent) { | ||
// node === false always fails in this case | ||
error('is unexpected') | ||
} else { | ||
// node === false | ||
errorIf('%s', [present(current)], 'is unexpected') | ||
} | ||
return | ||
} | ||
enforce(Object.getPrototypeOf(node) === Object.prototype, 'Schema is not an object') | ||
for (const key of Object.keys(node)) | ||
enforce(KNOWN_KEYWORDS.includes(key) || allowUnusedKeywords, 'Keyword not supported:', key) | ||
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)) | ||
const consume = (prop, ...ruleTypes) => { | ||
enforce(unused.has(prop), 'Unexpected double consumption:', prop) | ||
enforce(functions.hasOwn(node, prop), 'Is not an own property:', prop) | ||
enforce(ruleTypes.every((t) => schemaTypes.has(t)), 'Invalid type used in consume') | ||
enforce(ruleTypes.some((t) => schemaTypes.get(t)(node[prop])), 'Type not expected:', prop) | ||
unused.delete(prop) | ||
} | ||
const finish = () => { | ||
if (!definitelyPresent) fun.write('}') // undefined check | ||
enforce(unused.size === 0 || allowUnusedKeywords, 'Unprocessed keywords:', [...unused]) | ||
} | ||
if (node === root) { | ||
const $schema = node.$schema || $schemaDefault | ||
if (node.$schema) { | ||
if (typeof node.$schema !== 'string') throw new Error('Unexpected $schema') | ||
consume('$schema', 'string') | ||
} | ||
if ($schema) { | ||
const version = $schema.replace(/^http:\/\//, 'https://').replace(/#$/, '') | ||
enforce(schemaVersions.includes(version), 'Unexpected schema version:', version) | ||
const schemaIsOlderThan = (ver) => | ||
schemaVersions.indexOf(version) > | ||
schemaVersions.indexOf(`https://json-schema.org/${ver}/schema`) | ||
rootMeta.set(root, { | ||
exclusiveRefs: schemaIsOlderThan('draft/2019-09'), | ||
booleanRequired: schemaIsOlderThan('draft-04'), | ||
}) | ||
} | ||
} | ||
if (typeof node.description === 'string') consume('description', 'string') // unused, meta-only | ||
if (typeof node.title === 'string') consume('title', 'string') // unused, meta-only | ||
if (typeof node.$comment === 'string') consume('$comment', 'string') // unused, meta-only | ||
if (Array.isArray(node.examples)) consume('examples', 'array') // unused, meta-only | ||
// defining defs are allowed, those are validated on usage | ||
if (typeof node.$defs === 'object') { | ||
consume('$defs', 'object') | ||
} else if (typeof node.definitions === 'object') { | ||
consume('definitions', 'object') | ||
} | ||
const basePath = () => (basePathStack.length > 0 ? basePathStack[basePathStack.length - 1] : '') | ||
if (typeof node.$id === 'string') { | ||
basePathStack.push(joinPath(basePath(), node.$id)) | ||
consume('$id', 'string') | ||
} else if (typeof node.id === 'string') { | ||
basePathStack.push(joinPath(basePath(), node.id)) | ||
consume('id', 'string') | ||
} | ||
const booleanRequired = getMeta().booleanRequired && typeof node.required === 'boolean' | ||
if (node.default !== undefined && !useDefaults) consume('default', 'jsonval') // unused in this case | ||
const defaultIsPresent = node.default !== undefined && useDefaults // will consume on use | ||
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 here (e.g. at root)') | ||
} else if (defaultIsPresent || booleanRequired) { | ||
fun.write('if (!(%s)) {', present(current)) | ||
if (defaultIsPresent) { | ||
fun.write('%s = %j', name, node.default) | ||
consume('default', 'jsonval') | ||
} | ||
if (booleanRequired) { | ||
if (node.required === true) { | ||
if (!defaultIsPresent) error('is required') | ||
consume('required', 'boolean') | ||
} else if (node.required === false) { | ||
consume('required', 'boolean') | ||
} | ||
} | ||
fun.write('} else {') | ||
} else { | ||
fun.write('if (%s) {', present(current)) | ||
} | ||
if (node.$ref) { | ||
const resolved = resolveReference(root, schemas || {}, node.$ref, basePath()) | ||
const [sub, subRoot, path] = resolved[0] || [] | ||
if (sub || sub === false) { | ||
let n = cache.ref.get(sub) | ||
if (!n) { | ||
n = gensym('ref') | ||
cache.ref.set(sub, n) | ||
let fn = null // resolve cyclic dependencies | ||
scope[n] = (...args) => fn(...args) | ||
const override = { includeErrors: false, jsonCheck: false, isJSON } | ||
fn = compile(sub, subRoot, { ...opts, ...override }, scope, path) | ||
scope[n] = fn | ||
} | ||
errorIf('!%s(%s)', [n, name], 'referenced schema does not match') | ||
} else { | ||
fail('failed to resolve $ref:', node.$ref) | ||
} | ||
consume('$ref', 'string') | ||
if (getMeta().exclusiveRefs) { | ||
// ref overrides any sibling keywords for older schemas | ||
finish() | ||
return | ||
} | ||
} | ||
/* Preparation and methods, post-$ref validation will begin at the end of the function */ | ||
const hasSubValidation = | ||
node.$ref || ['allOf', 'anyOf', 'oneOf'].some((key) => Array.isArray(node[key])) | ||
const typeArray = | ||
node.type === undefined ? null : Array.isArray(node.type) ? node.type : [node.type] | ||
for (const t of typeArray || []) | ||
enforce(typeof t === 'string' && types.has(t), 'Unknown type:', t) | ||
// typeArray === null means no type validation, which is required if we don't have const or enum | ||
if (typeArray === null && node.const === undefined && !node.enum && !hasSubValidation) | ||
enforceValidation('type is required') | ||
const typeApplicable = (...possibleTypes) => | ||
typeArray === null || typeArray.some((x) => possibleTypes.includes(x)) | ||
const makeCompare = (variableName, complex) => { | ||
if (complex) { | ||
scope.deepEqual = functions.deepEqual | ||
return (e) => format('deepEqual(%s, %j)', variableName, e) | ||
} | ||
return (e) => format('(%s === %j)', variableName, e) | ||
} | ||
const enforceRegex = (pattern, target = node) => { | ||
enforce(typeof pattern === 'string', 'Invalid pattern:', pattern) | ||
if (requireValidation || requireStringValidation) | ||
enforce(/^\^.*\$$/.test(pattern), 'Should start with ^ and end with $:', pattern) | ||
if (complexityChecks && (pattern.match(/[{+*]/g) || []).length > 1) | ||
enforce(target.maxLength !== undefined, 'maxLength should be specified for:', pattern) | ||
} | ||
// 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 */ | ||
const checkNumbers = () => { | ||
const applyMinMax = (value, operator, message) => { | ||
enforce(Number.isFinite(value), 'Invalid minimum or maximum:', value) | ||
errorIf('!(%d %c %s)', [value, operator, name], message) | ||
} | ||
if (Number.isFinite(node.exclusiveMinimum)) { | ||
applyMinMax(node.exclusiveMinimum, '<', 'is less than exclusiveMinimum') | ||
consume('exclusiveMinimum', 'finite') | ||
} else if (node.minimum !== undefined) { | ||
applyMinMax(node.minimum, node.exclusiveMinimum ? '<' : '<=', 'is less than minimum') | ||
consume('minimum', 'finite') | ||
if (typeof node.exclusiveMinimum === 'boolean') consume('exclusiveMinimum', 'boolean') | ||
} | ||
if (Number.isFinite(node.exclusiveMaximum)) { | ||
applyMinMax(node.exclusiveMaximum, '>', 'is more than exclusiveMaximum') | ||
consume('exclusiveMaximum', 'finite') | ||
} else if (node.maximum !== undefined) { | ||
applyMinMax(node.maximum, node.exclusiveMaximum ? '>' : '>=', 'is more than maximum') | ||
consume('maximum', 'finite') | ||
if (typeof node.exclusiveMaximum === 'boolean') consume('exclusiveMaximum', 'boolean') | ||
} | ||
const multipleOf = node.multipleOf === undefined ? 'divisibleBy' : 'multipleOf' // draft3 support | ||
if (node[multipleOf] !== undefined) { | ||
enforce(Number.isFinite(node[multipleOf]), `Invalid ${multipleOf}:`, node[multipleOf]) | ||
scope.isMultipleOf = functions.isMultipleOf | ||
errorIf('!isMultipleOf(%s, %d)', [name, node[multipleOf]], 'has a remainder') | ||
consume(multipleOf, 'finite') | ||
} | ||
} | ||
const checkStrings = () => { | ||
if (node.maxLength !== undefined) { | ||
enforce(Number.isFinite(node.maxLength), 'Invalid maxLength:', node.maxLength) | ||
scope.stringLength = functions.stringLength | ||
errorIf('stringLength(%s) > %d', [name, node.maxLength], 'has longer length than allowed') | ||
consume('maxLength', 'natural') | ||
} | ||
if (node.minLength !== undefined) { | ||
enforce(Number.isFinite(node.minLength), 'Invalid minLength:', node.minLength) | ||
scope.stringLength = functions.stringLength | ||
errorIf('stringLength(%s) < %d', [name, node.minLength], 'has less length than allowed') | ||
consume('minLength', 'natural') | ||
} | ||
if (node.format && functions.hasOwn(fmts, node.format)) { | ||
const formatImpl = fmts[node.format] | ||
if (formatImpl instanceof RegExp || typeof formatImpl === 'function') { | ||
let n = cache.format.get(formatImpl) | ||
if (!n) { | ||
n = gensym('format') | ||
scope[n] = formatImpl | ||
cache.format.set(formatImpl, n) | ||
} | ||
if (formatImpl instanceof RegExp) { | ||
// built-in formats are fine, check only ones from options | ||
if (functions.hasOwn(optFormats, node.format)) enforceRegex(formatImpl.source) | ||
errorIf('!%s.test(%s)', [n, name], `must be ${node.format} format`) | ||
} else { | ||
errorIf('!%s(%s)', [n, name], `must be ${node.format} format`) | ||
} | ||
} else { | ||
fail('Unrecognized format used:', node.format) | ||
} | ||
consume('format', 'string') | ||
} else { | ||
enforce(!node.format, 'Unrecognized format used:', node.format) | ||
} | ||
if (node.pattern) { | ||
enforceRegex(node.pattern) | ||
if (!noopRegExps.has(node.pattern)) { | ||
const p = patterns(node.pattern) | ||
errorIf('!%s.test(%s)', [p, name], 'pattern mismatch') | ||
} | ||
consume('pattern', 'string') | ||
} | ||
const stringValidated = node.format || node.pattern || hasSubValidation | ||
if (typeApplicable('string') && requireStringValidation && !stringValidated) { | ||
fail('pattern or format must be specified for strings, use pattern: ^[\\s\\S]*$ to opt-out') | ||
} | ||
} | ||
const checkArrays = () => { | ||
if (node.maxItems !== undefined) { | ||
enforce(Number.isFinite(node.maxItems), 'Invalid maxItems:', node.maxItems) | ||
if (Array.isArray(node.items) && node.items.length > node.maxItems) | ||
fail(`Invalid maxItems: ${node.maxItems} is less than items array length`) | ||
errorIf('%s.length > %d', [name, node.maxItems], 'has more items than allowed') | ||
consume('maxItems', 'natural') | ||
} | ||
if (node.minItems !== undefined) { | ||
enforce(Number.isFinite(node.minItems), 'Invalid minItems:', node.minItems) | ||
// can be higher that .items length with additionalItems | ||
errorIf('%s.length < %d', [name, node.minItems], 'has less items than allowed') | ||
consume('minItems', 'natural') | ||
} | ||
if (node.items || node.items === false) { | ||
if (Array.isArray(node.items)) { | ||
for (let p = 0; p < node.items.length; p++) | ||
rule(currPropImm(p), node.items[p], subPath(`${p}`)) | ||
} else { | ||
const i = genloop() | ||
fun.block('for (let %s = 0; %s < %s.length; %s++) {', [i, i, name, i], '}', () => { | ||
rule(currPropVar(i), node.items, subPath('items')) | ||
}) | ||
} | ||
consume('items', 'object', 'array', 'boolean') | ||
} else if (typeApplicable('array') && !hasSubValidation) { | ||
enforceValidation('items rule must be specified') | ||
} | ||
if (!Array.isArray(node.items)) { | ||
// additionalItems is allowed, but ignored per some spec tests in this case! | ||
// We do nothing and let it throw except for in allowUnusedKeywords mode | ||
// As a result, this is not allowed by default, only in allowUnusedKeywords mode | ||
} else if (node.additionalItems === false) { | ||
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') | ||
} else if (node.additionalItems) { | ||
const i = genloop() | ||
const offset = node.items.length | ||
fun.block('for (let %s = %d; %s < %s.length; %s++) {', [i, offset, i, name, i], '}', () => { | ||
rule(currPropVar(i), node.additionalItems, subPath('additionalItems')) | ||
}) | ||
consume('additionalItems', 'object', 'boolean') | ||
} else if (node.items.length === node.maxItems) { | ||
// No additional items are possible | ||
} else { | ||
enforceValidation('additionalItems rule must be specified for fixed arrays') | ||
} | ||
if (node.contains || node.contains === false) { | ||
const prev = gensym('prev') | ||
const passes = gensym('passes') | ||
fun.write('let %s = 0', passes) | ||
const i = genloop() | ||
fun.block('for (let %s = 0; %s < %s.length; %s++) {', [i, i, name, i], '}', () => { | ||
fun.write('const %s = errors', prev) | ||
subrule(currPropVar(i), node.contains, subPath('contains')) | ||
fun.write('if (%s === errors) { %s++ } else errors = %s', prev, passes, prev) | ||
}) | ||
if (Number.isFinite(node.minContains)) { | ||
errorIf('%s < %d', [passes, node.minContains], 'array contains too few matching items') | ||
consume('minContains', 'natural') | ||
} else { | ||
errorIf('%s < 1', [passes], 'array does not contain a match') | ||
} | ||
if (Number.isFinite(node.maxContains)) { | ||
errorIf('%s > %d', [passes, node.maxContains], 'array contains too many matching items') | ||
consume('maxContains', 'natural') | ||
} | ||
consume('contains', 'object', 'boolean') | ||
} | ||
const isSimpleForUnique = () => { | ||
if (node.maxItems !== undefined) return true | ||
if (typeof node.items === 'object') { | ||
if (Array.isArray(node.items) && node.additionalItems === false) return true | ||
if (!Array.isArray(node.items) && node.items.type) { | ||
const itemTypes = Array.isArray(node.items.type) ? node.items.type : [node.items.type] | ||
const primitiveTypes = ['null', 'boolean', 'number', 'integer', 'string'] | ||
if (itemTypes.every((itemType) => primitiveTypes.includes(itemType))) return true | ||
} | ||
} | ||
return false | ||
} | ||
if (node.uniqueItems === true) { | ||
if (complexityChecks) | ||
enforce(isSimpleForUnique(), 'maxItems should be specified for non-primitive uniqueItems') | ||
scope.unique = functions.unique | ||
scope.deepEqual = functions.deepEqual | ||
errorIf('!unique(%s)', [name], 'must be unique') | ||
consume('uniqueItems', 'boolean') | ||
} else if (node.uniqueItems === false) { | ||
consume('uniqueItems', 'boolean') | ||
} | ||
} | ||
const checkObjects = () => { | ||
if (node.maxProperties !== undefined) { | ||
enforce(Number.isFinite(node.maxProperties), 'Invalid maxProperties:', node.maxProperties) | ||
errorIf('Object.keys(%s).length > %d', [name, node.maxProperties], 'too many properties') | ||
consume('maxProperties', 'natural') | ||
} | ||
if (node.minProperties !== undefined) { | ||
enforce(Number.isFinite(node.minProperties), 'Invalid minProperties:', node.minProperties) | ||
errorIf('Object.keys(%s).length < %d', [name, node.minProperties], 'too few properties') | ||
consume('minProperties', 'natural') | ||
} | ||
if (typeof node.propertyNames === 'object' || typeof node.propertyNames === 'boolean') { | ||
const key = gensym('key') | ||
fun.block('for (const %s of Object.keys(%s)) {', [key, name], '}', () => { | ||
const names = node.propertyNames | ||
const nameSchema = typeof names === 'object' ? { type: 'string', ...names } : names | ||
rule({ name: key }, nameSchema, subPath('propertyNames')) | ||
}) | ||
consume('propertyNames', 'object', 'boolean') | ||
} | ||
if (typeof node.additionalProperties === 'object' && typeof node.propertyNames !== 'object') { | ||
enforceValidation('wild-card additionalProperties requires propertyNames') | ||
} | ||
if (Array.isArray(node.required)) { | ||
for (const req of node.required) { | ||
const prop = currPropImm(req) | ||
errorIf('!(%s)', [present(prop)], 'is required', buildName(prop)) | ||
} | ||
consume('required', 'array') | ||
} | ||
const dependencies = node.dependencies === undefined ? 'dependentRequired' : 'dependencies' | ||
if (node[dependencies]) { | ||
for (const key of Object.keys(node[dependencies])) { | ||
let deps = node[dependencies][key] | ||
if (typeof deps === 'string') deps = [deps] | ||
const exists = (k) => present(currPropImm(k)) | ||
const item = currPropImm(key) | ||
if (Array.isArray(deps)) { | ||
const condition = safeand(...deps.map(exists)) | ||
errorIf('%s && !(%s)', [present(item), condition], 'dependencies not set') | ||
} else if (typeof deps === 'object' || typeof deps === 'boolean') { | ||
fun.block('if (%s) {', [present(item)], '}', () => { | ||
rule(current, deps, subPath(dependencies, key)) | ||
}) | ||
} else { | ||
fail('Unexpected dependencies entry') | ||
} | ||
} | ||
consume(dependencies, 'object') | ||
} | ||
if (typeof node.properties === 'object') { | ||
for (const p of Object.keys(node.properties)) { | ||
rule(currPropImm(p), node.properties[p], subPath('properties', p)) | ||
} | ||
consume('properties', 'object') | ||
} | ||
if (node.patternProperties) { | ||
const key = gensym('key') | ||
fun.block('for (const %s of Object.keys(%s)) {', [key, name], '}', () => { | ||
for (const p of Object.keys(node.patternProperties)) { | ||
enforceRegex(p, node.propertyNames || {}) | ||
fun.block('if (%s.test(%s)) {', [patterns(p), key], '}', () => { | ||
const sub = currPropVar(key, true) // always own property, from Object.keys | ||
rule(sub, node.patternProperties[p], subPath('patternProperties', p)) | ||
}) | ||
} | ||
}) | ||
consume('patternProperties', 'object') | ||
} | ||
if (node.additionalProperties || node.additionalProperties === false) { | ||
const key = gensym('key') | ||
const toCompare = (p) => format('%s !== %j', key, p) | ||
const toTest = (p) => format('!%s.test(%s)', patterns(p), key) | ||
const additionalProp = safeand( | ||
...Object.keys(node.properties || {}).map(toCompare), | ||
...Object.keys(node.patternProperties || {}).map(toTest) | ||
) | ||
fun.block('for (const %s of Object.keys(%s)) {', [key, name], '}', () => { | ||
fun.block('if (%s) {', [additionalProp], '}', () => { | ||
if (node.additionalProperties === false) { | ||
if (removeAdditional) { | ||
fun.write('delete %s[%s]', name, key) | ||
} else { | ||
error('has additional properties', null, format('%j + %s', `${name}.`, key)) | ||
} | ||
} else { | ||
const sub = currPropVar(key, true) // always own property, from Object.keys | ||
rule(sub, node.additionalProperties, subPath('additionalProperties')) | ||
} | ||
}) | ||
}) | ||
consume('additionalProperties', 'object', 'boolean') | ||
} else if (typeApplicable('object') && !hasSubValidation) { | ||
enforceValidation('additionalProperties rule must be specified') | ||
} | ||
} | ||
const checkConst = () => { | ||
if (node.const !== undefined) { | ||
const complex = typeof node.const === 'object' | ||
const compare = makeCompare(name, complex) | ||
errorIf('!%s', [compare(node.const)], 'must be const value') | ||
consume('const', 'jsonval') | ||
return true | ||
} else if (node.enum) { | ||
enforce(Array.isArray(node.enum), 'Invalid enum') | ||
const complex = node.enum.some((e) => typeof e === 'object') | ||
const compare = makeCompare(name, complex) | ||
errorIf('!(%s)', [safeor(...node.enum.map(compare))], 'must be an enum value') | ||
consume('enum', 'array') | ||
return true | ||
} | ||
return false | ||
} | ||
const checkGeneric = () => { | ||
if (node.not || node.not === false) { | ||
const prev = gensym('prev') | ||
fun.write('const %s = errors', prev) | ||
subrule(current, node.not, subPath('not')) | ||
fun.write('if (%s === errors) {', prev) | ||
error('negative schema matches') | ||
fun.write('} else errors = %s', prev) | ||
consume('not', 'object', 'boolean') | ||
} | ||
const thenOrElse = node.then || node.then === false || node.else || node.else === false | ||
if ((node.if || node.if === false) && thenOrElse) { | ||
const prev = gensym('prev') | ||
fun.write('const %s = errors', prev) | ||
subrule(current, node.if, subPath('if')) | ||
fun.write('if (%s !== errors) {', prev) | ||
fun.write('errors = %s', prev) | ||
if (node.else || node.else === false) { | ||
rule(current, node.else, subPath('else')) | ||
consume('else', 'object', 'boolean') | ||
} | ||
if (node.then || node.then === false) { | ||
fun.write('} else {') | ||
rule(current, node.then, subPath('then')) | ||
consume('then', 'object', 'boolean') | ||
} | ||
fun.write('}') | ||
consume('if', 'object', 'boolean') | ||
} | ||
if (node.allOf !== undefined) { | ||
enforce(Array.isArray(node.allOf), 'Invalid allOf') | ||
node.allOf.forEach((sch, key) => { | ||
rule(current, sch, subPath('allOf', key)) | ||
}) | ||
consume('allOf', 'array') | ||
} | ||
if (node.anyOf !== undefined) { | ||
enforce(Array.isArray(node.anyOf), 'Invalid anyOf') | ||
const prev = gensym('prev') | ||
node.anyOf.forEach((sch, i) => { | ||
if (i === 0) { | ||
fun.write('const %s = errors', prev) | ||
} else { | ||
fun.write('if (errors !== %s) {', prev) | ||
fun.write('errors = %s', prev) | ||
} | ||
subrule(current, sch, schemaPath) | ||
}) | ||
node.anyOf.forEach((sch, i) => { | ||
if (i > 0) fun.write('}') | ||
}) | ||
fun.write('if (%s !== errors) {', prev) | ||
fun.write('errors = %s', prev) | ||
error('no schemas match') | ||
fun.write('}') | ||
consume('anyOf', 'array') | ||
} | ||
if (node.oneOf !== undefined) { | ||
enforce(Array.isArray(node.oneOf), 'Invalid oneOf') | ||
const prev = gensym('prev') | ||
const passes = gensym('passes') | ||
fun.write('const %s = errors', prev) | ||
fun.write('let %s = 0', passes) | ||
for (const sch of node.oneOf) { | ||
subrule(current, sch, schemaPath) | ||
fun.write('if (%s === errors) { %s++ } else errors = %s', prev, passes, prev) | ||
} | ||
errorIf('%s !== 1', [passes], 'no (or more than one) schemas match') | ||
consume('oneOf', 'array') | ||
} | ||
} | ||
const maybeWrap = (shouldWrap, fmt, args, close, writeBody) => { | ||
if (!shouldWrap) return writeBody() | ||
fun.block(fmt, args, close, writeBody) | ||
} | ||
const typeWrap = (checkBlock, validTypes, queryType) => { | ||
const [funSize, unusedSize] = [fun.size(), unused.size] | ||
const alwaysValidType = typeArray && typeArray.every((type) => validTypes.includes(type)) | ||
maybeWrap(!alwaysValidType, 'if (%s) {', [queryType], '}', checkBlock) | ||
// enforce check that non-applicable blocks are empty and no rules were applied | ||
if (funSize !== fun.size() || unusedSize !== unused.size) | ||
enforce(typeApplicable(...validTypes), `Unexpected rules in type`, node.type) | ||
} | ||
/* Actual post-$ref validation happens here */ | ||
const needTypeValidation = typeArray !== null | ||
if (needTypeValidation) { | ||
const typeValidate = safeor(...typeArray.map((t) => types.get(t)(name))) | ||
errorIf('!(%s)', [typeValidate], 'is the wrong type') | ||
} | ||
if (node.type !== undefined) consume('type', 'string', 'array') | ||
// If type validation was needed and did not return early, wrap this inside an else clause. | ||
maybeWrap(needTypeValidation && allErrors, 'else {', [], '}', () => { | ||
if (checkConst()) { | ||
// const/enum shouldn't have any other validation rules except for already checked type/$ref | ||
enforce(unused.size === 0, 'Unexpected keywords mixed with const or enum:', [...unused]) | ||
return | ||
} | ||
typeWrap(checkNumbers, ['number', 'integer'], types.get('number')(name)) | ||
typeWrap(checkStrings, ['string'], types.get('string')(name)) | ||
typeWrap(checkArrays, ['array'], types.get('array')(name)) | ||
typeWrap(checkObjects, ['object'], types.get('object')(name)) | ||
checkGeneric() | ||
}) | ||
finish() | ||
if (opts.includeErrors) { | ||
fun.write('if (!deepEqual(data, JSON.parse(JSON.stringify(data)))) {') | ||
fun.write('validate.errors = [{schemaPath:"#",dataPath:"#",message:"not JSON compatible"}]') | ||
fun.write('return false') | ||
fun.write('}') | ||
fun.write('const res = actualValidate(data)') | ||
fun.write('validate.errors = actualValidate.errors') | ||
fun.write('return res') | ||
} else { | ||
fun.write('return deepEqual(data, JSON.parse(JSON.stringify(data))) && actualValidate(data)') | ||
} | ||
visit(optAllErrors, optIncludeErrors, [], { name: safe('data') }, schema, []) | ||
fun.write('return errors === 0') | ||
fun.write('}') | ||
if (dryRun) return | ||
const validate = fun.makeFunction(scope) | ||
@@ -844,4 +39,2 @@ validate.toModule = () => fun.makeModule(scope) | ||
const validator = (schema, opts = {}) => compile(schema, schema, opts, Object.create(null)) | ||
const parser = function(schema, opts = {}) { | ||
@@ -856,7 +49,7 @@ // strong mode is default in parser | ||
if (validate(data)) return data | ||
const message = validate.errors | ||
? validate.errors.map((err) => `${err.field} ${err.message}`).join('\n') | ||
: '' | ||
const error = new Error(`JSON validation error${message ? `: ${message}` : ''}`) | ||
error.errors = validate.errors | ||
const reason = validate.errors ? validate.errors[0] : null | ||
const keyword = reason && reason.schemaPath ? reason.schemaPath.replace(/.*\//, '') : '??' | ||
const explanation = reason ? ` for ${keyword} at ${reason.dataPath}` : '' | ||
const error = new Error(`JSON validation failed${explanation}`) | ||
if (validate.errors) error.errors = validate.errors | ||
throw error | ||
@@ -863,0 +56,0 @@ } |
@@ -17,3 +17,3 @@ 'use strict' | ||
if (Object.getPrototypeOf(item) !== Function.prototype) | ||
throw new Error('Can not stringify a function with unexpected prototype') | ||
throw new Error('Can not stringify: a function with unexpected prototype') | ||
@@ -29,11 +29,11 @@ const stringified = `${item}` | ||
// Shortened ES6 object method declaration | ||
throw new Error('Can stringify only either normal or arrow functions') | ||
throw new Error('Can not stringify: only either normal or arrow functions are supported') | ||
} else if (typeof item === 'object') { | ||
const proto = Object.getPrototypeOf(item) | ||
if (item instanceof RegExp && proto === RegExp.prototype) return format('%r', item) | ||
throw new Error('Can not stringify an object with unexpected prototype') | ||
throw new Error('Can not stringify: an object with unexpected prototype') | ||
} | ||
throw new Error(`Cannot stringify ${item} - unknown type ${typeof item}`) | ||
throw new Error(`Can not stringify: unknown type ${typeof item}`) | ||
} | ||
module.exports = jaystring |
'use strict' | ||
module.exports = [ | ||
'$schema', // version | ||
...['id', '$id', '$ref', 'definitions', '$defs'], // pointers | ||
...['$schema', '$vocabulary'], // version | ||
...['id', '$id', '$anchor', '$ref', 'definitions', '$defs', '$recursiveRef', '$recursiveAnchor'], // pointers | ||
...['type', 'required', 'default'], // generic | ||
@@ -14,3 +14,3 @@ ...['enum', 'const'], // constant values | ||
...['properties', 'maxProperties', 'minProperties', 'additionalProperties', 'patternProperties'], // objects | ||
...['propertyNames', 'dependencies', 'dependentRequired'], // objects | ||
...['propertyNames', 'dependencies', 'dependentRequired', 'dependentSchemas'], // objects | ||
// Unused meta keywords not affecting validation (annotations and comments) | ||
@@ -17,0 +17,0 @@ // https://json-schema.org/understanding-json-schema/reference/generic.html |
'use strict' | ||
function toPointer(path) { | ||
if (path.length === 0) return '#' | ||
return `#/${path.map((part) => `${part}`.replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}` | ||
} | ||
function untilde(string) { | ||
@@ -31,2 +26,3 @@ if (!string.includes('~')) return string | ||
if (typeof part !== 'string') throw new Error('Invalid JSON pointer') | ||
if (objpath) objpath.push(curr) // does not include target itself, but includes head | ||
const prop = untilde(part) | ||
@@ -36,5 +32,3 @@ if (typeof curr !== 'object') return undefined | ||
curr = curr[prop] | ||
if (objpath) objpath.push(curr) | ||
} | ||
if (objpath) objpath.pop() // does not include head or result | ||
return curr | ||
@@ -58,8 +52,10 @@ } | ||
const ids = objpath.map((obj) => (obj && (obj.$id || obj.id)) || '') | ||
return ids.filter((id) => id).reduce(joinPath, '') | ||
return ids.filter((id) => id && typeof id === 'string').reduce(joinPath, '') | ||
} | ||
// Returns a list of resolved entries, in a form: [schema, root, basePath] | ||
// basePath doesn't contain the target object $id itself | ||
function resolveReference(root, additionalSchemas, ref, base = '') { | ||
const ptr = joinPath(base, ref) | ||
const schemas = new Map(Object.entries(additionalSchemas)) | ||
const schemas = new Map(additionalSchemas) | ||
const self = (base || '').split('#')[0] | ||
@@ -81,9 +77,15 @@ if (self) schemas.set(self, root) | ||
if (path === ptr || (path === main && local === '')) { | ||
results.push([sub, root, ptr]) | ||
results.push([sub, root, oldPath]) | ||
} else if (path === main && local[0] === '/') { | ||
const objpath = [] | ||
const res = get(sub, local, objpath) | ||
if (res !== undefined) results.push([res, root, joinPath(path, objpath2path(objpath))]) | ||
if (res !== undefined) results.push([res, root, joinPath(oldPath, objpath2path(objpath))]) | ||
} | ||
} | ||
if (sub.$anchor && typeof sub.$anchor === 'string') { | ||
if (sub.$anchor.includes('#')) throw new Error("$anchor can't include '#'") | ||
if (sub.$anchor.startsWith('/')) throw new Error("$anchor can't start with '/'") | ||
path = joinPath(path, `#${sub.$anchor}`) | ||
if (path === ptr) results.push([sub, root, oldPath]) | ||
} | ||
for (const k of Object.keys(sub)) visit(sub[k], path) | ||
@@ -110,2 +112,36 @@ } | ||
module.exports = { toPointer, get, joinPath, resolveReference } | ||
const buildSchemas = (input) => { | ||
if (input) { | ||
switch (Object.getPrototypeOf(input)) { | ||
case Object.prototype: | ||
return new Map(Object.entries(input)) | ||
case Map.prototype: | ||
return new Map(input) | ||
case Array.prototype: { | ||
// In this case, schema ids are extracted from the schemas themselves | ||
const schemas = new Map() | ||
const cleanId = (id) => | ||
// # is allowed only as the last symbol here | ||
id && typeof id === 'string' && !/#./.test(id) ? id.replace(/#$/, '') : null | ||
for (const schema of input) { | ||
const visit = (sub) => { | ||
if (!sub || typeof sub !== 'object') return | ||
const id = cleanId(sub.$id || sub.id) | ||
if (id && id.includes('://')) { | ||
if (schemas.has(id)) throw new Error("Duplicate schema $id in 'schemas'") | ||
schemas.set(id, sub) | ||
} else if (sub === schema) { | ||
throw new Error("Schema with missing or invalid $id in 'schemas'") | ||
} | ||
for (const k of Object.keys(sub)) visit(sub[k]) | ||
} | ||
visit(schema) | ||
} | ||
return schemas | ||
} | ||
} | ||
} | ||
throw new Error("Unexpected value for 'schemas' option") | ||
} | ||
module.exports = { get, joinPath, resolveReference, buildSchemas } |
@@ -12,3 +12,10 @@ 'use strict' | ||
const jsval = (val) => { | ||
if ([Infinity, -Infinity, NaN, undefined].includes(val)) return `${val}` | ||
if ([Infinity, -Infinity, NaN, undefined, null].includes(val)) return `${val}` | ||
const primitive = ['string', 'boolean', 'number'].includes(typeof val) | ||
if (!primitive) { | ||
if (typeof val !== 'object') throw new Error('Unexpected value type') | ||
const proto = Object.getPrototypeOf(val) | ||
const ok = (proto === Array.prototype && Array.isArray(val)) || proto === Object.prototype | ||
if (!ok) throw new Error('Unexpected object given as value') | ||
} | ||
return ( | ||
@@ -62,3 +69,3 @@ JSON.stringify(val) | ||
const safe = (string) => { | ||
if (!/^[a-z][a-z0-9]*$/.test(string)) throw new Error('Does not look like a safe id') | ||
if (!/^[a-z][a-z0-9_]*$/i.test(string)) throw new Error('Does not look like a safe id') | ||
return new SafeString(string) | ||
@@ -65,0 +72,0 @@ } |
@@ -5,22 +5,13 @@ 'use strict' | ||
// https://mathiasbynens.be/notes/javascript-unicode#accounting-for-astral-symbols | ||
const stringLength = (string) => [...string].length | ||
const stringLength = (string) => | ||
/[\uD800-\uDFFF]/.test(string) ? [...string].length : string.length | ||
const isMultipleOf = (value, multipleOf) => { | ||
if (typeof multipleOf !== 'number' || !Number.isFinite(value)) | ||
throw new Error('multipleOf is not a number') | ||
if (typeof value !== 'number' || !Number.isFinite(value)) return false | ||
if (value === 0) return true | ||
if (multipleOf === 0) return false | ||
const digitsAfterDot = (number) => { | ||
if ((number | 0) === number) return 0 | ||
return String(number) | ||
.split('.') | ||
.pop().length | ||
} | ||
const digits = digitsAfterDot(multipleOf) | ||
if (digits === 0) return value % multipleOf === 0 | ||
const valueDigits = digitsAfterDot(value) | ||
if (valueDigits > digits) return false | ||
const factor = Math.pow(10, digits) | ||
return Math.round(factor * value) % Math.round(factor * multipleOf) === 0 | ||
// A isMultipleOf B: shortest decimal denoted as A % shortest decimal denoted as B === 0 | ||
// Optimized, sanity checks and precomputation are outside of this method | ||
const isMultipleOf = (value, divisor, factor, factorMultiple) => { | ||
if (value % divisor === 0) return true | ||
const multiple = value * factor | ||
if (multiple % factorMultiple === 0) return true | ||
const normal = Math.floor(multiple + 0.5) | ||
return normal / factor === value && normal % factorMultiple === 0 | ||
} | ||
@@ -72,2 +63,13 @@ | ||
module.exports = { stringLength, isMultipleOf, deepEqual, unique, hasOwn } | ||
// Used for error generation. Affects error performance, optimized | ||
const pointerPart = (s) => (/~\//.test(s) ? `${s}`.replace(/~/g, '~0').replace(/\//g, '~1') : s) | ||
const toPointer = (path) => (path.length === 0 ? '#' : `#/${path.map(pointerPart).join('/')}`) | ||
const errorMerge = ({ schemaPath, dataPath, ...more }, schemaBase, dataBase) => ({ | ||
schemaPath: `${schemaBase}${schemaPath.slice(1)}`, | ||
dataPath: `${dataBase}${dataPath.slice(1)}`, | ||
...more, | ||
}) | ||
const errorUtils = { toPointer, pointerPart, errorMerge } | ||
module.exports = { stringLength, isMultipleOf, deepEqual, unique, hasOwn, ...errorUtils } |
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
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
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
73741
12
1407
3