[APM] Support records in strict_keys_rt (#103391)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dario Gieselaar 2021-06-28 20:03:30 +02:00 committed by GitHub
parent 0f9b715dff
commit b51af01adc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 444 additions and 142 deletions

View file

@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [
]
SRC_DEPS = [
"//packages/kbn-config-schema",
"@npm//fp-ts",
"@npm//io-ts",
"@npm//lodash",

View file

@ -12,3 +12,4 @@ export { strictKeysRt } from './strict_keys_rt';
export { isoToEpochRt } from './iso_to_epoch_rt';
export { toNumberRt } from './to_number_rt';
export { toBooleanRt } from './to_boolean_rt';
export { toJsonSchema } from './to_json_schema';

View file

@ -0,0 +1,27 @@
/*
* 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 * as t from 'io-ts';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { schema, Type } from '@kbn/config-schema';
import { isLeft } from 'fp-ts/lib/Either';
export function propsToSchema<T extends t.Type<any>>(type: T): Type<t.TypeOf<T>> {
return schema.object(
{},
{
unknowns: 'allow',
validate: (val) => {
const decoded = type.decode(val);
if (isLeft(decoded)) {
return PathReporter.report(decoded).join('\n');
}
},
}
);
}

View file

@ -10,9 +10,76 @@ import * as t from 'io-ts';
import { isRight, isLeft } from 'fp-ts/lib/Either';
import { strictKeysRt } from './';
import { jsonRt } from '../json_rt';
import { PathReporter } from 'io-ts/lib/PathReporter';
describe('strictKeysRt', () => {
it('correctly and deeply validates object keys', () => {
const metricQueryRt = t.union(
[
t.type({
avg_over_time: t.intersection([
t.type({
field: t.string,
}),
t.partial({
range: t.string,
}),
]),
}),
t.type({
count_over_time: t.strict({}),
}),
],
'metric_query'
);
const metricExpressionRt = t.type(
{
expression: t.string,
},
'metric_expression'
);
const metricRt = t.intersection([
t.partial({
record: t.boolean,
}),
t.union([metricQueryRt, metricExpressionRt]),
]);
const metricContainerRt = t.record(t.string, metricRt);
const groupingRt = t.type(
{
by: t.record(
t.string,
t.type({
field: t.string,
}),
'by'
),
limit: t.number,
},
'grouping'
);
const queryRt = t.intersection(
[
t.union([groupingRt, t.strict({})]),
t.type({
index: t.union([t.string, t.array(t.string)]),
metrics: metricContainerRt,
}),
t.partial({
filter: t.string,
round: t.string,
runtime_mappings: t.string,
query_delay: t.string,
}),
],
'query'
);
const checks: Array<{ type: t.Type<any>; passes: any[]; fails: any[] }> = [
{
type: t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.string })]),
@ -42,6 +109,78 @@ describe('strictKeysRt', () => {
passes: [{ query: { bar: '', _inspect: true } }],
fails: [{ query: { _inspect: true } }],
},
{
type: t.type({
body: t.intersection([
t.partial({
from: t.string,
}),
t.type({
config: t.intersection([
t.partial({
from: t.string,
}),
t.type({
alert: t.type({}),
}),
t.union([
t.type({
query: queryRt,
}),
t.type({
queries: t.array(queryRt),
}),
]),
]),
}),
]),
}),
passes: [
{
body: {
config: {
alert: {},
query: {
index: ['apm-*'],
filter: 'processor.event:transaction',
metrics: {
avg_latency_1h: {
avg_over_time: {
field: 'transaction.duration.us',
},
},
},
},
},
},
},
],
fails: [
{
body: {
config: {
alert: {},
query: {
index: '',
metrics: {
avg_latency_1h: {
avg_over_time: {
field: '',
range: '',
},
},
rate_1h: {
count_over_time: {
field: '',
},
},
},
},
},
},
},
],
},
];
checks.forEach((check) => {
@ -54,9 +193,9 @@ describe('strictKeysRt', () => {
if (!isRight(result)) {
throw new Error(
`Expected ${JSON.stringify(value)} to be allowed, but validation failed with ${
result.left[0].message
}`
`Expected ${JSON.stringify(
value
)} to be allowed, but validation failed with ${PathReporter.report(result).join('\n')}`
);
}
});

View file

@ -7,9 +7,9 @@
*/
import * as t from 'io-ts';
import { either, isRight } from 'fp-ts/lib/Either';
import { mapValues, difference, isPlainObject, forEach } from 'lodash';
import { MergeType, mergeRt } from '../merge_rt';
import { either } from 'fp-ts/lib/Either';
import { difference, isPlainObject, forEach } from 'lodash';
import { MergeType } from '../merge_rt';
/*
Type that tracks validated keys, and fails when the input value
@ -17,153 +17,108 @@ import { MergeType, mergeRt } from '../merge_rt';
*/
type ParsableType =
| t.IntersectionType<any>
| t.UnionType<any>
| t.IntersectionType<ParsableType[]>
| t.UnionType<ParsableType[]>
| t.PartialType<any>
| t.ExactType<any>
| t.ExactType<ParsableType>
| t.InterfaceType<any>
| MergeType<any, any>;
| MergeType<any, any>
| t.DictionaryType<any, any>;
function getKeysInObject<T extends Record<string, unknown>>(
const tags = [
'DictionaryType',
'IntersectionType',
'MergeType',
'InterfaceType',
'PartialType',
'ExactType',
'UnionType',
];
function isParsableType(type: t.Mixed): type is ParsableType {
return tags.includes((type as any)._tag);
}
function getHandlingTypes(type: t.Mixed, key: string, value: object): t.Mixed[] {
if (!isParsableType(type)) {
return [];
}
switch (type._tag) {
case 'DictionaryType':
return [type.codomain];
case 'IntersectionType':
return type.types.map((i) => getHandlingTypes(i, key, value)).flat();
case 'MergeType':
return type.types.map((i) => getHandlingTypes(i, key, value)).flat();
case 'InterfaceType':
case 'PartialType':
return [type.props[key]];
case 'ExactType':
return getHandlingTypes(type.type, key, value);
case 'UnionType':
const matched = type.types.find((m) => m.is(value));
return matched ? getHandlingTypes(matched, key, value) : [];
}
}
function getHandledKeys<T extends Record<string, unknown>>(
type: t.Mixed,
object: T,
prefix: string = ''
): string[] {
const keys: string[] = [];
): { handled: Set<string>; all: Set<string> } {
const keys: {
handled: Set<string>;
all: Set<string>;
} = {
handled: new Set(),
all: new Set(),
};
forEach(object, (value, key) => {
const ownPrefix = prefix ? `${prefix}.${key}` : key;
keys.push(ownPrefix);
if (isPlainObject(object[key])) {
keys.push(...getKeysInObject(object[key] as Record<string, unknown>, ownPrefix));
keys.all.add(ownPrefix);
const handlingTypes = getHandlingTypes(type, key, object).filter(Boolean);
if (handlingTypes.length) {
keys.handled.add(ownPrefix);
}
if (isPlainObject(value)) {
handlingTypes.forEach((i) => {
const nextKeys = getHandledKeys(i, value as Record<string, unknown>, ownPrefix);
nextKeys.all.forEach((k) => keys.all.add(k));
nextKeys.handled.forEach((k) => keys.handled.add(k));
});
}
});
return keys;
}
function addToContextWhenValidated<T extends t.InterfaceType<any> | t.PartialType<any>>(
type: T,
prefix: string
): T {
const validate = (input: unknown, context: t.Context) => {
const result = type.validate(input, context);
const keysType = context[0].type as StrictKeysType;
if (!('trackedKeys' in keysType)) {
throw new Error('Expected a top-level StrictKeysType');
}
if (isRight(result)) {
keysType.trackedKeys.push(...Object.keys(type.props).map((propKey) => `${prefix}${propKey}`));
}
return result;
};
export function strictKeysRt<T extends t.Any>(type: T) {
return new t.Type(
type.name,
type.is,
(input, context) => {
return either.chain(type.validate(input, context), (i) => {
const keys = getHandledKeys(type, input as Record<string, unknown>);
if (type._tag === 'InterfaceType') {
return new t.InterfaceType(type.name, type.is, validate, type.encode, type.props) as T;
}
const excessKeys = difference([...keys.all], [...keys.handled]);
return new t.PartialType(type.name, type.is, validate, type.encode, type.props) as T;
}
function trackKeysOfValidatedTypes(type: ParsableType | t.Any, prefix: string = ''): t.Any {
if (!('_tag' in type)) {
return type;
}
const taggedType = type as ParsableType;
switch (taggedType._tag) {
case 'IntersectionType': {
const collectionType = type as t.IntersectionType<t.Any[]>;
return t.intersection(
collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any]
);
}
case 'UnionType': {
const collectionType = type as t.UnionType<t.Any[]>;
return t.union(
collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any]
);
}
case 'MergeType': {
const collectionType = type as MergeType<t.Any, t.Any>;
return mergeRt(
...(collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [
t.Any,
t.Any
])
);
}
case 'PartialType': {
const propsType = type as t.PartialType<any>;
return addToContextWhenValidated(
t.partial(
mapValues(propsType.props, (val, key) =>
trackKeysOfValidatedTypes(val, `${prefix}${key}.`)
)
),
prefix
);
}
case 'InterfaceType': {
const propsType = type as t.InterfaceType<any>;
return addToContextWhenValidated(
t.type(
mapValues(propsType.props, (val, key) =>
trackKeysOfValidatedTypes(val, `${prefix}${key}.`)
)
),
prefix
);
}
case 'ExactType': {
const exactType = type as t.ExactType<t.HasProps>;
return t.exact(trackKeysOfValidatedTypes(exactType.type, prefix) as t.HasProps);
}
default:
return type;
}
}
class StrictKeysType<
A = any,
O = A,
I = any,
T extends t.Type<A, O, I> = t.Type<A, O, I>
> extends t.Type<A, O, I> {
trackedKeys: string[];
constructor(type: T) {
const trackedType = trackKeysOfValidatedTypes(type);
super(
'strict_keys',
trackedType.is,
(input, context) => {
this.trackedKeys.length = 0;
return either.chain(trackedType.validate(input, context), (i) => {
const originalKeys = getKeysInObject(input as Record<string, unknown>);
const excessKeys = difference(originalKeys, this.trackedKeys);
if (excessKeys.length) {
return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`);
}
return t.success(i);
});
},
trackedType.encode
);
this.trackedKeys = [];
}
}
export function strictKeysRt<T extends t.Any>(type: T): T {
return (new StrictKeysType(type) as unknown) as T;
if (excessKeys.length) {
return t.failure(i, context, `Excess keys are not allowed: \n${excessKeys.join('\n')}`);
}
return t.success(i);
});
},
type.encode
);
}

View file

@ -0,0 +1,64 @@
/*
* 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 * as t from 'io-ts';
import { toJsonSchema } from './';
describe('toJsonSchema', () => {
it('converts simple types to JSON schema', () => {
expect(
toJsonSchema(
t.type({
foo: t.string,
})
)
).toEqual({
type: 'object',
properties: {
foo: {
type: 'string',
},
},
required: ['foo'],
});
expect(
toJsonSchema(
t.type({
foo: t.union([t.boolean, t.string]),
})
)
).toEqual({
type: 'object',
properties: {
foo: {
anyOf: [{ type: 'boolean' }, { type: 'string' }],
},
},
required: ['foo'],
});
});
it('converts record/dictionary types', () => {
expect(
toJsonSchema(
t.record(
t.string,
t.intersection([t.type({ foo: t.string }), t.partial({ bar: t.array(t.boolean) })])
)
)
).toEqual({
type: 'object',
additionalProperties: {
allOf: [
{ type: 'object', properties: { foo: { type: 'string' } }, required: ['foo'] },
{ type: 'object', properties: { bar: { type: 'array', items: { type: 'boolean' } } } },
],
},
});
});
});

View file

@ -0,0 +1,115 @@
/*
* 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 * as t from 'io-ts';
import { mapValues } from 'lodash';
type JSONSchemableValueType =
| t.StringType
| t.NumberType
| t.BooleanType
| t.ArrayType<t.Mixed>
| t.RecordC<t.Mixed, t.Mixed>
| t.DictionaryType<t.Mixed, t.Mixed>
| t.InterfaceType<t.Props>
| t.PartialType<t.Props>
| t.UnionType<t.Mixed[]>
| t.IntersectionType<t.Mixed[]>;
const tags = [
'StringType',
'NumberType',
'BooleanType',
'ArrayType',
'DictionaryType',
'InterfaceType',
'PartialType',
'UnionType',
'IntersectionType',
];
const isSchemableValueType = (type: t.Mixed): type is JSONSchemableValueType => {
// @ts-ignore
return tags.includes(type._tag);
};
interface JSONSchemaObject {
type: 'object';
required?: string[];
properties?: Record<string, JSONSchema>;
additionalProperties?: boolean | JSONSchema;
}
interface JSONSchemaOneOf {
oneOf: JSONSchema[];
}
interface JSONSchemaAllOf {
allOf: JSONSchema[];
}
interface JSONSchemaAnyOf {
anyOf: JSONSchema[];
}
interface JSONSchemaArray {
type: 'array';
items?: JSONSchema;
}
interface BaseJSONSchema {
type: string;
}
type JSONSchema =
| JSONSchemaObject
| JSONSchemaArray
| BaseJSONSchema
| JSONSchemaOneOf
| JSONSchemaAllOf
| JSONSchemaAnyOf;
export const toJsonSchema = (type: t.Mixed): JSONSchema => {
if (isSchemableValueType(type)) {
switch (type._tag) {
case 'ArrayType':
return { type: 'array', items: toJsonSchema(type.type) };
case 'BooleanType':
return { type: 'boolean' };
case 'DictionaryType':
return { type: 'object', additionalProperties: toJsonSchema(type.codomain) };
case 'InterfaceType':
return {
type: 'object',
properties: mapValues(type.props, toJsonSchema),
required: Object.keys(type.props),
};
case 'PartialType':
return { type: 'object', properties: mapValues(type.props, toJsonSchema) };
case 'UnionType':
return { anyOf: type.types.map(toJsonSchema) };
case 'IntersectionType':
return { allOf: type.types.map(toJsonSchema) };
case 'NumberType':
return { type: 'number' };
case 'StringType':
return { type: 'string' };
}
}
return {
type: 'object',
};
};

View file

@ -260,7 +260,7 @@ describe('createApi', () => {
body: {
attributes: { _inspect: [] },
message:
'Invalid value 1 supplied to : strict_keys/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)',
'Invalid value 1 supplied to : Partial<{| query: Partial<{| _inspect: pipe(JSON, boolean) |}> |}>/query: Partial<{| _inspect: pipe(JSON, boolean) |}>/_inspect: pipe(JSON, boolean)',
},
statusCode: 400,
});