Skip to content

Commit

Permalink
Introduce recordOf schema. Remove redundant declarations. (#26952)
Browse files Browse the repository at this point in the history
  • Loading branch information
azasypkin authored Dec 12, 2018
1 parent 9aecea4 commit 94b2b83
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 24 deletions.
11 changes: 11 additions & 0 deletions packages/kbn-config-schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
NumberType,
ObjectType,
Props,
RecordOfOptions,
RecordOfType,
StringOptions,
StringType,
Type,
Expand Down Expand Up @@ -105,6 +107,14 @@ function mapOf<K, V>(
return new MapOfType(keyType, valueType, options);
}

function recordOf<K extends string, V>(
keyType: Type<K>,
valueType: Type<V>,
options?: RecordOfOptions<K, V>
): Type<Record<K, V>> {
return new RecordOfType(keyType, valueType, options);
}

function oneOf<A, B, C, D, E, F, G, H, I, J>(
types: [Type<A>, Type<B>, Type<C>, Type<D>, Type<E>, Type<F>, Type<G>, Type<H>, Type<I>, Type<J>],
options?: TypeOptions<A | B | C | D | E | F | G | H | I | J>
Expand Down Expand Up @@ -175,6 +185,7 @@ export const schema = {
number,
object,
oneOf,
recordOf,
siblingRef,
string,
};
Expand Down
48 changes: 48 additions & 0 deletions packages/kbn-config-schema/src/internals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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',

Expand Down
Original file line number Diff line number Diff line change
@@ -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]"`;

Expand Down
1 change: 1 addition & 0 deletions packages/kbn-config-schema/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
20 changes: 9 additions & 11 deletions packages/kbn-config-schema/src/types/literal_type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,14 @@ import { Type } from './type';

export class LiteralType<T> extends Type<T> {
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<string, any>) {
switch (type) {
case 'any.required':
case 'any.allowOnly':
return `expected value to equal [${expectedValue}] but got [${value}]`;
}
}
}
13 changes: 10 additions & 3 deletions packages/kbn-config-schema/src/types/map_type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -51,9 +51,16 @@ export class MapOfType<K, V> extends Type<Map<K, V>> {
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);
}
}
}
122 changes: 122 additions & 0 deletions packages/kbn-config-schema/src/types/record_of_type.test.ts
Original file line number Diff line number Diff line change
@@ -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 } });
});
58 changes: 58 additions & 0 deletions packages/kbn-config-schema/src/types/record_type.ts
Original file line number Diff line number Diff line change
@@ -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<K extends string, V> = TypeOptions<Record<K, V>>;

export class RecordOfType<K extends string, V> extends Type<Record<K, V>> {
constructor(keyType: Type<K>, valueType: Type<V>, options: RecordOfOptions<K, V> = {}) {
const schema = internals.record().entries(keyType.getSchema(), valueType.getSchema());

super(schema, options);
}

protected handleError(
type: string,
{ entryKey, reason, value }: Record<string, any>,
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);
}
}
}
4 changes: 2 additions & 2 deletions packages/kbn-config-schema/src/types/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export abstract class Type<V> {
*/
protected readonly internalSchema: AnySchema;

constructor(schema: AnySchema, options: TypeOptions<V> = {}) {
protected constructor(schema: AnySchema, options: TypeOptions<V> = {}) {
if (options.defaultValue !== undefined) {
schema = schema.optional();

Expand All @@ -62,7 +62,7 @@ export abstract class Type<V> {
// only the last error handler is counted.
const schemaFlags = (schema.describe().flags as Record<string, any>) || {};
if (schemaFlags.error === undefined) {
schema = schema.error!(([error]) => this.onError(error));
schema = schema.error(([error]) => this.onError(error));
}

this.internalSchema = schema;
Expand Down
12 changes: 6 additions & 6 deletions packages/kbn-config-schema/types/joi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

0 comments on commit 94b2b83

Please sign in to comment.