diff --git a/src/platform/packages/shared/kbn-config-schema/index.ts b/src/platform/packages/shared/kbn-config-schema/index.ts index a00069021634a..f0c66f6aafe94 100644 --- a/src/platform/packages/shared/kbn-config-schema/index.ts +++ b/src/platform/packages/shared/kbn-config-schema/index.ts @@ -33,6 +33,8 @@ import type { TypeOptions, URIOptions, UnionTypeOptions, + PropsWithDiscriminator, + ObjectResultUnionType, } from './src/types'; import { AnyType, @@ -54,6 +56,7 @@ import { StringType, Type, UnionType, + DiscriminatedUnionType, URIType, StreamType, Lazy, @@ -224,6 +227,239 @@ function oneOf>>( return new UnionType(types, options); } +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator, + G extends PropsWithDiscriminator, + H extends PropsWithDiscriminator, + I extends PropsWithDiscriminator, + J extends PropsWithDiscriminator, + K extends PropsWithDiscriminator, + L extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType + ], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator, + G extends PropsWithDiscriminator, + H extends PropsWithDiscriminator, + I extends PropsWithDiscriminator, + J extends PropsWithDiscriminator, + K extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType + ], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator, + G extends PropsWithDiscriminator, + H extends PropsWithDiscriminator, + I extends PropsWithDiscriminator, + J extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType + ], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator, + G extends PropsWithDiscriminator, + H extends PropsWithDiscriminator, + I extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType + ], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator, + G extends PropsWithDiscriminator, + H extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType + ], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator, + G extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType, + ObjectType + ], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator, + F extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ObjectType, ObjectType, ObjectType, ObjectType, ObjectType, ObjectType], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator, + E extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ObjectType, ObjectType, ObjectType, ObjectType, ObjectType], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator, + D extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ObjectType, ObjectType, ObjectType, ObjectType], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator, + C extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ObjectType, ObjectType, ObjectType], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator, + B extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ObjectType, ObjectType], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion< + Discriminator extends string, + A extends PropsWithDiscriminator +>( + discriminator: Discriminator, + types: [ObjectType], + options?: UnionTypeOptions> +): Type>; +function discriminatedUnion>>( + discriminator: Discriminator, + types: RTS, + options?: UnionTypeOptions +): Type> { + return new DiscriminatedUnionType(discriminator, types, options); +} + function allOf< A extends Props, B extends Props, @@ -428,6 +664,8 @@ export const schema = { number, object, oneOf, + union: oneOf, + discriminatedUnion, recordOf, stream, siblingRef, diff --git a/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts b/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts index b9368f6b80b50..0f534f72c9728 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/oas_meta_fields.ts @@ -17,5 +17,6 @@ export const META_FIELD_X_OAS_MAX_LENGTH = 'x-oas-max-length' as const; export const META_FIELD_X_OAS_GET_ADDITIONAL_PROPERTIES = 'x-oas-get-additional-properties' as const; export const META_FIELD_X_OAS_DEPRECATED = 'x-oas-deprecated' as const; +export const META_FIELD_X_OAS_DISCRIMINATOR = 'x-oas-discriminator' as const; export const META_FIELD_X_OAS_ANY = 'x-oas-any-type' as const; export const META_FIELD_X_OAS_DISCONTINUED = 'x-oas-discontinued' as const; diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.test.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.test.ts new file mode 100644 index 0000000000000..92a6d2a3594dc --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.test.ts @@ -0,0 +1,205 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { expectType } from 'tsd'; + +import type { TypeOf } from '../..'; +import { schema } from '../..'; + +describe('DiscriminatedUnionType', () => { + const exampleType = schema.discriminatedUnion('type', [ + schema.object({ type: schema.literal('str'), string: schema.string() }), + schema.object({ type: schema.literal('num'), number: schema.number() }), + schema.object({ type: schema.literal('bool'), boolean: schema.boolean() }), + ]); + + it('should validate first type', () => { + const input = { type: 'str', string: 'test' }; + expect(exampleType.validate(input)).toEqual(input); + }); + + it('should validate second type', () => { + const input = { type: 'num', number: 123 }; + expect(exampleType.validate(input)).toEqual(input); + }); + + it('should validate third type', () => { + const input = { type: 'bool', boolean: true }; + expect(exampleType.validate(input)).toEqual(input); + }); + + it('should validate with default', () => { + const type = schema.discriminatedUnion( + 'type', + [ + schema.object({ type: schema.literal('str'), string: schema.string() }), + schema.object({ type: schema.literal('num'), number: schema.number() }), + schema.object({ type: schema.literal('bool'), boolean: schema.boolean() }), + ], + { defaultValue: { type: 'str', string: 'test' } } + ); + + expect(type.validate(undefined)).toEqual({ type: 'str', string: 'test' }); + }); + + describe('error validation', () => { + it('should handle missing discriminator', () => { + expect(() => exampleType.validate({})).toThrowErrorMatchingInlineSnapshot( + `"\\"type\\" property is required"` + ); + }); + + it('should handle invalid discriminator type', () => { + expect(() => + exampleType.validate({ type: 1, string: 'foo' }) + ).toThrowErrorMatchingInlineSnapshot( + `"expected \\"type\\" to be a string of [\\"str\\", \\"num\\", \\"bool\\"] but got [number]"` + ); + }); + + it('should handle invalid discriminator value', () => { + expect(() => + exampleType.validate({ type: 'invalid', string: 'foo' }) + ).toThrowErrorMatchingInlineSnapshot( + `"expected \\"type\\" to be one of [\\"str\\", \\"num\\", \\"bool\\"] but got [\\"invalid\\"]"` + ); + }); + + it('should handle nested errors', () => { + const type = schema.object({ + test: schema.string(), + nested: exampleType, + }); + expect(() => + type.validate({ test: 'test', nested: { type: 'invalid', string: 'foo' } }) + ).toThrowErrorMatchingInlineSnapshot( + `"[nested]: expected \\"type\\" to be one of [\\"str\\", \\"num\\", \\"bool\\"] but got [\\"invalid\\"]"` + ); + }); + + it('should handle validation only based on discriminator type schema', () => { + expect(() => + exampleType.validate({ type: 'str', string: 123 }) + ).toThrowErrorMatchingInlineSnapshot( + `"[string]: expected value of type [string] but got [number]"` + ); + }); + }); + + describe('schema config errors', () => { + it('should throw on duplicate discriminators', () => { + expect(() => { + schema.discriminatedUnion('type', [ + schema.object({ type: schema.literal('str'), string: schema.string() }), + schema.object({ type: schema.literal('num'), number: schema.number() }), + schema.object({ type: schema.literal('num'), num: schema.number() }), + ]); + }).toThrowErrorMatchingInlineSnapshot( + `"Discriminator for schema at index 2 must be a unique, num is already used"` + ); + }); + + it('should throw if multiple fallback schemas are provided', () => { + expect(() => { + schema.discriminatedUnion('type', [ + schema.object({ type: schema.literal('str'), string: schema.string() }), + schema.object({ type: schema.literal('num'), number: schema.number() }), + schema.object({ type: schema.string(), string: schema.string() }), + schema.object({ type: schema.string(), number: schema.number() }), + ]); + }).toThrowErrorMatchingInlineSnapshot(`"Only one fallback schema is allowed"`); + }); + }); + + describe('catch-all case', () => { + const catchAllType = schema.discriminatedUnion('type', [ + schema.object({ type: schema.literal('str'), string: schema.string() }), + schema.object({ type: schema.literal('num'), number: schema.number() }), + schema.object( + { + type: schema.string(), + }, + { + unknowns: 'allow', + } + ), + ]); + + it('should validate known type', () => { + const input = { type: 'str', string: 'test' }; + + expect(catchAllType.validate(input)).toEqual(input); + }); + + it('should validate unknown fallback type', () => { + const input = { type: 'unknown', unknown: 'test' }; + + expect(catchAllType.validate(input)).toEqual(input); + }); + + it('should invalidate bad known type, avoiding fallback schema', () => { + const input = { type: 'str', string: 123 }; + + expect(() => { + catchAllType.validate(input); + }).toThrowError(); + }); + + it('should invalidate bad fallback schema', () => { + const input = { type: 123, number: 123 }; + + expect(() => { + catchAllType.validate(input); + }).toThrowErrorMatchingInlineSnapshot( + `"[type]: expected value of type [string] but got [number]"` + ); + }); + }); + + describe('#extendsDeep', () => { + it('objects with unknown attributes are kept when extending with unknowns=allow', () => { + const allowSchema = exampleType.extendsDeep({ unknowns: 'allow' }); + const input = { type: 'str', string: 'test', unknown: 'thing' }; + expect(allowSchema.validate(input)).toEqual(input); + }); + + it('objects with unknown attributes are dropped when extending with unknowns=ignore', () => { + const ignoreSchema = exampleType.extendsDeep({ unknowns: 'ignore' }); + const input = { type: 'str', string: 'test' }; + expect(ignoreSchema.validate({ ...input, unknown: 'thing' })).toEqual(input); + }); + + it('objects with unknown attributes fail validation when extending with unknowns=forbid', () => { + const forbidSchema = exampleType.extendsDeep({ unknowns: 'forbid' }); + const input = { type: 'str', string: 'test' }; + expect(() => + forbidSchema.validate({ ...input, unknown: 'thing' }) + ).toThrowErrorMatchingInlineSnapshot(`"[unknown]: definition for this key is missing"`); + }); + }); + + describe('types', () => { + type ExampleType = TypeOf; + + test('should validate correct types', () => { + expectType({ type: 'str', string: 'test' }); + expectType({ type: 'num', number: 123 }); + expectType({ type: 'bool', boolean: true }); + }); + + test('should validate incorrect types', () => { + // @ts-expect-error - should a string + expectType({ type: 'str', string: 123 }); + // @ts-expect-error - should a number + expectType({ type: 'num', number: 'test' }); + // @ts-expect-error - should a boolean + expectType({ type: 'bool', boolean: 'true' }); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.ts new file mode 100644 index 0000000000000..23e261f9a0850 --- /dev/null +++ b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.ts @@ -0,0 +1,129 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Schema, SwitchCases } from 'joi'; +import typeDetect from 'type-detect'; + +import { internals } from '../internals'; +import { META_FIELD_X_OAS_DISCRIMINATOR } from '../oas_meta_fields'; +import type { ExtendsDeepOptions } from './type'; +import { Type } from './type'; +import type { ObjectResultType, Props } from './object_type'; +import type { ObjectType } from './object_type'; +import type { UnionTypeOptions } from './union_type'; + +export type ObjectResultUnionType = T extends Props ? ObjectResultType : never; + +export type PropsWithDiscriminator = Omit< + T, + Discriminator +> & { + [Key in Discriminator]: Type; +}; + +export class DiscriminatedUnionType< + Discriminator extends string, + RTS extends Array>, + T extends PropsWithDiscriminator +> extends Type { + private readonly discriminator: Discriminator; + private readonly discriminatedValues: string[]; + private readonly unionTypes: RTS; + private readonly typeOptions?: UnionTypeOptions; + + constructor(discriminator: Discriminator, types: RTS, options?: UnionTypeOptions) { + const discriminators = new Set(); + + let otherwise: Schema | undefined; + const switchCases = types.reduce((acc, type, index) => { + const discriminatorSchema = type.getPropSchemas()[discriminator]; + const discriminatorValue = discriminatorSchema.expectedValue; + + if (discriminatorValue == null) { + if (otherwise) { + throw new Error(`Only one fallback schema is allowed`); + } + + otherwise = type.getSchema(); + return acc; + } else { + if (typeof discriminatorValue !== 'string') { + throw new Error( + `Discriminator for schema at index ${index} must be a string type, got ${typeof discriminatorValue}` + ); + } + + if (discriminators.has(discriminatorValue)) { + throw new Error( + `Discriminator for schema at index ${index} must be a unique, ${discriminatorValue} is already used` + ); + } + + discriminators.add(discriminatorValue); + } + + acc.push({ + is: discriminatorValue, + then: type.getSchema(), + }); + + return acc; + }, []); + + let schema = internals + .alternatives() + .match('any') + .conditional( + internals.ref(`.${discriminator}`), // self reference object property + { + switch: switchCases, + otherwise, + } + ); + + schema = schema.meta({ [META_FIELD_X_OAS_DISCRIMINATOR]: discriminator }); + + super(schema, options); + + this.discriminator = discriminator; + this.discriminatedValues = Array.from(discriminators); + this.unionTypes = types; + this.typeOptions = options; + } + + public extendsDeep(options: ExtendsDeepOptions) { + return new DiscriminatedUnionType( + this.discriminator, + this.unionTypes.map((t) => t.extendsDeep(options)), + this.typeOptions + ); + } + + protected handleError(type: string, { value }: Record, path: string[]) { + switch (type) { + case 'alternatives.any': + const discriminatorValue = value[this.discriminator]; + + if (discriminatorValue == null) { + return `"${this.discriminator}" property is required`; + } + + const discriminators = this.discriminatedValues.map((v) => JSON.stringify(v)).join(', '); + const discriminatorType = typeDetect(discriminatorValue); + + if (discriminatorType !== 'string') { + return `expected "${this.discriminator}" to be a string of [${discriminators}] but got [${discriminatorType}]`; + } + + if (!this.discriminatedValues.includes(discriminatorValue)) { + return `expected "${this.discriminator}" to be one of [${discriminators}] but got ["${discriminatorValue}"]`; + } + } + } +} diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/index.ts b/src/platform/packages/shared/kbn-config-schema/src/types/index.ts index 0826c0a1e6ed6..b92baeff6c0db 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/index.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/index.ts @@ -43,6 +43,8 @@ export type { StringOptions } from './string_type'; export { StringType } from './string_type'; export type { UnionTypeOptions } from './union_type'; export { UnionType } from './union_type'; +export type { PropsWithDiscriminator, ObjectResultUnionType } from './discriminated_union_type'; +export { DiscriminatedUnionType } from './discriminated_union_type'; export type { URIOptions } from './uri_type'; export { URIType } from './uri_type'; export { NeverType } from './never_type'; diff --git a/src/platform/packages/shared/kbn-config-schema/src/types/type.ts b/src/platform/packages/shared/kbn-config-schema/src/types/type.ts index 7dcf799e9e61a..fb02181a848cb 100644 --- a/src/platform/packages/shared/kbn-config-schema/src/types/type.ts +++ b/src/platform/packages/shared/kbn-config-schema/src/types/type.ts @@ -25,6 +25,10 @@ import { Reference } from '../references'; * generating OpenAPI spec. */ export interface TypeMeta { + /** + * A unique identifier for this type, reduces duplication. + */ + id?: string; /** * A human-friendly description of this type to be used in documentation. */