'use strict'; const Assert = require('@hapi/hoek/lib/assert'); const Clone = require('@hapi/hoek/lib/clone'); const DeepEqual = require('@hapi/hoek/lib/deepEqual'); const Merge = require('@hapi/hoek/lib/merge'); const Cache = require('./cache'); const Common = require('./common'); const Compile = require('./compile'); const Errors = require('./errors'); const Extend = require('./extend'); const Manifest = require('./manifest'); const Messages = require('./messages'); const Modify = require('./modify'); const Ref = require('./ref'); const Trace = require('./trace'); const Validator = require('./validator'); const Values = require('./values'); const internals = {}; internals.Base = class { constructor(type) { // Naming: public, _private, $_extension, $_mutate{action} this.type = type; this.$_root = null; this._definition = {}; this._reset(); } _reset() { this._ids = new Modify.Ids(); this._preferences = null; this._refs = new Ref.Manager(); this._cache = null; this._valids = null; this._invalids = null; this._flags = {}; this._rules = []; this._singleRules = new Map(); // The rule options passed for non-multi rules this.$_terms = {}; // Hash of arrays of immutable objects (extended by other types) this.$_temp = { // Runtime state (not cloned) ruleset: null, // null: use last, false: error, number: start position whens: {} // Runtime cache of generated whens }; } // Manifest describe() { Assert(typeof Manifest.describe === 'function', 'Manifest functionality disabled'); return Manifest.describe(this); } // Rules allow(...values) { Common.verifyFlat(values, 'allow'); return this._values(values, '_valids'); } alter(targets) { Assert(targets && typeof targets === 'object' && !Array.isArray(targets), 'Invalid targets argument'); Assert(!this._inRuleset(), 'Cannot set alterations inside a ruleset'); const obj = this.clone(); obj.$_terms.alterations = obj.$_terms.alterations || []; for (const target in targets) { const adjuster = targets[target]; Assert(typeof adjuster === 'function', 'Alteration adjuster for', target, 'must be a function'); obj.$_terms.alterations.push({ target, adjuster }); } obj.$_temp.ruleset = false; return obj; } artifact(id) { Assert(id !== undefined, 'Artifact cannot be undefined'); Assert(!this._cache, 'Cannot set an artifact with a rule cache'); return this.$_setFlag('artifact', id); } cast(to) { Assert(to === false || typeof to === 'string', 'Invalid to value'); Assert(to === false || this._definition.cast[to], 'Type', this.type, 'does not support casting to', to); return this.$_setFlag('cast', to === false ? undefined : to); } default(value, options) { return this._default('default', value, options); } description(desc) { Assert(desc && typeof desc === 'string', 'Description must be a non-empty string'); return this.$_setFlag('description', desc); } empty(schema) { const obj = this.clone(); if (schema !== undefined) { schema = obj.$_compile(schema, { override: false }); } return obj.$_setFlag('empty', schema, { clone: false }); } error(err) { Assert(err, 'Missing error'); Assert(err instanceof Error || typeof err === 'function', 'Must provide a valid Error object or a function'); return this.$_setFlag('error', err); } example(example, options = {}) { Assert(example !== undefined, 'Missing example'); Common.assertOptions(options, ['override']); return this._inner('examples', example, { single: true, override: options.override }); } external(method, description) { if (typeof method === 'object') { Assert(!description, 'Cannot combine options with description'); description = method.description; method = method.method; } Assert(typeof method === 'function', 'Method must be a function'); Assert(description === undefined || description && typeof description === 'string', 'Description must be a non-empty string'); return this._inner('externals', { method, description }, { single: true }); } failover(value, options) { return this._default('failover', value, options); } forbidden() { return this.presence('forbidden'); } id(id) { if (!id) { return this.$_setFlag('id', undefined); } Assert(typeof id === 'string', 'id must be a non-empty string'); Assert(/^[^\.]+$/.test(id), 'id cannot contain period character'); return this.$_setFlag('id', id); } invalid(...values) { return this._values(values, '_invalids'); } label(name) { Assert(name && typeof name === 'string', 'Label name must be a non-empty string'); return this.$_setFlag('label', name); } meta(meta) { Assert(meta !== undefined, 'Meta cannot be undefined'); return this._inner('metas', meta, { single: true }); } note(...notes) { Assert(notes.length, 'Missing notes'); for (const note of notes) { Assert(note && typeof note === 'string', 'Notes must be non-empty strings'); } return this._inner('notes', notes); } only(mode = true) { Assert(typeof mode === 'boolean', 'Invalid mode:', mode); return this.$_setFlag('only', mode); } optional() { return this.presence('optional'); } prefs(prefs) { Assert(prefs, 'Missing preferences'); Assert(prefs.context === undefined, 'Cannot override context'); Assert(prefs.externals === undefined, 'Cannot override externals'); Assert(prefs.warnings === undefined, 'Cannot override warnings'); Assert(prefs.debug === undefined, 'Cannot override debug'); Common.checkPreferences(prefs); const obj = this.clone(); obj._preferences = Common.preferences(obj._preferences, prefs); return obj; } presence(mode) { Assert(['optional', 'required', 'forbidden'].includes(mode), 'Unknown presence mode', mode); return this.$_setFlag('presence', mode); } raw(enabled = true) { return this.$_setFlag('result', enabled ? 'raw' : undefined); } result(mode) { Assert(['raw', 'strip'].includes(mode), 'Unknown result mode', mode); return this.$_setFlag('result', mode); } required() { return this.presence('required'); } strict(enabled) { const obj = this.clone(); const convert = enabled === undefined ? false : !enabled; obj._preferences = Common.preferences(obj._preferences, { convert }); return obj; } strip(enabled = true) { return this.$_setFlag('result', enabled ? 'strip' : undefined); } tag(...tags) { Assert(tags.length, 'Missing tags'); for (const tag of tags) { Assert(tag && typeof tag === 'string', 'Tags must be non-empty strings'); } return this._inner('tags', tags); } unit(name) { Assert(name && typeof name === 'string', 'Unit name must be a non-empty string'); return this.$_setFlag('unit', name); } valid(...values) { Common.verifyFlat(values, 'valid'); const obj = this.allow(...values); obj.$_setFlag('only', !!obj._valids, { clone: false }); return obj; } when(condition, options) { const obj = this.clone(); if (!obj.$_terms.whens) { obj.$_terms.whens = []; } const when = Compile.when(obj, condition, options); if (!['any', 'link'].includes(obj.type)) { const conditions = when.is ? [when] : when.switch; for (const item of conditions) { Assert(!item.then || item.then.type === 'any' || item.then.type === obj.type, 'Cannot combine', obj.type, 'with', item.then && item.then.type); Assert(!item.otherwise || item.otherwise.type === 'any' || item.otherwise.type === obj.type, 'Cannot combine', obj.type, 'with', item.otherwise && item.otherwise.type); } } obj.$_terms.whens.push(when); return obj.$_mutateRebuild(); } // Helpers cache(cache) { Assert(!this._inRuleset(), 'Cannot set caching inside a ruleset'); Assert(!this._cache, 'Cannot override schema cache'); Assert(this._flags.artifact === undefined, 'Cannot cache a rule with an artifact'); const obj = this.clone(); obj._cache = cache || Cache.provider.provision(); obj.$_temp.ruleset = false; return obj; } clone() { const obj = Object.create(Object.getPrototypeOf(this)); return this._assign(obj); } concat(source) { Assert(Common.isSchema(source), 'Invalid schema object'); Assert(this.type === 'any' || source.type === 'any' || source.type === this.type, 'Cannot merge type', this.type, 'with another type:', source.type); Assert(!this._inRuleset(), 'Cannot concatenate onto a schema with open ruleset'); Assert(!source._inRuleset(), 'Cannot concatenate a schema with open ruleset'); let obj = this.clone(); if (this.type === 'any' && source.type !== 'any') { // Change obj to match source type const tmpObj = source.clone(); for (const key of Object.keys(obj)) { if (key !== 'type') { tmpObj[key] = obj[key]; } } obj = tmpObj; } obj._ids.concat(source._ids); obj._refs.register(source, Ref.toSibling); obj._preferences = obj._preferences ? Common.preferences(obj._preferences, source._preferences) : source._preferences; obj._valids = Values.merge(obj._valids, source._valids, source._invalids); obj._invalids = Values.merge(obj._invalids, source._invalids, source._valids); // Remove unique rules present in source for (const name of source._singleRules.keys()) { if (obj._singleRules.has(name)) { obj._rules = obj._rules.filter((target) => target.keep || target.name !== name); obj._singleRules.delete(name); } } // Rules for (const test of source._rules) { if (!source._definition.rules[test.method].multi) { obj._singleRules.set(test.name, test); } obj._rules.push(test); } // Flags if (obj._flags.empty && source._flags.empty) { obj._flags.empty = obj._flags.empty.concat(source._flags.empty); const flags = Object.assign({}, source._flags); delete flags.empty; Merge(obj._flags, flags); } else if (source._flags.empty) { obj._flags.empty = source._flags.empty; const flags = Object.assign({}, source._flags); delete flags.empty; Merge(obj._flags, flags); } else { Merge(obj._flags, source._flags); } // Terms for (const key in source.$_terms) { const terms = source.$_terms[key]; if (!terms) { if (!obj.$_terms[key]) { obj.$_terms[key] = terms; } continue; } if (!obj.$_terms[key]) { obj.$_terms[key] = terms.slice(); continue; } obj.$_terms[key] = obj.$_terms[key].concat(terms); } // Tracing if (this.$_root._tracer) { this.$_root._tracer._combine(obj, [this, source]); } // Rebuild return obj.$_mutateRebuild(); } extend(options) { Assert(!options.base, 'Cannot extend type with another base'); return Extend.type(this, options); } extract(path) { path = Array.isArray(path) ? path : path.split('.'); return this._ids.reach(path); } fork(paths, adjuster) { Assert(!this._inRuleset(), 'Cannot fork inside a ruleset'); let obj = this; // eslint-disable-line consistent-this for (let path of [].concat(paths)) { path = Array.isArray(path) ? path : path.split('.'); obj = obj._ids.fork(path, adjuster, obj); } obj.$_temp.ruleset = false; return obj; } rule(options) { const def = this._definition; Common.assertOptions(options, Object.keys(def.modifiers)); Assert(this.$_temp.ruleset !== false, 'Cannot apply rules to empty ruleset or the last rule added does not support rule properties'); const start = this.$_temp.ruleset === null ? this._rules.length - 1 : this.$_temp.ruleset; Assert(start >= 0 && start < this._rules.length, 'Cannot apply rules to empty ruleset'); const obj = this.clone(); for (let i = start; i < obj._rules.length; ++i) { const original = obj._rules[i]; const rule = Clone(original); for (const name in options) { def.modifiers[name](rule, options[name]); Assert(rule.name === original.name, 'Cannot change rule name'); } obj._rules[i] = rule; if (obj._singleRules.get(rule.name) === original) { obj._singleRules.set(rule.name, rule); } } obj.$_temp.ruleset = false; return obj.$_mutateRebuild(); } get ruleset() { Assert(!this._inRuleset(), 'Cannot start a new ruleset without closing the previous one'); const obj = this.clone(); obj.$_temp.ruleset = obj._rules.length; return obj; } get $() { return this.ruleset; } tailor(targets) { targets = [].concat(targets); Assert(!this._inRuleset(), 'Cannot tailor inside a ruleset'); let obj = this; // eslint-disable-line consistent-this if (this.$_terms.alterations) { for (const { target, adjuster } of this.$_terms.alterations) { if (targets.includes(target)) { obj = adjuster(obj); Assert(Common.isSchema(obj), 'Alteration adjuster for', target, 'failed to return a schema object'); } } } obj = obj.$_modify({ each: (item) => item.tailor(targets), ref: false }); obj.$_temp.ruleset = false; return obj.$_mutateRebuild(); } tracer() { return Trace.location ? Trace.location(this) : this; // $lab:coverage:ignore$ } validate(value, options) { return Validator.entry(value, this, options); } validateAsync(value, options) { return Validator.entryAsync(value, this, options); } // Extensions $_addRule(options) { // Normalize rule if (typeof options === 'string') { options = { name: options }; } Assert(options && typeof options === 'object', 'Invalid options'); Assert(options.name && typeof options.name === 'string', 'Invalid rule name'); for (const key in options) { Assert(key[0] !== '_', 'Cannot set private rule properties'); } const rule = Object.assign({}, options); // Shallow cloned rule._resolve = []; rule.method = rule.method || rule.name; const definition = this._definition.rules[rule.method]; const args = rule.args; Assert(definition, 'Unknown rule', rule.method); // Args const obj = this.clone(); if (args) { Assert(Object.keys(args).length === 1 || Object.keys(args).length === this._definition.rules[rule.name].args.length, 'Invalid rule definition for', this.type, rule.name); for (const key in args) { let arg = args[key]; if (arg === undefined) { delete args[key]; continue; } if (definition.argsByName) { const resolver = definition.argsByName.get(key); if (resolver.ref && Common.isResolvable(arg)) { rule._resolve.push(key); obj.$_mutateRegister(arg); } else { if (resolver.normalize) { arg = resolver.normalize(arg); args[key] = arg; } if (resolver.assert) { const error = Common.validateArg(arg, key, resolver); Assert(!error, error, 'or reference'); } } } args[key] = arg; } } // Unique rules if (!definition.multi) { obj._ruleRemove(rule.name, { clone: false }); obj._singleRules.set(rule.name, rule); } if (obj.$_temp.ruleset === false) { obj.$_temp.ruleset = null; } if (definition.priority) { obj._rules.unshift(rule); } else { obj._rules.push(rule); } return obj; } $_compile(schema, options) { return Compile.schema(this.$_root, schema, options); } $_createError(code, value, local, state, prefs, options = {}) { const flags = options.flags !== false ? this._flags : {}; const messages = options.messages ? Messages.merge(this._definition.messages, options.messages) : this._definition.messages; return new Errors.Report(code, value, local, flags, messages, state, prefs); } $_getFlag(name) { return this._flags[name]; } $_getRule(name) { return this._singleRules.get(name); } $_mapLabels(path) { path = Array.isArray(path) ? path : path.split('.'); return this._ids.labels(path); } $_match(value, state, prefs, overrides) { prefs = Object.assign({}, prefs); // Shallow cloned prefs.abortEarly = true; prefs._externals = false; state.snapshot(); const result = !Validator.validate(value, this, state, prefs, overrides).errors; state.restore(); return result; } $_modify(options) { Common.assertOptions(options, ['each', 'once', 'ref', 'schema']); return Modify.schema(this, options) || this; } $_mutateRebuild() { Assert(!this._inRuleset(), 'Cannot add this rule inside a ruleset'); this._refs.reset(); this._ids.reset(); const each = (item, { source, name, path, key }) => { const family = this._definition[source][name] && this._definition[source][name].register; if (family !== false) { this.$_mutateRegister(item, { family, key }); } }; this.$_modify({ each }); if (this._definition.rebuild) { this._definition.rebuild(this); } this.$_temp.ruleset = false; return this; } $_mutateRegister(schema, { family, key } = {}) { this._refs.register(schema, family); this._ids.register(schema, { key }); } $_property(name) { return this._definition.properties[name]; } $_reach(path) { return this._ids.reach(path); } $_rootReferences() { return this._refs.roots(); } $_setFlag(name, value, options = {}) { Assert(name[0] === '_' || !this._inRuleset(), 'Cannot set flag inside a ruleset'); const flag = this._definition.flags[name] || {}; if (DeepEqual(value, flag.default)) { value = undefined; } if (DeepEqual(value, this._flags[name])) { return this; } const obj = options.clone !== false ? this.clone() : this; if (value !== undefined) { obj._flags[name] = value; obj.$_mutateRegister(value); } else { delete obj._flags[name]; } if (name[0] !== '_') { obj.$_temp.ruleset = false; } return obj; } $_parent(method, ...args) { return this[method][Common.symbols.parent].call(this, ...args); } $_validate(value, state, prefs) { return Validator.validate(value, this, state, prefs); } // Internals _assign(target) { target.type = this.type; target.$_root = this.$_root; target.$_temp = Object.assign({}, this.$_temp); target.$_temp.whens = {}; target._ids = this._ids.clone(); target._preferences = this._preferences; target._valids = this._valids && this._valids.clone(); target._invalids = this._invalids && this._invalids.clone(); target._rules = this._rules.slice(); target._singleRules = Clone(this._singleRules, { shallow: true }); target._refs = this._refs.clone(); target._flags = Object.assign({}, this._flags); target._cache = null; target.$_terms = {}; for (const key in this.$_terms) { target.$_terms[key] = this.$_terms[key] ? this.$_terms[key].slice() : null; } // Backwards compatibility target.$_super = {}; for (const override in this.$_super) { target.$_super[override] = this._super[override].bind(target); } return target; } _bare() { const obj = this.clone(); obj._reset(); const terms = obj._definition.terms; for (const name in terms) { const term = terms[name]; obj.$_terms[name] = term.init; } return obj.$_mutateRebuild(); } _default(flag, value, options = {}) { Common.assertOptions(options, 'literal'); Assert(value !== undefined, 'Missing', flag, 'value'); Assert(typeof value === 'function' || !options.literal, 'Only function value supports literal option'); if (typeof value === 'function' && options.literal) { value = { [Common.symbols.literal]: true, literal: value }; } const obj = this.$_setFlag(flag, value); return obj; } _generate(value, state, prefs) { if (!this.$_terms.whens) { return { schema: this }; } // Collect matching whens const whens = []; const ids = []; for (let i = 0; i < this.$_terms.whens.length; ++i) { const when = this.$_terms.whens[i]; if (when.concat) { whens.push(when.concat); ids.push(`${i}.concat`); continue; } const input = when.ref ? when.ref.resolve(value, state, prefs) : value; const tests = when.is ? [when] : when.switch; const before = ids.length; for (let j = 0; j < tests.length; ++j) { const { is, then, otherwise } = tests[j]; const baseId = `${i}${when.switch ? '.' + j : ''}`; if (is.$_match(input, state.nest(is, `${baseId}.is`), prefs)) { if (then) { const localState = state.localize([...state.path, `${baseId}.then`], state.ancestors, state.schemas); const { schema: generated, id } = then._generate(value, localState, prefs); whens.push(generated); ids.push(`${baseId}.then${id ? `(${id})` : ''}`); break; } } else if (otherwise) { const localState = state.localize([...state.path, `${baseId}.otherwise`], state.ancestors, state.schemas); const { schema: generated, id } = otherwise._generate(value, localState, prefs); whens.push(generated); ids.push(`${baseId}.otherwise${id ? `(${id})` : ''}`); break; } } if (when.break && ids.length > before) { // Something matched break; } } // Check cache const id = ids.join(', '); state.mainstay.tracer.debug(state, 'rule', 'when', id); if (!id) { return { schema: this }; } if (!state.mainstay.tracer.active && this.$_temp.whens[id]) { return { schema: this.$_temp.whens[id], id }; } // Generate dynamic schema let obj = this; // eslint-disable-line consistent-this if (this._definition.generate) { obj = this._definition.generate(this, value, state, prefs); } // Apply whens for (const when of whens) { obj = obj.concat(when); } // Tracing if (this.$_root._tracer) { this.$_root._tracer._combine(obj, [this, ...whens]); } // Cache result this.$_temp.whens[id] = obj; return { schema: obj, id }; } _inner(type, values, options = {}) { Assert(!this._inRuleset(), `Cannot set ${type} inside a ruleset`); const obj = this.clone(); if (!obj.$_terms[type] || options.override) { obj.$_terms[type] = []; } if (options.single) { obj.$_terms[type].push(values); } else { obj.$_terms[type].push(...values); } obj.$_temp.ruleset = false; return obj; } _inRuleset() { return this.$_temp.ruleset !== null && this.$_temp.ruleset !== false; } _ruleRemove(name, options = {}) { if (!this._singleRules.has(name)) { return this; } const obj = options.clone !== false ? this.clone() : this; obj._singleRules.delete(name); const filtered = []; for (let i = 0; i < obj._rules.length; ++i) { const test = obj._rules[i]; if (test.name === name && !test.keep) { if (obj._inRuleset() && i < obj.$_temp.ruleset) { --obj.$_temp.ruleset; } continue; } filtered.push(test); } obj._rules = filtered; return obj; } _values(values, key) { Common.verifyFlat(values, key.slice(1, -1)); const obj = this.clone(); const override = values[0] === Common.symbols.override; if (override) { values = values.slice(1); } if (!obj[key] && values.length) { obj[key] = new Values(); } else if (override) { obj[key] = values.length ? new Values() : null; obj.$_mutateRebuild(); } if (!obj[key]) { return obj; } if (override) { obj[key].override(); } for (const value of values) { Assert(value !== undefined, 'Cannot call allow/valid/invalid with undefined'); Assert(value !== Common.symbols.override, 'Override must be the first value'); const other = key === '_invalids' ? '_valids' : '_invalids'; if (obj[other]) { obj[other].remove(value); if (!obj[other].length) { Assert(key === '_valids' || !obj._flags.only, 'Setting invalid value', value, 'leaves schema rejecting all values due to previous valid rule'); obj[other] = null; } } obj[key].add(value, obj._refs); } return obj; } }; internals.Base.prototype[Common.symbols.any] = { version: Common.version, compile: Compile.compile, root: '$_root' }; internals.Base.prototype.isImmutable = true; // Prevents Hoek from deep cloning schema objects (must be on prototype) // Aliases internals.Base.prototype.deny = internals.Base.prototype.invalid; internals.Base.prototype.disallow = internals.Base.prototype.invalid; internals.Base.prototype.equal = internals.Base.prototype.valid; internals.Base.prototype.exist = internals.Base.prototype.required; internals.Base.prototype.not = internals.Base.prototype.invalid; internals.Base.prototype.options = internals.Base.prototype.prefs; internals.Base.prototype.preferences = internals.Base.prototype.prefs; module.exports = new internals.Base();