From 8f7f2423f68908daa4e49a4f66ddc8a6abd3e8fb Mon Sep 17 00:00:00 2001 From: Gil Pedersen Date: Tue, 6 Feb 2018 15:24:02 +0100 Subject: [PATCH] Add symbol() type --- lib/index.js | 10 +- lib/language.js | 4 + lib/types/symbol/index.js | 97 +++++++++++++++++ test/types/symbol.js | 224 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 lib/types/symbol/index.js create mode 100644 test/types/symbol.js diff --git a/lib/index.js b/lib/index.js index f90238222..84475b204 100644 --- a/lib/index.js +++ b/lib/index.js @@ -22,7 +22,8 @@ const internals = { func: require('./types/func'), number: require('./types/number'), object: require('./types/object'), - string: require('./types/string') + string: require('./types/string'), + symbol: require('./types/symbol') }; internals.callWithDefaults = function (schema, args) { @@ -112,6 +113,13 @@ internals.root = function () { return internals.callWithDefaults.call(this, internals.string, args); }; + root.symbol = function (...args) { + + Hoek.assert(args.length === 0, 'Joi.symbol() does not allow arguments.'); + + return internals.callWithDefaults.call(this, internals.symbol, args); + }; + root.ref = function (...args) { return Ref.create(...args); diff --git a/lib/language.js b/lib/language.js index 4a808aea2..29a62c47b 100644 --- a/lib/language.js +++ b/lib/language.js @@ -157,5 +157,9 @@ exports.errors = { ref: 'references "{{ref}}" which is not a number', ip: 'must be a valid ip address with a {{cidr}} CIDR', ipVersion: 'must be a valid ip address of one of the following versions {{version}} with a {{cidr}} CIDR' + }, + symbol: { + base: 'must be a symbol', + map: 'must be one of {{map}}' } }; diff --git a/lib/types/symbol/index.js b/lib/types/symbol/index.js new file mode 100644 index 000000000..2a04feab8 --- /dev/null +++ b/lib/types/symbol/index.js @@ -0,0 +1,97 @@ +'use strict'; + +// Load modules + +const Any = require('../any'); +const Hoek = require('hoek'); + + +// Declare internals + +const internals = {}; + + +internals.Map = class extends Map { + + slice() { + + return new internals.Map(this); + } + + toString() { + + const entries = [...this].map(([key, symbol]) => { + + key = typeof key === 'symbol' ? key.toString() : JSON.stringify(key); + return `${key} => ${symbol.toString()}`; + }); + + return `Map { ${entries.join(', ')} }`; + } +}; + + +internals.Symbol = class extends Any { + + constructor() { + + super(); + this._type = 'symbol'; + this._inner.map = new internals.Map(); + } + + _base(value, state, options) { + + if (options.convert) { + const lookup = this._inner.map.get(value); + if (lookup) { + value = lookup; + } + + if (this._flags.allowOnly) { + return { + value, + errors: (typeof value === 'symbol') ? null : this.createError('symbol.map', { map: this._inner.map }, state, options) + }; + } + } + + return { + value, + errors: (typeof value === 'symbol') ? null : this.createError('symbol.base', null, state, options) + }; + } + + map(iterable) { + + if (iterable && !iterable[Symbol.iterator] && typeof iterable === 'object') { + iterable = Object.entries(iterable); + } + + Hoek.assert(iterable && iterable[Symbol.iterator], 'Iterable must be an iterable or object'); + const obj = this.clone(); + + const symbols = []; + for (const entry of iterable) { + Hoek.assert(entry && entry[Symbol.iterator], 'Entry must be an iterable'); + const [key, value] = entry; + + Hoek.assert(typeof key !== 'object' && typeof key !== 'function', 'Key must be a simple type'); + Hoek.assert(typeof value === 'symbol', 'Value must be a Symbol'); + obj._inner.map.set(key, value); + symbols.push(value); + } + + return obj.valid(...symbols); + } + + describe() { + + const description = Any.prototype.describe.call(this); + description.map = new Map(this._inner.map); + return description; + } +}; + + +module.exports = new internals.Symbol(); diff --git a/test/types/symbol.js b/test/types/symbol.js new file mode 100644 index 000000000..72ed7af31 --- /dev/null +++ b/test/types/symbol.js @@ -0,0 +1,224 @@ +'use strict'; + +// Load modules + +const Lab = require('lab'); +const Joi = require('../..'); +const Helper = require('../helper'); + + +// Declare internals + +const internals = {}; + + +// Test shortcuts + +const { describe, it, expect } = exports.lab = Lab.script(); + + +describe('symbol', () => { + + it('cannot be called on its own', () => { + + const symbol = Joi.symbol; + expect(() => symbol()).to.throw('Must be invoked on a Joi instance.'); + }); + + it('should throw an exception if arguments were passed.', () => { + + expect( + () => Joi.symbol('invalid argument.') + ).to.throw('Joi.symbol() does not allow arguments.'); + }); + + describe('validate()', () => { + + it('handles plain symbols', () => { + + const symbols = [Symbol(1), Symbol(2)]; + const rule = Joi.symbol(); + Helper.validate(rule, [ + [symbols[0], true, null, symbols[0]], + [symbols[1], true, null, symbols[1]], + [1, false, null, { + message: '"value" must be a symbol', + details: [{ + message: '"value" must be a symbol', + path: [], + type: 'symbol.base', + context: { label: 'value', key: undefined } + }] + }] + ]); + }); + + it('handles simple lookup', () => { + + const symbols = [Symbol(1), Symbol(2)]; + const otherSymbol = Symbol(1); + const rule = Joi.symbol().valid(symbols); + Helper.validate(rule, [ + [symbols[0], true, null, symbols[0]], + [symbols[1], true, null, symbols[1]], + [otherSymbol, false, null, { + message: '"value" must be one of [Symbol(1), Symbol(2)]', + details: [{ + message: '"value" must be one of [Symbol(1), Symbol(2)]', + path: [], + type: 'any.allowOnly', + context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined } + }] + }] + ]); + }); + + describe('map', () => { + + it('converts keys to correct symbol', () => { + + const symbols = [Symbol(1), Symbol(2), Symbol(3)]; + const otherSymbol = Symbol(1); + const map = new Map([[1, symbols[0]], ['two', symbols[1]], [symbols[0], symbols[2]]]); + const rule = Joi.symbol().map(map); + Helper.validate(rule, [ + [1, true, null, symbols[0]], + [symbols[0], true, null, symbols[0]], + ['1', false, null, { + message: '"value" must be one of Map { 1 => Symbol(1), "two" => Symbol(2), Symbol(1) => Symbol(3) }', + details: [{ + message: '"value" must be one of Map { 1 => Symbol(1), "two" => Symbol(2), Symbol(1) => Symbol(3) }', + path: [], + type: 'symbol.map', + context: { label: 'value', key: undefined, map } + }] + }], + ['two', true, null, symbols[1]], + [otherSymbol, false, null, { + message: '"value" must be one of [Symbol(1), Symbol(2), Symbol(3)]', + details: [{ + message: '"value" must be one of [Symbol(1), Symbol(2), Symbol(3)]', + path: [], + type: 'any.allowOnly', + context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined } + }] + }] + ]); + }); + + it('converts keys from object', () => { + + const symbols = [Symbol('one'), Symbol('two')]; + const otherSymbol = Symbol('one'); + const rule = Joi.symbol().map({ one: symbols[0], two: symbols[1] }); + Helper.validate(rule, [ + [symbols[0], true, null, symbols[0]], + ['one', true, null, symbols[0]], + ['two', true, null, symbols[1]], + [otherSymbol, false, null, { + message: '"value" must be one of [Symbol(one), Symbol(two)]', + details: [{ + message: '"value" must be one of [Symbol(one), Symbol(two)]', + path: [], + type: 'any.allowOnly', + context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined } + }] + }], + ['toString', false, null, { + message: '"value" must be one of Map { "one" => Symbol(one), "two" => Symbol(two) }', + details: [{ + message: '"value" must be one of Map { "one" => Symbol(one), "two" => Symbol(two) }', + path: [], + type: 'symbol.map', + context: { label: 'value', key: undefined, map: new Map([['one', symbols[0]], ['two', symbols[1]]]) } + }] + }] + ]); + }); + + it('appends to existing map', () => { + + const symbols = [Symbol(1), Symbol(2)]; + const otherSymbol = Symbol(1); + const rule = Joi.symbol().map([[1, symbols[0]]]).map([[2, symbols[1]]]); + Helper.validate(rule, [ + [1, true, null, symbols[0]], + [2, true, null, symbols[1]], + [otherSymbol, false, null, { + message: '"value" must be one of [Symbol(1), Symbol(2)]', + details: [{ + message: '"value" must be one of [Symbol(1), Symbol(2)]', + path: [], + type: 'any.allowOnly', + context: { value: otherSymbol, label: 'value', valids: symbols, key: undefined } + }] + }] + ]); + }); + + it('throws on bad input', () => { + + expect( + () => Joi.symbol().map() + ).to.throw('Iterable must be an iterable or object'); + + expect( + () => Joi.symbol().map(Symbol()) + ).to.throw('Iterable must be an iterable or object'); + + expect( + () => Joi.symbol().map([undefined]) + ).to.throw('Entry must be an iterable'); + + expect( + () => Joi.symbol().map([123]) + ).to.throw('Entry must be an iterable'); + + expect( + () => Joi.symbol().map([[123, 456]]) + ).to.throw('Value must be a Symbol'); + + expect( + () => Joi.symbol().map([[{}, Symbol()]]) + ).to.throw('Key must be a simple type'); + }); + }); + + it('handles plain symbols when convert is disabled', async () => { + + const symbols = [Symbol(1), Symbol(2)]; + const schema = Joi.symbol().map([[1, symbols[0]], ['two', symbols[1]]]).options({ convert: false }); + const result = await schema.validate(symbols[1]); + expect(result).to.equal(symbols[1]); + }); + + it('errors on mapped input and convert is disabled', async () => { + + const symbols = [Symbol(1), Symbol(2)]; + const schema = Joi.symbol().map([[1, symbols[0]], ['two', symbols[1]]]).options({ convert: false }); + const err = await expect(schema.validate(1)).to.reject(); + expect(err).to.be.an.error('"value" must be a symbol'); + expect(err.details).to.equal([{ + message: '"value" must be a symbol', + path: [], + type: 'symbol.base', + context: { label: 'value', key: undefined } + }]); + }); + + it('should describe value map', () => { + + const symbols = [Symbol(1), Symbol(2)]; + const map = new Map([[1, symbols[0]], ['two', symbols[1]]]); + const schema = Joi.symbol().map(map).describe(); + expect(schema).to.equal({ + type: 'symbol', + flags: { + allowOnly: true + }, + map, + valids: symbols + }); + }); + }); +});