From 9a67ab46ee76789fa228d1751ba05e43faca49eb Mon Sep 17 00:00:00 2001 From: Vitaliy Filippov Date: Sun, 22 Sep 2019 01:52:10 +0300 Subject: [PATCH] Initial commit --- ServerPropTypes.js | 457 +++++++++++++++++++++++++++++++++++++++++++++ UserError.js | 15 ++ test.js | 13 ++ 3 files changed, 485 insertions(+) create mode 100644 ServerPropTypes.js create mode 100644 UserError.js create mode 100644 test.js diff --git a/ServerPropTypes.js b/ServerPropTypes.js new file mode 100644 index 0000000..bb2b0b6 --- /dev/null +++ b/ServerPropTypes.js @@ -0,0 +1,457 @@ +/** + * Yet another omnivorous data validator + * + * Peculiar features: + * - Uses literal definitions (unlike, for example, Joi) + * - Simpler than JSON schema, but convertible to it + * - Supports comments and convenient user-readable validation errors + * - Coerces decimal strings to numbers + * - Coerces scalars to single-element arrays + * + * (c) Vitaliy Filippov 2019+ + * + * Version 2019-09-21 + */ +const UserError = require('./UserError.js'); + +class ServerPropTypes +{ + static check(v, type, options) + { + const sub = { + refs: options && options.refs ? (type.refs ? { ...options.refs, ...type.refs } : options.refs) : type.refs, + return_error: true, + }; + type = this.fill(type, sub); + let bad = false; + switch (type.type) + { + case 'any': + break; + case 'string': + if (v != null && typeof v != 'number' && typeof v != 'string' || type.required && (v == null || v === '')) + bad = true; + else + { + v = ''+v; + if (type.regex && !new RegExp(type.regex, type.regexOptions).exec(v)) + bad = true; + } + break; + case 'decimal': + if (v != null && typeof v != 'number' && typeof v != 'string' || type.required && (v == null || v == 0)) + bad = true; + else if (type.decimals != null) + { + const re = type.decimals > 0 ? /^-?\d+(\.\d+?)?0*$/ : /^-?\d+$/; + let m = re.exec(''+v); + if (m[1] && m[1].length > (type.decimals < 0 ? 0 : type.decimals) + 1) + bad = true; + } + break; + case 'num': + v = v == null ? null : parseFloat(v); + if (v != v) + bad = true; + else if (type.min != null && v < type.min || type.max != null && v > type.max) + bad = true; + else if (type.required && !v) + bad = true; + break; + case 'int': + v = v == null ? null : parseInt(v); + if (v != v) + bad = true; + else if (type.min != null && v < type.min || type.max != null && v > type.max) + bad = true; + else if (type.required && !v) + bad = true; + break; + case 'id': + v = v ? parseInt(v) : null; + if (v != v || v < 0 || v == null && type.required) + bad = true; + break; + case 'bool': + if ((v != null || type.required) && v !== false && v !== true && + v !== '' && v !== 0 && v !== '0' && v !== 1 && v !== '1') + bad = true; + if (v != null) + v = v && v !== '0' ? true : false; + else + v = null; + break; + case 'array': + if (!(v instanceof Array) && v != null && type.allow_scalar !== false) + v = [ v ]; + if (v != null) + { + if (type.items) + { + if (v.length != type.items.length) + bad = true; + else + { + let rv = []; + for (let i = 0; i < type.items.length; i++) + { + let st = this.check(v[i], type.items[i], sub); + if (st.error) + { + bad = { + path: [ i, ...(st.error.path||[]) ], + name: '№'+i + (st.error.name ? ' - '+st.error.name : ''), + }; + break; + } + rv[i] = st.value; + } + v = rv; + } + } + else if (type.of) + { + if (type.maxLength != null && v.length > type.maxLength || + type.minLength != null && v.length < type.minLength || + type.required && v.length == 0) + { + bad = true; + } + else + { + let rv = []; + const type_of = this.fill(type.of, sub); + for (let i = 0; i < v.length; i++) + { + let st = this.check(v[i], type_of, sub); + if (st.error) + { + bad = { + path: [ i, ...(st.error.path||[]) ], + name: '№'+i + (st.error.name ? ' - '+st.error.name : ''), + }; + break; + } + rv[i] = st.value; + } + v = rv; + } + } + } + else if (type.required) + { + bad = true; + } + else + { + v = null; + } + break; + case 'object': + if (typeof v != 'object' || v instanceof Array || type.required && !v) + { + bad = true; + } + else + { + if (type.items) + { + let nv = {}; + for (let k in type.items) + { + let st = this.check(v[k], type.items[k], sub); + if (st.error) + { + bad = { + path: [ k, ...(st.error.path||[]) ], + name: (type.comment + ? type.comment + (st.error.name ? ' - '+st.error.name : '') + : st.error.name), + }; + break; + } + if (k in v) + nv[k] = st.value; + } + v = nv; + } + else if (type.of || type.keyRegex) + { + let re = type.keyRegex ? new RegExp(type.keyRegex, type.keyRegexOptions) : null; + let nv = {}; + for (let k in v) + { + if (re && !re.exec(k)) + { + bad = true; + break; + } + if (type.of) + { + let st = this.check(v[k], type.of, sub); + if (st.error) + { + bad = { + path: [ k, ...(st.error.path||[]) ], + name: (type.comment + ? type.comment + ' - ' + k + (st.error.name ? ' - '+st.error.name : '') + : k + (st.error.name ? ' - '+st.error.name : '')), + }; + break; + } + nv[k] = st.value; + } + else + nv[k] = v[k]; + } + v = nv; + } + } + break; + case 'enum': + if (!type.of.filter(o => o === v).length) + bad = true; + break; + case 'one': + let type_of = this.expandOneOf(type, sub); + for (let i = 0; i < type_of.length; i++) + { + const ti = type_of[i]; + const st = this.check(v, ti, sub); + if (!st.error) + { + v = st.value; + bad = false; + break; + } + else + { + bad = bad || true; + if (ti.type == 'object' && ti.items && typeof v == 'object') + { + let fit_enum; + for (const k in ti.items) + { + if (ti.items[k].type == 'enum') + { + fit_enum = fit_enum || {}; + fit_enum[k] = ti.items[k]; + } + } + if (fit_enum) + { + let fitst = this.check(v, { type: 'object', items: fit_enum }, sub); + if (!fitst.error) + { + const fit_fields = Object.keys(ti.items).filter(k => ti.items[k].required && (k in v)).length; + if (!bad || bad === true || bad.fields < fit_fields) + { + bad = { fields: fit_fields, error: st.error }; + } + } + } + } + } + } + if (bad && bad !== true) + { + bad = bad.error; + if (type.comment) + bad.name = type.comment + (bad.name ? ' - ' + bad.name : ''); + } + break; + default: + throw new Error('unknown-type'); + } + if (bad) + { + if (bad === true) + { + bad = { + path: [], + name: type.comment, + }; + } + if (options && options.varname) + { + bad.name = options.varname + (bad.name ? ' - ' + bad.name : ''); + } + if (options && options.return_error) + { + return { error: bad }; + } + throw new UserError('invalid-format', bad); + } + return options && options.return_error ? { value: v } : v; + } + + static expandOneOf(type, options) + { + const type_of = []; + for (let o of type.of) + { + o = this.fill(type.required ? { ...o, required: true } : o, options); + if (o.type == 'one') + type_of.push.apply(type_of, this.expandOneOf(o, options)); + else + type_of.push(o); + } + return type_of; + } + + static fill(type, options) + { + if (type instanceof Array) + { + if (type.length == 1) + { + type = { type: 'array', of: type[0] }; + } + else if (!type.length) + { + type = { type: 'array', of: 'any' }; + } + else + { + type = { type: 'array', items: type }; + } + } + else if (typeof type == 'object') + { + if (!type.type) + { + type = { type: 'object', items: type }; + } + } + else if (typeof type == 'string') + { + type = { type }; + } + else + { + type = { type: 'enum', of: [ type ] }; + } + if (type.type == 'ref') + { + if (!options || !options.refs || !options.refs[type.ref]) + { + throw new UserError('ref-missing', { ref: type.ref }); + } + if (type.required || type.comment) + { + type = { ...options.refs[type.ref], required: type.required, comment: type.comment }; + } + else + { + type = options.refs[type.ref]; + } + } + return type; + } +} + +ServerPropTypes.metaType = { + refs: { + anytype: { + type: 'one', + of: [ + { type: 'ref', ref: 'spt' }, + { type: 'array', of: { type: 'ref', ref: 'spt' } }, + { type: 'object', of: { type: 'ref', ref: 'spt' } }, + { type: 'enum', of: [ 'any', 'string', 'decimal', 'num', 'int', 'id', 'bool', 'array', 'object' ] }, + { type: 'num' }, + { type: 'bool' }, + ], + }, + spt: { + type: 'one', + of: [ + { comment: 'Any type', type: 'object', items: { + type: { type: 'enum', of: [ 'any' ] }, + comment: { type: 'string', comment: 'Item comment' }, + } }, + { comment: 'String', type: 'object', items: { + type: { type: 'enum', of: [ 'string' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + regex: { type: 'string', comment: 'Regular expression' }, + regexOptions: { type: 'string', comment: 'Regular expression flags' }, + } }, + { comment: 'String-coded decimal', type: 'object', items: { + type: { type: 'enum', of: [ 'decimal' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + decimals: { type: 'int', comment: 'Allowed decimal places' }, + } }, + { comment: 'Number', type: 'object', items: { + type: { type: 'enum', of: [ 'num' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + min: { type: 'num', comment: 'Minimum value' }, + max: { type: 'num', comment: 'Maximum value' }, + } }, + { comment: 'Integer', type: 'object', items: { + type: { type: 'enum', of: [ 'int' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + min: { type: 'int', comment: 'Minimum value' }, + max: { type: 'int', comment: 'Maximum value' }, + } }, + { comment: 'ID (positive integer or NULL)', type: 'object', items: { + type: { type: 'enum', of: [ 'id' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + } }, + { comment: 'Boolean', type: 'object', items: { + type: { type: 'enum', of: [ 'bool' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + } }, + { comment: 'Array', type: 'object', items: { + type: { type: 'enum', of: [ 'array' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + of: { type: 'ref', ref: 'anytype', required: true, comment: 'Array item type' }, + minLength: { type: 'int', comment: 'Minimum length' }, + maxLength: { type: 'int', comment: 'Maximum length' }, + } }, + { comment: 'Tuple (fixed-length array)', type: 'object', items: { + type: { type: 'enum', of: [ 'array' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + items: { type: 'array', of: { type: 'ref', ref: 'anytype' }, required: true, comment: 'Tuple item types' }, + } }, + { comment: 'Unstructured hash', type: 'object', items: { + type: { type: 'enum', of: [ 'object' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + of: { type: 'ref', ref: 'anytype', required: true, comment: 'Hash item type' }, + keyRegex: { type: 'string', comment: 'Hash key regular expression' }, + keyRegexOptions: { type: 'string', comment: 'Hash key regular expression flags' }, + } }, + { comment: 'Structured object', type: 'object', items: { + type: { type: 'enum', of: [ 'object' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + items: { type: 'object', of: { type: 'ref', ref: 'anytype' }, required: true, comment: 'Field types' }, + } }, + { comment: 'Enum of constants', type: 'object', items: { + type: { type: 'enum', of: [ 'enum' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + of: { type: 'array', minLength: 1, required: true, comment: 'Constants' }, + } }, + { comment: 'One of types', type: 'object', items: { + type: { type: 'enum', of: [ 'one' ] }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, + of: { type: 'array', of: { type: 'ref', ref: 'anytype' }, minLength: 1, required: true, comment: 'Type options' }, + } }, + ], + }, + }, + type: 'ref', + ref: 'spt', +}; + +module.exports = ServerPropTypes; diff --git a/UserError.js b/UserError.js new file mode 100644 index 0000000..097f1fa --- /dev/null +++ b/UserError.js @@ -0,0 +1,15 @@ +class UserError extends Error +{ + constructor(code, props) + { + super(code); + this.props = props; + } + + toString() + { + return this.message+(this.props ? ' '+JSON.stringify(this.props, null, 4) : ''); + } +} + +module.exports = UserError; diff --git a/test.js b/test.js new file mode 100644 index 0000000..c72bfe7 --- /dev/null +++ b/test.js @@ -0,0 +1,13 @@ +const spt = require('./ServerPropTypes.js'); + +const type = { + segment_id: { type: 'array', of: 'int', comment: 'Сегмент(ы)' }, + init_area: { type: 'num', min: 0.1, required: true, comment: 'Начальное отклонение площади объекта' }, + search_order: { type: 'array', of: { + field: { type: 'enum', of2: [ 'area', 'radius', 'pubdate' ], required: true, comment: 'Поле' }, + step: { type: 'num', required: true, comment: 'Шаг' }, + iter: { type: 'int', required: true, comment: 'Число итераций' }, + }, comment: 'Порядок перебора параметров' }, +}; + +console.log(JSON.stringify(spt.check({ type: 'object', items: type }, spt.metaType, { return_error: true }), null, 2));