mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[OAS] Support lazy runtime types (#184000)
## Summary Close https://github.com/elastic/kibana/issues/182910 Add the ability to declare recursive schemas. Updates `@kbn/config-schema` to support recursive types. This design follows the underlying pattern provided by Joi: https://joi.dev/api/?v=17.13.0#linkref: ```ts const id = 'recursive'; const recursiveSchema: Type<RecursiveType> = schema.object( { name: schema.string(), self: schema.lazy<RecursiveType>(id), }, { meta: { id } } ); ``` Since using the `.link` API we are also using `.id` which enables us to leverage this mechanism OOTB with `joi-to-json` for OAS generation (thus we could delete a lot of code there). I chose to avoid using `id` originally because I thought it would be simpler if we control more of the conversion in config-schema's case but for recursive schemas and references I think this is a favourable trade off. Open to other ideas! --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
8550c2c30c
commit
1cc878f41d
26 changed files with 399 additions and 276 deletions
|
@ -146,7 +146,7 @@ const fullStatusResponse: () => Type<Omit<StatusResponse, 'metrics'>> = () =>
|
|||
},
|
||||
{
|
||||
meta: {
|
||||
id: 'core.status.response',
|
||||
id: 'core_status_response',
|
||||
description: `Kibana's operational status as well as a detailed breakdown of plugin statuses indication of various loads (like event loop utilization and network traffic) at time of request.`,
|
||||
},
|
||||
}
|
||||
|
@ -163,7 +163,7 @@ const redactedStatusResponse = () =>
|
|||
},
|
||||
{
|
||||
meta: {
|
||||
id: 'core.status.redactedResponse',
|
||||
id: 'core_status_redactedResponse',
|
||||
description: `A minimal representation of Kibana's operational status.`,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ import {
|
|||
URIType,
|
||||
StreamType,
|
||||
UnionTypeOptions,
|
||||
Lazy,
|
||||
} from './src/types';
|
||||
|
||||
export type { AnyType, ConditionalType, TypeOf, Props, SchemaStructureEntry, NullableProps };
|
||||
|
@ -216,6 +217,13 @@ function conditional<A extends ConditionalTypeValue, B, C>(
|
|||
return new ConditionalType(leftOperand, rightOperand, equalType, notEqualType, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Useful for creating recursive schemas.
|
||||
*/
|
||||
function lazy<T>(id: string) {
|
||||
return new Lazy<T>(id);
|
||||
}
|
||||
|
||||
export const schema = {
|
||||
any,
|
||||
arrayOf,
|
||||
|
@ -226,6 +234,7 @@ export const schema = {
|
|||
contextRef,
|
||||
duration,
|
||||
ip,
|
||||
lazy,
|
||||
literal,
|
||||
mapOf,
|
||||
maybe,
|
||||
|
@ -245,7 +254,6 @@ export type Schema = typeof schema;
|
|||
|
||||
import {
|
||||
META_FIELD_X_OAS_ANY,
|
||||
META_FIELD_X_OAS_REF_ID,
|
||||
META_FIELD_X_OAS_OPTIONAL,
|
||||
META_FIELD_X_OAS_DEPRECATED,
|
||||
META_FIELD_X_OAS_MAX_LENGTH,
|
||||
|
@ -255,7 +263,6 @@ import {
|
|||
|
||||
export const metaFields = Object.freeze({
|
||||
META_FIELD_X_OAS_ANY,
|
||||
META_FIELD_X_OAS_REF_ID,
|
||||
META_FIELD_X_OAS_OPTIONAL,
|
||||
META_FIELD_X_OAS_DEPRECATED,
|
||||
META_FIELD_X_OAS_MAX_LENGTH,
|
||||
|
|
|
@ -15,6 +15,5 @@ export const META_FIELD_X_OAS_MIN_LENGTH = 'x-oas-min-length' as const;
|
|||
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_REF_ID = 'x-oas-ref-id' as const;
|
||||
export const META_FIELD_X_OAS_DEPRECATED = 'x-oas-deprecated' as const;
|
||||
export const META_FIELD_X_OAS_ANY = 'x-oas-any-type' as const;
|
||||
|
|
|
@ -40,3 +40,4 @@ export { URIType } from './uri_type';
|
|||
export { NeverType } from './never_type';
|
||||
export type { IpOptions } from './ip_type';
|
||||
export { IpType } from './ip_type';
|
||||
export { Lazy } from './lazy';
|
||||
|
|
71
packages/kbn-config-schema/src/types/lazy.test.ts
Normal file
71
packages/kbn-config-schema/src/types/lazy.test.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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 { Type, schema } from '../..';
|
||||
|
||||
interface RecursiveType {
|
||||
name: string;
|
||||
self: undefined | RecursiveType;
|
||||
}
|
||||
|
||||
// Test our recursive type inference
|
||||
{
|
||||
const id = 'recursive';
|
||||
// @ts-expect-error
|
||||
const testObject: Type<RecursiveType> = schema.object(
|
||||
{
|
||||
name: schema.string(),
|
||||
notSelf: schema.lazy<RecursiveType>(id), // this declaration should fail
|
||||
},
|
||||
{ meta: { id } }
|
||||
);
|
||||
}
|
||||
|
||||
describe('lazy', () => {
|
||||
const id = 'recursive';
|
||||
const object = schema.object(
|
||||
{
|
||||
name: schema.string(),
|
||||
self: schema.lazy<RecursiveType>(id),
|
||||
},
|
||||
{ meta: { id } }
|
||||
);
|
||||
|
||||
it('allows recursive runtime types to be defined', () => {
|
||||
const self: RecursiveType = {
|
||||
name: 'self1',
|
||||
self: {
|
||||
name: 'self2',
|
||||
self: {
|
||||
name: 'self3',
|
||||
self: {
|
||||
name: 'self4',
|
||||
self: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { value, error } = object.getSchema().validate(self);
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).toEqual(self);
|
||||
});
|
||||
|
||||
it('detects invalid recursive types as expected', () => {
|
||||
const invalidSelf = {
|
||||
name: 'self1',
|
||||
self: {
|
||||
name: 123,
|
||||
self: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const { error, value } = object.getSchema().validate(invalidSelf);
|
||||
expect(value).toEqual(invalidSelf);
|
||||
expect(error?.message).toBe('expected value of type [string] but got [number]');
|
||||
});
|
||||
});
|
19
packages/kbn-config-schema/src/types/lazy.ts
Normal file
19
packages/kbn-config-schema/src/types/lazy.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { Type } from './type';
|
||||
import { internals } from '../internals';
|
||||
|
||||
/**
|
||||
* Use this type to construct recursive runtime schemas.
|
||||
*/
|
||||
export class Lazy<T> extends Type<T> {
|
||||
constructor(id: string) {
|
||||
super(internals.link(`#${id}`));
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { get } from 'lodash';
|
||||
import { expectType } from 'tsd';
|
||||
import { schema } from '../..';
|
||||
import { TypeOf } from './object_type';
|
||||
|
@ -21,6 +22,16 @@ test('returns value by default', () => {
|
|||
expect(type.validate(value)).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
test('meta', () => {
|
||||
const type = schema.object(
|
||||
{
|
||||
name: schema.string(),
|
||||
},
|
||||
{ meta: { id: 'test_id' } }
|
||||
);
|
||||
expect(get(type.getSchema().describe(), 'flags.id')).toEqual('test_id');
|
||||
});
|
||||
|
||||
test('returns empty object if undefined', () => {
|
||||
const type = schema.object({});
|
||||
expect(type.validate(undefined)).toEqual({});
|
||||
|
|
|
@ -69,8 +69,16 @@ interface UnknownOptions {
|
|||
unknowns?: OptionsForUnknowns;
|
||||
}
|
||||
|
||||
interface ObjectTypeOptionsMeta {
|
||||
/**
|
||||
* A string that uniquely identifies this schema. Used when generating OAS
|
||||
* to create refs instead of inline schemas.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export type ObjectTypeOptions<P extends Props = any> = TypeOptions<ObjectResultType<P>> &
|
||||
UnknownOptions;
|
||||
UnknownOptions & { meta?: TypeOptions<ObjectResultType<P>>['meta'] & ObjectTypeOptionsMeta };
|
||||
|
||||
export class ObjectType<P extends Props = any> extends Type<ObjectResultType<P>> {
|
||||
private props: P;
|
||||
|
@ -83,7 +91,7 @@ export class ObjectType<P extends Props = any> extends Type<ObjectResultType<P>>
|
|||
for (const [key, value] of Object.entries(props)) {
|
||||
schemaKeys[key] = value.getSchema();
|
||||
}
|
||||
const schema = internals
|
||||
let schema = internals
|
||||
.object()
|
||||
.keys(schemaKeys)
|
||||
.default()
|
||||
|
@ -91,6 +99,10 @@ export class ObjectType<P extends Props = any> extends Type<ObjectResultType<P>>
|
|||
.unknown(unknowns === 'allow')
|
||||
.options({ stripUnknown: { objects: unknowns === 'ignore' } });
|
||||
|
||||
if (options.meta?.id) {
|
||||
schema = schema.id(options.meta.id);
|
||||
}
|
||||
|
||||
super(schema, typeOptions);
|
||||
this.props = props;
|
||||
this.propSchemas = schemaKeys;
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import { get } from 'lodash';
|
||||
import { internals } from '../internals';
|
||||
import { Type, TypeOptions } from './type';
|
||||
import { META_FIELD_X_OAS_REF_ID, META_FIELD_X_OAS_DEPRECATED } from '../oas_meta_fields';
|
||||
import { META_FIELD_X_OAS_DEPRECATED } from '../oas_meta_fields';
|
||||
|
||||
class MyType extends Type<any> {
|
||||
constructor(opts: TypeOptions<any> = {}) {
|
||||
|
@ -20,12 +20,11 @@ class MyType extends Type<any> {
|
|||
describe('meta', () => {
|
||||
it('sets meta when provided', () => {
|
||||
const type = new MyType({
|
||||
meta: { description: 'my description', id: 'foo', deprecated: true },
|
||||
meta: { description: 'my description', deprecated: true },
|
||||
});
|
||||
const meta = type.getSchema().describe();
|
||||
expect(get(meta, 'flags.description')).toBe('my description');
|
||||
expect(get(meta, `metas[0].${META_FIELD_X_OAS_REF_ID}`)).toBe('foo');
|
||||
expect(get(meta, `metas[1].${META_FIELD_X_OAS_DEPRECATED}`)).toBe(true);
|
||||
expect(get(meta, `metas[0].${META_FIELD_X_OAS_DEPRECATED}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set meta when no provided', () => {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { AnySchema, CustomValidator, ErrorReport } from 'joi';
|
||||
import { META_FIELD_X_OAS_DEPRECATED, META_FIELD_X_OAS_REF_ID } from '../oas_meta_fields';
|
||||
import { META_FIELD_X_OAS_DEPRECATED } from '../oas_meta_fields';
|
||||
import { SchemaTypeError, ValidationError } from '../errors';
|
||||
import { Reference } from '../references';
|
||||
|
||||
|
@ -24,11 +24,6 @@ export interface TypeMeta {
|
|||
* Whether this field is deprecated.
|
||||
*/
|
||||
deprecated?: boolean;
|
||||
/**
|
||||
* A string that uniquely identifies this schema. Used when generating OAS
|
||||
* to create refs instead of inline schemas.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface TypeOptions<T> {
|
||||
|
@ -112,9 +107,6 @@ export abstract class Type<V> {
|
|||
if (options.meta.description) {
|
||||
schema = schema.description(options.meta.description);
|
||||
}
|
||||
if (options.meta.id) {
|
||||
schema = schema.meta({ [META_FIELD_X_OAS_REF_ID]: options.meta.id });
|
||||
}
|
||||
if (options.meta.deprecated) {
|
||||
schema = schema.meta({ [META_FIELD_X_OAS_DEPRECATED]: true });
|
||||
}
|
||||
|
|
|
@ -3,12 +3,7 @@
|
|||
exports[`generateOpenApiDocument @kbn/config-schema generates references in the expected format 1`] = `
|
||||
Object {
|
||||
"components": Object {
|
||||
"schemas": Object {
|
||||
"my.name": Object {
|
||||
"minLength": 1,
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"schemas": Object {},
|
||||
"securitySchemes": Object {
|
||||
"apiKeyAuth": Object {
|
||||
"in": "header",
|
||||
|
@ -46,7 +41,7 @@ Object {
|
|||
},
|
||||
},
|
||||
Object {
|
||||
"description": undefined,
|
||||
"description": "test",
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
|
@ -63,7 +58,8 @@ Object {
|
|||
"additionalProperties": false,
|
||||
"properties": Object {
|
||||
"name": Object {
|
||||
"$ref": "#/components/schemas/my.name",
|
||||
"minLength": 1,
|
||||
"type": "string",
|
||||
},
|
||||
"other": Object {
|
||||
"type": "string",
|
||||
|
@ -368,6 +364,105 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`generateOpenApiDocument @kbn/config-schema handles recursive schemas 1`] = `
|
||||
Object {
|
||||
"components": Object {
|
||||
"schemas": Object {
|
||||
"recursive": Object {
|
||||
"additionalProperties": false,
|
||||
"properties": Object {
|
||||
"name": Object {
|
||||
"type": "string",
|
||||
},
|
||||
"self": Object {
|
||||
"$ref": "#/components/schemas/recursive",
|
||||
},
|
||||
},
|
||||
"required": Array [
|
||||
"name",
|
||||
"self",
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
"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 {
|
||||
"/recursive": Object {
|
||||
"get": Object {
|
||||
"operationId": "/recursive#0",
|
||||
"parameters": Array [
|
||||
Object {
|
||||
"description": "The version of the API to use",
|
||||
"in": "header",
|
||||
"name": "elastic-api-version",
|
||||
"schema": Object {
|
||||
"default": "2023-10-31",
|
||||
"enum": Array [
|
||||
"2023-10-31",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"requestBody": Object {
|
||||
"content": Object {
|
||||
"application/json; Elastic-Api-Version=2023-10-31": Object {
|
||||
"schema": Object {
|
||||
"$ref": "#/components/schemas/recursive",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": Object {
|
||||
"200": Object {
|
||||
"content": Object {
|
||||
"application/json; Elastic-Api-Version=2023-10-31": Object {
|
||||
"schema": Object {
|
||||
"maxLength": 10,
|
||||
"minLength": 1,
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "No description",
|
||||
},
|
||||
},
|
||||
"summary": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
"security": Array [
|
||||
Object {
|
||||
"basicAuth": Array [],
|
||||
},
|
||||
],
|
||||
"servers": Array [
|
||||
Object {
|
||||
"url": "https://test.oas",
|
||||
},
|
||||
],
|
||||
"tags": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`generateOpenApiDocument unknown schema/validation produces the expected output 1`] = `
|
||||
Object {
|
||||
"components": Object {
|
||||
|
|
|
@ -7,9 +7,14 @@
|
|||
*/
|
||||
|
||||
import { generateOpenApiDocument } from './generate_oas';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { schema, Type } from '@kbn/config-schema';
|
||||
import { createTestRouters, createRouter, createVersionedRouter } from './generate_oas.test.util';
|
||||
|
||||
interface RecursiveType {
|
||||
name: string;
|
||||
self: undefined | RecursiveType;
|
||||
}
|
||||
|
||||
describe('generateOpenApiDocument', () => {
|
||||
describe('@kbn/config-schema', () => {
|
||||
it('generates the expected OpenAPI document', () => {
|
||||
|
@ -30,8 +35,8 @@ describe('generateOpenApiDocument', () => {
|
|||
});
|
||||
|
||||
it('generates references in the expected format', () => {
|
||||
const sharedIdSchema = schema.string({ minLength: 1, meta: { id: 'my.id' } });
|
||||
const sharedNameSchema = schema.string({ minLength: 1, meta: { id: 'my.name' } });
|
||||
const sharedIdSchema = schema.string({ minLength: 1, meta: { description: 'test' } });
|
||||
const sharedNameSchema = schema.string({ minLength: 1 });
|
||||
const otherSchema = schema.object({ name: sharedNameSchema, other: schema.string() });
|
||||
expect(
|
||||
generateOpenApiDocument(
|
||||
|
@ -70,6 +75,52 @@ describe('generateOpenApiDocument', () => {
|
|||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('handles recursive schemas', () => {
|
||||
const id = 'recursive';
|
||||
const recursiveSchema: Type<RecursiveType> = schema.object(
|
||||
{
|
||||
name: schema.string(),
|
||||
self: schema.lazy<RecursiveType>(id),
|
||||
},
|
||||
{ meta: { id } }
|
||||
);
|
||||
expect(
|
||||
generateOpenApiDocument(
|
||||
{
|
||||
routers: [
|
||||
createRouter({
|
||||
routes: [
|
||||
{
|
||||
isVersioned: false,
|
||||
path: '/recursive',
|
||||
method: 'get',
|
||||
validationSchemas: {
|
||||
request: {
|
||||
body: recursiveSchema,
|
||||
},
|
||||
response: {
|
||||
[200]: {
|
||||
body: () => schema.string({ maxLength: 10, minLength: 1 }),
|
||||
},
|
||||
},
|
||||
},
|
||||
options: { tags: ['foo'] },
|
||||
handler: jest.fn(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
versionedRouters: [],
|
||||
},
|
||||
{
|
||||
title: 'test',
|
||||
baseUrl: 'https://test.oas',
|
||||
version: '99.99.99',
|
||||
}
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown schema/validation', () => {
|
||||
|
|
|
@ -6,11 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema, metaFields } from '@kbn/config-schema';
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
import { omit } from 'lodash';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { is, tryConvertToRef, isNullableObjectType } from './lib';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { is, isNullableObjectType } from './lib';
|
||||
|
||||
describe('is', () => {
|
||||
test.each([
|
||||
|
@ -34,27 +31,6 @@ describe('is', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('tryConvertToRef', () => {
|
||||
const schemaObject: OpenAPIV3.SchemaObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
set(schemaObject, metaFields.META_FIELD_X_OAS_REF_ID, 'foo');
|
||||
expect(tryConvertToRef(schemaObject)).toEqual({
|
||||
idSchema: ['foo', { type: 'object', properties: { a: { type: 'string' } } }],
|
||||
ref: {
|
||||
$ref: '#/components/schemas/foo',
|
||||
},
|
||||
});
|
||||
|
||||
const schemaObject2 = omit(schemaObject, metaFields.META_FIELD_X_OAS_REF_ID);
|
||||
expect(tryConvertToRef(schemaObject2)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('isNullableObjectType', () => {
|
||||
const any = schema.any({});
|
||||
expect(isNullableObjectType(any.getSchema().describe())).toBe(false);
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import joi from 'joi';
|
||||
import { isConfigSchema, Type, metaFields } from '@kbn/config-schema';
|
||||
import { isConfigSchema, Type } from '@kbn/config-schema';
|
||||
import { get } from 'lodash';
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import type { KnownParameters } from '../../type';
|
||||
|
@ -16,38 +16,6 @@ import { parse } from './parse';
|
|||
|
||||
import { createCtx, IContext } from './post_process_mutations';
|
||||
|
||||
export const getSharedComponentId = (schema: OpenAPIV3.SchemaObject) => {
|
||||
if (metaFields.META_FIELD_X_OAS_REF_ID in schema) {
|
||||
return schema[metaFields.META_FIELD_X_OAS_REF_ID] as string;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeSharedComponentId = (
|
||||
schema: OpenAPIV3.SchemaObject & { [metaFields.META_FIELD_X_OAS_REF_ID]?: string }
|
||||
) => {
|
||||
const { [metaFields.META_FIELD_X_OAS_REF_ID]: id, ...rest } = schema;
|
||||
return rest;
|
||||
};
|
||||
|
||||
export const sharedComponentIdToRef = (id: string): OpenAPIV3.ReferenceObject => {
|
||||
return {
|
||||
$ref: `#/components/schemas/${id}`,
|
||||
};
|
||||
};
|
||||
|
||||
type IdSchemaTuple = [id: string, schema: OpenAPIV3.SchemaObject];
|
||||
|
||||
export const tryConvertToRef = (schema: OpenAPIV3.SchemaObject) => {
|
||||
const sharedId = getSharedComponentId(schema);
|
||||
if (sharedId) {
|
||||
const idSchema: IdSchemaTuple = [sharedId, removeSharedComponentId(schema)];
|
||||
return {
|
||||
idSchema,
|
||||
ref: sharedComponentIdToRef(sharedId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const isObjectType = (schema: joi.Schema | joi.Description): boolean => {
|
||||
return schema.type === 'object';
|
||||
};
|
||||
|
@ -100,8 +68,8 @@ export const unwrapKbnConfigSchema = (schema: unknown): joi.Schema => {
|
|||
|
||||
export const convert = (kbnConfigSchema: unknown) => {
|
||||
const schema = unwrapKbnConfigSchema(kbnConfigSchema);
|
||||
const { result, shared } = parse({ schema, ctx: createCtx({ refs: true }) });
|
||||
return { schema: result, shared: Object.fromEntries(shared.entries()) };
|
||||
const { result, shared } = parse({ schema, ctx: createCtx() });
|
||||
return { schema: result, shared };
|
||||
};
|
||||
|
||||
const convertObjectMembersToParameterObjects = (
|
||||
|
@ -152,11 +120,11 @@ const convertObjectMembersToParameterObjects = (
|
|||
|
||||
export const convertQuery = (kbnConfigSchema: unknown) => {
|
||||
const schema = unwrapKbnConfigSchema(kbnConfigSchema);
|
||||
const ctx = createCtx({ refs: false }); // For now context is not shared between body, params and queries
|
||||
const ctx = createCtx();
|
||||
const result = convertObjectMembersToParameterObjects(ctx, schema, {}, false);
|
||||
return {
|
||||
query: result,
|
||||
shared: Object.fromEntries(ctx.sharedSchemas.entries()),
|
||||
shared: ctx.getSharedSchemas(),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -172,7 +140,7 @@ export const convertPathParameters = (
|
|||
const result = convertObjectMembersToParameterObjects(ctx, schema, knownParameters, true);
|
||||
return {
|
||||
params: result,
|
||||
shared: Object.fromEntries(ctx.sharedSchemas.entries()),
|
||||
shared: ctx.getSharedSchemas(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { isJoiToJsonSpecialSchemas, joi2JsonInternal } from './parse';
|
||||
|
||||
describe('isJoiToJsonSpecialSchemas', () => {
|
||||
test.each([
|
||||
[joi2JsonInternal(schema.object({ foo: schema.string() }).getSchema()), false],
|
||||
[
|
||||
joi2JsonInternal(
|
||||
schema.object({ foo: schema.string() }, { meta: { id: 'yes' } }).getSchema()
|
||||
),
|
||||
true,
|
||||
],
|
||||
[{}, false],
|
||||
])('correctly detects special schemas %#', (input, output) => {
|
||||
expect(isJoiToJsonSpecialSchemas(input)).toBe(output);
|
||||
});
|
||||
});
|
|
@ -9,6 +9,7 @@
|
|||
import Joi from 'joi';
|
||||
import joiToJsonParse from 'joi-to-json';
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import { omit } from 'lodash';
|
||||
import { createCtx, postProcessMutations } from './post_process_mutations';
|
||||
import type { IContext } from './post_process_mutations';
|
||||
|
||||
|
@ -17,13 +18,34 @@ interface ParseArgs {
|
|||
ctx?: IContext;
|
||||
}
|
||||
|
||||
export interface JoiToJsonReferenceObject extends OpenAPIV3.BaseSchemaObject {
|
||||
schemas: { [id: string]: OpenAPIV3.SchemaObject };
|
||||
}
|
||||
|
||||
type ParseResult = OpenAPIV3.SchemaObject | JoiToJsonReferenceObject;
|
||||
|
||||
export const isJoiToJsonSpecialSchemas = (
|
||||
parseResult: ParseResult
|
||||
): parseResult is JoiToJsonReferenceObject => {
|
||||
return 'schemas' in parseResult;
|
||||
};
|
||||
|
||||
export const joi2JsonInternal = (schema: Joi.Schema) => {
|
||||
return joiToJsonParse(schema, 'open-api');
|
||||
};
|
||||
|
||||
export const parse = ({ schema, ctx = createCtx() }: ParseArgs) => {
|
||||
const parsed: OpenAPIV3.SchemaObject = joi2JsonInternal(schema);
|
||||
postProcessMutations({ schema: parsed, ctx });
|
||||
const result = ctx.processRef(parsed);
|
||||
return { shared: ctx.sharedSchemas, result };
|
||||
const parsed: ParseResult = joi2JsonInternal(schema);
|
||||
let result: OpenAPIV3.SchemaObject;
|
||||
if (isJoiToJsonSpecialSchemas(parsed)) {
|
||||
Object.entries(parsed.schemas).forEach(([id, s]) => {
|
||||
postProcessMutations({ schema: s, ctx });
|
||||
ctx.addSharedSchema(id, s);
|
||||
});
|
||||
result = omit(parsed, 'schemas');
|
||||
} else {
|
||||
result = parsed;
|
||||
}
|
||||
postProcessMutations({ schema: result, ctx });
|
||||
return { shared: ctx.getSharedSchemas(), result };
|
||||
};
|
||||
|
|
|
@ -6,41 +6,22 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema, metaFields } from '@kbn/config-schema';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { joi2JsonInternal } from '../parse';
|
||||
import { createCtx } from './context';
|
||||
|
||||
it('does not convert and record refs by default', () => {
|
||||
it('records schemas as expected', () => {
|
||||
const ctx = createCtx();
|
||||
const obj = schema.object({}, { meta: { id: 'foo' } });
|
||||
const parsed = joi2JsonInternal(obj.getSchema());
|
||||
const result = ctx.processRef(parsed);
|
||||
expect(result).toMatchObject({ type: 'object', properties: {} });
|
||||
expect(ctx.sharedSchemas.get('foo')).toBeUndefined();
|
||||
expect(metaFields.META_FIELD_X_OAS_REF_ID in result).toBe(false);
|
||||
});
|
||||
|
||||
it('can convert and record refs', () => {
|
||||
const ctx = createCtx({ refs: true });
|
||||
const obj = schema.object({}, { meta: { id: 'foo' } });
|
||||
const parsed = joi2JsonInternal(obj.getSchema());
|
||||
const result = ctx.processRef(parsed);
|
||||
expect(result).toEqual({ $ref: '#/components/schemas/foo' });
|
||||
expect(ctx.sharedSchemas.get('foo')).toMatchObject({ type: 'object', properties: {} });
|
||||
expect(metaFields.META_FIELD_X_OAS_REF_ID in ctx.sharedSchemas.get('foo')!).toBe(false);
|
||||
});
|
||||
|
||||
it('can use provided shared schemas Map', () => {
|
||||
const myMap = new Map<string, any>();
|
||||
const ctx = createCtx({ refs: true, sharedSchemas: myMap });
|
||||
const obj = schema.object({}, { meta: { id: 'foo' } });
|
||||
const parsed = joi2JsonInternal(obj.getSchema());
|
||||
ctx.processRef(parsed);
|
||||
|
||||
const obj2 = schema.object({}, { meta: { id: 'bar' } });
|
||||
const parsed2 = joi2JsonInternal(obj2.getSchema());
|
||||
ctx.processRef(parsed2);
|
||||
|
||||
expect(myMap.get('foo')).toMatchObject({ type: 'object', properties: {} });
|
||||
expect(myMap.get('bar')).toMatchObject({ type: 'object', properties: {} });
|
||||
const objA = schema.object({});
|
||||
const objB = schema.object({});
|
||||
const a = joi2JsonInternal(objA.getSchema());
|
||||
const b = joi2JsonInternal(objB.getSchema());
|
||||
|
||||
ctx.addSharedSchema('a', a);
|
||||
ctx.addSharedSchema('b', b);
|
||||
|
||||
expect(ctx.getSharedSchemas()).toMatchObject({
|
||||
a: { properties: {} },
|
||||
b: { properties: {} },
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,42 +7,27 @@
|
|||
*/
|
||||
|
||||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import { processRef as processRefMutation } from './mutations/ref';
|
||||
import { removeSharedComponentId } from '../lib';
|
||||
|
||||
export interface IContext {
|
||||
sharedSchemas: Map<string, OpenAPIV3.SchemaObject>;
|
||||
/**
|
||||
* Attempt to convert a schema object to ref, my perform side-effect
|
||||
*
|
||||
* Will return the schema sans the ref meta ID if refs are disabled
|
||||
*
|
||||
* @note see also {@link Options['refs']}
|
||||
*/
|
||||
processRef: (
|
||||
schema: OpenAPIV3.SchemaObject
|
||||
) => OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject;
|
||||
addSharedSchema: (id: string, schema: OpenAPIV3.SchemaObject) => void;
|
||||
getSharedSchemas: () => { [id: string]: OpenAPIV3.SchemaObject };
|
||||
}
|
||||
|
||||
interface Options {
|
||||
sharedSchemas?: Map<string, OpenAPIV3.SchemaObject>;
|
||||
refs?: boolean;
|
||||
}
|
||||
|
||||
class Context implements IContext {
|
||||
readonly sharedSchemas: Map<string, OpenAPIV3.SchemaObject>;
|
||||
readonly refs: boolean;
|
||||
private readonly sharedSchemas: Map<string, OpenAPIV3.SchemaObject>;
|
||||
constructor(opts: Options) {
|
||||
this.sharedSchemas = opts.sharedSchemas ?? new Map();
|
||||
this.refs = !!opts.refs;
|
||||
}
|
||||
public processRef(
|
||||
schema: OpenAPIV3.SchemaObject
|
||||
): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject {
|
||||
if (this.refs) {
|
||||
return processRefMutation(this, schema) ?? schema;
|
||||
}
|
||||
return removeSharedComponentId(schema);
|
||||
public addSharedSchema(id: string, schema: OpenAPIV3.SchemaObject): void {
|
||||
this.sharedSchemas.set(id, schema);
|
||||
}
|
||||
|
||||
public getSharedSchemas() {
|
||||
return Object.fromEntries(this.sharedSchemas.entries());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,9 +10,12 @@ import type { OpenAPIV3 } from 'openapi-types';
|
|||
import * as mutations from './mutations';
|
||||
import type { IContext } from './context';
|
||||
import { isAnyType } from './mutations/utils';
|
||||
import { isReferenceObject } from '../../common';
|
||||
|
||||
type Schema = OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject;
|
||||
|
||||
interface PostProcessMutationsArgs {
|
||||
schema: OpenAPIV3.SchemaObject;
|
||||
schema: Schema;
|
||||
ctx: IContext;
|
||||
}
|
||||
|
||||
|
@ -23,7 +26,9 @@ export const postProcessMutations = ({ ctx, schema }: PostProcessMutationsArgs)
|
|||
|
||||
const arrayContainers: Array<keyof OpenAPIV3.SchemaObject> = ['allOf', 'oneOf', 'anyOf'];
|
||||
|
||||
const walkSchema = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
||||
const walkSchema = (ctx: IContext, schema: Schema): void => {
|
||||
if (isReferenceObject(schema)) return;
|
||||
|
||||
if (isAnyType(schema)) {
|
||||
mutations.processAnyType(schema);
|
||||
return;
|
||||
|
@ -41,7 +46,7 @@ const walkSchema = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
|||
walkSchema(ctx, value as OpenAPIV3.SchemaObject);
|
||||
});
|
||||
}
|
||||
mutations.processObject(ctx, schema);
|
||||
mutations.processObject(schema);
|
||||
} else if (type === 'string') {
|
||||
mutations.processString(schema);
|
||||
} else if (type === 'record') {
|
||||
|
@ -57,7 +62,6 @@ const walkSchema = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
|||
if (schema[arrayContainer]) {
|
||||
schema[arrayContainer].forEach((s: OpenAPIV3.SchemaObject, idx: number) => {
|
||||
walkSchema(ctx, s);
|
||||
schema[arrayContainer][idx] = ctx.processRef(s);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -15,18 +15,23 @@ test.each([
|
|||
[
|
||||
'processMap',
|
||||
processMap,
|
||||
schema.mapOf(schema.string(), schema.object({ a: schema.string() }, { meta: { id: 'myRef' } })),
|
||||
schema.mapOf(
|
||||
schema.string(),
|
||||
schema.object({ a: schema.string() }, { meta: { id: 'myRef1' } })
|
||||
),
|
||||
'myRef1',
|
||||
],
|
||||
[
|
||||
'processRecord',
|
||||
processRecord,
|
||||
schema.recordOf(
|
||||
schema.string(),
|
||||
schema.object({ a: schema.string() }, { meta: { id: 'myRef' } })
|
||||
schema.object({ a: schema.string() }, { meta: { id: 'myRef2' } })
|
||||
),
|
||||
'myRef2',
|
||||
],
|
||||
])('%p parses any additional properties specified', (_, processFn, obj) => {
|
||||
const ctx = createCtx({ refs: true });
|
||||
])('%p parses any additional properties specified', (_, processFn, obj, refId) => {
|
||||
const ctx = createCtx();
|
||||
const parsed = joi2JsonInternal(obj.getSchema());
|
||||
|
||||
processFn(ctx, parsed);
|
||||
|
@ -34,17 +39,19 @@ test.each([
|
|||
expect(parsed).toEqual({
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
$ref: '#/components/schemas/myRef',
|
||||
$ref: `#/components/schemas/${refId}`,
|
||||
},
|
||||
});
|
||||
expect(ctx.sharedSchemas.get('myRef')).toEqual({
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
a: {
|
||||
type: 'string',
|
||||
expect(ctx.getSharedSchemas()).toEqual({
|
||||
[refId]: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
a: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['a'],
|
||||
},
|
||||
required: ['a'],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -48,21 +48,11 @@ const processAdditionalProperties = (ctx: IContext, schema: OpenAPIV3.SchemaObje
|
|||
export const processRecord = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
||||
schema.type = 'object';
|
||||
processAdditionalProperties(ctx, schema);
|
||||
if (schema.additionalProperties) {
|
||||
schema.additionalProperties = ctx.processRef(
|
||||
schema.additionalProperties as OpenAPIV3.SchemaObject
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const processMap = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
||||
schema.type = 'object';
|
||||
processAdditionalProperties(ctx, schema);
|
||||
if (schema.additionalProperties) {
|
||||
schema.additionalProperties = ctx.processRef(
|
||||
schema.additionalProperties as OpenAPIV3.SchemaObject
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const processAllTypes = (schema: OpenAPIV3.SchemaObject): void => {
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { joi2JsonInternal } from '../../parse';
|
||||
import { createCtx } from '../context';
|
||||
import { processObject } from './object';
|
||||
|
||||
test.each([
|
||||
|
@ -41,36 +40,6 @@ test.each([
|
|||
],
|
||||
])('processObject %#', (input, result) => {
|
||||
const parsed = joi2JsonInternal(input.getSchema());
|
||||
processObject(createCtx(), parsed);
|
||||
processObject(parsed);
|
||||
expect(parsed).toEqual(result);
|
||||
});
|
||||
|
||||
test('refs', () => {
|
||||
const fooSchema = schema.object({ n: schema.number() }, { meta: { id: 'foo' } });
|
||||
const barSchema = schema.object({ foo: fooSchema, s: schema.string() });
|
||||
const parsed = joi2JsonInternal(barSchema.getSchema());
|
||||
const ctx = createCtx({ refs: true });
|
||||
|
||||
// Simulate us walking the schema
|
||||
processObject(ctx, parsed.properties.foo);
|
||||
|
||||
processObject(ctx, parsed);
|
||||
expect(parsed).toEqual({
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
foo: {
|
||||
$ref: '#/components/schemas/foo',
|
||||
},
|
||||
s: { type: 'string' },
|
||||
},
|
||||
required: ['foo', 's'],
|
||||
});
|
||||
expect(ctx.sharedSchemas.size).toBe(1);
|
||||
expect(ctx.sharedSchemas.get('foo')).toEqual({
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: { n: { type: 'number' } },
|
||||
required: ['n'],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import { metaFields } from '@kbn/config-schema';
|
||||
import { deleteField, stripBadDefault } from './utils';
|
||||
import { IContext } from '../context';
|
||||
|
||||
const { META_FIELD_X_OAS_OPTIONAL } = metaFields;
|
||||
|
||||
|
@ -51,17 +50,8 @@ const removeNeverType = (schema: OpenAPIV3.SchemaObject): void => {
|
|||
}
|
||||
};
|
||||
|
||||
const processObjectRefs = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
||||
if (schema.properties) {
|
||||
Object.keys(schema.properties).forEach((key) => {
|
||||
schema.properties![key] = ctx.processRef(schema.properties![key] as OpenAPIV3.SchemaObject);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const processObject = (ctx: IContext, schema: OpenAPIV3.SchemaObject): void => {
|
||||
export const processObject = (schema: OpenAPIV3.SchemaObject): void => {
|
||||
stripBadDefault(schema);
|
||||
removeNeverType(schema);
|
||||
populateRequiredFields(schema);
|
||||
processObjectRefs(ctx, schema);
|
||||
};
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { createCtx } from '../context';
|
||||
import { joi2JsonInternal } from '../../parse';
|
||||
import { processRef } from './ref';
|
||||
|
||||
test('create a new ref entry', () => {
|
||||
const ctx = createCtx({ refs: true });
|
||||
const obj = schema.object({ a: schema.string() }, { meta: { id: 'id' } });
|
||||
const parsed = joi2JsonInternal(obj.getSchema());
|
||||
const result = processRef(ctx, parsed);
|
||||
expect(result).toEqual({
|
||||
$ref: '#/components/schemas/id',
|
||||
});
|
||||
expect(ctx.sharedSchemas.get('id')).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* 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 type { OpenAPIV3 } from 'openapi-types';
|
||||
import { tryConvertToRef } from '../../lib';
|
||||
import type { IContext } from '../context';
|
||||
|
||||
export const processRef = (ctx: IContext, schema: OpenAPIV3.SchemaObject) => {
|
||||
const result = tryConvertToRef(schema);
|
||||
if (result) {
|
||||
const [id, s] = result.idSchema;
|
||||
ctx.sharedSchemas.set(id, s);
|
||||
return result.ref;
|
||||
}
|
||||
};
|
|
@ -17,6 +17,5 @@
|
|||
"@kbn/core-http-router-server-internal",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/core-http-server",
|
||||
"@kbn/safer-lodash-set",
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue