mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
Object versioning package (#153182)
This commit is contained in:
parent
340ee10086
commit
e8a20bb258
40 changed files with 1949 additions and 12 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -464,6 +464,7 @@ src/plugins/navigation @elastic/appex-sharedux
|
|||
src/plugins/newsfeed @elastic/kibana-core
|
||||
test/common/plugins/newsfeed @elastic/kibana-core
|
||||
x-pack/plugins/notifications @elastic/appex-sharedux
|
||||
packages/kbn-object-versioning @elastic/appex-sharedux
|
||||
x-pack/test/cases_api_integration/common/plugins/observability @elastic/response-ops
|
||||
x-pack/plugins/observability @elastic/actionable-observability
|
||||
x-pack/test/security_api_integration/plugins/oidc_provider @elastic/kibana-security
|
||||
|
|
|
@ -480,6 +480,7 @@
|
|||
"@kbn/newsfeed-plugin": "link:src/plugins/newsfeed",
|
||||
"@kbn/newsfeed-test-plugin": "link:test/common/plugins/newsfeed",
|
||||
"@kbn/notifications-plugin": "link:x-pack/plugins/notifications",
|
||||
"@kbn/object-versioning": "link:packages/kbn-object-versioning",
|
||||
"@kbn/observability-fixtures-plugin": "link:x-pack/test/cases_api_integration/common/plugins/observability",
|
||||
"@kbn/observability-plugin": "link:x-pack/plugins/observability",
|
||||
"@kbn/oidc-provider-plugin": "link:x-pack/test/security_api_integration/plugins/oidc_provider",
|
||||
|
|
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",
|
||||
]
|
||||
}
|
|
@ -9,5 +9,5 @@ import type { Type } from '@kbn/config-schema';
|
|||
|
||||
export interface ProcedureSchemas {
|
||||
in: Type<any> | false;
|
||||
out: Type<any> | false;
|
||||
out?: Type<any> | false;
|
||||
}
|
||||
|
|
|
@ -43,6 +43,9 @@ const setup = ({ registerFooType = false }: { registerFooType?: boolean } = {})
|
|||
latest: 'v1',
|
||||
request: 'v1',
|
||||
},
|
||||
utils: {
|
||||
getTransforms: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const core = new Core({ logger });
|
||||
|
|
|
@ -7,15 +7,18 @@
|
|||
*/
|
||||
|
||||
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
|
||||
|
||||
import type { Version } from '../../common';
|
||||
import type { ContentManagementGetTransformsFn } from '@kbn/object-versioning';
|
||||
import type { Version as LegacyVersion } from '../../common';
|
||||
|
||||
/** Context that is sent to all storage instance methods */
|
||||
export interface StorageContext {
|
||||
requestHandlerContext: RequestHandlerContext;
|
||||
version: {
|
||||
request: Version;
|
||||
latest: Version;
|
||||
request: LegacyVersion;
|
||||
latest: LegacyVersion;
|
||||
};
|
||||
utils: {
|
||||
getTransforms: ContentManagementGetTransformsFn;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -45,6 +48,6 @@ export interface ContentTypeDefinition<S extends ContentStorage = ContentStorage
|
|||
/** The storage layer for the content. It must implment the ContentStorage interface. */
|
||||
storage: S;
|
||||
version: {
|
||||
latest: Version;
|
||||
latest: LegacyVersion;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -129,6 +129,7 @@ describe('ContentManagementPlugin', () => {
|
|||
const context = {
|
||||
requestHandlerContext: mockedRequestHandlerContext,
|
||||
contentRegistry: 'mockedContentRegistry',
|
||||
getTransformsFactory: expect.any(Function),
|
||||
};
|
||||
expect(mockGet).toHaveBeenCalledWith(context, input);
|
||||
expect(mockCreate).toHaveBeenCalledWith(context, input);
|
||||
|
|
|
@ -8,10 +8,13 @@
|
|||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning';
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
import { bulkGet } from './bulk_get';
|
||||
|
||||
const { fn, schemas } = bulkGet;
|
||||
|
@ -159,7 +162,11 @@ describe('RPC -> bulkGet()', () => {
|
|||
});
|
||||
|
||||
const requestHandlerContext = 'mockedRequestHandlerContext';
|
||||
const ctx: any = { contentRegistry, requestHandlerContext };
|
||||
const ctx: any = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
|
||||
return { ctx, storage };
|
||||
};
|
||||
|
@ -188,6 +195,9 @@ describe('RPC -> bulkGet()', () => {
|
|||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
utils: {
|
||||
getTransforms: expect.any(Function),
|
||||
},
|
||||
},
|
||||
['123', '456'],
|
||||
undefined
|
||||
|
@ -213,5 +223,53 @@ describe('RPC -> bulkGet()', () => {
|
|||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('object versioning', () => {
|
||||
test('should expose a utility to transform and validate services objects', () => {
|
||||
const { ctx, storage } = setup();
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, ids: ['1234'], version: 'v1' });
|
||||
const [[storageContext]] = storage.bulkGet.mock.calls;
|
||||
|
||||
// getTransforms() utils should be available from context
|
||||
const { getTransforms } = storageContext.utils ?? {};
|
||||
expect(getTransforms).not.toBeUndefined();
|
||||
|
||||
const definitions: ContentManagementServiceDefinitionVersioned = {
|
||||
1: {
|
||||
bulkGet: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {},
|
||||
};
|
||||
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// Some smoke tests for the getTransforms() utils. Complete test suite is inside
|
||||
// the package @kbn/object-versioning
|
||||
expect(transforms.bulkGet.in.options.up({ version1: 'foo' }).value).toEqual({
|
||||
version1: 'foo',
|
||||
version2: 'added',
|
||||
});
|
||||
|
||||
const optionsUpTransform = transforms.bulkGet.in.options.up({ version1: 123 });
|
||||
|
||||
expect(optionsUpTransform.value).toBe(null);
|
||||
expect(optionsUpTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.bulkGet.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -28,6 +28,9 @@ export const bulkGet: ProcedureDefinition<Context, BulkGetIn<string>, BulkGetRes
|
|||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
utils: {
|
||||
getTransforms: ctx.getTransformsFactory(contentTypeId),
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.bulkGet(storageContext, ids, options);
|
||||
|
||||
|
|
|
@ -7,10 +7,14 @@
|
|||
*/
|
||||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning';
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
import { create } from './create';
|
||||
|
||||
const { fn, schemas } = create;
|
||||
|
@ -131,7 +135,11 @@ describe('RPC -> create()', () => {
|
|||
});
|
||||
|
||||
const requestHandlerContext = 'mockedRequestHandlerContext';
|
||||
const ctx: any = { contentRegistry, requestHandlerContext };
|
||||
const ctx: any = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
|
||||
return { ctx, storage };
|
||||
};
|
||||
|
@ -160,6 +168,9 @@ describe('RPC -> create()', () => {
|
|||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
utils: {
|
||||
getTransforms: expect.any(Function),
|
||||
},
|
||||
},
|
||||
{ title: 'Hello' },
|
||||
undefined
|
||||
|
@ -185,5 +196,53 @@ describe('RPC -> create()', () => {
|
|||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('object versioning', () => {
|
||||
test('should expose a utility to transform and validate services objects', () => {
|
||||
const { ctx, storage } = setup();
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, data: { title: 'Hello' }, version: 'v1' });
|
||||
const [[storageContext]] = storage.create.mock.calls;
|
||||
|
||||
// getTransforms() utils should be available from context
|
||||
const { getTransforms } = storageContext.utils ?? {};
|
||||
expect(getTransforms).not.toBeUndefined();
|
||||
|
||||
const definitions: ContentManagementServiceDefinitionVersioned = {
|
||||
1: {
|
||||
create: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {},
|
||||
};
|
||||
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// Some smoke tests for the getTransforms() utils. Complete test suite is inside
|
||||
// the package @kbn/object-versioning
|
||||
expect(transforms.create.in.options.up({ version1: 'foo' }).value).toEqual({
|
||||
version1: 'foo',
|
||||
version2: 'added',
|
||||
});
|
||||
|
||||
const optionsUpTransform = transforms.create.in.options.up({ version1: 123 });
|
||||
|
||||
expect(optionsUpTransform.value).toBe(null);
|
||||
expect(optionsUpTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.create.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,6 +27,9 @@ export const create: ProcedureDefinition<Context, CreateIn<string>> = {
|
|||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
utils: {
|
||||
getTransforms: ctx.getTransformsFactory(contentTypeId),
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.create(storageContext, data, options);
|
||||
|
||||
|
|
|
@ -8,10 +8,13 @@
|
|||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
import { deleteProc } from './delete';
|
||||
|
||||
const { fn, schemas } = deleteProc;
|
||||
|
@ -128,7 +131,11 @@ describe('RPC -> delete()', () => {
|
|||
});
|
||||
|
||||
const requestHandlerContext = 'mockedRequestHandlerContext';
|
||||
const ctx: any = { contentRegistry, requestHandlerContext };
|
||||
const ctx: any = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
|
||||
return { ctx, storage };
|
||||
};
|
||||
|
@ -153,6 +160,9 @@ describe('RPC -> delete()', () => {
|
|||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
utils: {
|
||||
getTransforms: expect.any(Function),
|
||||
},
|
||||
},
|
||||
'1234',
|
||||
undefined
|
||||
|
@ -178,5 +188,53 @@ describe('RPC -> delete()', () => {
|
|||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('object versioning', () => {
|
||||
test('should expose a utility to transform and validate services objects', () => {
|
||||
const { ctx, storage } = setup();
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', version: 'v1' });
|
||||
const [[storageContext]] = storage.delete.mock.calls;
|
||||
|
||||
// getTransforms() utils should be available from context
|
||||
const { getTransforms } = storageContext.utils ?? {};
|
||||
expect(getTransforms).not.toBeUndefined();
|
||||
|
||||
const definitions: ContentManagementServiceDefinitionVersioned = {
|
||||
1: {
|
||||
delete: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {},
|
||||
};
|
||||
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// Some smoke tests for the getTransforms() utils. Complete test suite is inside
|
||||
// the package @kbn/object-versioning
|
||||
expect(transforms.delete.in.options.up({ version1: 'foo' }).value).toEqual({
|
||||
version1: 'foo',
|
||||
version2: 'added',
|
||||
});
|
||||
|
||||
const optionsUpTransform = transforms.delete.in.options.up({ version1: 123 });
|
||||
|
||||
expect(optionsUpTransform.value).toBe(null);
|
||||
expect(optionsUpTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.delete.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -26,6 +26,9 @@ export const deleteProc: ProcedureDefinition<Context, DeleteIn<string>> = {
|
|||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
utils: {
|
||||
getTransforms: ctx.getTransformsFactory(contentTypeId),
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.delete(storageContext, id, options);
|
||||
|
||||
|
|
|
@ -8,10 +8,13 @@
|
|||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning';
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
import { get } from './get';
|
||||
|
||||
const { fn, schemas } = get;
|
||||
|
@ -128,7 +131,11 @@ describe('RPC -> get()', () => {
|
|||
});
|
||||
|
||||
const requestHandlerContext = 'mockedRequestHandlerContext';
|
||||
const ctx: any = { contentRegistry, requestHandlerContext };
|
||||
const ctx: any = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
|
||||
return { ctx, storage };
|
||||
};
|
||||
|
@ -153,6 +160,9 @@ describe('RPC -> get()', () => {
|
|||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
utils: {
|
||||
getTransforms: expect.any(Function),
|
||||
},
|
||||
},
|
||||
'1234',
|
||||
undefined
|
||||
|
@ -178,5 +188,53 @@ describe('RPC -> get()', () => {
|
|||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('object versioning', () => {
|
||||
test('should expose a utility to transform and validate services objects', () => {
|
||||
const { ctx, storage } = setup();
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', version: 'v1' });
|
||||
const [[storageContext]] = storage.get.mock.calls;
|
||||
|
||||
// getTransforms() utils should be available from context
|
||||
const { getTransforms } = storageContext.utils ?? {};
|
||||
expect(getTransforms).not.toBeUndefined();
|
||||
|
||||
const definitions: ContentManagementServiceDefinitionVersioned = {
|
||||
1: {
|
||||
get: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {},
|
||||
};
|
||||
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// Some smoke tests for the getTransforms() utils. Complete test suite is inside
|
||||
// the package @kbn/object-versioning
|
||||
expect(transforms.get.in.options.up({ version1: 'foo' }).value).toEqual({
|
||||
version1: 'foo',
|
||||
version2: 'added',
|
||||
});
|
||||
|
||||
const optionsUpTransform = transforms.get.in.options.up({ version1: 123 });
|
||||
|
||||
expect(optionsUpTransform.value).toBe(null);
|
||||
expect(optionsUpTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.get.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,6 +27,9 @@ export const get: ProcedureDefinition<Context, GetIn<string>> = {
|
|||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
utils: {
|
||||
getTransforms: ctx.getTransformsFactory(contentTypeId),
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.get(storageContext, id, options);
|
||||
|
||||
|
|
|
@ -13,6 +13,9 @@ import { ContentRegistry } from '../../core/registry';
|
|||
import { createMockedStorage } from '../../core/mocks';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { search } from './search';
|
||||
import { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
|
||||
const { fn, schemas } = search;
|
||||
|
||||
|
@ -147,7 +150,11 @@ describe('RPC -> search()', () => {
|
|||
});
|
||||
|
||||
const requestHandlerContext = 'mockedRequestHandlerContext';
|
||||
const ctx: any = { contentRegistry, requestHandlerContext };
|
||||
const ctx: any = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
|
||||
return { ctx, storage };
|
||||
};
|
||||
|
@ -176,6 +183,9 @@ describe('RPC -> search()', () => {
|
|||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
utils: {
|
||||
getTransforms: expect.any(Function),
|
||||
},
|
||||
},
|
||||
{ title: 'Hello' },
|
||||
undefined
|
||||
|
@ -201,5 +211,53 @@ describe('RPC -> search()', () => {
|
|||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('object versioning', () => {
|
||||
test('should expose a utility to transform and validate services objects', () => {
|
||||
const { ctx, storage } = setup();
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, query: { title: 'Hello' }, version: 'v1' });
|
||||
const [[storageContext]] = storage.search.mock.calls;
|
||||
|
||||
// getTransforms() utils should be available from context
|
||||
const { getTransforms } = storageContext.utils ?? {};
|
||||
expect(getTransforms).not.toBeUndefined();
|
||||
|
||||
const definitions: ContentManagementServiceDefinitionVersioned = {
|
||||
1: {
|
||||
search: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {},
|
||||
};
|
||||
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// Some smoke tests for the getTransforms() utils. Complete test suite is inside
|
||||
// the package @kbn/object-versioning
|
||||
expect(transforms.search.in.options.up({ version1: 'foo' }).value).toEqual({
|
||||
version1: 'foo',
|
||||
version2: 'added',
|
||||
});
|
||||
|
||||
const optionsUpTransform = transforms.search.in.options.up({ version1: 123 });
|
||||
|
||||
expect(optionsUpTransform.value).toBe(null);
|
||||
expect(optionsUpTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.search.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,6 +27,9 @@ export const search: ProcedureDefinition<Context, SearchIn<string>> = {
|
|||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
utils: {
|
||||
getTransforms: ctx.getTransformsFactory(contentTypeId),
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.search(storageContext, query, options);
|
||||
|
||||
|
|
|
@ -8,10 +8,13 @@
|
|||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning';
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
import { update } from './update';
|
||||
|
||||
const { fn, schemas } = update;
|
||||
|
@ -139,7 +142,11 @@ describe('RPC -> update()', () => {
|
|||
});
|
||||
|
||||
const requestHandlerContext = 'mockedRequestHandlerContext';
|
||||
const ctx: any = { contentRegistry, requestHandlerContext };
|
||||
const ctx: any = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
|
||||
return { ctx, storage };
|
||||
};
|
||||
|
@ -169,6 +176,9 @@ describe('RPC -> update()', () => {
|
|||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
utils: {
|
||||
getTransforms: expect.any(Function),
|
||||
},
|
||||
},
|
||||
'123',
|
||||
{ title: 'Hello' },
|
||||
|
@ -196,5 +206,58 @@ describe('RPC -> update()', () => {
|
|||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('object versioning', () => {
|
||||
test('should expose a utility to transform and validate services objects', () => {
|
||||
const { ctx, storage } = setup();
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
data: { title: 'Hello' },
|
||||
});
|
||||
const [[storageContext]] = storage.update.mock.calls;
|
||||
|
||||
// getTransforms() utils should be available from context
|
||||
const { getTransforms } = storageContext.utils ?? {};
|
||||
expect(getTransforms).not.toBeUndefined();
|
||||
|
||||
const definitions: ContentManagementServiceDefinitionVersioned = {
|
||||
1: {
|
||||
update: {
|
||||
in: {
|
||||
options: {
|
||||
schema: schema.object({
|
||||
version1: schema.string(),
|
||||
}),
|
||||
up: (pre: object) => ({ ...pre, version2: 'added' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
2: {},
|
||||
};
|
||||
|
||||
const transforms = getTransforms(definitions, 1);
|
||||
|
||||
// Some smoke tests for the getTransforms() utils. Complete test suite is inside
|
||||
// the package @kbn/object-versioning
|
||||
expect(transforms.update.in.options.up({ version1: 'foo' }).value).toEqual({
|
||||
version1: 'foo',
|
||||
version2: 'added',
|
||||
});
|
||||
|
||||
const optionsUpTransform = transforms.update.in.options.up({ version1: 123 });
|
||||
|
||||
expect(optionsUpTransform.value).toBe(null);
|
||||
expect(optionsUpTransform.error?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
|
||||
expect(transforms.update.in.options.validate({ version1: 123 })?.message).toBe(
|
||||
'[version1]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -26,6 +26,9 @@ export const update: ProcedureDefinition<Context, UpdateIn<string>> = {
|
|||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
utils: {
|
||||
getTransforms: ctx.getTransformsFactory(contentTypeId),
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.update(storageContext, id, data, options);
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import { ProcedureName } from '../../../common';
|
|||
import type { ContentRegistry } from '../../core';
|
||||
|
||||
import type { RpcService } from '../rpc_service';
|
||||
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
|
||||
import type { Context as RpcContext } from '../types';
|
||||
import { wrapError } from './error_wrapper';
|
||||
|
||||
|
@ -53,6 +54,7 @@ export function initRpcRoutes(
|
|||
const context: RpcContext = {
|
||||
contentRegistry,
|
||||
requestHandlerContext,
|
||||
getTransformsFactory: getServiceObjectTransformFactory,
|
||||
};
|
||||
const { name } = request.params as { name: ProcedureName };
|
||||
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 LRUCache from 'lru-cache';
|
||||
import type { ObjectMigrationDefinition } from '@kbn/object-versioning';
|
||||
import type {
|
||||
ContentManagementServiceDefinitionVersioned,
|
||||
Version,
|
||||
ContentManagementGetTransformsFn,
|
||||
} from '@kbn/object-versioning';
|
||||
import {
|
||||
compileServiceDefinitions,
|
||||
getContentManagmentServicesTransforms,
|
||||
} from '@kbn/object-versioning';
|
||||
|
||||
/**
|
||||
* We keep a cache of compiled service definition to avoid unnecessary recompile on every request.
|
||||
*/
|
||||
const compiledCache = new LRUCache<string, { [path: string]: ObjectMigrationDefinition }>({
|
||||
max: 50,
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap the "getContentManagmentServicesTransforms()" handler from the @kbn/object-versioning package
|
||||
* to be able to cache the service definitions compilations so we can reuse them accross request as the
|
||||
* services definitions won't change until a new Elastic version is released. In which case the cache
|
||||
* will be cleared.
|
||||
*
|
||||
* @param contentTypeId The content type id for the service definition
|
||||
* @returns A "getContentManagmentServicesTransforms()"
|
||||
*/
|
||||
export const getServiceObjectTransformFactory =
|
||||
(contentTypeId: string): ContentManagementGetTransformsFn =>
|
||||
(definitions: ContentManagementServiceDefinitionVersioned, requestVersion: Version) => {
|
||||
const compiledFromCache = compiledCache.get(contentTypeId);
|
||||
if (compiledFromCache) {
|
||||
return getContentManagmentServicesTransforms(definitions, requestVersion, compiledFromCache);
|
||||
}
|
||||
|
||||
const compiled = compileServiceDefinitions(definitions);
|
||||
compiledCache.set(contentTypeId, compiled);
|
||||
|
||||
return getContentManagmentServicesTransforms(definitions, requestVersion, compiled);
|
||||
};
|
|
@ -6,9 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
|
||||
import type { ContentManagementGetTransformsFn } from '@kbn/object-versioning';
|
||||
import type { ContentRegistry } from '../core';
|
||||
|
||||
export interface Context {
|
||||
contentRegistry: ContentRegistry;
|
||||
requestHandlerContext: RequestHandlerContext;
|
||||
getTransformsFactory: (contentTypeId: string) => ContentManagementGetTransformsFn;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"@kbn/core",
|
||||
"@kbn/config-schema",
|
||||
"@kbn/core-http-request-handler-context-server",
|
||||
"@kbn/object-versioning",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -922,6 +922,8 @@
|
|||
"@kbn/newsfeed-test-plugin/*": ["test/common/plugins/newsfeed/*"],
|
||||
"@kbn/notifications-plugin": ["x-pack/plugins/notifications"],
|
||||
"@kbn/notifications-plugin/*": ["x-pack/plugins/notifications/*"],
|
||||
"@kbn/object-versioning": ["packages/kbn-object-versioning"],
|
||||
"@kbn/object-versioning/*": ["packages/kbn-object-versioning/*"],
|
||||
"@kbn/observability-fixtures-plugin": ["x-pack/test/cases_api_integration/common/plugins/observability"],
|
||||
"@kbn/observability-fixtures-plugin/*": ["x-pack/test/cases_api_integration/common/plugins/observability/*"],
|
||||
"@kbn/observability-plugin": ["x-pack/plugins/observability"],
|
||||
|
|
|
@ -4581,6 +4581,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/object-versioning@link:packages/kbn-object-versioning":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/observability-fixtures-plugin@link:x-pack/test/cases_api_integration/common/plugins/observability":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue