mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
Object versioning package (#153182)
This commit is contained in:
parent
340ee10086
commit
e8a20bb258
40 changed files with 1949 additions and 12 deletions
3
packages/kbn-object-versioning/README.md
Normal file
3
packages/kbn-object-versioning/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/object-versioning
|
||||
|
||||
Empty package generated by @kbn/generate
|
26
packages/kbn-object-versioning/index.ts
Normal file
26
packages/kbn-object-versioning/index.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
initTransform,
|
||||
getContentManagmentServicesTransforms,
|
||||
compileServiceDefinitions,
|
||||
} from './lib';
|
||||
|
||||
export type {
|
||||
Version,
|
||||
VersionableObject,
|
||||
ObjectMigrationDefinition,
|
||||
ObjectTransform,
|
||||
ObjectTransforms,
|
||||
TransformReturn,
|
||||
ContentManagementServiceDefinitionVersioned,
|
||||
ContentManagementServiceTransforms,
|
||||
ContentManagementServicesDefinition,
|
||||
ContentManagementGetTransformsFn,
|
||||
} from './lib';
|
13
packages/kbn-object-versioning/jest.config.js
Normal file
13
packages/kbn-object-versioning/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../..',
|
||||
roots: ['<rootDir>/packages/kbn-object-versioning'],
|
||||
};
|
5
packages/kbn-object-versioning/kibana.jsonc
Normal file
5
packages/kbn-object-versioning/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/object-versioning",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
/*
|
||||
* 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 { isConfigSchema, schema } from '@kbn/config-schema';
|
||||
import type { Type } from '@kbn/config-schema';
|
||||
|
||||
// Validate that the value is a function
|
||||
const functionSchema = schema.any({
|
||||
validate: (value) => {
|
||||
if (typeof value !== 'function') {
|
||||
return 'Must be a function';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Validate that the value is a kbn config Schema (Type<any>)
|
||||
const kbnConfigSchema = schema.any({
|
||||
validate: (value) => {
|
||||
if (!isConfigSchema(value)) {
|
||||
return 'Invalid schema type.';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// VersionableObject schema
|
||||
const versionableObjectSchema = schema.object(
|
||||
{
|
||||
schema: schema.maybe(kbnConfigSchema),
|
||||
down: schema.maybe(functionSchema),
|
||||
up: schema.maybe(functionSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
);
|
||||
|
||||
const getOptionalInOutSchemas = (props: { in: Type<any>; out: Type<any> }) =>
|
||||
schema.maybe(schema.object(props, { unknowns: 'forbid' }));
|
||||
|
||||
// Schema to validate the "get" service objects
|
||||
// Note: the "bulkGet" and "delete" services also use this schema as they allow the same IN/OUT objects
|
||||
const getSchemas = getOptionalInOutSchemas({
|
||||
in: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
options: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
out: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
result: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
// Schema to validate the "create" service objects
|
||||
// Note: the "update" service also uses this schema as they allow the same IN/OUT objects
|
||||
const createSchemas = getOptionalInOutSchemas({
|
||||
in: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
data: schema.maybe(versionableObjectSchema),
|
||||
options: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
out: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
result: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
// Schema to validate the "search" service objects
|
||||
const searchSchemas = getOptionalInOutSchemas({
|
||||
in: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
query: schema.maybe(versionableObjectSchema),
|
||||
options: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
out: schema.maybe(
|
||||
schema.object(
|
||||
{
|
||||
result: schema.maybe(versionableObjectSchema),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
export const serviceDefinitionSchema = schema.object(
|
||||
{
|
||||
get: getSchemas,
|
||||
bulkGet: getSchemas,
|
||||
create: createSchemas,
|
||||
update: createSchemas,
|
||||
delete: getSchemas,
|
||||
search: searchSchemas,
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
);
|
|
@ -0,0 +1,403 @@
|
|||
/*
|
||||
* 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 { set } from '@kbn/safer-lodash-set';
|
||||
import { get } from 'lodash';
|
||||
import { getTransforms } from './content_management_services_versioning';
|
||||
import type { ServiceDefinitionVersioned } from './content_management_types';
|
||||
|
||||
/**
|
||||
* Wrap the key with [] if it is a key from an Array
|
||||
* @param key The object key
|
||||
* @param isArrayItem Flag to indicate if it is the key of an Array
|
||||
*/
|
||||
const renderKey = (key: string, isArrayItem: boolean): string => (isArrayItem ? `[${key}]` : key);
|
||||
|
||||
const flattenObject = (
|
||||
obj: Record<any, any>,
|
||||
prefix: string[] = [],
|
||||
isArrayItem = false
|
||||
): Record<any, any> =>
|
||||
Object.keys(obj).reduce<Record<any, any>>((acc, k) => {
|
||||
const nextValue = obj[k];
|
||||
|
||||
if (typeof nextValue === 'object' && nextValue !== null) {
|
||||
const isNextValueArray = Array.isArray(nextValue);
|
||||
const dotSuffix = isNextValueArray ? '' : '.';
|
||||
|
||||
if (Object.keys(nextValue).length > 0) {
|
||||
return {
|
||||
...acc,
|
||||
...flattenObject(
|
||||
nextValue,
|
||||
[...prefix, `${renderKey(k, isArrayItem)}${dotSuffix}`],
|
||||
isNextValueArray
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = `${prefix.join('')}${renderKey(k, isArrayItem)}`;
|
||||
acc[fullPath] = nextValue;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Create an object and set a value at the specific path
|
||||
*
|
||||
* @param path The path where to create the object
|
||||
* @param value The value to set at the path
|
||||
* @returns An object with a value at the provided path
|
||||
*/
|
||||
const setObjectValue = (path: string, value: unknown) => {
|
||||
const obj = {};
|
||||
set(obj, path, value);
|
||||
return obj;
|
||||
};
|
||||
|
||||
const wrapVersion = (obj: object, version = 1): object => ({
|
||||
[version]: obj,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get all the test cases for a versionable object at a specific path
|
||||
*
|
||||
* @param path The versionable object path (e.g. "get.in.options")
|
||||
* @returns A veresioned service definition
|
||||
*/
|
||||
const getVersionnableObjectTests = (path: string) => {
|
||||
return [
|
||||
{
|
||||
definitions: wrapVersion(setObjectValue(path, 123)),
|
||||
expected: false,
|
||||
ref: 'versionable object is not an object',
|
||||
},
|
||||
{
|
||||
definitions: wrapVersion(
|
||||
setObjectValue(path, {
|
||||
up: 123,
|
||||
})
|
||||
),
|
||||
expected: false,
|
||||
ref: '"up" transform is not a function',
|
||||
},
|
||||
{
|
||||
definitions: wrapVersion(
|
||||
setObjectValue(path, {
|
||||
down: 123,
|
||||
})
|
||||
),
|
||||
expected: false,
|
||||
ref: '"down" transform is not a function',
|
||||
},
|
||||
{
|
||||
definitions: wrapVersion(
|
||||
setObjectValue(path, {
|
||||
schema: 123,
|
||||
})
|
||||
),
|
||||
expected: false,
|
||||
ref: '"schema" is not a valid validation Type',
|
||||
},
|
||||
{
|
||||
definitions: wrapVersion(
|
||||
setObjectValue(path, {
|
||||
schema: schema.object({
|
||||
foo: schema.string(),
|
||||
}),
|
||||
up: () => ({}),
|
||||
down: () => ({}),
|
||||
})
|
||||
),
|
||||
expected: true,
|
||||
ref: `valid versionable object [${path}]`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Get the tests to validate the services props
|
||||
const getInvalidServiceObjectTests = () =>
|
||||
['get', 'bulkGet', 'create', 'update', 'delete', 'search'].map((service) => ({
|
||||
definitions: {
|
||||
1: {
|
||||
[service]: {
|
||||
unknown: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
ref: `invalid ${service}: unknown prop`,
|
||||
}));
|
||||
|
||||
describe('CM services getTransforms()', () => {
|
||||
describe('validation', () => {
|
||||
[
|
||||
{
|
||||
definitions: 123,
|
||||
expected: false,
|
||||
ref: 'definition is not an object',
|
||||
error: 'Invalid service definition. Must be an object.',
|
||||
},
|
||||
// Test that each version is an integer
|
||||
{
|
||||
definitions: { a: {} },
|
||||
expected: false,
|
||||
ref: 'invalid version',
|
||||
error: 'Invalid version [a]. Must be an integer.',
|
||||
},
|
||||
{
|
||||
definitions: { '123a': {} },
|
||||
expected: false,
|
||||
ref: 'invalid version (2)',
|
||||
error: 'Invalid version [123a]. Must be an integer.',
|
||||
},
|
||||
{
|
||||
definitions: wrapVersion({ foo: 'bar', get: { in: { options: { up: () => ({}) } } } }),
|
||||
expected: false,
|
||||
ref: 'invalid root prop',
|
||||
},
|
||||
// Test that each service only accepts an "in" and "out" prop
|
||||
...getInvalidServiceObjectTests(),
|
||||
// Test that each versionable object has a valid definition
|
||||
...getVersionnableObjectTests('get.in.options'),
|
||||
...getVersionnableObjectTests('get.out.result'),
|
||||
...getVersionnableObjectTests('bulkGet.in.options'),
|
||||
...getVersionnableObjectTests('bulkGet.out.result'),
|
||||
...getVersionnableObjectTests('create.in.options'),
|
||||
...getVersionnableObjectTests('create.in.data'),
|
||||
...getVersionnableObjectTests('create.out.result'),
|
||||
...getVersionnableObjectTests('update.in.options'),
|
||||
...getVersionnableObjectTests('update.in.data'),
|
||||
...getVersionnableObjectTests('update.out.result'),
|
||||
...getVersionnableObjectTests('delete.in.options'),
|
||||
...getVersionnableObjectTests('delete.out.result'),
|
||||
...getVersionnableObjectTests('search.in.options'),
|
||||
...getVersionnableObjectTests('search.in.query'),
|
||||
...getVersionnableObjectTests('search.out.result'),
|
||||
].forEach(({ definitions, expected, ref, error = 'Invalid services definition.' }: any) => {
|
||||
test(`validate: ${ref}`, () => {
|
||||
if (expected === false) {
|
||||
expect(() => {
|
||||
getTransforms(definitions, 1);
|
||||
}).toThrowError(error);
|
||||
} else {
|
||||
expect(() => {
|
||||
getTransforms(definitions, 1);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transforms', () => {
|
||||
describe('validate objects', () => {
|
||||
const setup = (definitions: ServiceDefinitionVersioned) => {
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// We flatten the object and extract the paths so we can later make sure that
|
||||
// each of them have "up()" and "down()" that are callable. Even if they simply proxy
|
||||
// the data that we send them in.
|
||||
const flattened = flattenObject(transforms);
|
||||
const paths = Object.keys(flattened);
|
||||
|
||||
// Remove the last section of the path as that's where our ServiceObject is
|
||||
// e.g. path === "get.in.options.up" --> the versionable object is at "get.in.options"
|
||||
const serviceObjectPaths = paths.map((path) => {
|
||||
const index = path.lastIndexOf('.');
|
||||
if (index < 0) {
|
||||
throw new Error(`Invalid transforms [${JSON.stringify(transforms)}]`);
|
||||
}
|
||||
return path.substring(0, index);
|
||||
});
|
||||
|
||||
return {
|
||||
transforms,
|
||||
serviceObjectPaths: [...new Set(serviceObjectPaths)],
|
||||
};
|
||||
};
|
||||
|
||||
test('should return a ServiceObject for each of the CM services objects', () => {
|
||||
const { serviceObjectPaths } = setup({ 1: {} });
|
||||
expect(serviceObjectPaths.sort()).toEqual(
|
||||
[
|
||||
'get.in.options',
|
||||
'get.out.result',
|
||||
'bulkGet.in.options',
|
||||
'bulkGet.out.result',
|
||||
'create.in.options',
|
||||
'create.in.data',
|
||||
'create.out.result',
|
||||
'update.in.options',
|
||||
'update.in.data',
|
||||
'update.out.result',
|
||||
'delete.in.options',
|
||||
'delete.out.result',
|
||||
'search.in.query',
|
||||
'search.in.options',
|
||||
'search.out.result',
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
test('each of the services objects must have a up, down and validate method', () => {
|
||||
const { transforms, serviceObjectPaths } = setup({ 1: {} });
|
||||
|
||||
// Test every service object...
|
||||
serviceObjectPaths.forEach((path) => {
|
||||
const serviceObject = get(transforms, path);
|
||||
|
||||
// We haven't passed any definition for any object. We still expect the
|
||||
// up(), down() methods to exist and to be callable
|
||||
const data = { foo: 'bar' };
|
||||
expect(serviceObject.up(data).value).toBe(data);
|
||||
expect(serviceObject.down(data).value).toBe(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('up/down transform & validation', () => {
|
||||
const definitions: ServiceDefinitionVersioned = {
|
||||
1: {
|
||||
get: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
out: {
|
||||
result: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {
|
||||
get: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
version2: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version3: 'added' }),
|
||||
},
|
||||
},
|
||||
out: {
|
||||
result: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
version2: schema.string(),
|
||||
}),
|
||||
down: (pre: any) => {
|
||||
const { version1 } = pre;
|
||||
return { version1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
3: {
|
||||
get: {
|
||||
out: {
|
||||
result: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
version2: schema.string(),
|
||||
version3: schema.string(),
|
||||
}),
|
||||
down: (pre: any) => {
|
||||
const { version1, version2 } = pre;
|
||||
return { version1, version2 };
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('should up transform an object', () => {
|
||||
const requestVersion = 1;
|
||||
const transforms = getTransforms(definitions, requestVersion);
|
||||
const initial = { version1: 'option version 1' };
|
||||
const upTransform = transforms.get.in.options.up(initial);
|
||||
expect(upTransform.value).toEqual({ ...initial, version2: 'added', version3: 'added' });
|
||||
});
|
||||
|
||||
test('should validate object *before* up transform', () => {
|
||||
const requestVersion = 1;
|
||||
const transforms = getTransforms(definitions, requestVersion);
|
||||
const upTransform = transforms.get.in.options.up({ unknown: 'foo' });
|
||||
expect(upTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [undefined]'
|
||||
);
|
||||
});
|
||||
|
||||
test('should down transform an object', () => {
|
||||
const requestVersion = 1;
|
||||
const transforms = getTransforms(definitions, requestVersion);
|
||||
const downTransform = transforms.get.out.result.down({
|
||||
version1: 'foo',
|
||||
version2: 'bar',
|
||||
version3: 'superBar',
|
||||
});
|
||||
expect(downTransform.value).toEqual({ version1: 'foo' });
|
||||
});
|
||||
|
||||
test('should validate object *before* down transform', () => {
|
||||
const requestVersion = 1;
|
||||
const transforms = getTransforms(definitions, requestVersion);
|
||||
|
||||
// Implicitly down transform from "latest" version (which is version 3 in our case)
|
||||
const downTransform = transforms.get.out.result.down({
|
||||
version1: 'foo',
|
||||
version2: 'bar',
|
||||
version3: 123,
|
||||
});
|
||||
expect(downTransform.error?.message).toBe(
|
||||
'[version3]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate object *before* down transform (2)', () => {
|
||||
const requestVersion = 1;
|
||||
const transforms = getTransforms(definitions, requestVersion);
|
||||
|
||||
// Explicitly down transform from version 1
|
||||
const downTransformFrom = 1;
|
||||
const downTransform = transforms.get.out.result.down({ version1: 123 }, downTransformFrom);
|
||||
expect(downTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
|
||||
test('should expose a method to validate at the specific version', () => {
|
||||
const requestVersion = 1;
|
||||
const transforms = getTransforms(definitions, requestVersion);
|
||||
|
||||
// Validate request version (1)
|
||||
expect(transforms.get.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.get.in.options.validate({ version1: 'foo' })).toBe(null);
|
||||
|
||||
// Validate version 2 schema
|
||||
expect(transforms.get.in.options.validate({ version1: 'foo' }, 2)?.message).toBe(
|
||||
'[version2]: expected value of type [string] but got [undefined]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* 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 { get } from 'lodash';
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
|
||||
import { ObjectMigrationDefinition, Version, VersionableObject } from './types';
|
||||
import type {
|
||||
ServiceDefinitionVersioned,
|
||||
ServicesDefinition,
|
||||
ServiceTransforms,
|
||||
} from './content_management_types';
|
||||
import { serviceDefinitionSchema } from './content_management_services_schemas';
|
||||
import { validateObj, validateVersion } from './utils';
|
||||
import { initTransform } from './object_transform';
|
||||
|
||||
const serviceObjectPaths = [
|
||||
'get.in.options',
|
||||
'get.out.result',
|
||||
'bulkGet.in.options',
|
||||
'bulkGet.out.result',
|
||||
'create.in.options',
|
||||
'create.in.data',
|
||||
'create.out.result',
|
||||
'update.in.options',
|
||||
'update.in.data',
|
||||
'update.out.result',
|
||||
'delete.in.options',
|
||||
'delete.out.result',
|
||||
'search.in.query',
|
||||
'search.in.options',
|
||||
'search.out.result',
|
||||
];
|
||||
|
||||
const validateServiceDefinitions = (definitions: ServiceDefinitionVersioned) => {
|
||||
if (definitions === null || Array.isArray(definitions) || typeof definitions !== 'object') {
|
||||
throw new Error('Invalid service definition. Must be an object.');
|
||||
}
|
||||
|
||||
if (Object.keys(definitions).length === 0) {
|
||||
throw new Error('At least one version must be defined.');
|
||||
}
|
||||
|
||||
Object.entries(definitions).forEach(([version, definition]) => {
|
||||
const { result: isVersionValid } = validateVersion(version);
|
||||
|
||||
if (!isVersionValid) {
|
||||
throw new Error(`Invalid version [${version}]. Must be an integer.`);
|
||||
}
|
||||
|
||||
const error = validateObj(definition, serviceDefinitionSchema);
|
||||
if (error !== null) {
|
||||
throw new Error(`Invalid services definition. [${error}]`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a versionned service definition to a flattened service definition
|
||||
* where _each object_ is versioned (at the leaf).
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* From this
|
||||
* {
|
||||
* 1: {
|
||||
* get: {
|
||||
* in: {
|
||||
* options: { up: () => {} } // 1
|
||||
* }
|
||||
* },
|
||||
* ...
|
||||
* },
|
||||
* 2: {
|
||||
* get: {
|
||||
* in: {
|
||||
* options: { up: () => {} } // 2
|
||||
* }
|
||||
* },
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* To this
|
||||
*
|
||||
* {
|
||||
* 'get.in.options': { // Flattend path
|
||||
* 1: { up: () => {} }, // 1
|
||||
* 2: { up: () => {} } // 2
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const compile = (
|
||||
definitions: ServiceDefinitionVersioned
|
||||
): { [path: string]: ObjectMigrationDefinition } => {
|
||||
validateServiceDefinitions(definitions);
|
||||
|
||||
const flattened: { [path: string]: ObjectMigrationDefinition } = {};
|
||||
|
||||
Object.entries(definitions).forEach(([version, definition]: [string, ServicesDefinition]) => {
|
||||
serviceObjectPaths.forEach((path) => {
|
||||
const versionableObject: VersionableObject = get(definition, path) ?? {};
|
||||
|
||||
const objectMigrationDefinition: ObjectMigrationDefinition = {
|
||||
...(get(flattened, path) ?? {}),
|
||||
[version]: versionableObject,
|
||||
};
|
||||
|
||||
flattened[path] = objectMigrationDefinition;
|
||||
});
|
||||
});
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const getDefaultTransforms = () => ({
|
||||
up: (input: any) => input,
|
||||
down: (input: any) => input,
|
||||
validate: () => null,
|
||||
});
|
||||
|
||||
const getDefaultServiceTransforms = (): ServiceTransforms => ({
|
||||
get: {
|
||||
in: {
|
||||
options: getDefaultTransforms(),
|
||||
},
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
bulkGet: {
|
||||
in: {
|
||||
options: getDefaultTransforms(),
|
||||
},
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
in: {
|
||||
options: getDefaultTransforms(),
|
||||
data: getDefaultTransforms(),
|
||||
},
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
in: {
|
||||
options: getDefaultTransforms(),
|
||||
data: getDefaultTransforms(),
|
||||
},
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
in: {
|
||||
options: getDefaultTransforms(),
|
||||
},
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
search: {
|
||||
in: {
|
||||
options: getDefaultTransforms(),
|
||||
query: getDefaultTransforms(),
|
||||
},
|
||||
out: {
|
||||
result: getDefaultTransforms(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getTransforms = (
|
||||
definitions: ServiceDefinitionVersioned,
|
||||
requestVersion: Version,
|
||||
_compiled?: { [path: string]: ObjectMigrationDefinition }
|
||||
): ServiceTransforms => {
|
||||
// Compile the definition into a flattened object with ObjectMigrationDefinition
|
||||
const compiled = _compiled ?? compile(definitions);
|
||||
|
||||
// Initiate transform for specific request version
|
||||
const transformsForRequest = getDefaultServiceTransforms();
|
||||
|
||||
Object.entries(compiled).forEach(([path, objectMigrationDefinition]) => {
|
||||
const objectTransforms = initTransform(requestVersion)(objectMigrationDefinition);
|
||||
set(transformsForRequest, path, objectTransforms);
|
||||
});
|
||||
|
||||
return transformsForRequest;
|
||||
};
|
||||
|
||||
export type GetTransformsFn = typeof getTransforms;
|
121
packages/kbn-object-versioning/lib/content_management_types.ts
Normal file
121
packages/kbn-object-versioning/lib/content_management_types.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* 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 { ObjectTransforms, Version, VersionableObject } from './types';
|
||||
|
||||
export interface ServicesDefinition {
|
||||
get?: {
|
||||
in?: {
|
||||
options?: VersionableObject;
|
||||
};
|
||||
out?: {
|
||||
result?: VersionableObject;
|
||||
};
|
||||
};
|
||||
bulkGet?: {
|
||||
in?: {
|
||||
options?: VersionableObject;
|
||||
};
|
||||
out?: {
|
||||
result?: VersionableObject;
|
||||
};
|
||||
};
|
||||
create?: {
|
||||
in?: {
|
||||
data?: VersionableObject;
|
||||
options?: VersionableObject;
|
||||
};
|
||||
out?: {
|
||||
result?: VersionableObject;
|
||||
};
|
||||
};
|
||||
update?: {
|
||||
in?: {
|
||||
data?: VersionableObject;
|
||||
options?: VersionableObject;
|
||||
};
|
||||
out?: {
|
||||
result?: VersionableObject;
|
||||
};
|
||||
};
|
||||
delete?: {
|
||||
in?: {
|
||||
options?: VersionableObject;
|
||||
};
|
||||
out?: {
|
||||
result?: VersionableObject;
|
||||
};
|
||||
};
|
||||
search?: {
|
||||
in?: {
|
||||
query?: VersionableObject;
|
||||
options?: VersionableObject;
|
||||
};
|
||||
out?: {
|
||||
result?: VersionableObject;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceTransforms {
|
||||
get: {
|
||||
in: {
|
||||
options: ObjectTransforms;
|
||||
};
|
||||
out: {
|
||||
result: ObjectTransforms;
|
||||
};
|
||||
};
|
||||
bulkGet: {
|
||||
in: {
|
||||
options: ObjectTransforms;
|
||||
};
|
||||
out: {
|
||||
result: ObjectTransforms;
|
||||
};
|
||||
};
|
||||
create: {
|
||||
in: {
|
||||
data: ObjectTransforms;
|
||||
options: ObjectTransforms;
|
||||
};
|
||||
out: {
|
||||
result: ObjectTransforms;
|
||||
};
|
||||
};
|
||||
update: {
|
||||
in: {
|
||||
data: ObjectTransforms;
|
||||
options: ObjectTransforms;
|
||||
};
|
||||
out: {
|
||||
result: ObjectTransforms;
|
||||
};
|
||||
};
|
||||
delete: {
|
||||
in: {
|
||||
options: ObjectTransforms;
|
||||
};
|
||||
out: {
|
||||
result: ObjectTransforms;
|
||||
};
|
||||
};
|
||||
search: {
|
||||
in: {
|
||||
query: ObjectTransforms;
|
||||
options: ObjectTransforms;
|
||||
};
|
||||
out: {
|
||||
result: ObjectTransforms;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServiceDefinitionVersioned {
|
||||
[version: Version]: ServicesDefinition;
|
||||
}
|
30
packages/kbn-object-versioning/lib/index.ts
Normal file
30
packages/kbn-object-versioning/lib/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { initTransform } from './object_transform';
|
||||
export {
|
||||
getTransforms as getContentManagmentServicesTransforms,
|
||||
compile as compileServiceDefinitions,
|
||||
} from './content_management_services_versioning';
|
||||
|
||||
export type { GetTransformsFn as ContentManagementGetTransformsFn } from './content_management_services_versioning';
|
||||
|
||||
export type {
|
||||
Version,
|
||||
VersionableObject,
|
||||
ObjectMigrationDefinition,
|
||||
ObjectTransform,
|
||||
ObjectTransforms,
|
||||
TransformReturn,
|
||||
} from './types';
|
||||
|
||||
export type {
|
||||
ServiceTransforms as ContentManagementServiceTransforms,
|
||||
ServicesDefinition as ContentManagementServicesDefinition,
|
||||
ServiceDefinitionVersioned as ContentManagementServiceDefinitionVersioned,
|
||||
} from './content_management_types';
|
220
packages/kbn-object-versioning/lib/object_transform.test.ts
Normal file
220
packages/kbn-object-versioning/lib/object_transform.test.ts
Normal file
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* 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 { initTransform } from './object_transform';
|
||||
|
||||
import type {
|
||||
ObjectMigrationDefinition,
|
||||
ObjectTransforms,
|
||||
Version,
|
||||
VersionableObject,
|
||||
} from './types';
|
||||
|
||||
interface FooV1 {
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
const v1Tv2Transform = jest.fn((v1: FooV1): FooV2 => {
|
||||
const [firstName, lastName] = v1.fullName.split(' ');
|
||||
return { firstName, lastName };
|
||||
});
|
||||
|
||||
const fooDefV1: VersionableObject = {
|
||||
schema: schema.object({
|
||||
fullName: schema.string({ minLength: 1 }),
|
||||
}),
|
||||
up: v1Tv2Transform,
|
||||
};
|
||||
|
||||
interface FooV2 {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
const v2Tv1Transform = jest.fn((v2: FooV2): FooV1 => {
|
||||
return {
|
||||
fullName: `${v2.firstName} ${v2.lastName}`,
|
||||
};
|
||||
});
|
||||
|
||||
const fooDefV2: VersionableObject = {
|
||||
schema: schema.object({
|
||||
firstName: schema.string(),
|
||||
lastName: schema.string(),
|
||||
}),
|
||||
down: v2Tv1Transform,
|
||||
};
|
||||
|
||||
const fooMigrationDef: ObjectMigrationDefinition = {
|
||||
1: fooDefV1,
|
||||
2: fooDefV2,
|
||||
};
|
||||
|
||||
const setup = (browserVersion: Version): ObjectTransforms => {
|
||||
const transformsFactory = initTransform(browserVersion);
|
||||
return transformsFactory(fooMigrationDef);
|
||||
};
|
||||
|
||||
describe('object transform', () => {
|
||||
describe('initTransform()', () => {
|
||||
test('it should validate that version numbers are valid', () => {
|
||||
expect(() => {
|
||||
initTransform(2)({
|
||||
// @ts-expect-error
|
||||
abc: { up: () => undefined },
|
||||
});
|
||||
}).toThrowError('Invalid version number [abc].');
|
||||
});
|
||||
});
|
||||
|
||||
describe('up()', () => {
|
||||
test('it should up transform to the latest version', () => {
|
||||
const fooTransforms = setup(1);
|
||||
const { value } = fooTransforms.up({ fullName: 'John Snow' });
|
||||
const expected = { firstName: 'John', lastName: 'Snow' };
|
||||
expect(value).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it should forward object if on same version', () => {
|
||||
const fooTransforms = setup(2);
|
||||
const obj = { firstName: 'John', lastName: 'Snow' };
|
||||
const { value } = fooTransforms.up(obj);
|
||||
expect(value).toBe(obj);
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test('it should validate the object before up transform', () => {
|
||||
const fooTransforms = setup(1);
|
||||
const { error } = fooTransforms.up({ unknown: 'John Snow' });
|
||||
expect(error!.message).toBe(
|
||||
'[fullName]: expected value of type [string] but got [undefined]'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should validate that the version to up transform to exists', () => {
|
||||
const fooTransforms = setup(1);
|
||||
const { error } = fooTransforms.up({ fullName: 'John Snow' }, 3);
|
||||
expect(error!.message).toBe('Unvalid version to up transform to [3].');
|
||||
});
|
||||
|
||||
test('it should validate that the version to up transform from exists', () => {
|
||||
const fooTransforms = setup(0);
|
||||
const { error } = fooTransforms.up({ fullName: 'John Snow' });
|
||||
expect(error!.message).toBe('Unvalid version to up transform from [0].');
|
||||
});
|
||||
|
||||
test('it should handle errors while up transforming', () => {
|
||||
const fooTransforms = setup(1);
|
||||
|
||||
v1Tv2Transform.mockImplementation((v1) => {
|
||||
return (v1 as any).unknown.split('');
|
||||
});
|
||||
|
||||
const { error } = fooTransforms.up({ fullName: 'John Snow' });
|
||||
|
||||
expect(error!.message).toBe(
|
||||
`[Transform error] Cannot read properties of undefined (reading 'split').`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('down()', () => {
|
||||
test('it should down transform to a previous version', () => {
|
||||
const fooTransforms = setup(1);
|
||||
const { value } = fooTransforms.down({ firstName: 'John', lastName: 'Snow' });
|
||||
const expected = { fullName: 'John Snow' };
|
||||
expect(value).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it should forward object if on same version', () => {
|
||||
const fooTransforms = setup(1);
|
||||
const obj = { fullName: 'John Snow' };
|
||||
const { value } = fooTransforms.down(obj, 1);
|
||||
expect(value).toBe(obj);
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
test('it should validate the object before down transform', () => {
|
||||
const fooTransforms = setup(1);
|
||||
|
||||
const { error } = fooTransforms.down({ bad: 'Unknown' });
|
||||
expect(error).not.toBe(null);
|
||||
expect(error!.message).toBe(
|
||||
'[firstName]: expected value of type [string] but got [undefined]'
|
||||
);
|
||||
});
|
||||
|
||||
test('it should validate that the version to down transform from exists', () => {
|
||||
const fooTransforms = setup(1);
|
||||
const { error } = fooTransforms.down({ fullName: 'John Snow' }, 3);
|
||||
expect(error!.message).toBe('Unvalid version to down transform from [3].');
|
||||
});
|
||||
|
||||
test('it should validate that the version to down transform to exists', () => {
|
||||
const fooTransforms = setup(0);
|
||||
const { error } = fooTransforms.down({ firstName: 'John', lastName: 'Snow' });
|
||||
expect(error!.message).toBe('Unvalid version to down transform to [0].');
|
||||
});
|
||||
|
||||
test('it should handle errors while down transforming', () => {
|
||||
const fooTransforms = setup(1);
|
||||
|
||||
v2Tv1Transform.mockImplementation((v2) => {
|
||||
return (v2 as any).unknown.split('');
|
||||
});
|
||||
|
||||
const { error } = fooTransforms.down({ firstName: 'John', lastName: 'Snow' });
|
||||
|
||||
expect(error!.message).toBe(
|
||||
`[Transform error] Cannot read properties of undefined (reading 'split').`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate()', () => {
|
||||
test('it should validate the object at the specific version', () => {
|
||||
const def: ObjectMigrationDefinition = {
|
||||
1: {
|
||||
schema: schema.string(),
|
||||
},
|
||||
2: {
|
||||
schema: schema.number(),
|
||||
},
|
||||
};
|
||||
|
||||
// Init transforms for version 1
|
||||
let transformsFactory = initTransform(1);
|
||||
expect(transformsFactory(def).validate(123)?.message).toBe(
|
||||
'expected value of type [string] but got [number]'
|
||||
);
|
||||
expect(transformsFactory(def).validate('foo')).toBe(null);
|
||||
|
||||
// Can validate another version than the requested one
|
||||
expect(transformsFactory(def).validate('foo', 2)?.message).toBe(
|
||||
'expected value of type [number] but got [string]'
|
||||
);
|
||||
expect(transformsFactory(def).validate(123, 2)).toBe(null);
|
||||
|
||||
// Init transform for version 2
|
||||
transformsFactory = initTransform(2);
|
||||
expect(transformsFactory(def).validate('foo')?.message).toBe(
|
||||
'expected value of type [number] but got [string]'
|
||||
);
|
||||
expect(transformsFactory(def).validate(123)).toBe(null);
|
||||
|
||||
// Init transform for version 7 (invalid)
|
||||
transformsFactory = initTransform(7);
|
||||
expect(() => {
|
||||
transformsFactory(def).validate(123);
|
||||
}).toThrowError('Invalid version number [7].');
|
||||
});
|
||||
});
|
||||
});
|
195
packages/kbn-object-versioning/lib/object_transform.ts
Normal file
195
packages/kbn-object-versioning/lib/object_transform.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
/*
|
||||
* 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 { ObjectMigrationDefinition, ObjectTransform, ObjectTransforms, Version } from './types';
|
||||
import { validateObj, validateVersion } from './utils';
|
||||
|
||||
/**
|
||||
* Extract versions metadata from an object migration definition
|
||||
*
|
||||
* @param migrationDefinition The object migration definition
|
||||
* @returns Metadata about the versions (list of available, last supported, latest)
|
||||
*/
|
||||
const getVersionsMeta = (migrationDefinition: ObjectMigrationDefinition) => {
|
||||
const versions = Object.keys(migrationDefinition)
|
||||
.map((version) => {
|
||||
const { result, value } = validateVersion(version);
|
||||
if (!result) {
|
||||
throw new Error(`Invalid version number [${version}].`);
|
||||
}
|
||||
return value as number;
|
||||
})
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const latestVersion = versions[versions.length - 1];
|
||||
const lastSupportedVersion = versions[0];
|
||||
|
||||
return {
|
||||
versions,
|
||||
lastSupportedVersion,
|
||||
latestVersion,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of pure functions to transform an object from one version
|
||||
* to another. Either "up" or "down" according if the "to" is > than the "from"
|
||||
*
|
||||
* @param from The version to start from
|
||||
* @param to The version to end to
|
||||
* @param migrationDefinition The object migration definition
|
||||
* @returns An array of transform functions
|
||||
*/
|
||||
const getTransformFns = (
|
||||
from: Version,
|
||||
to: Version,
|
||||
migrationDefinition: ObjectMigrationDefinition
|
||||
): ObjectTransform[] => {
|
||||
const fns: ObjectTransform[] = [];
|
||||
|
||||
let i = from;
|
||||
let fn: ObjectTransform | undefined;
|
||||
if (to > from) {
|
||||
while (i <= to) {
|
||||
fn = migrationDefinition[i].up;
|
||||
if (fn) {
|
||||
fns.push(fn);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else if (to < from) {
|
||||
while (i >= to) {
|
||||
fn = migrationDefinition[i].down;
|
||||
if (fn) {
|
||||
fns.push(fn);
|
||||
}
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
return fns;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate a transform for a specific request version. After we initiate the transforms
|
||||
* for a specific version we can then pass different `ObjectMigrationDefinition` to the provided
|
||||
* handler to start up/down transforming different object based on this request version.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* const transforms = initTransform(2); // start from version "2"
|
||||
* const fooTransforms = transforms(fooMigrationDefinition);
|
||||
* const barTransforms = transforms(barMigrationDefinition);
|
||||
*
|
||||
* // Up transform the objects to the latest, starting from version "2"
|
||||
* const { value: fooOnLatest } = foo.up();
|
||||
* const { value: barOnLatest } = bar.up();
|
||||
* ```
|
||||
*
|
||||
* @param requestVersion The starting version before up/down transforming
|
||||
* @returns A handler to pass an object migration definition
|
||||
*/
|
||||
export const initTransform =
|
||||
(requestVersion: Version) =>
|
||||
(migrationDefinition: ObjectMigrationDefinition): ObjectTransforms => {
|
||||
const { latestVersion } = getVersionsMeta(migrationDefinition);
|
||||
|
||||
const getVersion = (v: Version | 'latest'): Version => (v === 'latest' ? latestVersion : v);
|
||||
|
||||
const validateFn = (value: unknown, version: number = requestVersion) => {
|
||||
const def = migrationDefinition[version];
|
||||
|
||||
if (!def) {
|
||||
throw new Error(`Invalid version number [${version}].`);
|
||||
}
|
||||
|
||||
const { schema } = def;
|
||||
|
||||
if (schema) {
|
||||
return validateObj(value, schema);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
up: (obj, to = 'latest', { validate = true }: { validate?: boolean } = {}) => {
|
||||
try {
|
||||
if (!migrationDefinition[requestVersion]) {
|
||||
return {
|
||||
error: new Error(`Unvalid version to up transform from [${requestVersion}].`),
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (validate) {
|
||||
const error = validateFn(obj, requestVersion);
|
||||
if (error) {
|
||||
return { error, value: null };
|
||||
}
|
||||
}
|
||||
|
||||
const targetVersion = getVersion(to);
|
||||
|
||||
if (!migrationDefinition[targetVersion]) {
|
||||
return {
|
||||
error: new Error(`Unvalid version to up transform to [${to}].`),
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fns = getTransformFns(requestVersion, targetVersion, migrationDefinition);
|
||||
|
||||
const value = fns.reduce((acc, fn) => fn(acc), obj);
|
||||
return { value, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
value: null,
|
||||
error: new Error(`[Transform error] ${e.message ?? 'could not transform object'}.`),
|
||||
};
|
||||
}
|
||||
},
|
||||
down: (obj, from = 'latest', { validate = true }: { validate?: boolean } = {}) => {
|
||||
try {
|
||||
if (!migrationDefinition[requestVersion]) {
|
||||
return {
|
||||
error: new Error(`Unvalid version to down transform to [${requestVersion}].`),
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
const fromVersion = getVersion(from);
|
||||
|
||||
if (!migrationDefinition[fromVersion]) {
|
||||
return {
|
||||
error: new Error(`Unvalid version to down transform from [${from}].`),
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (validate) {
|
||||
const error = validateFn(obj, fromVersion);
|
||||
if (error) {
|
||||
return { error, value: null };
|
||||
}
|
||||
}
|
||||
|
||||
const fns = getTransformFns(fromVersion, requestVersion, migrationDefinition);
|
||||
const value = fns.reduce((acc, fn) => fn(acc), obj);
|
||||
|
||||
return { value, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
value: null,
|
||||
error: new Error(`[Transform error] ${e.message ?? 'could not transform object'}.`),
|
||||
};
|
||||
}
|
||||
},
|
||||
validate: validateFn,
|
||||
};
|
||||
};
|
52
packages/kbn-object-versioning/lib/types.ts
Normal file
52
packages/kbn-object-versioning/lib/types.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 { Type, ValidationError } from '@kbn/config-schema';
|
||||
|
||||
export type Version = number;
|
||||
|
||||
export type ObjectTransform<I extends object = any, O extends object = any> = (input: I) => O;
|
||||
|
||||
export interface VersionableObject<I extends object = any, O extends object = any> {
|
||||
schema?: Type<any>;
|
||||
down?: ObjectTransform;
|
||||
up?: ObjectTransform;
|
||||
}
|
||||
|
||||
export interface ObjectMigrationDefinition {
|
||||
[version: Version]: VersionableObject;
|
||||
}
|
||||
|
||||
export type TransformReturn<T = object> =
|
||||
| {
|
||||
value: T;
|
||||
error: null;
|
||||
}
|
||||
| {
|
||||
value: null;
|
||||
error: ValidationError | Error;
|
||||
};
|
||||
|
||||
export interface ObjectTransforms<Current = any, Previous = any, Next = any> {
|
||||
up: (
|
||||
obj: Current,
|
||||
version?: Version | 'latest',
|
||||
options?: {
|
||||
/** Validate the object _before_ up transform */
|
||||
validate?: boolean;
|
||||
}
|
||||
) => TransformReturn<Next>;
|
||||
down: (
|
||||
obj: Current,
|
||||
version?: Version | 'latest',
|
||||
options?: {
|
||||
/** Validate the object _before_ down transform */
|
||||
validate?: boolean;
|
||||
}
|
||||
) => TransformReturn<Previous>;
|
||||
validate: (obj: any, version?: Version) => ValidationError | null;
|
||||
}
|
32
packages/kbn-object-versioning/lib/utils.test.ts
Normal file
32
packages/kbn-object-versioning/lib/utils.test.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 { validateVersion } from './utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('validateVersion()', () => {
|
||||
[
|
||||
{ input: '123', isValid: true, expected: 123 },
|
||||
{ input: 123, isValid: true, expected: 123 },
|
||||
{ input: 1.23, isValid: false, expected: null },
|
||||
{ input: '123a', isValid: false, expected: null },
|
||||
{ input: 'abc', isValid: false, expected: null },
|
||||
{ input: undefined, isValid: false, expected: null },
|
||||
{ input: null, isValid: false, expected: null },
|
||||
{ input: [123], isValid: false, expected: null },
|
||||
{ input: { 123: true }, isValid: false, expected: null },
|
||||
{ input: () => 123, isValid: false, expected: null },
|
||||
].forEach(({ input, expected, isValid }) => {
|
||||
test(`validate: [${input}]`, () => {
|
||||
const { result, value } = validateVersion(input);
|
||||
expect(result).toBe(isValid);
|
||||
expect(value).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
50
packages/kbn-object-versioning/lib/utils.ts
Normal file
50
packages/kbn-object-versioning/lib/utils.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 { Type, ValidationError } from '@kbn/config-schema';
|
||||
import { Version } from './types';
|
||||
|
||||
/**
|
||||
* Validate an object based on a schema.
|
||||
*
|
||||
* @param obj The object to validate
|
||||
* @param objSchema The schema to validate the object against
|
||||
* @returns null or ValidationError
|
||||
*/
|
||||
export const validateObj = (obj: unknown, objSchema?: Type<any>): ValidationError | null => {
|
||||
if (objSchema === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
objSchema.validate(obj);
|
||||
return null;
|
||||
} catch (e: any) {
|
||||
return e as ValidationError;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateVersion = (version: unknown): { result: boolean; value: Version | null } => {
|
||||
if (typeof version === 'string') {
|
||||
const isValid = /^\d+$/.test(version);
|
||||
if (isValid) {
|
||||
const parsed = parseInt(version, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return { result: false, value: null };
|
||||
}
|
||||
return { result: true, value: parsed };
|
||||
}
|
||||
return { result: false, value: null };
|
||||
} else {
|
||||
const isValid = Number.isInteger(version);
|
||||
return {
|
||||
result: isValid,
|
||||
value: isValid ? (version as Version) : null,
|
||||
};
|
||||
}
|
||||
};
|
6
packages/kbn-object-versioning/package.json
Normal file
6
packages/kbn-object-versioning/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/object-versioning",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
22
packages/kbn-object-versioning/tsconfig.json
Normal file
22
packages/kbn-object-versioning/tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
"react"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/config-schema",
|
||||
"@kbn/safer-lodash-set",
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue