diff --git a/packages/kbn-config-schema/src/index.ts b/packages/kbn-config-schema/src/index.ts index 8b53f38ac5edb..5ead81c0ddacf 100644 --- a/packages/kbn-config-schema/src/index.ts +++ b/packages/kbn-config-schema/src/index.ts @@ -40,6 +40,8 @@ import { NumberType, ObjectType, Props, + RecordOfOptions, + RecordOfType, StringOptions, StringType, Type, @@ -105,6 +107,14 @@ function mapOf( return new MapOfType(keyType, valueType, options); } +function recordOf( + keyType: Type, + valueType: Type, + options?: RecordOfOptions +): Type> { + return new RecordOfType(keyType, valueType, options); +} + function oneOf( types: [Type, Type, Type, Type, Type, Type, Type, Type, Type, Type], options?: TypeOptions @@ -175,6 +185,7 @@ export const schema = { number, object, oneOf, + recordOf, siblingRef, string, }; diff --git a/packages/kbn-config-schema/src/internals/index.ts b/packages/kbn-config-schema/src/internals/index.ts index 78216e18555b3..e5a5b446de4f5 100644 --- a/packages/kbn-config-schema/src/internals/index.ts +++ b/packages/kbn-config-schema/src/internals/index.ts @@ -274,6 +274,54 @@ export const internals = Joi.extend([ }, ], }, + { + name: 'record', + pre(value: any, state: State, options: ValidationOptions) { + if (!isPlainObject(value)) { + return this.createError('record.base', { value }, state, options); + } + + return value as any; + }, + rules: [ + anyCustomRule, + { + name: 'entries', + params: { key: Joi.object().schema(), value: Joi.object().schema() }, + validate(params, value, state, options) { + const result = {} as Record; + for (const [entryKey, entryValue] of Object.entries(value)) { + const { value: validatedEntryKey, error: keyError } = Joi.validate( + entryKey, + params.key + ); + + if (keyError) { + return this.createError('record.key', { entryKey, reason: keyError }, state, options); + } + + const { value: validatedEntryValue, error: valueError } = Joi.validate( + entryValue, + params.value + ); + + if (valueError) { + return this.createError( + 'record.value', + { entryKey, reason: valueError }, + state, + options + ); + } + + result[validatedEntryKey] = validatedEntryValue; + } + + return result as any; + }, + }, + ], + }, { name: 'array', diff --git a/packages/kbn-config-schema/src/types/__snapshots__/map_of_type.test.ts.snap b/packages/kbn-config-schema/src/types/__snapshots__/map_of_type.test.ts.snap index 83cca66b02450..21b71ddd2487d 100644 --- a/packages/kbn-config-schema/src/types/__snapshots__/map_of_type.test.ts.snap +++ b/packages/kbn-config-schema/src/types/__snapshots__/map_of_type.test.ts.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`fails when not receiving expected key type 1`] = `"[name]: expected value of type [number] but got [string]"`; +exports[`fails when not receiving expected key type 1`] = `"[key(\\"name\\")]: expected value of type [number] but got [string]"`; exports[`fails when not receiving expected value type 1`] = `"[name]: expected value of type [string] but got [number]"`; -exports[`includes namespace in failure when wrong key type 1`] = `"[foo-namespace.name]: expected value of type [number] but got [string]"`; +exports[`includes namespace in failure when wrong key type 1`] = `"[foo-namespace.key(\\"name\\")]: expected value of type [number] but got [string]"`; exports[`includes namespace in failure when wrong top-level type 1`] = `"[foo-namespace]: expected value of type [Map] or [object] but got [Array]"`; diff --git a/packages/kbn-config-schema/src/types/index.ts b/packages/kbn-config-schema/src/types/index.ts index 199bb7d9fa546..0a0aa308ebc94 100644 --- a/packages/kbn-config-schema/src/types/index.ts +++ b/packages/kbn-config-schema/src/types/index.ts @@ -29,5 +29,6 @@ export { MaybeType } from './maybe_type'; export { MapOfOptions, MapOfType } from './map_type'; export { NumberOptions, NumberType } from './number_type'; export { ObjectType, Props, TypeOf } from './object_type'; +export { RecordOfOptions, RecordOfType } from './record_type'; export { StringOptions, StringType } from './string_type'; export { UnionType } from './union_type'; diff --git a/packages/kbn-config-schema/src/types/literal_type.ts b/packages/kbn-config-schema/src/types/literal_type.ts index d53d08238cee5..b5ddaa2f68d4f 100644 --- a/packages/kbn-config-schema/src/types/literal_type.ts +++ b/packages/kbn-config-schema/src/types/literal_type.ts @@ -22,16 +22,14 @@ import { Type } from './type'; export class LiteralType extends Type { constructor(value: T) { - super(internals.any(), { - // Before v13.3.0 Joi.any().value() didn't provide raw value if validation - // fails, so to display this value in error message we should provide - // custom validation function. Once we upgrade Joi, we'll be able to use - // `value()` with custom `any.allowOnly` error handler instead. - validate(valueToValidate) { - if (valueToValidate !== value) { - return `expected value to equal [${value}] but got [${valueToValidate}]`; - } - }, - }); + super(internals.any().valid(value)); + } + + protected handleError(type: string, { value, valids: [expectedValue] }: Record) { + switch (type) { + case 'any.required': + case 'any.allowOnly': + return `expected value to equal [${expectedValue}] but got [${value}]`; + } } } diff --git a/packages/kbn-config-schema/src/types/map_type.ts b/packages/kbn-config-schema/src/types/map_type.ts index 2773728f7478d..3acf14a69125e 100644 --- a/packages/kbn-config-schema/src/types/map_type.ts +++ b/packages/kbn-config-schema/src/types/map_type.ts @@ -18,7 +18,7 @@ */ import typeDetect from 'type-detect'; -import { SchemaTypeError } from '../errors'; +import { SchemaTypeError, SchemaTypesError } from '../errors'; import { internals } from '../internals'; import { Type, TypeOptions } from './type'; @@ -51,9 +51,16 @@ export class MapOfType extends Type> { case 'map.key': case 'map.value': const childPathWithIndex = reason.path.slice(); - childPathWithIndex.splice(path.length, 0, entryKey.toString()); + childPathWithIndex.splice( + path.length, + 0, + // If `key` validation failed, let's stress that to make error more obvious. + type === 'map.key' ? `key("${entryKey}")` : entryKey.toString() + ); - return new SchemaTypeError(reason.message, childPathWithIndex); + return reason instanceof SchemaTypesError + ? new SchemaTypesError(reason, childPathWithIndex, reason.errors) + : new SchemaTypeError(reason, childPathWithIndex); } } } diff --git a/packages/kbn-config-schema/src/types/record_of_type.test.ts b/packages/kbn-config-schema/src/types/record_of_type.test.ts new file mode 100644 index 0000000000000..036e34213411f --- /dev/null +++ b/packages/kbn-config-schema/src/types/record_of_type.test.ts @@ -0,0 +1,122 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { schema } from '..'; + +test('handles object as input', () => { + const type = schema.recordOf(schema.string(), schema.string()); + const value = { + name: 'foo', + }; + expect(type.validate(value)).toEqual({ name: 'foo' }); +}); + +test('fails when not receiving expected value type', () => { + const type = schema.recordOf(schema.string(), schema.string()); + const value = { + name: 123, + }; + + expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot( + `"[name]: expected value of type [string] but got [number]"` + ); +}); + +test('fails when not receiving expected key type', () => { + const type = schema.recordOf( + schema.oneOf([schema.literal('nickName'), schema.literal('lastName')]), + schema.string() + ); + + const value = { + name: 'foo', + }; + + expect(() => type.validate(value)).toThrowErrorMatchingInlineSnapshot(` +"[key(\\"name\\")]: types that failed validation: +- [0]: expected value to equal [nickName] but got [name] +- [1]: expected value to equal [lastName] but got [name]" +`); +}); + +test('includes namespace in failure when wrong top-level type', () => { + const type = schema.recordOf(schema.string(), schema.string()); + expect(() => type.validate([], {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace]: expected value of type [object] but got [Array]"` + ); +}); + +test('includes namespace in failure when wrong value type', () => { + const type = schema.recordOf(schema.string(), schema.string()); + const value = { + name: 123, + }; + + expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace.name]: expected value of type [string] but got [number]"` + ); +}); + +test('includes namespace in failure when wrong key type', () => { + const type = schema.recordOf(schema.string({ minLength: 10 }), schema.string()); + const value = { + name: 'foo', + }; + + expect(() => type.validate(value, {}, 'foo-namespace')).toThrowErrorMatchingInlineSnapshot( + `"[foo-namespace.key(\\"name\\")]: value is [name] but it must have a minimum length of [10]."` + ); +}); + +test('returns default value if undefined', () => { + const obj = { foo: 'bar' }; + + const type = schema.recordOf(schema.string(), schema.string(), { + defaultValue: obj, + }); + + expect(type.validate(undefined)).toEqual(obj); +}); + +test('recordOf within recordOf', () => { + const type = schema.recordOf(schema.string(), schema.recordOf(schema.string(), schema.number())); + const value = { + foo: { + bar: 123, + }, + }; + + expect(type.validate(value)).toEqual({ foo: { bar: 123 } }); +}); + +test('object within recordOf', () => { + const type = schema.recordOf( + schema.string(), + schema.object({ + bar: schema.number(), + }) + ); + const value = { + foo: { + bar: 123, + }, + }; + + expect(type.validate(value)).toEqual({ foo: { bar: 123 } }); +}); diff --git a/packages/kbn-config-schema/src/types/record_type.ts b/packages/kbn-config-schema/src/types/record_type.ts new file mode 100644 index 0000000000000..cefb0a7921f4d --- /dev/null +++ b/packages/kbn-config-schema/src/types/record_type.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { SchemaTypeError, SchemaTypesError } from '../errors'; +import { internals } from '../internals'; +import { Type, TypeOptions } from './type'; + +export type RecordOfOptions = TypeOptions>; + +export class RecordOfType extends Type> { + constructor(keyType: Type, valueType: Type, options: RecordOfOptions = {}) { + const schema = internals.record().entries(keyType.getSchema(), valueType.getSchema()); + + super(schema, options); + } + + protected handleError( + type: string, + { entryKey, reason, value }: Record, + path: string[] + ) { + switch (type) { + case 'any.required': + case 'record.base': + return `expected value of type [object] but got [${typeDetect(value)}]`; + case 'record.key': + case 'record.value': + const childPathWithIndex = reason.path.slice(); + childPathWithIndex.splice( + path.length, + 0, + // If `key` validation failed, let's stress that to make error more obvious. + type === 'record.key' ? `key("${entryKey}")` : entryKey.toString() + ); + + return reason instanceof SchemaTypesError + ? new SchemaTypesError(reason, childPathWithIndex, reason.errors) + : new SchemaTypeError(reason, childPathWithIndex); + } + } +} diff --git a/packages/kbn-config-schema/src/types/type.ts b/packages/kbn-config-schema/src/types/type.ts index e8d0370a443dc..6d5ddf6b24afb 100644 --- a/packages/kbn-config-schema/src/types/type.ts +++ b/packages/kbn-config-schema/src/types/type.ts @@ -38,7 +38,7 @@ export abstract class Type { */ protected readonly internalSchema: AnySchema; - constructor(schema: AnySchema, options: TypeOptions = {}) { + protected constructor(schema: AnySchema, options: TypeOptions = {}) { if (options.defaultValue !== undefined) { schema = schema.optional(); @@ -62,7 +62,7 @@ export abstract class Type { // only the last error handler is counted. const schemaFlags = (schema.describe().flags as Record) || {}; if (schemaFlags.error === undefined) { - schema = schema.error!(([error]) => this.onError(error)); + schema = schema.error(([error]) => this.onError(error)); } this.internalSchema = schema; diff --git a/packages/kbn-config-schema/types/joi.d.ts b/packages/kbn-config-schema/types/joi.d.ts index 8f3082e342043..5c7e42d0d6f5f 100644 --- a/packages/kbn-config-schema/types/joi.d.ts +++ b/packages/kbn-config-schema/types/joi.d.ts @@ -29,11 +29,15 @@ declare module 'joi' { entries(key: AnySchema, value: AnySchema): this; } - // In more recent Joi types we can use `Root` type instead of `typeof Joi`. - export type JoiRoot = typeof Joi & { + interface RecordSchema extends AnySchema { + entries(key: AnySchema, value: AnySchema): this; + } + + export type JoiRoot = Joi.Root & { bytes: () => BytesSchema; duration: () => AnySchema; map: () => MapSchema; + record: () => RecordSchema; }; interface AnySchema { @@ -44,8 +48,4 @@ declare module 'joi' { interface ObjectSchema { schema(): this; } - - // Joi types define only signature with single extension, but Joi supports - // an array form as well. It's fixed in more recent Joi types. - function extend(extension: Joi.Extension | Joi.Extension[]): any; }