mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[APM] Support records in strict_keys_rt (#103391)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
0f9b715dff
commit
b51af01adc
8 changed files with 444 additions and 142 deletions
|
@ -25,6 +25,7 @@ NPM_MODULE_EXTRA_FILES = [
|
|||
]
|
||||
|
||||
SRC_DEPS = [
|
||||
"//packages/kbn-config-schema",
|
||||
"@npm//fp-ts",
|
||||
"@npm//io-ts",
|
||||
"@npm//lodash",
|
||||
|
|
|
@ -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';
|
||||
|
|
27
packages/kbn-io-ts-utils/src/props_to_schema/index.ts
Normal file
27
packages/kbn-io-ts-utils/src/props_to_schema/index.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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')}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
64
packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts
Normal file
64
packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts
Normal 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' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
115
packages/kbn-io-ts-utils/src/to_json_schema/index.ts
Normal file
115
packages/kbn-io-ts-utils/src/to_json_schema/index.ts
Normal 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',
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue