From b51af01adc348a847c695c5bbe57996c15af7ae6 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 28 Jun 2021 20:03:30 +0200 Subject: [PATCH] [APM] Support records in strict_keys_rt (#103391) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- packages/kbn-io-ts-utils/BUILD.bazel | 1 + packages/kbn-io-ts-utils/src/index.ts | 1 + .../src/props_to_schema/index.ts | 27 ++ .../src/strict_keys_rt/index.test.ts | 145 ++++++++++- .../src/strict_keys_rt/index.ts | 231 +++++++----------- .../src/to_json_schema/index.test.ts | 64 +++++ .../src/to_json_schema/index.ts | 115 +++++++++ .../routes/register_routes/index.test.ts | 2 +- 8 files changed, 444 insertions(+), 142 deletions(-) create mode 100644 packages/kbn-io-ts-utils/src/props_to_schema/index.ts create mode 100644 packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts create mode 100644 packages/kbn-io-ts-utils/src/to_json_schema/index.ts diff --git a/packages/kbn-io-ts-utils/BUILD.bazel b/packages/kbn-io-ts-utils/BUILD.bazel index 6b26173fe8f3..053030a6f11a 100644 --- a/packages/kbn-io-ts-utils/BUILD.bazel +++ b/packages/kbn-io-ts-utils/BUILD.bazel @@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [ ] SRC_DEPS = [ + "//packages/kbn-config-schema", "@npm//fp-ts", "@npm//io-ts", "@npm//lodash", diff --git a/packages/kbn-io-ts-utils/src/index.ts b/packages/kbn-io-ts-utils/src/index.ts index 418a5a41a2be..a60bc2086fa3 100644 --- a/packages/kbn-io-ts-utils/src/index.ts +++ b/packages/kbn-io-ts-utils/src/index.ts @@ -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'; diff --git a/packages/kbn-io-ts-utils/src/props_to_schema/index.ts b/packages/kbn-io-ts-utils/src/props_to_schema/index.ts new file mode 100644 index 000000000000..5915df1b0102 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/props_to_schema/index.ts @@ -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>(type: T): Type> { + return schema.object( + {}, + { + unknowns: 'allow', + validate: (val) => { + const decoded = type.decode(val); + + if (isLeft(decoded)) { + return PathReporter.report(decoded).join('\n'); + } + }, + } + ); +} diff --git a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts index ab20ca42a283..6b19026cb1be 100644 --- a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.test.ts @@ -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; 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')}` ); } }); diff --git a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts index 56afdf54463f..cb3d9bb2100d 100644 --- a/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts +++ b/packages/kbn-io-ts-utils/src/strict_keys_rt/index.ts @@ -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 - | t.UnionType + | t.IntersectionType + | t.UnionType | t.PartialType - | t.ExactType + | t.ExactType | t.InterfaceType - | MergeType; + | MergeType + | t.DictionaryType; -function getKeysInObject>( +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>( + type: t.Mixed, object: T, prefix: string = '' -): string[] { - const keys: string[] = []; +): { handled: Set; all: Set } { + const keys: { + handled: Set; + all: Set; + } = { + 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, 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, ownPrefix); + nextKeys.all.forEach((k) => keys.all.add(k)); + nextKeys.handled.forEach((k) => keys.handled.add(k)); + }); } }); + return keys; } -function addToContextWhenValidated | t.PartialType>( - 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(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); - 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; - return t.intersection( - collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] - ); - } - - case 'UnionType': { - const collectionType = type as t.UnionType; - return t.union( - collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [t.Any, t.Any] - ); - } - - case 'MergeType': { - const collectionType = type as MergeType; - return mergeRt( - ...(collectionType.types.map((rt) => trackKeysOfValidatedTypes(rt, prefix)) as [ - t.Any, - t.Any - ]) - ); - } - - case 'PartialType': { - const propsType = type as t.PartialType; - - return addToContextWhenValidated( - t.partial( - mapValues(propsType.props, (val, key) => - trackKeysOfValidatedTypes(val, `${prefix}${key}.`) - ) - ), - prefix - ); - } - - case 'InterfaceType': { - const propsType = type as t.InterfaceType; - - return addToContextWhenValidated( - t.type( - mapValues(propsType.props, (val, key) => - trackKeysOfValidatedTypes(val, `${prefix}${key}.`) - ) - ), - prefix - ); - } - - case 'ExactType': { - const exactType = type as t.ExactType; - - 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 = t.Type -> extends t.Type { - 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); - 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(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 + ); } diff --git a/packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts b/packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts new file mode 100644 index 000000000000..cac7d3b2aae5 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts @@ -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' } } } }, + ], + }, + }); + }); +}); diff --git a/packages/kbn-io-ts-utils/src/to_json_schema/index.ts b/packages/kbn-io-ts-utils/src/to_json_schema/index.ts new file mode 100644 index 000000000000..fc196a7c3123 --- /dev/null +++ b/packages/kbn-io-ts-utils/src/to_json_schema/index.ts @@ -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.RecordC + | t.DictionaryType + | t.InterfaceType + | t.PartialType + | t.UnionType + | t.IntersectionType; + +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; + 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', + }; +}; diff --git a/x-pack/plugins/apm/server/routes/register_routes/index.test.ts b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts index 82b73d46da5c..158d7ee7e76a 100644 --- a/x-pack/plugins/apm/server/routes/register_routes/index.test.ts +++ b/x-pack/plugins/apm/server/routes/register_routes/index.test.ts @@ -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, });