[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:
Jean-Louis Leysens 2024-05-23 09:50:38 +02:00 committed by GitHub
parent 8550c2c30c
commit 1cc878f41d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 399 additions and 276 deletions

View file

@ -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.`,
},
}

View file

@ -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,

View file

@ -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;

View file

@ -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';

View 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]');
});
});

View 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}`));
}
}

View file

@ -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({});

View file

@ -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;

View file

@ -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', () => {

View file

@ -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 });
}

View file

@ -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 {

View file

@ -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', () => {

View file

@ -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);

View file

@ -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(),
};
};

View file

@ -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);
});
});

View file

@ -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 };
};

View file

@ -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: {} },
});
});

View file

@ -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());
}
}

View file

@ -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;
}

View file

@ -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'],
});
});

View file

@ -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 => {

View file

@ -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'],
});
});

View file

@ -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);
};

View file

@ -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',
},
},
});
});

View file

@ -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;
}
};

View file

@ -17,6 +17,5 @@
"@kbn/core-http-router-server-internal",
"@kbn/config-schema",
"@kbn/core-http-server",
"@kbn/safer-lodash-set",
]
}