Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Obs AI Assistant] OpenAPI spec for public API #183151

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/kbn-io-ts-utils/src/parseable_types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type ParseableType =
| t.PartialType<t.Props>
| t.UnionType<t.Mixed[]>
| t.IntersectionType<t.Mixed[]>
| t.LiteralType<string | boolean | number>
| MergeType<t.Mixed, t.Mixed>;

const parseableTags = [
Expand All @@ -32,6 +33,7 @@ const parseableTags = [
'UnionType',
'IntersectionType',
'MergeType',
'LiteralType',
];

export const isParsableType = (type: t.Type<any> | ParseableType): type is ParseableType => {
Expand Down
101 changes: 97 additions & 4 deletions packages/kbn-io-ts-utils/src/to_json_schema/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,104 @@ describe('toJsonSchema', () => {
).toEqual({
type: 'object',
additionalProperties: {
allOf: [
{ type: 'object', properties: { foo: { type: 'string' } }, required: ['foo'] },
{ type: 'object', properties: { bar: { type: 'array', items: { type: 'boolean' } } } },
],
type: 'object',
properties: {
foo: {
type: 'string',
},
bar: {
type: 'array',
items: {
type: 'boolean',
},
},
},
required: ['foo'],
},
});
});

it('converts literal types', () => {
expect(
toJsonSchema(
t.type({
myEnum: t.union([t.literal(true), t.literal(0), t.literal('foo')]),
})
)
).toEqual({
type: 'object',
properties: {
myEnum: {
anyOf: [
{
type: 'boolean',
const: true,
},
{
type: 'number',
const: 0,
},
{
type: 'string',
const: 'foo',
},
],
},
},
required: ['myEnum'],
});
});

it('merges schemas where possible', () => {
expect(
toJsonSchema(
t.intersection([
t.type({
myEnum: t.union([t.literal('foo'), t.literal('bar')]),
}),
t.type({
requiredProperty: t.string,
anotherRequiredProperty: t.string,
}),
t.partial({
optionalProperty: t.string,
}),
t.type({
mixedProperty: t.string,
}),
t.type({
mixedProperty: t.number,
}),
])
)
).toEqual({
type: 'object',
properties: {
myEnum: {
type: 'string',
enum: ['foo', 'bar'],
},
optionalProperty: {
type: 'string',
},
requiredProperty: {
type: 'string',
},
anotherRequiredProperty: {
type: 'string',
},
mixedProperty: {
anyOf: [
{
type: 'string',
},
{
type: 'number',
},
],
},
},
required: ['myEnum', 'requiredProperty', 'anotherRequiredProperty', 'mixedProperty'],
});
});
});
107 changes: 99 additions & 8 deletions packages/kbn-io-ts-utils/src/to_json_schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import * as t from 'io-ts';
import { mapValues } from 'lodash';
import { forEach, isArray, isPlainObject, mapValues, mergeWith, uniq } from 'lodash';
import { isParsableType, ParseableType } from '../parseable_types';

interface JSONSchemaObject {
Expand Down Expand Up @@ -35,6 +35,8 @@ interface JSONSchemaArray {

interface BaseJSONSchema {
type: string;
const?: string | number | boolean;
enum?: Array<string | number | boolean>;
}

type JSONSchema =
Expand All @@ -45,43 +47,132 @@ type JSONSchema =
| JSONSchemaAllOf
| JSONSchemaAnyOf;

export const toJsonSchema = (type: t.Type<any> | ParseableType): JSONSchema => {
const naiveToJsonSchema = (type: t.Type<any> | ParseableType): JSONSchema => {
if (isParsableType(type)) {
switch (type._tag) {
case 'ArrayType':
return { type: 'array', items: toJsonSchema(type.type) };
return { type: 'array', items: naiveToJsonSchema(type.type) };

case 'BooleanType':
return { type: 'boolean' };

case 'DictionaryType':
return { type: 'object', additionalProperties: toJsonSchema(type.codomain) };
return { type: 'object', additionalProperties: naiveToJsonSchema(type.codomain) };

case 'InterfaceType':
return {
type: 'object',
properties: mapValues(type.props, toJsonSchema),
properties: mapValues(type.props, naiveToJsonSchema),
required: Object.keys(type.props),
};

case 'PartialType':
return { type: 'object', properties: mapValues(type.props, toJsonSchema) };
return { type: 'object', properties: mapValues(type.props, naiveToJsonSchema) };

case 'UnionType':
return { anyOf: type.types.map(toJsonSchema) };
return { anyOf: type.types.map(naiveToJsonSchema) };

case 'IntersectionType':
return { allOf: type.types.map(toJsonSchema) };
return { allOf: type.types.map(naiveToJsonSchema) };

case 'NumberType':
return { type: 'number' };

case 'StringType':
return { type: 'string' };

case 'LiteralType':
return {
type: typeof type.value,
const: type.value,
};
}
}

return {
type: 'object',
};
};

export const toJsonSchema = (type: t.Type<any> | ParseableType): JSONSchema => {
const result = naiveToJsonSchema(type);

function mergeAllOf(allOf: JSONSchemaAllOf['allOf']) {
return mergeWith(
{},
...allOf,
function mergeRecursively(objectValue: any, sourceValue: any, keyToMerge: string): object {
if (objectValue === undefined) {
return sourceValue;
}

if (isPlainObject(sourceValue) && keyToMerge !== 'properties' && 'type' in sourceValue) {
const isMergable = sourceValue.type === objectValue.type;
if (!isMergable) {
return { anyOf: [objectValue, sourceValue] };
}
}

if (isPlainObject(objectValue) && isPlainObject(sourceValue)) {
forEach(sourceValue, (value, key) => {
objectValue[key] = mergeRecursively(objectValue[key], value, key);
});
return objectValue;
}

if (isArray(objectValue) && isArray(sourceValue)) {
forEach(sourceValue, (value) => {
if (objectValue.indexOf(value) === -1) {
objectValue.push(value);
}
});
return objectValue;
}

return sourceValue;
}
);
}

function walkObject(
object: any,
iteratee: (source: Record<string, any>, value: any, key: string) => unknown
) {
if (isPlainObject(object) || isArray(object)) {
forEach(object as Record<string, any>, (value, key) => {
object = iteratee(object, walkObject(value, iteratee), key);
});
}
return object;
}

return walkObject(result, (source, value, key) => {
if (key === 'allOf') {
// merge t.intersection() where possible
const merged = mergeAllOf(value);
return merged;
} else if (key === 'anyOf') {
// merge t.union() where possible
const anyOf = value as JSONSchemaAnyOf['anyOf'];
const types = uniq(anyOf.map((schema) => ('type' in schema ? schema.type : schema)));

// only merge if type is the same everywhere
if (
types.length === 1 &&
typeof types[0] === 'string' &&
['string', 'number', 'boolean'].includes(types[0])
) {
return {
type: types[0],
enum: anyOf
.filter((schema): schema is BaseJSONSchema => 'const' in schema || 'enum' in schema)
.flatMap((schema) => schema.const || schema.enum || []),
};
}
}
if (isPlainObject(source)) {
source[key] = value;
}
return source;
});
};