diff --git a/src/core/server/integration_tests/http/oas.test.ts b/src/core/server/integration_tests/http/oas.test.ts index 478c572326b15..b6e9295451d48 100644 --- a/src/core/server/integration_tests/http/oas.test.ts +++ b/src/core/server/integration_tests/http/oas.test.ts @@ -182,8 +182,7 @@ it.each([ ], requestBody: { content: { - 'application/json; Elastic-Api-Version=1': {}, // Multiple body types - 'application/json; Elastic-Api-Version=2': {}, + 'application/json; Elastic-Api-Version=2': {}, // Only the latest version }, }, }, diff --git a/src/platform/packages/shared/kbn-config-schema/README.md b/src/platform/packages/shared/kbn-config-schema/README.md index 58e89b4b0fea2..dd088bb480324 100644 --- a/src/platform/packages/shared/kbn-config-schema/README.md +++ b/src/platform/packages/shared/kbn-config-schema/README.md @@ -5,38 +5,41 @@ Kibana configuration entries providing developers with a fully typed model of th ## Table of Contents -* [Why `@kbn/config-schema`?](#why-kbnconfig-schema) -* [Base concepts](#base-concepts) -* [Basic types](#basic-types) - * [`schema.string()`](#schemastring) - * [`schema.number()`](#schemanumber) - * [`schema.boolean()`](#schemaboolean) - * [`schema.literal()`](#schemaliteral) - * [`schema.buffer()`](#schemabuffer) - * [`schema.stream()`](#schemastream) -* [Composite types](#composite-types) - * [`schema.arrayOf()`](#schemaarrayof) - * [`schema.object()`](#schemaobject) - * [`schema.recordOf()`](#schemarecordof) - * [`schema.mapOf()`](#schemamapof) - * [`schema.intersection()` / `schema.allOf()`](#schemaintersection--schemaallof) -* [Advanced types](#advanced-types) - * [`schema.oneOf()`](#schemaoneof) - * [`schema.any()`](#schemaany) - * [`schema.maybe()`](#schemamaybe) - * [`schema.nullable()`](#schemanullable) - * [`schema.never()`](#schemanever) - * [`schema.uri()`](#schemauri) - * [`schema.byteSize()`](#schemabytesize) - * [`schema.duration()`](#schemaduration) - * [`schema.conditional()`](#schemaconditional) - * [`schema.lazy()`](#schemalazy) -* [References](#references) - * [`schema.contextRef()`](#schemacontextref) - * [`schema.siblingRef()`](#schemasiblingref) -* [Custom validation](#custom-validation) -* [Default values](#default-values) -* [Extending object schemas](#extending-object-schemas) +- [`@kbn/config-schema` — The Kibana config validation library](#kbnconfig-schema--the-kibana-config-validation-library) + - [Table of Contents](#table-of-contents) + - [Why `@kbn/config-schema`?](#why-kbnconfig-schema) + - [Base concepts](#base-concepts) + - [Basic types](#basic-types) + - [`schema.string()`](#schemastring) + - [`schema.number()`](#schemanumber) + - [`schema.boolean()`](#schemaboolean) + - [`schema.literal()`](#schemaliteral) + - [`schema.buffer()`](#schemabuffer) + - [`schema.stream()`](#schemastream) + - [Composite types](#composite-types) + - [`schema.arrayOf()`](#schemaarrayof) + - [`schema.object()`](#schemaobject) + - [`schema.recordOf()`](#schemarecordof) + - [`schema.mapOf()`](#schemamapof) + - [`schema.intersection()` / `schema.allOf()`](#schemaintersection--schemaallof) + - [Advanced types](#advanced-types) + - [`schema.oneOf()`](#schemaoneof) + - [`schema.discriminatedUnion()`](#schemadiscriminatedunion) + - [`schema.any()`](#schemaany) + - [`schema.maybe()`](#schemamaybe) + - [`schema.nullable()`](#schemanullable) + - [`schema.never()`](#schemanever) + - [`schema.uri()`](#schemauri) + - [`schema.byteSize()`](#schemabytesize) + - [`schema.duration()`](#schemaduration) + - [`schema.conditional()`](#schemaconditional) + - [`schema.lazy()`](#schemalazy) + - [References](#references) + - [`schema.contextRef()`](#schemacontextref) + - [`schema.siblingRef()`](#schemasiblingref) + - [Custom validation](#custom-validation) + - [Default values](#default-values) + - [Extending object schemas](#extending-object-schemas) ## Why `@kbn/config-schema`? @@ -307,7 +310,7 @@ __Notes:__ ### `schema.intersection()` / `schema.allOf()` -Creates an `object` schema being the intersection of the provided `object` schemas. +Creates an `object` schema being the intersection of the provided `object` schemas. Note that schema construction will throw an error if some of the intersection schema share the same key(s). See the documentation for [schema.object](#schemaobject). @@ -350,6 +353,37 @@ __Notes:__ * Since the result data type is a type union you should use various TypeScript type guards to get the exact type. * Can't use the `unknowns` option since this is implemented on top of `joi.alternatives()`, and it doesn't accept this option. +### `schema.discriminatedUnion()` + +Allows a list of alternative object schemas to validate input data against, using a common discriminator property to determine which schema to use. + +__Output type:__ `TObject1 | TObject2 | TObject3 | ..... as TUnion` + +__Options:__ + * `defaultValue: TUnion | Reference | (() => TUnion)` - defines a default value, see [Default values](#default-values) section for more details. + * `validate: (value: TUnion) => string | void` - defines a custom validator function, see [Custom validation](#custom-validation) section for more details. + +__Usage:__ +```typescript +const valueSchema = schema.discriminatedUnion('type', [ + schema.object({ type: schema.literal('str'), value: schema.string() }), + schema.object({ type: schema.literal('num'), value: schema.number() }), + schema.object({ type: schema.literal('bool'), value: schema.boolean() }), +]); + +// Valid inputs: +// { type: 'str', value: 'hello' } +// { type: 'num', value: 123 } +// { type: 'bool', value: true } +``` + +__Notes:__ +* The first argument is the name of the discriminator property that must be present in all object schemas. +* Each object schema defines the discriminator property using `schema.literal()` with a unique string value. +* Discriminator values must be unique across all schemas - duplicate values will throw an error at schema construction time. +* You can define a fallback schema by using a non-literal type (e.g., `schema.string()`) for the discriminator property. Only one fallback schema is allowed. +* Unlike `schema.oneOf()`, this provides better error messages since it can identify which schema variant was intended based on the discriminator value. + ### `schema.any()` Indicates that input data shouldn't be validated and returned as is. diff --git a/src/platform/packages/shared/kbn-config-schema/index.ts b/src/platform/packages/shared/kbn-config-schema/index.ts index f0c66f6aafe94..cab0ea827ec90 100644 --- a/src/platform/packages/shared/kbn-config-schema/index.ts +++ b/src/platform/packages/shared/kbn-config-schema/index.ts @@ -683,6 +683,8 @@ import { META_FIELD_X_OAS_MAX_LENGTH, META_FIELD_X_OAS_MIN_LENGTH, META_FIELD_X_OAS_GET_ADDITIONAL_PROPERTIES, + META_FIELD_X_OAS_DISCRIMINATOR, + META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE, } from './src/oas_meta_fields'; export const metaFields = Object.freeze({ @@ -693,4 +695,6 @@ export const metaFields = Object.freeze({ META_FIELD_X_OAS_MAX_LENGTH, META_FIELD_X_OAS_MIN_LENGTH, META_FIELD_X_OAS_GET_ADDITIONAL_PROPERTIES, + META_FIELD_X_OAS_DISCRIMINATOR, + META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE, }); 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 0f534f72c9728..55937baeed543 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 @@ -18,5 +18,8 @@ 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; +/** An internal meta field used to indicate that the "default" special case handler in a discriminator */ +export const META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE = + 'x-oas-discriminator-default-case' 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.ts b/src/platform/packages/shared/kbn-config-schema/src/types/discriminated_union_type.ts index 23e261f9a0850..e4941a3f5666a 100644 --- 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 @@ -11,7 +11,10 @@ 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 { + META_FIELD_X_OAS_DISCRIMINATOR, + META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE, +} from '../oas_meta_fields'; import type { ExtendsDeepOptions } from './type'; import { Type } from './type'; import type { ObjectResultType, Props } from './object_type'; @@ -50,7 +53,7 @@ export class DiscriminatedUnionType< throw new Error(`Only one fallback schema is allowed`); } - otherwise = type.getSchema(); + otherwise = type.getSchema().meta({ [META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE]: true }); return acc; } else { if (typeof discriminatorValue !== 'string') { @@ -76,9 +79,19 @@ export class DiscriminatedUnionType< return acc; }, []); - let schema = internals + // This is a workaround to add the discriminator to the first case because our parser + // strips it off the alternatives.match container. + // https://github.com/kenspirit/joi-to-json/pull/58 + if (switchCases.length > 0) { + switchCases[0].then = (switchCases[0]!.then! as Schema).meta({ + [META_FIELD_X_OAS_DISCRIMINATOR]: discriminator, + }); + } + + const schema = internals .alternatives() .match('any') + .meta({ [META_FIELD_X_OAS_DISCRIMINATOR]: discriminator }) .conditional( internals.ref(`.${discriminator}`), // self reference object property { @@ -87,8 +100,6 @@ export class DiscriminatedUnionType< } ); - schema = schema.meta({ [META_FIELD_X_OAS_DISCRIMINATOR]: discriminator }); - super(schema, options); this.discriminator = discriminator; diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap b/src/platform/packages/shared/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap index 226fdc7ada431..2641705011ac0 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/__snapshots__/generate_oas.test.ts.snap @@ -644,6 +644,129 @@ Object { } `; +exports[`generateOpenApiDocument @kbn/config-schema handles discriminator schemas 1`] = ` +Object { + "components": Object { + "schemas": Object { + "my-a-my-team": Object { + "additionalProperties": false, + "properties": Object { + "type": Object { + "enum": Array [ + "a", + ], + "type": "string", + }, + "value": Object { + "type": "string", + }, + }, + "required": Array [ + "type", + "value", + ], + "type": "object", + }, + "my-b-my-team": Object { + "additionalProperties": false, + "properties": Object { + "type": Object { + "enum": Array [ + "b", + ], + "type": "string", + }, + "value": Object { + "type": "number", + }, + }, + "required": Array [ + "type", + "value", + ], + "type": "object", + }, + "my-catch-all-my-team": Object { + "additionalProperties": false, + "properties": Object { + "type": Object { + "type": "string", + }, + "value": Object { + "type": "boolean", + }, + }, + "required": Array [ + "type", + "value", + ], + "type": "object", + "x-oas-discriminator-default-case": true, + }, + }, + "securitySchemes": Object { + "apiKeyAuth": Object { + "in": "header", + "name": "Authorization", + "type": "apiKey", + }, + "basicAuth": Object { + "scheme": "basic", + "type": "http", + }, + }, + }, + "externalDocs": undefined, + "info": Object { + "description": undefined, + "title": "test", + "version": "99.99.99", + }, + "openapi": "3.0.0", + "paths": Object { + "/foo/{id}": Object { + "get": Object { + "operationId": "get-foo-id", + "parameters": Array [], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "discriminator": Object { + "propertyName": "type", + }, + "oneOf": Array [ + Object { + "$ref": "#/components/schemas/my-a-my-team", + }, + Object { + "$ref": "#/components/schemas/my-b-my-team", + }, + ], + }, + }, + }, + }, + "responses": Object {}, + "summary": "", + "tags": Array [], + }, + }, + }, + "security": Array [ + Object { + "basicAuth": Array [], + }, + ], + "servers": Array [ + Object { + "url": "https://test.oas", + }, + ], + "tags": Array [], +} +`; + exports[`generateOpenApiDocument @kbn/config-schema handles recursive schemas 1`] = ` Object { "components": Object { diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/generate_oas.test.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/generate_oas.test.ts index 515cf6289d2fa..be073b318cfd5 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/generate_oas.test.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/generate_oas.test.ts @@ -217,6 +217,76 @@ describe('generateOpenApiDocument', () => { ) ).toMatchSnapshot(); }); + + it('handles discriminator schemas', async () => { + const discriminatorSchema = schema.discriminatedUnion('type', [ + schema.object( + { type: schema.literal('a'), value: schema.string() }, + { meta: { id: 'my-a-my-team' } } + ), + schema.object( + { type: schema.literal('b'), value: schema.number() }, + { meta: { id: 'my-b-my-team' } } + ), + schema.object( + { type: schema.string(), value: schema.boolean() }, + { meta: { id: 'my-catch-all-my-team' } } + ), + ]); + + const [routers, versionedRouters] = createTestRouters({ + routers: { + testRouter: { + routes: [ + { + method: 'get', + path: '/foo/{id}', + options: { access: 'public' }, + validationSchemas: { + request: { + body: discriminatorSchema, + }, + }, + handler: jest.fn(), + }, + ], + }, + }, + versionedRouters: { + testVersionedRouter: { + routes: [ + { + method: 'get', + path: '/foo/{id}', + options: { access: 'public', security: { authz: { requiredPrivileges: ['foo'] } } }, + handlers: [ + { + fn: jest.fn(), + options: { + version: '99.99.99', + validate: { request: { body: discriminatorSchema } }, + }, + }, + ], + }, + ], + }, + }, + }); + expect( + await generateOpenApiDocument( + { + routers, + versionedRouters, + }, + { + title: 'test', + baseUrl: 'https://test.oas', + version: '99.99.99', + } + ) + ).toMatchSnapshot(); + }); }); describe('Zod', () => { diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/index.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/index.ts index 166f574edfaf4..14020f8627c55 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/index.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/index.ts @@ -32,8 +32,14 @@ export class OasConverter { }); } + public derefSharedSchema(id: string) { + return this.#sharedSchemas.get(id); + } + public convert(schema: unknown) { - const { schema: oasSchema, shared } = this.#getConverter(schema)!.convert(schema); + const { schema: oasSchema, shared } = this.#getConverter(schema)!.convert(schema, { + sharedSchemas: this.#sharedSchemas, + }); this.#addComponents(shared); return oasSchema as OpenAPIV3.SchemaObject; } diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/lib.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/lib.ts index a4c8f88fe27b8..faf580da850ab 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/lib.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/lib.ts @@ -12,7 +12,7 @@ import type { Type } from '@kbn/config-schema'; import { isConfigSchema } from '@kbn/config-schema'; import { get } from 'lodash'; import type { OpenAPIV3 } from 'openapi-types'; -import type { KnownParameters } from '../../type'; +import type { ConvertOptions, KnownParameters } from '../../type'; import { isReferenceObject } from '../common'; import { parse } from './parse'; @@ -69,9 +69,9 @@ export const unwrapKbnConfigSchema = (schema: unknown): joi.Schema => { return schema.getSchema(); }; -export const convert = (kbnConfigSchema: unknown) => { +export const convert = (kbnConfigSchema: unknown, { sharedSchemas }: ConvertOptions = {}) => { const schema = unwrapKbnConfigSchema(kbnConfigSchema); - const { result, shared } = parse({ schema, ctx: createCtx() }); + const { result, shared } = parse({ schema, ctx: createCtx({ sharedSchemas }) }); return { schema: result, shared }; }; diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/context.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/context.ts index 682452fa2f8c2..bf2a6858ef916 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/context.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/context.ts @@ -8,9 +8,11 @@ */ import type { OpenAPIV3 } from 'openapi-types'; +import { getIdFromRefString } from './mutations/utils'; export interface IContext { addSharedSchema: (id: string, schema: OpenAPIV3.SchemaObject) => void; + derefSharedSchema: (id: string) => OpenAPIV3.SchemaObject | undefined; getSharedSchemas: () => { [id: string]: OpenAPIV3.SchemaObject }; } @@ -20,16 +22,27 @@ interface Options { class Context implements IContext { private readonly sharedSchemas: Map; + private readonly namespace?: string; constructor(opts: Options) { this.sharedSchemas = opts.sharedSchemas ?? new Map(); } + public addSharedSchema(id: string, schema: OpenAPIV3.SchemaObject): void { this.sharedSchemas.set(id, schema); } + /** Assumes id is in the form of "#/components/schemas/my-schema-my-team" */ + public derefSharedSchema(id: string) { + return this.sharedSchemas.get(getIdFromRefString(id)); + } + public getSharedSchemas() { return Object.fromEntries(this.sharedSchemas.entries()); } + + public getNamespace() { + return this.namespace; + } } export const createCtx = (opts: Options = { sharedSchemas: new Map() }) => { diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/index.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/index.ts index fef72ea93a9e8..ae1fa47ea19b6 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/index.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/index.ts @@ -64,6 +64,7 @@ const walkSchema = (ctx: IContext, schema: Schema): void => { schema[arrayContainer].forEach((s: OpenAPIV3.SchemaObject) => { walkSchema(ctx, s); }); + mutations.processDiscriminator(ctx, schema); mutations.processEnum(schema); break; } diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/discriminator.test.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/discriminator.test.ts new file mode 100644 index 0000000000000..cf9b2acef8315 --- /dev/null +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/discriminator.test.ts @@ -0,0 +1,168 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { joi2JsonInternal } from '../../parse'; +import { processDiscriminator } from './discriminator'; +import { createCtx } from '../context'; +import { cloneDeep } from 'lodash'; + +test('base case', () => { + const testSchema = schema.discriminatedUnion('type', [ + schema.object( + { type: schema.literal('str'), value: schema.string() }, + { meta: { id: 'my-str-my-team' } } + ), + schema.object( + { type: schema.literal('num'), value: schema.number() }, + { meta: { id: 'my-num-team' } } + ), + ]); + const { schemas, ...parsed } = joi2JsonInternal(testSchema.getSchema()); + const ctx = createCtx({ sharedSchemas: new Map(Object.entries(schemas)) }); + processDiscriminator(ctx, parsed); + expect(parsed).toEqual({ + oneOf: [ + { + $ref: '#/components/schemas/my-str-my-team', + }, + { + $ref: '#/components/schemas/my-num-team', + }, + ], + discriminator: { + propertyName: 'type', + }, + }); + + expect(ctx.getSharedSchemas()).toMatchObject({ + 'my-str-my-team': { + type: 'object', + properties: { + type: { type: 'string', enum: ['str'] }, + }, + }, + 'my-num-team': { + type: 'object', + properties: { + type: { type: 'string', enum: ['num'] }, + }, + }, + }); +}); + +test('with default case', () => { + const testSchema = schema.discriminatedUnion('type', [ + schema.object( + { type: schema.literal('str'), value: schema.string() }, + { meta: { id: 'my-str-my-team' } } + ), + schema.object( + { type: schema.literal('num'), value: schema.number() }, + { meta: { id: 'my-num-team' } } + ), + schema.object( + { type: schema.string(), value: schema.number() }, + { meta: { id: 'my-catch-all-my-team' } } + ), + ]); + + const { schemas, ...parsed } = joi2JsonInternal(testSchema.getSchema()); + const ctx = createCtx({ sharedSchemas: new Map(Object.entries(schemas)) }); + processDiscriminator(ctx, parsed); + expect(parsed).toEqual({ + oneOf: [ + { + $ref: '#/components/schemas/my-str-my-team', + }, + { + $ref: '#/components/schemas/my-num-team', + }, + ], + discriminator: { + propertyName: 'type', + }, + }); + + expect(ctx.getSharedSchemas()).toMatchObject({ + 'my-str-my-team': { + type: 'object', + properties: { + type: { type: 'string', enum: ['str'] }, + }, + }, + 'my-num-team': { + type: 'object', + properties: { + type: { type: 'string', enum: ['num'] }, + }, + }, + 'my-catch-all-my-team': { + type: 'object', + properties: { + type: { type: 'string' }, + value: { type: 'number' }, + }, + }, + }); +}); + +describe('throws if any schema has no ID', () => { + test('first schema has no ID', () => { + const parsed = joi2JsonInternal( + schema + .discriminatedUnion('type', [ + schema.object({ type: schema.literal('num'), value: schema.number() }), + schema.object( + { type: schema.literal('str'), value: schema.string() }, + { meta: { id: 'my-str-my-team' } } + ), + ]) + .getSchema() + ); + const ctx = createCtx({ sharedSchemas: new Map(Object.entries(parsed.schemas)) }); + expect(() => processDiscriminator(ctx, parsed)).toThrow( + 'When using schema.discriminator ensure that every entry schema has an ID.' + ); + }); + test('other schema has no ID', () => { + const parsed = joi2JsonInternal( + schema + .discriminatedUnion('type', [ + schema.object( + { type: schema.literal('str'), value: schema.string() }, + { meta: { id: 'my-str-my-team' } } + ), + schema.object({ type: schema.literal('num'), value: schema.number() }), + ]) + .getSchema() + ); + const ctx = createCtx({ sharedSchemas: new Map(Object.entries(parsed.schemas)) }); + expect(() => processDiscriminator(ctx, parsed)).toThrow( + 'When using schema.discriminator ensure that every entry schema has an ID.' + ); + }); +}); + +it.each([ + schema.oneOf([ + schema.object({ type: schema.literal('str'), value: schema.string() }), + schema.object({ type: schema.literal('num'), value: schema.number() }), + ]), + schema.union([ + schema.object({ type: schema.literal('str'), value: schema.string() }), + schema.object({ type: schema.literal('num'), value: schema.number() }), + ]), +])('does not alter other union types %#', (inputSchema) => { + const ctx = createCtx(); + const parsed = joi2JsonInternal(inputSchema.getSchema()); + const parsedCopy = cloneDeep(parsed); + processDiscriminator(ctx, parsedCopy); + expect(parsedCopy).toEqual(parsed); +}); diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/discriminator.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/discriminator.ts new file mode 100644 index 0000000000000..90233d458a717 --- /dev/null +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/discriminator.ts @@ -0,0 +1,78 @@ +/* + * 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 { metaFields } from '@kbn/config-schema'; +import dedent from 'dedent'; +import type { OpenAPIV3 } from 'openapi-types'; +import { deleteField } from './utils'; +import type { IContext } from '../context'; +import { isReferenceObject } from '../../../common'; + +const { META_FIELD_X_OAS_DISCRIMINATOR, META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE } = metaFields; + +export const processDiscriminator = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => { + const firstSchema = isReferenceObject(schema.anyOf?.[0]) + ? ctx.derefSharedSchema(schema.anyOf?.[0].$ref) + : schema.anyOf?.[0]; + if (!firstSchema) return; + if (!(META_FIELD_X_OAS_DISCRIMINATOR in firstSchema)) return; + + if (!schema.anyOf?.every((entry) => isReferenceObject(entry))) { + throw new Error( + dedent`When using schema.discriminator ensure that every entry schema has an ID. + + IDs must be short and **globally** unique to your schema instance. Consider a common post-fix to guarantee uniqueness like: "my-schema-my-team" (no '.' are allowed in IDs) + + For example: + + schema.discriminatedUnion('type', [ + schema.object( + { type: schema.literal('str'), value: schema.string() }, + { meta: { id: 'my-str-my-team' } } + ), + schema.object( + { type: schema.literal('num'), value: schema.number() }, + { meta: { id: 'my-num-my-team' } } + ), + ]), + + Otherwise we cannot generate OAS for this schema. + + Debug details: expected reference object, got ${JSON.stringify(schema)}.` + ); + } + + const propertyName = firstSchema[ + META_FIELD_X_OAS_DISCRIMINATOR as keyof OpenAPIV3.SchemaObject + ] as string; + + schema.discriminator = { propertyName }; + deleteField(firstSchema, META_FIELD_X_OAS_DISCRIMINATOR); + + schema.oneOf = schema.anyOf; + deleteField(schema, 'anyOf'); + + let catchAllIdx = -1; + + ((schema.oneOf ?? []) as OpenAPIV3.ReferenceObject[]).forEach((entry, idx) => { + const sharedSchema = ctx.derefSharedSchema(entry.$ref); + if (!sharedSchema) + throw new Error( + `Shared schema ${entry.$ref} not found. This is likely a bug in the OAS generator.` + ); + if (META_FIELD_X_OAS_DISCRIMINATOR_DEFAULT_CASE in sharedSchema) { + catchAllIdx = idx; + return; + } + }); + + if (catchAllIdx > -1) { + schema.oneOf?.splice(catchAllIdx, 1); + } +}; diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/index.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/index.ts index 7a44f0fc2de82..62e07de12aa0f 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/index.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/index.ts @@ -72,3 +72,5 @@ export const processAnyType = (schema: OpenAPIV3.SchemaObject): void => { export { processObject } from './object'; export { processEnum } from './enum'; + +export { processDiscriminator } from './discriminator'; diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/utils.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/utils.ts index abce6d470a35d..655ad21192e1c 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/utils.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/oas_converter/kbn_config_schema/post_process_mutations/mutations/utils.ts @@ -50,3 +50,8 @@ export const deleteField = (schema: object, field: string): void => { export const isAnyType = (schema: OpenAPIV3.SchemaObject): boolean => { return metaFields.META_FIELD_X_OAS_ANY in schema; }; + +/** Assumes ref is in the form of "#/components/schemas/my-schema-my-team" */ +export const getIdFromRefString = (ref: string): string => { + return ref.split('/').pop()!; +}; diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.test.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.test.ts index 1622b1a9a11f3..0dc1b2f82988c 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.test.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.test.ts @@ -19,119 +19,14 @@ import { schema } from '@kbn/config-schema'; import type { CoreVersionedRouter } from '@kbn/core-http-router-server-internal'; import { get } from 'lodash'; import { OasConverter } from './oas_converter'; -import { - extractVersionedRequestBodies, - extractVersionedResponses, - processVersionedRouter, -} from './process_versioned_router'; +import { processVersionedRouter } from './process_versioned_router'; import type { VersionedRouterRoute } from '@kbn/core-http-server'; import { createOpIdGenerator, setXState } from './util'; -let oasConverter: OasConverter; -beforeEach(() => { - oasConverter = new OasConverter(); -}); - afterEach(() => { jest.clearAllMocks(); }); -describe('extractVersionedRequestBodies', () => { - test('handles full request config as expected', () => { - expect( - extractVersionedRequestBodies(createInternalTestRoute(), oasConverter, ['application/json']) - ).toEqual({ - 'application/json; Elastic-Api-Version=1': { - schema: { - additionalProperties: false, - properties: { - foo: { - type: 'string', - }, - }, - required: ['foo'], - type: 'object', - }, - }, - 'application/json; Elastic-Api-Version=2': { - schema: { - additionalProperties: false, - properties: { - foo2: { - type: 'string', - }, - }, - required: ['foo2'], - type: 'object', - }, - }, - }); - }); -}); - -describe('extractVersionedResponses', () => { - test('handles full response config as expected', () => { - expect( - extractVersionedResponses(createInternalTestRoute(), oasConverter, ['application/test+json']) - ).toEqual({ - 200: { - description: 'OK response 1\nOK response 2', // merge multiple version descriptions - content: { - 'application/test+json; Elastic-Api-Version=1': { - schema: { - type: 'object', - additionalProperties: false, - properties: { - bar: { type: 'number', minimum: 1, maximum: 99 }, - }, - required: ['bar'], - }, - }, - 'application/test+json; Elastic-Api-Version=2': { - schema: { - type: 'object', - additionalProperties: false, - properties: { - bar2: { type: 'number', minimum: 1, maximum: 99 }, - }, - required: ['bar2'], - }, - }, - }, - }, - 404: { - description: 'Not Found response 1', - content: { - 'application/test2+json; Elastic-Api-Version=1': { - schema: { - type: 'object', - additionalProperties: false, - properties: { - ok: { type: 'boolean', enum: [false] }, - }, - required: ['ok'], - }, - }, - }, - }, - 500: { - content: { - 'application/test2+json; Elastic-Api-Version=2': { - schema: { - type: 'object', - additionalProperties: false, - properties: { - ok: { type: 'boolean', enum: [false] }, - }, - required: ['ok'], - }, - }, - }, - }, - }); - }); -}); - describe('processVersionedRouter', () => { it('correctly extracts the version based on the version filter', async () => { const baseCase = await processVersionedRouter({ @@ -262,70 +157,3 @@ const createTestRoute: () => VersionedRouterRoute = () => ({ }, ], }); - -const createInternalTestRoute: () => VersionedRouterRoute = () => ({ - path: '/foo', - method: 'get', - isVersioned: true, - options: { - access: 'internal', - deprecated: true, - discontinued: 'discontinued versioned router', - options: { body: { access: ['application/test+json'] } as any }, - security: { - authz: { - requiredPrivileges: ['manage_spaces'], - }, - }, - description: 'This is a test route description.', - }, - handlers: [ - { - fn: jest.fn(), - options: { - version: '1', - validate: () => ({ - request: { - body: schema.object({ foo: schema.string() }), - }, - response: { - 200: { - description: 'OK response 1', - bodyContentType: 'application/test+json', - body: () => schema.object({ bar: schema.number({ min: 1, max: 99 }) }), - }, - 404: { - description: 'Not Found response 1', - bodyContentType: 'application/test2+json', - body: () => schema.object({ ok: schema.literal(false) }), - }, - unsafe: { body: false }, - }, - }), - }, - }, - { - fn: jest.fn(), - options: { - version: '2', - validate: () => ({ - request: { - body: schema.object({ foo2: schema.string() }), - }, - response: { - 200: { - description: 'OK response 2', - bodyContentType: 'application/test+json', - body: () => schema.object({ bar2: schema.number({ min: 1, max: 99 }) }), - }, - 500: { - bodyContentType: 'application/test2+json', - body: () => schema.object({ ok: schema.literal(false) }), - }, - unsafe: { body: false }, - }, - }), - }, - }, - ], -}); diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.ts index 81cf778049690..93f43d0e38138 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/process_versioned_router.ts @@ -30,7 +30,6 @@ import { getXsrfHeaderForMethod, setXState, } from './util'; -import { isReferenceObject } from './oas_converter/common'; import { mergeOperation } from './merge_operation'; export interface ProcessVersionedRouterOptions { @@ -117,7 +116,7 @@ export const processVersionedRouter = async ({ const contentType = extractContentType(route.options.options?.body); // If any handler is deprecated we show deprecated: true in the spec const hasDeprecations = route.handlers.some(({ options }) => !!options.options?.deprecated); - const hasVersionFilter = Boolean(filters?.version); + const operationId = getOpId({ path: route.path, method: route.method }); const operation: OpenAPIV3.OperationObject = { summary: route.options.summary ?? '', tags: route.options.options?.tags ? extractTags(route.options.options.tags) : [], @@ -126,16 +125,17 @@ export const processVersionedRouter = async ({ ...(route.options.discontinued ? { 'x-discontinued': route.options.discontinued } : {}), requestBody: hasBody ? { - content: hasVersionFilter - ? extractVersionedRequestBody(handler, route.options.access, converter, contentType) - : extractVersionedRequestBodies(route, converter, contentType), + content: extractVersionedRequestBody( + handler, + route.options.access, + converter, + contentType + ), } : undefined, - responses: hasVersionFilter - ? extractVersionedResponse(handler, route.options.access, converter, contentType) - : extractVersionedResponses(route, converter, contentType), + responses: extractVersionedResponse(handler, route, converter, contentType, operationId), parameters, - operationId: getOpId({ path: route.path, method: route.method }), + operationId, }; setXState(route.options.options?.availability, operation, env); @@ -158,19 +158,6 @@ export const processVersionedRouter = async ({ return { paths }; }; -export const extractVersionedRequestBodies = ( - route: VersionedRouterRoute, - converter: OasConverter, - contentType: string[] -): OpenAPIV3.RequestBodyObject['content'] => { - return route.handlers.reduce((acc, handler) => { - return { - ...acc, - ...extractVersionedRequestBody(handler, route.options.access, converter, contentType), - }; - }, {}); -}; - export const extractVersionedRequestBody = ( handler: VersionedRouterRoute['handlers'][0], access: 'public' | 'internal', @@ -189,9 +176,10 @@ export const extractVersionedRequestBody = ( export const extractVersionedResponse = ( handler: VersionedRouterRoute['handlers'][0], - access: 'public' | 'internal', + route: VersionedRouterRoute, converter: OasConverter, - contentType: string[] + contentType: string[], + operationId: string ) => { const schemas = extractValidationSchemaFromVersionedHandler(handler); if (!schemas?.response) return {}; @@ -204,7 +192,7 @@ export const extractVersionedResponse = ( const schema = converter.convert(maybeSchema); const contentTypeString = getVersionedContentTypeString( handler.options.version, - access, + route.options.access, responseSchema.bodyContentType ? [responseSchema.bodyContentType] : contentType ); newContent = { @@ -225,49 +213,6 @@ export const extractVersionedResponse = ( return result; }; -const mergeDescriptions = ( - existing: undefined | string, - toAppend: OpenAPIV3.ResponsesObject[string] -): string | undefined => { - if (!isReferenceObject(toAppend) && toAppend.description) { - return existing?.length ? `${existing}\n${toAppend.description}` : toAppend.description; - } - return existing; -}; - -const mergeVersionedResponses = (a: OpenAPIV3.ResponsesObject, b: OpenAPIV3.ResponsesObject) => { - const result: OpenAPIV3.ResponsesObject = Object.assign({}, a); - for (const [statusCode, responseContent] of Object.entries(b)) { - const existing = (result[statusCode] as OpenAPIV3.ResponseObject) ?? {}; - result[statusCode] = { - ...result[statusCode], - description: mergeDescriptions(existing.description, responseContent)!, - content: Object.assign( - {}, - existing.content, - (responseContent as OpenAPIV3.ResponseObject).content - ), - }; - } - return result; -}; - -export const extractVersionedResponses = ( - route: VersionedRouterRoute, - converter: OasConverter, - contentType: string[] -): OpenAPIV3.ResponsesObject => { - return route.handlers.reduce((acc, handler) => { - const responses = extractVersionedResponse( - handler, - route.options.access, - converter, - contentType - ); - return mergeVersionedResponses(acc, responses); - }, {}); -}; - const extractValidationSchemaFromVersionedHandler = ( handler: VersionedRouterRoute['handlers'][0] ) => { diff --git a/src/platform/packages/shared/kbn-router-to-openapispec/src/type.ts b/src/platform/packages/shared/kbn-router-to-openapispec/src/type.ts index 4b4a7c094fba5..c2214e92c6049 100644 --- a/src/platform/packages/shared/kbn-router-to-openapispec/src/type.ts +++ b/src/platform/packages/shared/kbn-router-to-openapispec/src/type.ts @@ -14,6 +14,10 @@ export interface KnownParameters { [paramName: string]: { optional: boolean }; } +export interface ConvertOptions { + sharedSchemas?: Map; +} + export interface OpenAPIConverter { convertPathParameters( schema: unknown, @@ -28,7 +32,10 @@ export interface OpenAPIConverter { shared: { [key: string]: OpenAPIV3.SchemaObject }; }; - convert(schema: unknown): { + convert( + schema: unknown, + opts?: ConvertOptions + ): { schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject; shared: { [key: string]: OpenAPIV3.SchemaObject }; };