Migrate joi to 17.4.0 and adapt the codebase (#99899)

* bump joi to 17.4.0, start adapting stuff

* remove custom validation rule, adapt instead

* fix error handling

* fix error handling again

* fix strings type & validation

* fix buffers and arrays

* fix bytes

* fix bytes_size type

* update conditional_type error messages in tests

* fix duration and map types

* first attempt to fix union type error messages

* revert conditional type assertions back to master state

* fix object type

* fix record type

* fix stream types

* rename test files to match sources

* fix union type tests

* temporary adapt feature/home usages of Joi

* fix lint

* adapt test assertion

* fix http config schema validation

* fix @kbn/test Config class

* fix config again

* fix reporting schema tests

* fix security solution schema

* adapt url tests

* remove useless comment

* remove space

* typo

* review comments
This commit is contained in:
Pierre Gayvallet 2021-05-20 10:55:59 +02:00 committed by GitHub
parent bc3b9d694b
commit 8fc9115a6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 462 additions and 464 deletions

View file

@ -259,7 +259,7 @@
"io-ts": "^2.0.5",
"ipaddr.js": "2.0.0",
"isbinaryfile": "4.0.2",
"joi": "^13.5.2",
"joi": "^17.4.0",
"jquery": "^3.5.0",
"js-levenshtein": "^1.1.6",
"js-search": "^1.4.3",
@ -546,7 +546,7 @@
"@types/jest": "^26.0.22",
"@types/jest-specific-snapshot": "^0.5.5",
"@types/jest-when": "^2.7.2",
"@types/joi": "^13.4.2",
"@types/joi": "^17.2.3",
"@types/jquery": "^3.3.31",
"@types/js-search": "^1.4.0",
"@types/js-yaml": "^3.11.1",

View file

@ -7,75 +7,30 @@
*/
import Joi from 'joi';
import {
AnySchema,
JoiRoot,
Reference,
Rules,
SchemaLike,
State,
ValidationErrorItem,
ValidationOptions,
} from 'joi';
import type { JoiRoot, CustomHelpers } from 'joi';
import { isPlainObject } from 'lodash';
import { isDuration } from 'moment';
import { Stream } from 'stream';
import { ByteSizeValue, ensureByteSizeValue } from '../byte_size_value';
import { ensureDuration } from '../duration';
export { AnySchema, Reference, SchemaLike, ValidationErrorItem };
function isMap<K, V>(o: any): o is Map<K, V> {
return o instanceof Map;
}
const anyCustomRule: Rules = {
name: 'custom',
params: {
validator: Joi.func().maxArity(1).required(),
},
validate(params, value, state, options) {
let validationResultMessage;
try {
validationResultMessage = params.validator(value);
} catch (e) {
validationResultMessage = e.message || e;
}
if (typeof validationResultMessage === 'string') {
return this.createError(
'any.custom',
{ value, message: validationResultMessage },
state,
options
);
}
return value;
},
};
/**
* @internal
*/
export const internals = Joi.extend([
export const internals: JoiRoot = Joi.extend(
{
name: 'any',
rules: [anyCustomRule],
},
{
name: 'boolean',
type: 'boolean',
base: Joi.boolean(),
coerce(value: any, state: State, options: ValidationOptions) {
coerce(value, { error }: CustomHelpers) {
// If value isn't defined, let Joi handle default value if it's defined.
if (value === undefined) {
return value;
return {
value,
};
}
// Allow strings 'true' and 'false' to be coerced to booleans (case-insensitive).
// From Joi docs on `Joi.boolean`:
// > Generates a schema object that matches a boolean data type. Can also
// > be called via bool(). If the validation convert option is on
@ -87,135 +42,125 @@ export const internals = Joi.extend([
}
if (typeof value !== 'boolean') {
return this.createError('boolean.base', { value }, state, options);
return {
errors: [error('boolean.base')],
};
}
return value;
return {
value,
};
},
rules: [anyCustomRule],
},
{
name: 'binary',
base: Joi.binary(),
coerce(value: any, state: State, options: ValidationOptions) {
// If value isn't defined, let Joi handle default value if it's defined.
if (value !== undefined && !(typeof value === 'object' && Buffer.isBuffer(value))) {
return this.createError('binary.base', { value }, state, options);
}
return value;
},
rules: [anyCustomRule],
},
{
name: 'stream',
pre(value: any, state: State, options: ValidationOptions) {
// If value isn't defined, let Joi handle default value if it's defined.
type: 'stream',
prepare(value, { error }) {
if (value instanceof Stream) {
return value as any;
return { value };
}
return this.createError('stream.base', { value }, state, options);
return {
errors: [error('stream.base')],
};
},
rules: [anyCustomRule],
},
{
name: 'string',
base: Joi.string(),
rules: [anyCustomRule],
},
{
name: 'bytes',
coerce(value: any, state: State, options: ValidationOptions) {
type: 'bytes',
coerce(value: any, { error }) {
try {
if (typeof value === 'string') {
return ByteSizeValue.parse(value);
return { value: ByteSizeValue.parse(value) };
}
if (typeof value === 'number') {
return new ByteSizeValue(value);
return { value: new ByteSizeValue(value) };
}
} catch (e) {
return this.createError('bytes.parse', { value, message: e.message }, state, options);
return {
errors: [error('bytes.parse', { message: e.message })],
};
}
return value;
return { value };
},
pre(value: any, state: State, options: ValidationOptions) {
validate(value, { error }) {
// If value isn't defined, let Joi handle default value if it's defined.
if (value instanceof ByteSizeValue) {
return value as any;
return { value };
}
return this.createError('bytes.base', { value }, state, options);
return {
errors: [error('bytes.base')],
};
},
rules: [
anyCustomRule,
{
name: 'min',
params: {
limit: Joi.alternatives([Joi.number(), Joi.string()]).required(),
rules: {
min: {
args: [
{
name: 'limit',
assert: Joi.alternatives([Joi.number(), Joi.string()]).required(),
},
],
method(limit) {
return this.$_addRule({ name: 'min', args: { limit } });
},
validate(params, value, state, options) {
const limit = ensureByteSizeValue(params.limit);
validate(value, { error }, args) {
const limit = ensureByteSizeValue(args.limit);
if (value.isLessThan(limit)) {
return this.createError('bytes.min', { value, limit }, state, options);
return error('bytes.min', { value, limit });
}
return value;
},
},
{
name: 'max',
params: {
limit: Joi.alternatives([Joi.number(), Joi.string()]).required(),
max: {
args: [
{
name: 'limit',
assert: Joi.alternatives([Joi.number(), Joi.string()]).required(),
},
],
method(limit) {
return this.$_addRule({ name: 'max', args: { limit } });
},
validate(params, value, state, options) {
const limit = ensureByteSizeValue(params.limit);
validate(value, { error }, args) {
const limit = ensureByteSizeValue(args.limit);
if (value.isGreaterThan(limit)) {
return this.createError('bytes.max', { value, limit }, state, options);
return error('bytes.max', { value, limit });
}
return value;
},
},
],
},
},
{
name: 'duration',
coerce(value: any, state: State, options: ValidationOptions) {
type: 'duration',
coerce(value, { error }) {
try {
if (typeof value === 'string' || typeof value === 'number') {
return ensureDuration(value);
return { value: ensureDuration(value) };
}
} catch (e) {
return this.createError('duration.parse', { value, message: e.message }, state, options);
return {
errors: [error('duration.parse', { message: e.message })],
};
}
return value;
return { value };
},
pre(value: any, state: State, options: ValidationOptions) {
validate(value, { error }) {
if (!isDuration(value)) {
return this.createError('duration.base', { value }, state, options);
return {
errors: [error('duration.base')],
};
}
return value;
return { value };
},
rules: [anyCustomRule],
},
{
name: 'number',
type: 'number',
base: Joi.number(),
coerce(value: any, state: State, options: ValidationOptions) {
coerce(value, { error }) {
// If value isn't defined, let Joi handle default value if it's defined.
if (value === undefined) {
return value;
return { value };
}
// Do we want to allow strings that can be converted, e.g. "2"? (Joi does)
@ -226,198 +171,197 @@ export const internals = Joi.extend([
// > strings that can be converted to numbers)
const coercedValue: any = typeof value === 'string' ? Number(value) : value;
if (typeof coercedValue !== 'number' || isNaN(coercedValue)) {
return this.createError('number.base', { value }, state, options);
return {
errors: [error('number.base')],
};
}
return value;
return { value };
},
rules: [anyCustomRule],
},
{
name: 'object',
type: 'object',
base: Joi.object(),
coerce(value: any, state: State, options: ValidationOptions) {
coerce(value: any, { error, prefs }) {
if (value === undefined || isPlainObject(value)) {
return value;
return { value };
}
if (options.convert && typeof value === 'string') {
if (prefs.convert && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (isPlainObject(parsed)) {
return parsed;
}
return this.createError('object.base', { value: parsed }, state, options);
return { value: parsed };
} catch (e) {
return this.createError('object.parse', { value }, state, options);
return { errors: [error('object.parse')] };
}
}
return this.createError('object.base', { value }, state, options);
return { errors: [error('object.base')] };
},
rules: [anyCustomRule],
},
{
name: 'map',
coerce(value: any, state: State, options: ValidationOptions) {
type: 'array',
base: Joi.array(),
coerce(value: any, { error, prefs }) {
if (value === undefined || Array.isArray(value)) {
return { value };
}
if (prefs.convert && typeof value === 'string') {
try {
// ensuring that the parsed object is an array is done by the base's validation
return { value: JSON.parse(value) };
} catch (e) {
return {
errors: [error('array.parse')],
};
}
}
return {
errors: [error('array.base')],
};
},
},
{
type: 'map',
coerce(value, { error, prefs }) {
if (value === undefined) {
return value;
return { value };
}
if (isPlainObject(value)) {
return new Map(Object.entries(value));
return { value: new Map(Object.entries(value)) };
}
if (options.convert && typeof value === 'string') {
if (prefs.convert && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (isPlainObject(parsed)) {
return new Map(Object.entries(parsed));
return { value: new Map(Object.entries(parsed)) };
}
return this.createError('map.base', { value: parsed }, state, options);
return {
value: parsed,
};
} catch (e) {
return this.createError('map.parse', { value }, state, options);
return {
errors: [error('map.parse')],
};
}
}
return value;
return { value };
},
pre(value: any, state: State, options: ValidationOptions) {
validate(value, { error }) {
if (!isMap(value)) {
return this.createError('map.base', { value }, state, options);
return {
errors: [error('map.base')],
};
}
return value as any;
return { value };
},
rules: [
anyCustomRule,
{
name: 'entries',
params: {
key: Joi.object().schema(),
value: Joi.object().schema(),
rules: {
entries: {
args: [
{
name: 'key',
assert: Joi.object().schema(),
},
{
name: 'value',
assert: Joi.object().schema(),
},
],
method(key, value) {
return this.$_addRule({ name: 'entries', args: { key, value } });
},
validate(params, value, state, options) {
validate(value, { error }, args, options) {
const result = new Map();
for (const [entryKey, entryValue] of value) {
const { value: validatedEntryKey, error: keyError } = Joi.validate(
entryKey,
params.key,
{ presence: 'required' }
);
if (keyError) {
return this.createError('map.key', { entryKey, reason: keyError }, state, options);
let validatedEntryKey: any;
try {
validatedEntryKey = Joi.attempt(entryKey, args.key, { presence: 'required' });
} catch (e) {
return error('map.key', { entryKey, reason: e });
}
const { value: validatedEntryValue, error: valueError } = Joi.validate(
entryValue,
params.value,
{ presence: 'required' }
);
if (valueError) {
return this.createError(
'map.value',
{ entryKey, reason: valueError },
state,
options
);
let validatedEntryValue: any;
try {
validatedEntryValue = Joi.attempt(entryValue, args.value, { presence: 'required' });
} catch (e) {
return error('map.value', { entryKey, reason: e });
}
result.set(validatedEntryKey, validatedEntryValue);
}
return result as any;
return result;
},
},
],
},
},
{
name: 'record',
pre(value: any, state: State, options: ValidationOptions) {
type: 'record',
coerce(value, { error, prefs }) {
if (value === undefined || isPlainObject(value)) {
return value;
return { value };
}
if (options.convert && typeof value === 'string') {
if (prefs.convert && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (isPlainObject(parsed)) {
return parsed;
}
return this.createError('record.base', { value: parsed }, state, options);
return { value: parsed };
} catch (e) {
return this.createError('record.parse', { value }, state, options);
return {
errors: [error('record.parse')],
};
}
}
return this.createError('record.base', { value }, state, options);
return {
errors: [error('record.base')],
};
},
rules: [
anyCustomRule,
{
name: 'entries',
params: { key: Joi.object().schema(), value: Joi.object().schema() },
validate(params, value, state, options) {
validate(value, { error }) {
if (!isPlainObject(value)) {
return {
errors: [error('record.base')],
};
}
return { value };
},
rules: {
entries: {
args: [
{
name: 'key',
assert: Joi.object().schema(),
},
{
name: 'value',
assert: Joi.object().schema(),
},
],
method(key, value) {
return this.$_addRule({ name: 'entries', args: { key, value } });
},
validate(value, { error }, args) {
const result = {} as Record<string, any>;
for (const [entryKey, entryValue] of Object.entries(value)) {
const { value: validatedEntryKey, error: keyError } = Joi.validate(
entryKey,
params.key,
{ presence: 'required' }
);
if (keyError) {
return this.createError('record.key', { entryKey, reason: keyError }, state, options);
let validatedEntryKey: any;
try {
validatedEntryKey = Joi.attempt(entryKey, args.key, { presence: 'required' });
} catch (e) {
return error('record.key', { entryKey, reason: e });
}
const { value: validatedEntryValue, error: valueError } = Joi.validate(
entryValue,
params.value,
{ presence: 'required' }
);
if (valueError) {
return this.createError(
'record.value',
{ entryKey, reason: valueError },
state,
options
);
let validatedEntryValue: any;
try {
validatedEntryValue = Joi.attempt(entryValue, args.value, { presence: 'required' });
} catch (e) {
return error('record.value', { entryKey, reason: e });
}
result[validatedEntryKey] = validatedEntryValue;
}
return result as any;
return result;
},
},
],
},
{
name: 'array',
base: Joi.array(),
coerce(value: any, state: State, options: ValidationOptions) {
if (value === undefined || Array.isArray(value)) {
return value;
}
if (options.convert && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed;
}
return this.createError('array.base', { value: parsed }, state, options);
} catch (e) {
return this.createError('array.parse', { value }, state, options);
}
}
return this.createError('array.base', { value }, state, options);
},
rules: [anyCustomRule],
},
]) as JoiRoot;
}
);

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import { internals, Reference as InternalReference } from '../internals';
import type { Reference as InternalReference } from 'joi';
import { internals } from '../internals';
export class Reference<T> {
public static isReference<V>(value: V | Reference<V> | undefined): value is Reference<V> {

View file

@ -27,6 +27,10 @@ test('includes namespace in failure', () => {
);
});
test('coerces strings to buffer', () => {
expect(schema.buffer().validate('abc')).toStrictEqual(Buffer.from('abc'));
});
describe('#defaultValue', () => {
test('returns default when undefined', () => {
const value = Buffer.from('Hi!');
@ -49,8 +53,4 @@ test('returns error when not a buffer', () => {
expect(() => schema.buffer().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [Buffer] but got [Array]"`
);
expect(() => schema.buffer().validate('abc')).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [Buffer] but got [string]"`
);
});

View file

@ -348,15 +348,15 @@ test('works within `oneOf`', () => {
expect(type.validate(['a', 'b'], { type: 'array' })).toEqual(['a', 'b']);
expect(() => type.validate(1, { type: 'string' })).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: expected value of type [string] but got [number]
- [1]: expected value of type [array] but got [number]"
`);
"types that failed validation:
- [0]: expected value of type [string] but got [number]
- [1]: expected value of type [array] but got [number]"
`);
expect(() => type.validate(true, { type: 'string' })).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: expected value of type [string] but got [boolean]
- [1]: expected value of type [array] but got [boolean]"
`);
"types that failed validation:
- [0]: expected value of type [string] but got [boolean]
- [1]: expected value of type [array] but got [boolean]"
`);
});
describe('#validate', () => {

View file

@ -17,7 +17,7 @@ export class LiteralType<T> extends Type<T> {
protected handleError(type: string, { value, valids: [expectedValue] }: Record<string, any>) {
switch (type) {
case 'any.required':
case 'any.allowOnly':
case 'any.only':
return `expected value to equal [${expectedValue}]`;
}
}

View file

@ -14,7 +14,7 @@ export class MaybeType<V> extends Type<V | undefined> {
type
.getSchema()
.optional()
.default(() => undefined, 'undefined')
.default(() => undefined)
);
}
}

View file

@ -181,14 +181,15 @@ test('called with wrong type', () => {
test('handles oneOf', () => {
const type = schema.object({
key: schema.oneOf([schema.string()]),
key: schema.oneOf([schema.string(), schema.arrayOf(schema.string())]),
});
expect(type.validate({ key: 'foo' })).toEqual({ key: 'foo' });
expect(() => type.validate({ key: 123 })).toThrowErrorMatchingInlineSnapshot(`
"[key]: types that failed validation:
- [key.0]: expected value of type [string] but got [number]"
`);
"[key]: types that failed validation:
- [key.0]: expected value of type [string] but got [number]
- [key.1]: expected value of type [array] but got [number]"
`);
});
test('handles references', () => {

View file

@ -6,8 +6,9 @@
* Side Public License, v 1.
*/
import type { AnySchema } from 'joi';
import typeDetect from 'type-detect';
import { AnySchema, internals } from '../internals';
import { internals } from '../internals';
import { Type, TypeOptions } from './type';
import { ValidationError } from '../errors';
@ -185,7 +186,7 @@ export class ObjectType<P extends Props = any> extends Type<ObjectResultType<P>>
return `expected a plain object value, but found [${typeDetect(value)}] instead.`;
case 'object.parse':
return `could not parse object value from json input`;
case 'object.allowUnknown':
case 'object.unknown':
return `definition for this key is missing`;
case 'object.child':
return reason[0];

View file

@ -46,13 +46,7 @@ test('includes namespace in failure', () => {
describe('#defaultValue', () => {
test('returns default when undefined', () => {
const value = new Stream();
expect(schema.stream({ defaultValue: value }).validate(undefined)).toMatchInlineSnapshot(`
Stream {
"_events": Object {},
"_eventsCount": 0,
"_maxListeners": undefined,
}
`);
expect(schema.stream({ defaultValue: value }).validate(undefined)).toBeInstanceOf(Stream);
});
test('returns value when specified', () => {

View file

@ -30,6 +30,20 @@ test('includes namespace in failure', () => {
);
});
test('returns error when not string', () => {
expect(() => schema.string().validate(123)).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [number]"`
);
expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [Array]"`
);
expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [RegExp]"`
);
});
describe('#minLength', () => {
test('returns value when longer string', () => {
expect(schema.string({ minLength: 2 }).validate('foo')).toBe('foo');
@ -71,14 +85,13 @@ describe('#hostname', () => {
expect(hostNameSchema.validate('www.example.com')).toBe('www.example.com');
expect(hostNameSchema.validate('3domain.local')).toBe('3domain.local');
expect(hostNameSchema.validate('hostname')).toBe('hostname');
expect(hostNameSchema.validate('2387628')).toBe('2387628');
expect(hostNameSchema.validate('::1')).toBe('::1');
expect(hostNameSchema.validate('0:0:0:0:0:0:0:1')).toBe('0:0:0:0:0:0:0:1');
expect(hostNameSchema.validate('xn----ascii-7gg5ei7b1i.xn--90a3a')).toBe(
'xn----ascii-7gg5ei7b1i.xn--90a3a'
);
const hostNameWithMaxAllowedLength = 'a'.repeat(255);
const hostNameWithMaxAllowedLength = Array(4).fill('a'.repeat(63)).join('.');
expect(hostNameSchema.validate(hostNameWithMaxAllowedLength)).toBe(
hostNameWithMaxAllowedLength
);
@ -87,6 +100,12 @@ describe('#hostname', () => {
test('returns error when value is not a valid hostname', () => {
const hostNameSchema = schema.string({ hostname: true });
expect(() => hostNameSchema.validate('2387628')).toThrowErrorMatchingInlineSnapshot(
`"value must be a valid hostname (see RFC 1123)."`
);
expect(() =>
hostNameSchema.validate(Array(4).fill('a'.repeat(64)).join('.'))
).toThrowErrorMatchingInlineSnapshot(`"value must be a valid hostname (see RFC 1123)."`);
expect(() => hostNameSchema.validate('host:name')).toThrowErrorMatchingInlineSnapshot(
`"value must be a valid hostname (see RFC 1123)."`
);
@ -99,16 +118,14 @@ describe('#hostname', () => {
expect(() => hostNameSchema.validate('0:?:0:0:0:0:0:1')).toThrowErrorMatchingInlineSnapshot(
`"value must be a valid hostname (see RFC 1123)."`
);
const tooLongHostName = 'a'.repeat(256);
expect(() => hostNameSchema.validate(tooLongHostName)).toThrowErrorMatchingInlineSnapshot(
expect(() => hostNameSchema.validate('a'.repeat(256))).toThrowErrorMatchingInlineSnapshot(
`"value must be a valid hostname (see RFC 1123)."`
);
});
test('returns error when empty string', () => {
expect(() => schema.string({ hostname: true }).validate('')).toThrowErrorMatchingInlineSnapshot(
`"any.empty"`
`"\\"value\\" is not allowed to be empty"`
);
});
@ -176,17 +193,3 @@ describe('#validate', () => {
);
});
});
test('returns error when not string', () => {
expect(() => schema.string().validate(123)).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [number]"`
);
expect(() => schema.string().validate([1, 2, 3])).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [Array]"`
);
expect(() => schema.string().validate(/abc/)).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [RegExp]"`
);
});

View file

@ -8,7 +8,7 @@
import typeDetect from 'type-detect';
import { internals } from '../internals';
import { Type, TypeOptions } from './type';
import { Type, TypeOptions, convertValidationFunction } from './type';
export type StringOptions = TypeOptions<string> & {
minLength?: number;
@ -25,26 +25,32 @@ export class StringType extends Type<string> {
let schema =
options.hostname === true
? internals.string().hostname()
: internals.any().custom((value) => {
if (typeof value !== 'string') {
return `expected value of type [string] but got [${typeDetect(value)}]`;
}
});
: internals.any().custom(
convertValidationFunction((value) => {
if (typeof value !== 'string') {
return `expected value of type [string] but got [${typeDetect(value)}]`;
}
})
);
if (options.minLength !== undefined) {
schema = schema.custom((value) => {
if (value.length < options.minLength!) {
return `value has length [${value.length}] but it must have a minimum length of [${options.minLength}].`;
}
});
schema = schema.custom(
convertValidationFunction((value) => {
if (value.length < options.minLength!) {
return `value has length [${value.length}] but it must have a minimum length of [${options.minLength}].`;
}
})
);
}
if (options.maxLength !== undefined) {
schema = schema.custom((value) => {
if (value.length > options.maxLength!) {
return `value has length [${value.length}] but it must have a maximum length of [${options.maxLength}].`;
}
});
schema = schema.custom(
convertValidationFunction((value) => {
if (value.length > options.maxLength!) {
return `value has length [${value.length}] but it must have a maximum length of [${options.maxLength}].`;
}
})
);
}
super(schema, options);

View file

@ -6,8 +6,8 @@
* Side Public License, v 1.
*/
import type { AnySchema, CustomValidator, ErrorReport } from 'joi';
import { SchemaTypeError, ValidationError } from '../errors';
import { AnySchema, internals, ValidationErrorItem } from '../internals';
import { Reference } from '../references';
export interface TypeOptions<T> {
@ -15,6 +15,25 @@ export interface TypeOptions<T> {
validate?: (value: T) => string | void;
}
export const convertValidationFunction = <T = unknown>(
validate: (value: T) => string | void
): CustomValidator<T> => {
return (value, { error }) => {
let validationResultMessage;
try {
validationResultMessage = validate(value);
} catch (e) {
validationResultMessage = e.message || e;
}
if (typeof validationResultMessage === 'string') {
return error('any.custom', { message: validationResultMessage });
}
return value;
};
};
export abstract class Type<V> {
// This is just to enable the `TypeOf` helper, and because TypeScript would
// fail if it wasn't initialized we use a "trick" to which basically just
@ -36,24 +55,23 @@ export abstract class Type<V> {
// If default value is a function, then we must provide description for it.
if (typeof options.defaultValue === 'function') {
schema = schema.default(options.defaultValue, 'Type default value');
schema = schema.default(options.defaultValue);
} else {
schema = schema.default(
Reference.isReference(options.defaultValue)
? options.defaultValue.getSchema()
: options.defaultValue
: (options.defaultValue as any)
);
}
}
if (options.validate) {
schema = schema.custom(options.validate);
schema = schema.custom(convertValidationFunction(options.validate));
}
// Attach generic error handler only if it hasn't been attached yet since
// only the last error handler is counted.
const schemaFlags = (schema.describe().flags as Record<string, any>) || {};
if (schemaFlags.error === undefined) {
if (schema.$_getFlag('error') === undefined) {
schema = schema.error(([error]) => this.onError(error));
}
@ -61,7 +79,7 @@ export abstract class Type<V> {
}
public validate(value: any, context: Record<string, any> = {}, namespace?: string): V {
const { value: validatedValue, error } = internals.validate(value, this.internalSchema, {
const { value: validatedValue, error } = this.internalSchema.validate(value, {
context,
presence: 'required',
});
@ -88,14 +106,19 @@ export abstract class Type<V> {
return undefined;
}
private onError(error: SchemaTypeError | ValidationErrorItem): SchemaTypeError {
private onError(error: SchemaTypeError | ErrorReport): SchemaTypeError {
if (error instanceof SchemaTypeError) {
return error;
}
const { context = {}, type, path, message } = error;
const { local, code, path, value } = error;
const convertedPath = path.map((entry) => entry.toString());
const context: Record<string, any> = {
...local,
value,
};
const errorHandleResult = this.handleError(type, context, path);
const errorHandleResult = this.handleError(code, context, convertedPath);
if (errorHandleResult instanceof SchemaTypeError) {
return errorHandleResult;
}
@ -103,15 +126,18 @@ export abstract class Type<V> {
// If error handler just defines error message, then wrap it into proper
// `SchemaTypeError` instance.
if (typeof errorHandleResult === 'string') {
return new SchemaTypeError(errorHandleResult, path);
return new SchemaTypeError(errorHandleResult, convertedPath);
}
// If error is produced by the custom validator, just extract source message
// from context and wrap it into `SchemaTypeError` instance.
if (type === 'any.custom') {
return new SchemaTypeError(context.message, path);
if (code === 'any.custom' && context.message) {
return new SchemaTypeError(context.message, convertedPath);
}
return new SchemaTypeError(message || type, path);
// `message` is only initialized once `toString` has been called (...)
// see https://github.com/sideway/joi/blob/master/lib/errors.js
const message = error.toString();
return new SchemaTypeError(message || code, convertedPath);
}
}

View file

@ -62,22 +62,42 @@ test('handles object', () => {
});
test('handles object with wrong type', () => {
const type = schema.oneOf([schema.object({ age: schema.number() })]);
const type = schema.oneOf([schema.object({ age: schema.number() }), schema.string()]);
expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0.age]: expected value of type [number] but got [string]"
`);
"types that failed validation:
- [0.age]: expected value of type [number] but got [string]
- [1]: expected value of type [string] but got [Object]"
`);
});
test('use shorter error messages when defining only one type', () => {
const type = schema.oneOf([schema.object({ age: schema.number() })]);
expect(() => type.validate({ age: 'foo' })).toThrowErrorMatchingInlineSnapshot(
`"[age]: expected value of type [number] but got [string]"`
);
});
test('includes namespace in failure', () => {
const type = schema.oneOf([schema.object({ age: schema.number() })]);
const type = schema.oneOf([schema.object({ age: schema.number() }), schema.string()]);
expect(() => type.validate({ age: 'foo' }, {}, 'foo-namespace'))
.toThrowErrorMatchingInlineSnapshot(`
"[foo-namespace]: types that failed validation:
- [foo-namespace.0.age]: expected value of type [number] but got [string]"
`);
"[foo-namespace]: types that failed validation:
- [foo-namespace.0.age]: expected value of type [number] but got [string]
- [foo-namespace.1]: expected value of type [string] but got [Object]"
`);
});
test('includes namespace in failure in shorthand mode', () => {
const type = schema.oneOf([schema.object({ age: schema.number() })]);
expect(() =>
type.validate({ age: 'foo' }, {}, 'foo-namespace')
).toThrowErrorMatchingInlineSnapshot(
`"[foo-namespace.age]: expected value of type [number] but got [string]"`
);
});
test('handles multiple objects with same key', () => {
@ -106,33 +126,32 @@ test('handles maybe', () => {
test('fails if not matching type', () => {
const type = schema.oneOf([schema.string()]);
expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: expected value of type [string] but got [boolean]"
`);
expect(() => type.validate(123)).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: expected value of type [string] but got [number]"
`);
expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [boolean]"`
);
expect(() => type.validate(123)).toThrowErrorMatchingInlineSnapshot(
`"expected value of type [string] but got [number]"`
);
});
test('fails if not matching multiple types', () => {
const type = schema.oneOf([schema.string(), schema.number()]);
expect(() => type.validate(false)).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: expected value of type [string] but got [boolean]
- [1]: expected value of type [number] but got [boolean]"
`);
"types that failed validation:
- [0]: expected value of type [string] but got [boolean]
- [1]: expected value of type [number] but got [boolean]"
`);
});
test('fails if not matching literal', () => {
const type = schema.oneOf([schema.literal('foo')]);
const type = schema.oneOf([schema.literal('foo'), schema.literal('dolly')]);
expect(() => type.validate('bar')).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: expected value to equal [foo]"
`);
"types that failed validation:
- [0]: expected value to equal [foo]
- [1]: expected value to equal [dolly]"
`);
});
test('fails if nested union type fail', () => {
@ -142,12 +161,10 @@ test('fails if nested union type fail', () => {
]);
expect(() => type.validate('aaa')).toThrowErrorMatchingInlineSnapshot(`
"types that failed validation:
- [0]: types that failed validation:
- [0]: expected value of type [boolean] but got [string]
- [1]: types that failed validation:
- [0]: types that failed validation:
- [0]: could not parse object value from json input
- [1]: expected value of type [number] but got [string]"
`);
"types that failed validation:
- [0]: expected value of type [boolean] but got [string]
- [1]: types that failed validation:
- [0]: could not parse object value from json input
- [1]: expected value of type [number] but got [string]"
`);
});

View file

@ -13,20 +13,21 @@ import { Type, TypeOptions } from './type';
export class UnionType<RTS extends Array<Type<any>>, T> extends Type<T> {
constructor(types: RTS, options?: TypeOptions<T>) {
const schema = internals.alternatives(types.map((type) => type.getSchema()));
const schema = internals.alternatives(types.map((type) => type.getSchema())).match('any');
super(schema, options);
}
protected handleError(type: string, { reason, value }: Record<string, any>, path: string[]) {
protected handleError(type: string, { value, details }: Record<string, any>, path: string[]) {
switch (type) {
case 'any.required':
return `expected at least one defined value but got [${typeDetect(value)}]`;
case 'alternatives.child':
case 'alternatives.match':
return new SchemaTypesError(
'types that failed validation:',
path,
reason.map((e: SchemaTypeError, index: number) => {
details.map((detail: AlternativeErrorDetail, index: number) => {
const e = detail.context.error;
const childPathWithIndex = e.path.slice();
childPathWithIndex.splice(path.length, 0, index.toString());
@ -38,3 +39,9 @@ export class UnionType<RTS extends Array<Type<any>>, T> extends Type<T> {
}
}
}
interface AlternativeErrorDetail {
context: {
error: SchemaTypeError;
};
}

View file

@ -12,6 +12,7 @@ import { ByteSizeValue } from '../src/byte_size_value';
declare module 'joi' {
interface BytesSchema extends AnySchema {
min(limit: number | string | ByteSizeValue): this;
max(limit: number | string | ByteSizeValue): this;
}
@ -23,6 +24,14 @@ declare module 'joi' {
entries(key: AnySchema, value: AnySchema): this;
}
interface ErrorReport {
// missing from the typedef
// see https://github.com/sideway/joi/blob/master/lib/errors.js
local?: Record<string, any>;
toString(): string;
}
export type JoiRoot = Joi.Root & {
bytes: () => BytesSchema;
duration: () => AnySchema;
@ -30,13 +39,4 @@ declare module 'joi' {
record: () => RecordSchema;
stream: () => AnySchema;
};
interface AnySchema {
custom(validator: (value: any) => string | void): this;
}
// Joi types don't include `schema` function even though it's supported.
interface ObjectSchema {
schema(): this;
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Config } from './config';
describe('Config', () => {
it('recognizes keys under `object().pattern`', () => {
const config = new Config({
settings: {
services: {
foo: () => 42,
},
},
primary: true,
path: process.cwd(),
});
expect(config.has('services.foo')).toEqual(true);
expect(config.get('services.foo')()).toEqual(42);
});
});

View file

@ -50,20 +50,20 @@ export class Config {
values: Record<string, any>,
childSchema: any
): boolean {
if (!childSchema._inner) {
if (!childSchema.$_terms.keys && !childSchema.$_terms.patterns) {
return false;
}
// normalize child and pattern checks so we can iterate the checks in a single loop
const checks: Array<{ test: (k: string) => boolean; schema: Schema }> = [
// match children first, they have priority
...(childSchema._inner.children || []).map((child: { key: string; schema: Schema }) => ({
...(childSchema.$_terms.keys || []).map((child: { key: string; schema: Schema }) => ({
test: (k: string) => child.key === k,
schema: child.schema,
})),
// match patterns on any key that doesn't match an explicit child
...(childSchema._inner.patterns || []).map((pattern: { regex: RegExp; rule: Schema }) => ({
...(childSchema.$_terms.patterns || []).map((pattern: { regex: RegExp; rule: Schema }) => ({
test: (k: string) => pattern.regex.test(k) && has(values, k),
schema: pattern.rule,
})),

View file

@ -9,6 +9,7 @@
import { dirname, resolve } from 'path';
import Joi from 'joi';
import type { CustomHelpers } from 'joi';
// valid pattern for ID
// enforced camel-case identifiers for consistency
@ -54,15 +55,17 @@ const dockerServerSchema = () =>
image: requiredWhenEnabled(Joi.string()),
port: requiredWhenEnabled(Joi.number()),
portInContainer: requiredWhenEnabled(Joi.number()),
waitForLogLine: Joi.alternatives(Joi.object().type(RegExp), Joi.string()).optional(),
waitForLogLine: Joi.alternatives(Joi.object().instance(RegExp), Joi.string()).optional(),
waitFor: Joi.func().optional(),
args: Joi.array().items(Joi.string()).optional(),
})
.default();
const defaultRelativeToConfigPath = (path: string) => {
const makeDefault: any = (_: any, options: any) => resolve(dirname(options.context.path), path);
makeDefault.description = `<config.js directory>/${path}`;
const makeDefault = (parent: any, helpers: CustomHelpers) => {
helpers.schema.description(`<config.js directory>/${path}`);
return resolve(dirname(helpers.prefs.context!.path), path);
};
return makeDefault;
};

View file

@ -13,7 +13,7 @@ describe('config.validate()', () => {
// This is intentionally not editable in the raw CSP config.
// Users should set `server.securityResponseHeaders.disableEmbedding` to control this config property.
expect(() => config.schema.validate({ disableEmbedding: true })).toThrowError(
'[disableEmbedding.0]: expected value to equal [false]'
'[disableEmbedding]: expected value to equal [false]'
);
});
});

View file

@ -119,7 +119,7 @@ Object {
exports[`throws if invalid hostname 1`] = `"[host]: value must be a valid hostname (see RFC 1123)."`;
exports[`throws if invalid hostname 2`] = `"[host]: value 0 is not a valid hostname (use \\"0.0.0.0\\" to bind to all interfaces)"`;
exports[`throws if invalid hostname 2`] = `"[host]: value must be a valid hostname (see RFC 1123)."`;
exports[`with TLS throws if TLS is enabled but \`redirectHttpFromPort\` is equal to \`port\` 1`] = `"Kibana does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."`;

View file

@ -87,11 +87,6 @@ const configSchema = schema.object(
host: schema.string({
defaultValue: 'localhost',
hostname: true,
validate(value) {
if (value === '0') {
return 'value 0 is not a valid hostname (use "0.0.0.0" to bind to all interfaces)';
}
},
}),
maxPayload: schema.byteSize({
defaultValue: '1048576b',

View file

@ -302,7 +302,7 @@ describe('Reporting Config Schema', () => {
// kibanaServer
const throwValidationErr = () => ConfigSchema.validate({ kibanaServer: { hostname: '0' } });
expect(throwValidationErr).toThrowError(
`[kibanaServer.hostname]: must not be "0" for the headless browser to correctly resolve the host`
`[kibanaServer.hostname]: value must be a valid hostname (see RFC 1123).`
);
});
});

View file

@ -9,16 +9,7 @@ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema';
import moment from 'moment';
const KibanaServerSchema = schema.object({
hostname: schema.maybe(
schema.string({
validate(value) {
if (value === '0') {
return 'must not be "0" for the headless browser to correctly resolve the host';
}
},
hostname: true,
})
),
hostname: schema.maybe(schema.string({ hostname: true })),
port: schema.maybe(schema.number()),
protocol: schema.maybe(
schema.string({

View file

@ -87,29 +87,7 @@ const MacEntrySchema = schema.object({
...CommonEntrySchema,
});
/*
* Entry Schema depending on Os type using schema.conditional.
* If OS === WINDOWS then use Windows schema,
* else if OS === LINUX then use Linux schema,
* else use Mac schema
*/
const EntrySchemaDependingOnOS = schema.conditional(
schema.siblingRef('os'),
OperatingSystem.WINDOWS,
WindowsEntrySchema,
schema.conditional(
schema.siblingRef('os'),
OperatingSystem.LINUX,
LinuxEntrySchema,
MacEntrySchema
)
);
/*
* Entities array schema.
* The validate function checks there is no duplicated entry inside the array
*/
const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, {
const entriesSchemaOptions = {
minSize: 1,
validate(entries: ConditionEntry[]) {
return (
@ -118,7 +96,27 @@ const EntriesSchema = schema.arrayOf(EntrySchemaDependingOnOS, {
.join(', ') || undefined
);
},
});
};
/*
* Entities array schema depending on Os type using schema.conditional.
* If OS === WINDOWS then use Windows schema,
* else if OS === LINUX then use Linux schema,
* else use Mac schema
*
* The validate function checks there is no duplicated entry inside the array
*/
const EntriesSchema = schema.conditional(
schema.siblingRef('os'),
OperatingSystem.WINDOWS,
schema.arrayOf(WindowsEntrySchema, entriesSchemaOptions),
schema.conditional(
schema.siblingRef('os'),
OperatingSystem.LINUX,
schema.arrayOf(LinuxEntrySchema, entriesSchemaOptions),
schema.arrayOf(MacEntrySchema, entriesSchemaOptions)
)
);
const getTrustedAppForOsScheme = (forUpdateFlow: boolean = false) =>
schema.object({

View file

@ -5200,10 +5200,12 @@
resolved "https://registry.yarnpkg.com/@types/joi/-/joi-14.3.4.tgz#eed1e14cbb07716079c814138831a520a725a1e0"
integrity sha512-1TQNDJvIKlgYXGNIABfgFp9y0FziDpuGrd799Q5RcnsDu+krD+eeW/0Fs5PHARvWWFelOhIG2OPCo6KbadBM4A==
"@types/joi@^13.4.2":
version "13.6.1"
resolved "https://registry.yarnpkg.com/@types/joi/-/joi-13.6.1.tgz#325486a397504f8e22c8c551dc8b0e1d41d5d5ae"
integrity sha512-JxZ0NP8NuB0BJOXi1KvAA6rySLTPmhOy4n2gzSFq/IFM3LNFm0h+2Vn/bPPgEYlWqzS2NPeLgKqfm75baX+Hog==
"@types/joi@^17.2.3":
version "17.2.3"
resolved "https://registry.yarnpkg.com/@types/joi/-/joi-17.2.3.tgz#b7768ed9d84f1ebd393328b9f97c1cf3d2b94798"
integrity sha512-dGjs/lhrWOa+eO0HwgxCSnDm5eMGCsXuvLglMghJq32F6q5LyyNuXb41DHzrg501CKNOSSAHmfB7FDGeUnDmzw==
dependencies:
joi "*"
"@types/jquery@*", "@types/jquery@^3.3.31":
version "3.3.31"
@ -15331,11 +15333,6 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoek@5.x.x:
version "5.0.4"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-5.0.4.tgz#0f7fa270a1cafeb364a4b2ddfaa33f864e4157da"
integrity sha512-Alr4ZQgoMlnere5FZJsIyfIjORBqZll5POhDsF4q64dPuJR6rNxXdDxtHSQq8OXRurhmx+PWYEE8bXRROY8h0w==
hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.5, hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
@ -16701,13 +16698,6 @@ isbinaryfile@4.0.2:
resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.2.tgz#bfc45642da645681c610cca831022e30af426488"
integrity sha512-C3FSxJdNrEr2F4z6uFtNzECDM5hXk+46fxaa+cwBe5/XrWSmzdG8DDgyjfX6/NRdBB21q2JXuRAzPCUs+fclnQ==
isemail@3.x.x:
version "3.1.4"
resolved "https://registry.yarnpkg.com/isemail/-/isemail-3.1.4.tgz#76e2187ff7bee59d57522c6fd1c3f09a331933cf"
integrity sha512-yE/W5osEWuAGSLVixV9pAexhkbZzglmuhO2CxdHu7IBh7uzuZogQ4bk0lE26HoZ6HD4ZYfKRKilkNuCnuJIBJw==
dependencies:
punycode "2.x.x"
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@ -17433,14 +17423,16 @@ jju@~1.4.0:
resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a"
integrity sha1-o6vicYryQaKykE+EpiWXDzia4yo=
joi@^13.5.2:
version "13.7.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-13.7.0.tgz#cfd85ebfe67e8a1900432400b4d03bbd93fb879f"
integrity sha512-xuY5VkHfeOYK3Hdi91ulocfuFopwgbSORmIwzcwHKESQhC7w1kD5jaVSPnqDxS2I8t3RZ9omCKAxNwXN5zG1/Q==
joi@*, joi@^17.4.0:
version "17.4.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.0.tgz#b5c2277c8519e016316e49ababd41a1908d9ef20"
integrity sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==
dependencies:
hoek "5.x.x"
isemail "3.x.x"
topo "3.x.x"
"@hapi/hoek" "^9.0.0"
"@hapi/topo" "^5.0.0"
"@sideway/address" "^4.1.0"
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
joi@^17.3.0:
version "17.3.0"
@ -22404,16 +22396,16 @@ punycode@1.3.2:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=
punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
punycode@^1.2.4, punycode@^1.3.2:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
punycode@^2.1.0, punycode@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
pupa@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.0.1.tgz#dbdc9ff48ffbea4a26a069b6f9f7abb051008726"
@ -27021,13 +27013,6 @@ toidentifier@1.0.0:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
topo@3.x.x:
version "3.0.0"
resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.0.tgz#37e48c330efeac784538e0acd3e62ca5e231fe7a"
integrity sha512-Tlu1fGlR90iCdIPURqPiufqAlCZYzLjHYVVbcFWDMcX7+tK8hdZWAfsMrD/pBul9jqHHwFjNdf1WaxA9vTRRhw==
dependencies:
hoek "5.x.x"
topojson-client@3.1.0, topojson-client@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99"