Object versioning package (#153182)

This commit is contained in:
Sébastien Loix 2023-03-15 17:27:47 +00:00 committed by GitHub
parent 340ee10086
commit e8a20bb258
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1949 additions and 12 deletions

View file

@ -0,0 +1,3 @@
# @kbn/object-versioning
Empty package generated by @kbn/generate

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

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

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/object-versioning",
"owner": "@elastic/appex-sharedux"
}

View file

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

View file

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

View file

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

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

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

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

View 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,
};
};

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

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

View 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,
};
}
};

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/object-versioning",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View 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",
]
}