diff --git a/ServerPropTypes.js b/ServerPropTypes.js index 4d8f59c..9322cd0 100644 --- a/ServerPropTypes.js +++ b/ServerPropTypes.js @@ -2,16 +2,16 @@ * Yet another omnivorous data validator * * Peculiar features: - * - Uses literal definitions (unlike, for example, Joi) + * - Uses literal definitions (unlike Joi!) * - Simpler than JSON schema, but convertible to it * - Supports comments and convenient user-readable validation errors * - Converts strings to numbers if possible * - Converts scalars to single-element arrays - * - Validates its own meta-type + * - Validates itself (unlike JSON Schema and Joi too) * * (c) Vitaliy Filippov 2019+ * - * Version 2019-09-21 + * Version 2019-09-22 */ const UserError = require('./UserError.js'); @@ -24,266 +24,309 @@ class ServerPropTypes return_error: true, }; type = this.fill(type, sub); - let bad = false; - switch (type.type) + const fn = this['check_'+type.type]; + if (!fn) { - 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+1) + (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+1) + (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 (v != null && 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'); + throw new Error('unknown-type'); } - if (bad) + const r = fn.call(this, v, type, sub); + if (r.error) { - if (bad === true) + if (r.error === true) { - bad = { + r.error = { path: [], name: type.comment, }; } if (options && options.varname) { - bad.name = options.varname + (bad.name ? ' - ' + bad.name : ''); + r.error.name = options.varname + (r.error.name ? ' - ' + r.error.name : ''); } if (options && options.return_error) { - return { error: bad }; + return { error: r.error }; } - throw new UserError('invalid-format', bad); + throw new UserError('invalid-format', r.error); } - return options && options.return_error ? { value: v } : v; + return options && options.return_error ? { value: r.value } : r.value; + } + + static check_any(v, type) + { + return { value: v }; + } + + static check_string(v, type) + { + let error = false; + if (v != null && typeof v != 'number' && typeof v != 'string' || type.required && (v == null || v === '')) + error = true; + else + { + v = ''+v; + if (type.regex && !new RegExp(type.regex, type.regexOptions).exec(v)) + error = true; + } + return { value: v, error }; + } + + static check_decimal(v, type) + { + let error = false; + if (v != null && typeof v != 'number' && typeof v != 'string' || type.required && (v == null || v == 0)) + error = 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) + error = true; + } + return { value: v, error }; + } + + static check_num(v, type) + { + let error = false; + v = v == null ? null : parseFloat(v); + if (v != v) + error = true; + else if (type.min != null && v < type.min || type.max != null && v > type.max) + error = true; + else if (type.required && !v) + error = true; + return { value: v, error }; + } + + static check_int(v, type) + { + let error = false; + v = v == null ? null : parseInt(v); + if (v != v) + error = true; + else if (type.min != null && v < type.min || type.max != null && v > type.max) + error = true; + else if (type.required && !v) + error = true; + return { value: v, error }; + } + + static check_id(v, type) + { + let error = false; + v = v ? parseInt(v) : null; + if (v != v || v < 0 || v == null && type.required) + error = true; + return { value: v, error }; + } + + static check_bool(v, type) + { + let error = false; + if ((v != null || type.required) && v !== false && v !== true && + v !== '' && v !== 0 && v !== '0' && v !== 1 && v !== '1') + error = true; + if (v != null) + v = v && v !== '0' ? true : false; + else + v = null; + return { value: v, error }; + } + + static check_array(v, type, sub) + { + let error = false; + if (!(v instanceof Array) && v != null && type.allow_scalar !== false) + v = [ v ]; + if (v != null) + { + if (type.items) + { + if (v.length != type.items.length) + error = 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) + { + error = { + path: [ i, ...(st.error.path||[]) ], + name: '№'+(i+1) + (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) + { + error = 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) + { + error = { + path: [ i, ...(st.error.path||[]) ], + name: '№'+(i+1) + (st.error.name ? ' - '+st.error.name : ''), + }; + break; + } + rv[i] = st.value; + } + v = rv; + } + } + } + else if (type.required) + { + error = true; + } + else + { + v = null; + } + return { value: v, error }; + } + + static check_object(v, type, sub) + { + let error = false; + if (v != null && typeof v != 'object' || v instanceof Array || type.required && !v) + { + error = 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) + { + error = { + 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)) + { + error = true; + break; + } + if (type.of) + { + let st = this.check(v[k], type.of, sub); + if (st.error) + { + error = { + 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; + } + } + return { value: v, error }; + } + + static check_enum(v, type) + { + let error = false; + if (!type.of.filter(o => o === v).length) + error = true; + return { value: v, error }; + } + + static check_one(v, type, sub) + { + let error = false; + 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; + error = false; + break; + } + else + { + error = error || 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' || typeof ti.items[k] != 'object') + { + 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 (!error || error === true || error.fields < fit_fields) + { + error = { fields: fit_fields, error: st.error }; + } + } + } + } + } + } + if (error && error !== true) + { + error = error.error; + if (type.comment) + error.name = type.comment + (error.name ? ' - ' + error.name : ''); + } + return { value: v, error }; } static expandOneOf(type, options) @@ -291,7 +334,9 @@ class ServerPropTypes const type_of = []; for (let o of type.of) { - o = this.fill(type.required ? { ...o, required: true } : o, options); + o = this.fill(o, options); + if (type.required) + o = { ...o, required: true }; if (o.type == 'one') type_of.push.apply(type_of, this.expandOneOf(o, options)); else @@ -324,12 +369,9 @@ class ServerPropTypes type = { type: 'object', items: type }; } } - else if (typeof type == 'string') - { - type = { type }; - } else { + // Scalar is a fixed value type = { type: 'enum', of: [ type ] }; } if (type.type == 'ref') @@ -351,6 +393,12 @@ class ServerPropTypes } } +const common = { + refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, + required: { type: 'bool', comment: 'Item is required' }, + comment: { type: 'string', comment: 'Item comment' }, +}; + ServerPropTypes.metaType = { refs: { anytype: { @@ -359,113 +407,86 @@ ServerPropTypes.metaType = { { 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' }, + { type: 'string' }, ], }, spt: { type: 'one', of: [ { comment: 'Reference to another type', type: 'object', items: { - type: { type: 'enum', of: [ 'ref' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'ref', + ...common, ref: { type: 'string', comment: 'Type reference', required: true }, } }, { comment: 'Any type', type: 'object', items: { - type: { type: 'enum', of: [ 'any' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'any', + ...common, } }, { comment: 'String', type: 'object', items: { - type: { type: 'enum', of: [ 'string' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'string', + ...common, 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' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'decimal', + ...common, decimals: { type: 'int', comment: 'Allowed decimal places' }, } }, { comment: 'Number', type: 'object', items: { - type: { type: 'enum', of: [ 'num' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'num', + ...common, min: { type: 'num', comment: 'Minimum value' }, max: { type: 'num', comment: 'Maximum value' }, } }, { comment: 'Integer', type: 'object', items: { - type: { type: 'enum', of: [ 'int' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'int', + ...common, 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' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'id', + ...common, } }, { comment: 'Boolean', type: 'object', items: { - type: { type: 'enum', of: [ 'bool' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'bool', + ...common, } }, { comment: 'Array', type: 'object', items: { - type: { type: 'enum', of: [ 'array' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'array', + ...common, 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' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'array', + ...common, 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' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'object', + ...common, 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' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'object', + ...common, 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' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, - of: { type: 'array', of: 'any', minLength: 1, required: true, comment: 'Constants' }, + type: 'enum', + ...common, + of: { type: 'array', of: { type: 'any' }, minLength: 1, required: true, comment: 'Constants' }, } }, { comment: 'One of types', type: 'object', items: { - type: { type: 'enum', of: [ 'one' ] }, - refs: { type: 'object', of: { type: 'ref', ref: 'anytype' } }, - required: { type: 'bool', comment: 'Item is required' }, - comment: { type: 'string', comment: 'Item comment' }, + type: 'one', + ...common, of: { type: 'array', of: { type: 'ref', ref: 'anytype' }, minLength: 1, required: true, comment: 'Type options' }, } }, ], diff --git a/test.js b/test.js index 0cdc49e..e19a1aa 100644 --- a/test.js +++ b/test.js @@ -13,4 +13,7 @@ const type = { console.log(JSON.stringify(spt.check({ type: 'object', items: type }, spt.metaType, { return_error: true }), null, 2)); // Self-validate! +let r = {}; +for (let i = 0; i < 1000; i++) + r = spt.check(spt.metaType, spt.metaType, { return_error: true }); console.log(JSON.stringify(spt.check(spt.metaType, spt.metaType, { return_error: true }), null, 2));