mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Content management] BWC - versioned content (#152729)
This commit is contained in:
parent
e36ed9658d
commit
206a9d114d
44 changed files with 715 additions and 804 deletions
|
@ -8,6 +8,7 @@
|
|||
|
||||
import * as React from 'react';
|
||||
import { ContentClientProvider, ContentClient } from '@kbn/content-management-plugin/public';
|
||||
import { ContentTypeRegistry } from '@kbn/content-management-plugin/public/registry';
|
||||
|
||||
import { Todos } from '../todos';
|
||||
import { TodosClient } from './todos_client';
|
||||
|
@ -19,6 +20,10 @@ export default {
|
|||
};
|
||||
|
||||
const todosClient = new TodosClient();
|
||||
|
||||
const contentTypeRegistry = new ContentTypeRegistry();
|
||||
contentTypeRegistry.register({ id: 'todos', version: { latest: 'v1' } });
|
||||
|
||||
const contentClient = new ContentClient((contentType: string) => {
|
||||
switch (contentType) {
|
||||
case 'todos':
|
||||
|
@ -27,7 +32,7 @@ const contentClient = new ContentClient((contentType: string) => {
|
|||
default:
|
||||
throw new Error(`Unknown content type: ${contentType}`);
|
||||
}
|
||||
});
|
||||
}, contentTypeRegistry);
|
||||
|
||||
export const SimpleTodoApp = () => (
|
||||
<ContentClientProvider contentClient={contentClient}>
|
||||
|
|
|
@ -31,10 +31,17 @@ export class ContentManagementExamplesPlugin
|
|||
},
|
||||
});
|
||||
|
||||
contentManagement.registry.register({
|
||||
id: 'todos',
|
||||
version: {
|
||||
latest: 'v1',
|
||||
},
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start(core: CoreStart) {
|
||||
public start(core: CoreStart, deps: StartDeps) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -13,20 +13,13 @@ import {
|
|||
} from '@kbn/content-management-plugin/server';
|
||||
import { v4 } from 'uuid';
|
||||
import {
|
||||
createInSchema,
|
||||
searchInSchema,
|
||||
Todo,
|
||||
TODO_CONTENT_ID,
|
||||
updateInSchema,
|
||||
TodoSearchOut,
|
||||
TodoCreateOut,
|
||||
TodoUpdateOut,
|
||||
TodoDeleteOut,
|
||||
TodoGetOut,
|
||||
createOutSchema,
|
||||
getOutSchema,
|
||||
updateOutSchema,
|
||||
searchOutSchema,
|
||||
TodoUpdateIn,
|
||||
TodoSearchIn,
|
||||
TodoCreateIn,
|
||||
|
@ -39,40 +32,10 @@ export const registerTodoContentType = ({
|
|||
}) => {
|
||||
contentManagement.register({
|
||||
id: TODO_CONTENT_ID,
|
||||
schemas: {
|
||||
content: {
|
||||
create: {
|
||||
in: {
|
||||
data: createInSchema,
|
||||
},
|
||||
out: {
|
||||
result: createOutSchema,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
in: {
|
||||
data: updateInSchema,
|
||||
},
|
||||
out: {
|
||||
result: updateOutSchema,
|
||||
},
|
||||
},
|
||||
search: {
|
||||
in: {
|
||||
query: searchInSchema,
|
||||
},
|
||||
out: {
|
||||
result: searchOutSchema,
|
||||
},
|
||||
},
|
||||
get: {
|
||||
out: {
|
||||
result: getOutSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
storage: new TodosStorage(),
|
||||
version: {
|
||||
latest: 'v1',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -19,7 +19,4 @@ export type {
|
|||
SearchIn,
|
||||
} from './rpc';
|
||||
|
||||
export { procedureNames } from './rpc/constants';
|
||||
|
||||
// intentionally not exporting schemas to not include @kbn/schema in the public bundle
|
||||
// export { schemas as rpcSchemas } from './rpc';
|
||||
export type { Version } from './types';
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { Version } from '../types';
|
||||
import { versionSchema } from './constants';
|
||||
|
||||
import type { ProcedureSchemas } from './types';
|
||||
|
||||
|
@ -13,6 +15,7 @@ export const bulkGetSchemas: ProcedureSchemas = {
|
|||
in: schema.object(
|
||||
{
|
||||
contentTypeId: schema.string(),
|
||||
version: versionSchema,
|
||||
ids: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
|
||||
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
},
|
||||
|
@ -27,5 +30,6 @@ export const bulkGetSchemas: ProcedureSchemas = {
|
|||
export interface BulkGetIn<T extends string = string, Options extends object = object> {
|
||||
contentTypeId: T;
|
||||
ids: string[];
|
||||
version?: Version;
|
||||
options?: Options;
|
||||
}
|
||||
|
|
|
@ -5,7 +5,20 @@
|
|||
* 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 { validateVersion } from '../utils';
|
||||
|
||||
export const procedureNames = ['get', 'bulkGet', 'create', 'update', 'delete', 'search'] as const;
|
||||
|
||||
export type ProcedureName = typeof procedureNames[number];
|
||||
|
||||
export const versionSchema = schema.string({
|
||||
validate: (value) => {
|
||||
try {
|
||||
validateVersion(value);
|
||||
} catch (e) {
|
||||
return 'must follow the pattern [v${number}]';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { Version } from '../types';
|
||||
import { versionSchema } from './constants';
|
||||
|
||||
import type { ProcedureSchemas } from './types';
|
||||
|
||||
|
@ -13,6 +15,7 @@ export const createSchemas: ProcedureSchemas = {
|
|||
in: schema.object(
|
||||
{
|
||||
contentTypeId: schema.string(),
|
||||
version: versionSchema,
|
||||
// --> "data" to create a content will be defined by each content type
|
||||
data: schema.recordOf(schema.string(), schema.any()),
|
||||
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
|
@ -29,5 +32,6 @@ export interface CreateIn<
|
|||
> {
|
||||
contentTypeId: T;
|
||||
data: Data;
|
||||
version?: Version;
|
||||
options?: Options;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { Version } from '../types';
|
||||
import { versionSchema } from './constants';
|
||||
|
||||
import type { ProcedureSchemas } from './types';
|
||||
|
||||
|
@ -14,6 +16,7 @@ export const deleteSchemas: ProcedureSchemas = {
|
|||
{
|
||||
contentTypeId: schema.string(),
|
||||
id: schema.string({ minLength: 1 }),
|
||||
version: versionSchema,
|
||||
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
|
@ -24,5 +27,6 @@ export const deleteSchemas: ProcedureSchemas = {
|
|||
export interface DeleteIn<T extends string = string, Options extends object = object> {
|
||||
contentTypeId: T;
|
||||
id: string;
|
||||
version?: Version;
|
||||
options?: Options;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { Version } from '../types';
|
||||
import { versionSchema } from './constants';
|
||||
|
||||
import type { ProcedureSchemas } from './types';
|
||||
|
||||
|
@ -14,6 +16,7 @@ export const getSchemas: ProcedureSchemas = {
|
|||
{
|
||||
contentTypeId: schema.string(),
|
||||
id: schema.string({ minLength: 1 }),
|
||||
version: versionSchema,
|
||||
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
},
|
||||
{ unknowns: 'forbid' }
|
||||
|
@ -25,5 +28,6 @@ export const getSchemas: ProcedureSchemas = {
|
|||
export interface GetIn<T extends string = string, Options extends object = object> {
|
||||
id: string;
|
||||
contentTypeId: T;
|
||||
version?: Version;
|
||||
options?: Options;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { Version } from '../types';
|
||||
import { versionSchema } from './constants';
|
||||
|
||||
import type { ProcedureSchemas } from './types';
|
||||
|
||||
|
@ -13,6 +15,7 @@ export const searchSchemas: ProcedureSchemas = {
|
|||
in: schema.object(
|
||||
{
|
||||
contentTypeId: schema.string(),
|
||||
version: versionSchema,
|
||||
// --> "query" that can be executed will be defined by each content type
|
||||
query: schema.recordOf(schema.string(), schema.any()),
|
||||
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
|
@ -32,5 +35,6 @@ export interface SearchIn<
|
|||
> {
|
||||
contentTypeId: T;
|
||||
query: Query;
|
||||
version?: Version;
|
||||
options?: Options;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { Version } from '../types';
|
||||
import { versionSchema } from './constants';
|
||||
|
||||
import type { ProcedureSchemas } from './types';
|
||||
|
||||
|
@ -14,6 +16,7 @@ export const updateSchemas: ProcedureSchemas = {
|
|||
{
|
||||
contentTypeId: schema.string(),
|
||||
id: schema.string({ minLength: 1 }),
|
||||
version: versionSchema,
|
||||
// --> "data" to update a content will be defined by each content type
|
||||
data: schema.recordOf(schema.string(), schema.any()),
|
||||
options: schema.maybe(schema.object({}, { unknowns: 'allow' })),
|
||||
|
@ -31,5 +34,6 @@ export interface UpdateIn<
|
|||
contentTypeId: T;
|
||||
id: string;
|
||||
data: Data;
|
||||
version?: Version;
|
||||
options?: Options;
|
||||
}
|
||||
|
|
9
src/plugins/content_management/common/types.ts
Normal file
9
src/plugins/content_management/common/types.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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 type Version = `v${number}`;
|
57
src/plugins/content_management/common/utils.test.ts
Normal file
57
src/plugins/content_management/common/utils.test.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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', () => {
|
||||
const isValid = (version: unknown): boolean => {
|
||||
try {
|
||||
validateVersion(version);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
[
|
||||
{
|
||||
version: 'v1',
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
version: 'v689584563',
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
version: 'v0', // Invalid: must be >= 1
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
version: 'av0',
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
version: '1',
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
version: 'vv1',
|
||||
expected: false,
|
||||
},
|
||||
].forEach(({ version, expected }) => {
|
||||
test(`should validate [${version}] version`, () => {
|
||||
expect(isValid(version)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
test('should return the version number', () => {
|
||||
expect(validateVersion('v7')).toBe(7);
|
||||
});
|
||||
});
|
||||
});
|
26
src/plugins/content_management/common/utils.ts
Normal file
26
src/plugins/content_management/common/utils.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.
|
||||
*/
|
||||
|
||||
/** Utility to validate that a content version follows the pattern `v${number}` */
|
||||
export const validateVersion = (version: unknown): number => {
|
||||
if (typeof version !== 'string') {
|
||||
throw new Error(`Invalid version [${version}]. Must follow the pattern [v$\{number\}]`);
|
||||
}
|
||||
|
||||
if (/^v\d+$/.test(version) === false) {
|
||||
throw new Error(`Invalid version [${version}]. Must follow the pattern [v$\{number\}]`);
|
||||
}
|
||||
|
||||
const versionNumber = parseInt(version.substring(1), 10);
|
||||
|
||||
if (versionNumber < 1) {
|
||||
throw new Error(`Version must be >= 1`);
|
||||
}
|
||||
|
||||
return versionNumber;
|
||||
};
|
|
@ -11,11 +11,17 @@ import { takeWhile, toArray } from 'rxjs/operators';
|
|||
import { createCrudClientMock } from '../crud_client/crud_client.mock';
|
||||
import { ContentClient } from './content_client';
|
||||
import type { GetIn, CreateIn, UpdateIn, DeleteIn, SearchIn } from '../../common';
|
||||
import { ContentTypeRegistry } from '../registry';
|
||||
|
||||
const setup = () => {
|
||||
const crudClient = createCrudClientMock();
|
||||
const contentClient = new ContentClient(() => crudClient);
|
||||
return { crudClient, contentClient };
|
||||
const contentTypeRegistry = new ContentTypeRegistry();
|
||||
contentTypeRegistry.register({
|
||||
id: 'testType',
|
||||
version: { latest: 'v3' },
|
||||
});
|
||||
const contentClient = new ContentClient(() => crudClient, contentTypeRegistry);
|
||||
return { crudClient, contentClient, contentTypeRegistry };
|
||||
};
|
||||
|
||||
describe('#get', () => {
|
||||
|
@ -25,9 +31,29 @@ describe('#get', () => {
|
|||
const output = { test: 'test' };
|
||||
crudClient.get.mockResolvedValueOnce(output);
|
||||
expect(await contentClient.get(input)).toEqual(output);
|
||||
expect(crudClient.get).toBeCalledWith({ ...input, version: 'v3' }); // latest version added
|
||||
});
|
||||
|
||||
it('does not add the latest version if one is passed', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
const input: GetIn = { id: 'test', contentTypeId: 'testType', version: 'v1' };
|
||||
await contentClient.get(input);
|
||||
expect(crudClient.get).toBeCalledWith(input);
|
||||
});
|
||||
|
||||
it('throws if version is not valid', async () => {
|
||||
const { contentClient } = setup();
|
||||
let input = { id: 'test', contentTypeId: 'testType', version: 'vv' }; // Invalid format
|
||||
await expect(async () => {
|
||||
contentClient.get(input as any);
|
||||
}).rejects.toThrowError('Invalid version [vv]. Must follow the pattern [v${number}]');
|
||||
|
||||
input = { id: 'test', contentTypeId: 'testType', version: 'v4' }; // Latest version is v3
|
||||
await expect(async () => {
|
||||
contentClient.get(input as any);
|
||||
}).rejects.toThrowError('Invalid version [v4]. Latest version is [v3]');
|
||||
});
|
||||
|
||||
it('calls rpcClient.get$ with input and returns output', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
const input: GetIn = { id: 'test', contentTypeId: 'testType' };
|
||||
|
@ -58,6 +84,13 @@ describe('#create', () => {
|
|||
crudClient.create.mockResolvedValueOnce(output);
|
||||
|
||||
expect(await contentClient.create(input)).toEqual(output);
|
||||
expect(crudClient.create).toBeCalledWith({ ...input, version: 'v3' }); // latest version added
|
||||
});
|
||||
|
||||
it('does not add the latest version if one is passed', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
const input: CreateIn = { contentTypeId: 'testType', data: { foo: 'bar' }, version: 'v1' };
|
||||
await contentClient.create(input);
|
||||
expect(crudClient.create).toBeCalledWith(input);
|
||||
});
|
||||
});
|
||||
|
@ -65,11 +98,28 @@ describe('#create', () => {
|
|||
describe('#update', () => {
|
||||
it('calls rpcClient.update with input and returns output', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
const input: UpdateIn = { contentTypeId: 'testType', id: 'test', data: { foo: 'bar' } };
|
||||
const input: UpdateIn = {
|
||||
contentTypeId: 'testType',
|
||||
id: 'test',
|
||||
data: { foo: 'bar' },
|
||||
};
|
||||
const output = { test: 'test' };
|
||||
crudClient.update.mockResolvedValueOnce(output);
|
||||
|
||||
expect(await contentClient.update(input)).toEqual(output);
|
||||
expect(crudClient.update).toBeCalledWith({ ...input, version: 'v3' }); // latest version added
|
||||
});
|
||||
|
||||
it('does not add the latest version if one is passed', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
|
||||
const input: UpdateIn = {
|
||||
contentTypeId: 'testType',
|
||||
id: 'test',
|
||||
data: { foo: 'bar' },
|
||||
version: 'v1',
|
||||
};
|
||||
await contentClient.update(input);
|
||||
expect(crudClient.update).toBeCalledWith(input);
|
||||
});
|
||||
});
|
||||
|
@ -82,6 +132,13 @@ describe('#delete', () => {
|
|||
crudClient.delete.mockResolvedValueOnce(output);
|
||||
|
||||
expect(await contentClient.delete(input)).toEqual(output);
|
||||
expect(crudClient.delete).toBeCalledWith({ ...input, version: 'v3' }); // latest version added
|
||||
});
|
||||
|
||||
it('does not add the latest version if one is passed', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
const input: DeleteIn = { contentTypeId: 'testType', id: 'test', version: 'v1' };
|
||||
await contentClient.delete(input);
|
||||
expect(crudClient.delete).toBeCalledWith(input);
|
||||
});
|
||||
});
|
||||
|
@ -93,6 +150,13 @@ describe('#search', () => {
|
|||
const output = { hits: [{ id: 'test' }] };
|
||||
crudClient.search.mockResolvedValueOnce(output);
|
||||
expect(await contentClient.search(input)).toEqual(output);
|
||||
expect(crudClient.search).toBeCalledWith({ ...input, version: 'v3' }); // latest version added
|
||||
});
|
||||
|
||||
it('does not add the latest version if one is passed', async () => {
|
||||
const { crudClient, contentClient } = setup();
|
||||
const input: SearchIn = { contentTypeId: 'testType', query: {}, version: 'v1' };
|
||||
await contentClient.search(input);
|
||||
expect(crudClient.search).toBeCalledWith(input);
|
||||
});
|
||||
|
||||
|
|
|
@ -9,7 +9,9 @@
|
|||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createQueryObservable } from './query_observable';
|
||||
import type { CrudClient } from '../crud_client';
|
||||
import type { CreateIn, GetIn, UpdateIn, DeleteIn, SearchIn } from '../../common';
|
||||
import type { CreateIn, GetIn, UpdateIn, DeleteIn, SearchIn, Version } from '../../common';
|
||||
import { validateVersion } from '../../common/utils';
|
||||
import type { ContentTypeRegistry } from '../registry';
|
||||
|
||||
export const queryKeyBuilder = {
|
||||
all: (type: string) => [type] as const,
|
||||
|
@ -21,19 +23,50 @@ export const queryKeyBuilder = {
|
|||
},
|
||||
};
|
||||
|
||||
const addVersion = <I extends { contentTypeId: string; version?: Version }>(
|
||||
input: I,
|
||||
contentTypeRegistry: ContentTypeRegistry
|
||||
): I & { version: Version } => {
|
||||
const contentType = contentTypeRegistry.get(input.contentTypeId);
|
||||
|
||||
if (!contentType) {
|
||||
throw new Error(`Unknown content type [${input.contentTypeId}]`);
|
||||
}
|
||||
|
||||
const version = input.version ?? contentType.version.latest;
|
||||
|
||||
const versionNumber = validateVersion(version);
|
||||
|
||||
if (versionNumber > parseInt(contentType.version.latest.substring(1), 10)) {
|
||||
throw new Error(
|
||||
`Invalid version [${version}]. Latest version is [${contentType.version.latest}]`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
version,
|
||||
};
|
||||
};
|
||||
|
||||
const createQueryOptionBuilder = ({
|
||||
crudClientProvider,
|
||||
contentTypeRegistry,
|
||||
}: {
|
||||
crudClientProvider: (contentType: string) => CrudClient;
|
||||
contentTypeRegistry: ContentTypeRegistry;
|
||||
}) => {
|
||||
return {
|
||||
get: <I extends GetIn = GetIn, O = unknown>(input: I) => {
|
||||
get: <I extends GetIn = GetIn, O = unknown>(_input: I) => {
|
||||
const input = addVersion(_input, contentTypeRegistry);
|
||||
return {
|
||||
queryKey: queryKeyBuilder.item(input.contentTypeId, input.id),
|
||||
queryFn: () => crudClientProvider(input.contentTypeId).get(input) as Promise<O>,
|
||||
};
|
||||
},
|
||||
search: <I extends SearchIn = SearchIn, O = unknown>(input: I) => {
|
||||
search: <I extends SearchIn = SearchIn, O = unknown>(_input: I) => {
|
||||
const input = addVersion(_input, contentTypeRegistry);
|
||||
|
||||
return {
|
||||
queryKey: queryKeyBuilder.search(input.contentTypeId, input.query),
|
||||
queryFn: () => crudClientProvider(input.contentTypeId).search(input) as Promise<O>,
|
||||
|
@ -46,10 +79,14 @@ export class ContentClient {
|
|||
readonly queryClient: QueryClient;
|
||||
readonly queryOptionBuilder: ReturnType<typeof createQueryOptionBuilder>;
|
||||
|
||||
constructor(private readonly crudClientProvider: (contentType: string) => CrudClient) {
|
||||
constructor(
|
||||
private readonly crudClientProvider: (contentType: string) => CrudClient,
|
||||
private readonly contentTypeRegistry: ContentTypeRegistry
|
||||
) {
|
||||
this.queryClient = new QueryClient();
|
||||
this.queryOptionBuilder = createQueryOptionBuilder({
|
||||
crudClientProvider: this.crudClientProvider,
|
||||
contentTypeRegistry: this.contentTypeRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -62,22 +99,33 @@ export class ContentClient {
|
|||
}
|
||||
|
||||
create<I extends CreateIn, O = unknown>(input: I): Promise<O> {
|
||||
return this.crudClientProvider(input.contentTypeId).create(input) as Promise<O>;
|
||||
return this.crudClientProvider(input.contentTypeId).create(
|
||||
addVersion(input, this.contentTypeRegistry)
|
||||
) as Promise<O>;
|
||||
}
|
||||
|
||||
update<I extends UpdateIn, O = unknown>(input: I): Promise<O> {
|
||||
return this.crudClientProvider(input.contentTypeId).update(input) as Promise<O>;
|
||||
return this.crudClientProvider(input.contentTypeId).update(
|
||||
addVersion(input, this.contentTypeRegistry)
|
||||
) as Promise<O>;
|
||||
}
|
||||
|
||||
delete<I extends DeleteIn, O = unknown>(input: I): Promise<O> {
|
||||
return this.crudClientProvider(input.contentTypeId).delete(input) as Promise<O>;
|
||||
return this.crudClientProvider(input.contentTypeId).delete(
|
||||
addVersion(input, this.contentTypeRegistry)
|
||||
) as Promise<O>;
|
||||
}
|
||||
|
||||
search<I extends SearchIn, O = unknown>(input: I): Promise<O> {
|
||||
return this.crudClientProvider(input.contentTypeId).search(input) as Promise<O>;
|
||||
return this.crudClientProvider(input.contentTypeId).search(
|
||||
addVersion(input, this.contentTypeRegistry)
|
||||
) as Promise<O>;
|
||||
}
|
||||
|
||||
search$<I extends SearchIn, O = unknown>(input: I) {
|
||||
return createQueryObservable(this.queryClient, this.queryOptionBuilder.search<I, O>(input));
|
||||
return createQueryObservable(
|
||||
this.queryClient,
|
||||
this.queryOptionBuilder.search<I, O>(addVersion(input, this.contentTypeRegistry))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,10 +17,16 @@ import {
|
|||
useDeleteContentMutation,
|
||||
} from './content_client_mutation_hooks';
|
||||
import type { CreateIn, UpdateIn, DeleteIn } from '../../common';
|
||||
import { ContentTypeRegistry } from '../registry';
|
||||
|
||||
const setup = () => {
|
||||
const crudClient = createCrudClientMock();
|
||||
const contentClient = new ContentClient(() => crudClient);
|
||||
const contentTypeRegistry = new ContentTypeRegistry();
|
||||
contentTypeRegistry.register({
|
||||
id: 'testType',
|
||||
version: { latest: 'v3' },
|
||||
});
|
||||
const contentClient = new ContentClient(() => crudClient, contentTypeRegistry);
|
||||
|
||||
const Wrapper: React.FC = ({ children }) => (
|
||||
<ContentClientProvider contentClient={contentClient}>{children}</ContentClientProvider>
|
||||
|
@ -36,7 +42,7 @@ const setup = () => {
|
|||
describe('useCreateContentMutation', () => {
|
||||
test('should call rpcClient.create with input and resolve with output', async () => {
|
||||
const { Wrapper, crudClient } = setup();
|
||||
const input: CreateIn = { contentTypeId: 'testType', data: { foo: 'bar' } };
|
||||
const input: CreateIn = { contentTypeId: 'testType', data: { foo: 'bar' }, version: 'v2' };
|
||||
const output = { test: 'test' };
|
||||
crudClient.create.mockResolvedValueOnce(output);
|
||||
const { result, waitFor } = renderHook(() => useCreateContentMutation(), { wrapper: Wrapper });
|
||||
|
@ -51,7 +57,12 @@ describe('useCreateContentMutation', () => {
|
|||
describe('useUpdateContentMutation', () => {
|
||||
test('should call rpcClient.update with input and resolve with output', async () => {
|
||||
const { Wrapper, crudClient } = setup();
|
||||
const input: UpdateIn = { contentTypeId: 'testType', id: 'test', data: { foo: 'bar' } };
|
||||
const input: UpdateIn = {
|
||||
contentTypeId: 'testType',
|
||||
id: 'test',
|
||||
data: { foo: 'bar' },
|
||||
version: 'v2',
|
||||
};
|
||||
const output = { test: 'test' };
|
||||
crudClient.update.mockResolvedValueOnce(output);
|
||||
const { result, waitFor } = renderHook(() => useUpdateContentMutation(), { wrapper: Wrapper });
|
||||
|
@ -66,7 +77,7 @@ describe('useUpdateContentMutation', () => {
|
|||
describe('useDeleteContentMutation', () => {
|
||||
test('should call rpcClient.delete with input and resolve with output', async () => {
|
||||
const { Wrapper, crudClient } = setup();
|
||||
const input: DeleteIn = { contentTypeId: 'testType', id: 'test' };
|
||||
const input: DeleteIn = { contentTypeId: 'testType', id: 'test', version: 'v2' };
|
||||
const output = { test: 'test' };
|
||||
crudClient.delete.mockResolvedValueOnce(output);
|
||||
const { result, waitFor } = renderHook(() => useDeleteContentMutation(), { wrapper: Wrapper });
|
||||
|
|
|
@ -13,10 +13,16 @@ import { ContentClient } from './content_client';
|
|||
import { createCrudClientMock } from '../crud_client/crud_client.mock';
|
||||
import { useGetContentQuery, useSearchContentQuery } from './content_client_query_hooks';
|
||||
import type { GetIn, SearchIn } from '../../common';
|
||||
import { ContentTypeRegistry } from '../registry';
|
||||
|
||||
const setup = () => {
|
||||
const crudClient = createCrudClientMock();
|
||||
const contentClient = new ContentClient(() => crudClient);
|
||||
const contentTypeRegistry = new ContentTypeRegistry();
|
||||
contentTypeRegistry.register({
|
||||
id: 'testType',
|
||||
version: { latest: 'v2' },
|
||||
});
|
||||
const contentClient = new ContentClient(() => crudClient, contentTypeRegistry);
|
||||
|
||||
const Wrapper: React.FC = ({ children }) => (
|
||||
<ContentClientProvider contentClient={contentClient}>{children}</ContentClientProvider>
|
||||
|
@ -32,7 +38,7 @@ const setup = () => {
|
|||
describe('useGetContentQuery', () => {
|
||||
test('should call rpcClient.get with input and resolve with output', async () => {
|
||||
const { crudClient, Wrapper } = setup();
|
||||
const input: GetIn = { id: 'test', contentTypeId: 'testType' };
|
||||
const input: GetIn = { id: 'test', contentTypeId: 'testType', version: 'v2' };
|
||||
const output = { test: 'test' };
|
||||
crudClient.get.mockResolvedValueOnce(output);
|
||||
const { result, waitFor } = renderHook(() => useGetContentQuery(input), { wrapper: Wrapper });
|
||||
|
@ -44,7 +50,7 @@ describe('useGetContentQuery', () => {
|
|||
describe('useSearchContentQuery', () => {
|
||||
test('should call rpcClient.search with input and resolve with output', async () => {
|
||||
const { crudClient, Wrapper } = setup();
|
||||
const input: SearchIn = { contentTypeId: 'testType', query: {} };
|
||||
const input: SearchIn = { contentTypeId: 'testType', query: {}, version: 'v2' };
|
||||
const output = { hits: [{ id: 'test' }] };
|
||||
crudClient.search.mockResolvedValueOnce(output);
|
||||
const { result, waitFor } = renderHook(() => useSearchContentQuery(input), {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
|
||||
import type { CoreStart, Plugin } from '@kbn/core/public';
|
||||
import {
|
||||
ContentManagementPublicStart,
|
||||
ContentManagementPublicSetup,
|
||||
|
@ -26,18 +26,33 @@ export class ContentManagementPlugin
|
|||
StartDependencies
|
||||
>
|
||||
{
|
||||
public setup(core: CoreSetup, deps: SetupDependencies) {
|
||||
private contentTypeRegistry: ContentTypeRegistry;
|
||||
|
||||
constructor() {
|
||||
this.contentTypeRegistry = new ContentTypeRegistry();
|
||||
}
|
||||
|
||||
public setup() {
|
||||
return {
|
||||
registry: {} as ContentTypeRegistry,
|
||||
registry: {
|
||||
register: this.contentTypeRegistry.register.bind(this.contentTypeRegistry),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public start(core: CoreStart, deps: StartDependencies) {
|
||||
const rpcClient = new RpcClient(core.http);
|
||||
const contentTypeRegistry = new ContentTypeRegistry();
|
||||
|
||||
const contentClient = new ContentClient(
|
||||
(contentType) => contentTypeRegistry.get(contentType)?.crud ?? rpcClient
|
||||
(contentType) => this.contentTypeRegistry.get(contentType)?.crud ?? rpcClient,
|
||||
this.contentTypeRegistry
|
||||
);
|
||||
return { client: contentClient, registry: contentTypeRegistry };
|
||||
return {
|
||||
client: contentClient,
|
||||
registry: {
|
||||
get: this.contentTypeRegistry.get.bind(this.contentTypeRegistry),
|
||||
getAll: this.contentTypeRegistry.getAll.bind(this.contentTypeRegistry),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { ContentType } from './content_type';
|
|||
import type { ContentTypeDefinition } from './content_type_definition';
|
||||
|
||||
test('create a content type with just an id', () => {
|
||||
const type = new ContentType({ id: 'test' });
|
||||
const type = new ContentType({ id: 'test', version: { latest: 'v1' } });
|
||||
|
||||
expect(type.id).toBe('test');
|
||||
expect(type.name).toBe('test');
|
||||
|
@ -24,6 +24,7 @@ test('create a content type with all the full definition', () => {
|
|||
name: 'Test',
|
||||
icon: 'test',
|
||||
description: 'Test description',
|
||||
version: { latest: 'v1' },
|
||||
};
|
||||
const type = new ContentType(definition);
|
||||
|
||||
|
|
|
@ -31,4 +31,8 @@ export class ContentType {
|
|||
public get crud(): CrudClient | undefined {
|
||||
return this.definition.crud;
|
||||
}
|
||||
|
||||
public get version(): ContentTypeDefinition['version'] {
|
||||
return this.definition.version;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { Version } from '../../common';
|
||||
import type { CrudClient } from '../crud_client';
|
||||
|
||||
/**
|
||||
|
@ -39,4 +40,9 @@ export interface ContentTypeDefinition {
|
|||
* If not provided the default CRUD client is used assuming that this type has a server-side content registry
|
||||
*/
|
||||
crud?: CrudClient;
|
||||
|
||||
version: {
|
||||
/** The latest version for this content */
|
||||
latest: Version;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -14,28 +14,63 @@ beforeEach(() => {
|
|||
registry = new ContentTypeRegistry();
|
||||
});
|
||||
|
||||
const versionInfo = {
|
||||
latest: 'v2',
|
||||
} as const;
|
||||
|
||||
test('registering a content type', () => {
|
||||
const type = registry.register({
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
icon: 'test',
|
||||
description: 'Test description',
|
||||
version: versionInfo,
|
||||
});
|
||||
|
||||
expect(type.id).toBe('test');
|
||||
expect(type.name).toBe('Test');
|
||||
expect(type.icon).toBe('test');
|
||||
expect(type.description).toBe('Test description');
|
||||
expect(type.version).toEqual(versionInfo);
|
||||
});
|
||||
|
||||
test('registering already registered content type throws', () => {
|
||||
registry.register({
|
||||
id: 'test',
|
||||
version: versionInfo,
|
||||
});
|
||||
|
||||
expect(() => registry.register({ id: 'test' })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Content type with id \\"test\\" already registered."`
|
||||
);
|
||||
expect(() =>
|
||||
registry.register({ id: 'test', version: versionInfo })
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Content type with id \\"test\\" already registered."`);
|
||||
});
|
||||
|
||||
test('registering without version throws', () => {
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'test',
|
||||
} as any);
|
||||
}).toThrowError('Invalid version [undefined]. Must follow the pattern [v${number}]');
|
||||
});
|
||||
|
||||
test('registering invalid version throws', () => {
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'test',
|
||||
version: {
|
||||
latest: 'bad',
|
||||
},
|
||||
} as any);
|
||||
}).toThrowError('Invalid version [bad]. Must follow the pattern [v${number}]');
|
||||
|
||||
expect(() => {
|
||||
registry.register({
|
||||
id: 'test',
|
||||
version: {
|
||||
latest: 'v0',
|
||||
},
|
||||
});
|
||||
}).toThrowError('Version must be >= 1');
|
||||
});
|
||||
|
||||
test('getting non registered content returns undefined', () => {
|
||||
|
@ -45,6 +80,7 @@ test('getting non registered content returns undefined', () => {
|
|||
test('get', () => {
|
||||
const type = registry.register({
|
||||
id: 'test',
|
||||
version: versionInfo,
|
||||
});
|
||||
|
||||
expect(registry.get('test')).toEqual(type);
|
||||
|
@ -53,9 +89,11 @@ test('get', () => {
|
|||
test('getAll', () => {
|
||||
registry.register({
|
||||
id: 'test1',
|
||||
version: versionInfo,
|
||||
});
|
||||
registry.register({
|
||||
id: 'test2',
|
||||
version: versionInfo,
|
||||
});
|
||||
|
||||
expect(registry.getAll()).toHaveLength(2);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import type { ContentTypeDefinition } from './content_type_definition';
|
||||
import { ContentType } from './content_type';
|
||||
import { validateVersion } from '../../common/utils';
|
||||
|
||||
export class ContentTypeRegistry {
|
||||
private readonly types: Map<string, ContentType> = new Map();
|
||||
|
@ -16,6 +17,9 @@ export class ContentTypeRegistry {
|
|||
if (this.types.has(definition.id)) {
|
||||
throw new Error(`Content type with id "${definition.id}" already registered.`);
|
||||
}
|
||||
|
||||
validateVersion(definition.version?.latest);
|
||||
|
||||
const type = new ContentType(definition);
|
||||
this.types.set(type.id, type);
|
||||
|
||||
|
|
|
@ -6,7 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { API_ENDPOINT, procedureNames } from '../../common';
|
||||
import { API_ENDPOINT } from '../../common';
|
||||
import { procedureNames } from '../../common/rpc';
|
||||
|
||||
import { RpcClient } from './rpc_client';
|
||||
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { Core } from './core';
|
||||
import { createMemoryStorage, FooContent } from './mocks';
|
||||
|
@ -37,11 +35,14 @@ import { ContentTypeDefinition, StorageContext } from './types';
|
|||
const logger = loggingSystemMock.createLogger();
|
||||
|
||||
const FOO_CONTENT_ID = 'foo';
|
||||
const fooSchema = schema.object({ title: schema.string() });
|
||||
|
||||
const setup = ({ registerFooType = false }: { registerFooType?: boolean } = {}) => {
|
||||
const ctx: StorageContext = {
|
||||
requestHandlerContext: {} as any,
|
||||
version: {
|
||||
latest: 'v1',
|
||||
request: 'v1',
|
||||
},
|
||||
};
|
||||
|
||||
const core = new Core({ logger });
|
||||
|
@ -49,12 +50,8 @@ const setup = ({ registerFooType = false }: { registerFooType?: boolean } = {})
|
|||
const contentDefinition: ContentTypeDefinition = {
|
||||
id: FOO_CONTENT_ID,
|
||||
storage: createMemoryStorage(),
|
||||
schemas: {
|
||||
content: {
|
||||
create: { in: { data: fooSchema } },
|
||||
update: { in: { data: fooSchema } },
|
||||
search: { in: { query: schema.any() } },
|
||||
},
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
};
|
||||
const cleanUp = () => {
|
||||
|
@ -111,6 +108,27 @@ describe('Content Core', () => {
|
|||
|
||||
cleanUp();
|
||||
});
|
||||
|
||||
test('should throw if latest version passed is not valid', () => {
|
||||
const { coreSetup, cleanUp, contentDefinition } = setup();
|
||||
|
||||
const {
|
||||
contentRegistry,
|
||||
api: { register },
|
||||
} = coreSetup;
|
||||
|
||||
expect(contentRegistry.isContentRegistered(FOO_CONTENT_ID)).toBe(false);
|
||||
|
||||
expect(() => {
|
||||
register({ ...contentDefinition, version: undefined } as any);
|
||||
}).toThrowError('Invalid version [undefined]. Must follow the pattern [v${number}]');
|
||||
|
||||
expect(() => {
|
||||
register({ ...contentDefinition, version: { latest: 'v0' } });
|
||||
}).toThrowError('Version must be >= 1');
|
||||
|
||||
cleanUp();
|
||||
});
|
||||
});
|
||||
|
||||
describe('crud()', () => {
|
||||
|
|
|
@ -12,7 +12,7 @@ export type { CoreApi } from './core';
|
|||
|
||||
export type { ContentType } from './content_type';
|
||||
|
||||
export type { ContentStorage, ContentTypeDefinition, StorageContext, RpcSchemas } from './types';
|
||||
export type { ContentStorage, ContentTypeDefinition, StorageContext } from './types';
|
||||
|
||||
export type { ContentRegistry } from './registry';
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { validateVersion } from '../../common/utils';
|
||||
import { ContentType } from './content_type';
|
||||
import { EventBus } from './event_bus';
|
||||
import type { ContentStorage, ContentTypeDefinition } from './types';
|
||||
|
@ -26,6 +27,8 @@ export class ContentRegistry {
|
|||
throw new Error(`Content [${definition.id}] is already registered`);
|
||||
}
|
||||
|
||||
validateVersion(definition.version?.latest);
|
||||
|
||||
const contentType = new ContentType(definition, this.eventBus);
|
||||
|
||||
this.types.set(contentType.id, contentType);
|
||||
|
|
|
@ -6,12 +6,17 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { Type } from '@kbn/config-schema';
|
||||
import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server';
|
||||
|
||||
import type { Version } from '../../common';
|
||||
|
||||
/** Context that is sent to all storage instance methods */
|
||||
export interface StorageContext {
|
||||
requestHandlerContext: RequestHandlerContext;
|
||||
version: {
|
||||
request: Version;
|
||||
latest: Version;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentStorage {
|
||||
|
@ -34,68 +39,12 @@ export interface ContentStorage {
|
|||
search(ctx: StorageContext, query: object, options: unknown): Promise<any>;
|
||||
}
|
||||
|
||||
export interface RpcSchemas {
|
||||
get?: {
|
||||
in?: {
|
||||
options?: Type<any>;
|
||||
};
|
||||
out?: {
|
||||
result: Type<any>;
|
||||
};
|
||||
};
|
||||
bulkGet?: {
|
||||
in?: {
|
||||
options?: Type<any>;
|
||||
};
|
||||
out?: {
|
||||
result: Type<any>;
|
||||
};
|
||||
};
|
||||
create: {
|
||||
in: {
|
||||
data: Type<any>;
|
||||
options?: Type<any>;
|
||||
};
|
||||
out?: {
|
||||
result: Type<any>;
|
||||
};
|
||||
};
|
||||
update: {
|
||||
in: {
|
||||
data: Type<any>;
|
||||
options?: Type<any>;
|
||||
};
|
||||
out?: {
|
||||
result: Type<any>;
|
||||
};
|
||||
};
|
||||
delete?: {
|
||||
in?: {
|
||||
options?: Type<any>;
|
||||
};
|
||||
out?: {
|
||||
result: Type<any>;
|
||||
};
|
||||
};
|
||||
search: {
|
||||
in: {
|
||||
query: Type<any>;
|
||||
options?: Type<any>;
|
||||
};
|
||||
out?: {
|
||||
result: Type<any>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type ContentSchemas = RpcSchemas;
|
||||
|
||||
export interface ContentTypeDefinition<S extends ContentStorage = ContentStorage> {
|
||||
/** Unique id for the content type */
|
||||
id: string;
|
||||
/** The storage layer for the content. It must implment the ContentStorage interface. */
|
||||
storage: S;
|
||||
schemas: {
|
||||
content: ContentSchemas;
|
||||
version: {
|
||||
latest: Version;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@
|
|||
import { loggingSystemMock, coreMock } from '@kbn/core/server/mocks';
|
||||
import { ContentManagementPlugin } from './plugin';
|
||||
import { IRouter } from '@kbn/core/server';
|
||||
import { ProcedureName, procedureNames } from '../common';
|
||||
import type { ProcedureName } from '../common';
|
||||
import { procedureNames } from '../common/rpc';
|
||||
|
||||
jest.mock('./core', () => ({
|
||||
...jest.requireActual('./core'),
|
||||
|
|
|
@ -21,7 +21,7 @@ import {
|
|||
ContentManagementServerStart,
|
||||
SetupDependencies,
|
||||
} from './types';
|
||||
import { procedureNames } from '../common';
|
||||
import { procedureNames } from '../common/rpc';
|
||||
|
||||
type CreateRouterFn = CoreSetup['http']['createRouter'];
|
||||
|
||||
|
|
|
@ -6,12 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import type { RpcSchemas } from '../../core';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { bulkGet } from './bulk_get';
|
||||
|
||||
|
@ -29,41 +28,49 @@ if (!outputSchema) {
|
|||
}
|
||||
|
||||
const FOO_CONTENT_ID = 'foo';
|
||||
const fooDataSchema = schema.object({ title: schema.string() }, { unknowns: 'forbid' });
|
||||
|
||||
describe('RPC -> bulkGet()', () => {
|
||||
describe('Input/Output validation', () => {
|
||||
const ids = ['123', '456'];
|
||||
const validInput = { contentTypeId: 'foo', ids, version: 'v1' };
|
||||
|
||||
/**
|
||||
* These tests are for the procedure call itself. Every RPC needs to declare in/out schema
|
||||
* We will test _specific_ validation schema inside the procedure in separate tests.
|
||||
*/
|
||||
test('should validate that a contentTypeId and "ids" array is passed', () => {
|
||||
const ids = ['123', '456'];
|
||||
|
||||
[
|
||||
{ input: { contentTypeId: 'foo', ids } },
|
||||
{ input: validInput },
|
||||
{
|
||||
input: { ids }, // contentTypeId missing
|
||||
input: omit(validInput, 'contentTypeId'),
|
||||
expectedError: '[contentTypeId]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo' }, // ids missing
|
||||
input: omit(validInput, 'version'),
|
||||
expectedError: '[version]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { ...validInput, version: '1' }, // invalid version format
|
||||
expectedError: '[version]: must follow the pattern [v${number}]',
|
||||
},
|
||||
{
|
||||
input: omit(validInput, 'ids'),
|
||||
expectedError: '[ids]: expected value of type [array] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', ids: [] }, // ids array needs at least one value
|
||||
input: { ...validInput, ids: [] }, // ids array needs at least one value
|
||||
expectedError: '[ids]: array size is [0], but cannot be smaller than [1]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', ids: [''] }, // ids must havr 1 char min
|
||||
input: { ...validInput, ids: [''] }, // ids must havr 1 char min
|
||||
expectedError: '[ids.0]: value has length [0] but it must have a minimum length of [1].',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', ids: 123 }, // ids is not an array of string
|
||||
input: { ...validInput, ids: 123 }, // ids is not an array of string
|
||||
expectedError: '[ids]: expected value of type [array] but got [number]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', ids, unknown: 'foo' },
|
||||
input: { ...validInput, unknown: 'foo' },
|
||||
expectedError: '[unknown]: definition for this key is missing',
|
||||
},
|
||||
].forEach(({ input, expectedError }) => {
|
||||
|
@ -86,6 +93,7 @@ describe('RPC -> bulkGet()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
ids: ['123'],
|
||||
version: 'v1',
|
||||
options: { any: 'object' },
|
||||
},
|
||||
inputSchema
|
||||
|
@ -96,6 +104,7 @@ describe('RPC -> bulkGet()', () => {
|
|||
error = validate(
|
||||
{
|
||||
contentTypeId: 'foo',
|
||||
version: 'v1',
|
||||
ids: ['123'],
|
||||
options: 123, // Not an object
|
||||
},
|
||||
|
@ -138,24 +147,14 @@ describe('RPC -> bulkGet()', () => {
|
|||
});
|
||||
|
||||
describe('procedure', () => {
|
||||
const createSchemas = (): RpcSchemas => {
|
||||
return {
|
||||
bulkGet: {
|
||||
in: {
|
||||
query: fooDataSchema,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const setup = ({ contentSchemas = createSchemas() } = {}) => {
|
||||
const setup = () => {
|
||||
const contentRegistry = new ContentRegistry(new EventBus());
|
||||
const storage = createMockedStorage();
|
||||
contentRegistry.register({
|
||||
id: FOO_CONTENT_ID,
|
||||
storage,
|
||||
schemas: {
|
||||
content: contentSchemas,
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -171,7 +170,11 @@ describe('RPC -> bulkGet()', () => {
|
|||
const expected = ['Item1', 'Item2'];
|
||||
storage.bulkGet.mockResolvedValueOnce(expected);
|
||||
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, ids: ['123', '456'] });
|
||||
const result = await fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
version: 'v1',
|
||||
ids: ['123', '456'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
|
@ -179,7 +182,13 @@ describe('RPC -> bulkGet()', () => {
|
|||
});
|
||||
|
||||
expect(storage.bulkGet).toHaveBeenCalledWith(
|
||||
{ requestHandlerContext: ctx.requestHandlerContext },
|
||||
{
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
},
|
||||
['123', '456'],
|
||||
undefined
|
||||
);
|
||||
|
@ -193,53 +202,15 @@ describe('RPC -> bulkGet()', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should enforce a schema for options if options are passed', () => {
|
||||
test('should throw if the request version is higher than the registered version', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
ids: ['123', '456'],
|
||||
options: { foo: 'bar' },
|
||||
version: 'v7',
|
||||
})
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [bulkGet.in.options].'));
|
||||
});
|
||||
|
||||
test('should validate the options', () => {
|
||||
const { ctx } = setup({
|
||||
contentSchemas: {
|
||||
bulkGet: {
|
||||
in: {
|
||||
query: fooDataSchema,
|
||||
options: schema.object({ validOption: schema.maybe(schema.boolean()) }),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
ids: ['123', '456'],
|
||||
options: { foo: 'bar' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[foo]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should validate the result if schema is provided', () => {
|
||||
const { ctx, storage } = setup({
|
||||
contentSchemas: {
|
||||
bulkGet: {
|
||||
in: { query: fooDataSchema },
|
||||
out: { result: schema.object({ validField: schema.maybe(schema.boolean()) }) },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const invalidResult = { wrongField: 'bad' };
|
||||
storage.bulkGet.mockResolvedValueOnce(invalidResult);
|
||||
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, ids: ['123', '456'] })
|
||||
).rejects.toEqual(new Error('[wrongField]: definition for this key is missing'));
|
||||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,45 +11,26 @@ import type { BulkGetIn } from '../../../common';
|
|||
import type { StorageContext, ContentCrud } from '../../core';
|
||||
import type { ProcedureDefinition } from '../rpc_service';
|
||||
import type { Context } from '../types';
|
||||
import { validate } from '../../utils';
|
||||
import { BulkGetResponse } from '../../core/crud';
|
||||
import { validateRequestVersion } from './utils';
|
||||
|
||||
export const bulkGet: ProcedureDefinition<Context, BulkGetIn<string>, BulkGetResponse> = {
|
||||
schemas: rpcSchemas.bulkGet,
|
||||
fn: async (ctx, { contentTypeId, ids, options }) => {
|
||||
fn: async (ctx, { contentTypeId, version: _version, ids, options }) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId);
|
||||
const { bulkGet: schemas } = contentDefinition.schemas.content;
|
||||
|
||||
// Validate the possible options
|
||||
if (options) {
|
||||
if (!schemas?.in?.options) {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [bulkGet.in.options].');
|
||||
}
|
||||
const error = validate(options, schemas.in.options);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const version = validateRequestVersion(_version, contentDefinition.version.latest);
|
||||
|
||||
// Execute CRUD
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(contentTypeId);
|
||||
const storageContext: StorageContext = {
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.bulkGet(storageContext, ids, options);
|
||||
|
||||
// Validate result
|
||||
const resultSchema = schemas?.out?.result;
|
||||
if (resultSchema) {
|
||||
const error = validate(result.items, resultSchema);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,11 +6,10 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { omit } from 'lodash';
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import type { RpcSchemas } from '../../core';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { create } from './create';
|
||||
|
||||
|
@ -28,31 +27,36 @@ if (!outputSchema) {
|
|||
}
|
||||
|
||||
const FOO_CONTENT_ID = 'foo';
|
||||
const fooDataSchema = schema.object({ title: schema.string() }, { unknowns: 'forbid' });
|
||||
|
||||
describe('RPC -> create()', () => {
|
||||
describe('Input/Output validation', () => {
|
||||
/**
|
||||
* These tests are for the procedure call itself. Every RPC needs to declare in/out schema
|
||||
* We will test _specific_ validation schema inside the procedure in separate tests.
|
||||
*/
|
||||
test('should validate that a contentTypeId and "data" object is passed', () => {
|
||||
const validInput = { contentTypeId: 'foo', version: 'v1', data: { title: 'hello' } };
|
||||
|
||||
test('should validate the input', () => {
|
||||
[
|
||||
{ input: { contentTypeId: 'foo', data: { title: 'hello' } } },
|
||||
{ input: validInput },
|
||||
{
|
||||
input: { data: { title: 'hello' } }, // contentTypeId missing
|
||||
input: omit(validInput, 'contentTypeId'),
|
||||
expectedError: '[contentTypeId]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo' }, // data missing
|
||||
input: omit(validInput, 'version'),
|
||||
expectedError: '[version]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { ...validInput, version: '1' }, // invalid version format
|
||||
expectedError: '[version]: must follow the pattern [v${number}]',
|
||||
},
|
||||
{
|
||||
input: omit(validInput, 'data'),
|
||||
expectedError: '[data]: expected value of type [object] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', data: 123 }, // data is not an object
|
||||
input: { ...validInput, data: 123 }, // data is not an object
|
||||
expectedError: '[data]: expected value of type [object] but got [number]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', data: { title: 'hello' }, unknown: 'foo' },
|
||||
input: { ...validInput, unknown: 'foo' },
|
||||
expectedError: '[unknown]: definition for this key is missing',
|
||||
},
|
||||
].forEach(({ input, expectedError }) => {
|
||||
|
@ -75,6 +79,7 @@ describe('RPC -> create()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
data: { title: 'hello' },
|
||||
version: 'v1',
|
||||
options: { any: 'object' },
|
||||
},
|
||||
inputSchema
|
||||
|
@ -86,6 +91,7 @@ describe('RPC -> create()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
data: { title: 'hello' },
|
||||
version: 'v1',
|
||||
options: 123, // Not an object
|
||||
},
|
||||
inputSchema
|
||||
|
@ -113,24 +119,14 @@ describe('RPC -> create()', () => {
|
|||
});
|
||||
|
||||
describe('procedure', () => {
|
||||
const createSchemas = (): RpcSchemas => {
|
||||
return {
|
||||
create: {
|
||||
in: {
|
||||
data: fooDataSchema,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const setup = ({ contentSchemas = createSchemas() } = {}) => {
|
||||
const setup = () => {
|
||||
const contentRegistry = new ContentRegistry(new EventBus());
|
||||
const storage = createMockedStorage();
|
||||
contentRegistry.register({
|
||||
id: FOO_CONTENT_ID,
|
||||
storage,
|
||||
schemas: {
|
||||
content: contentSchemas,
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -146,7 +142,11 @@ describe('RPC -> create()', () => {
|
|||
const expected = 'CreateResult';
|
||||
storage.create.mockResolvedValueOnce(expected);
|
||||
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, data: { title: 'Hello' } });
|
||||
const result = await fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
version: 'v1',
|
||||
data: { title: 'Hello' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
|
@ -154,7 +154,13 @@ describe('RPC -> create()', () => {
|
|||
});
|
||||
|
||||
expect(storage.create).toHaveBeenCalledWith(
|
||||
{ requestHandlerContext: ctx.requestHandlerContext },
|
||||
{
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
},
|
||||
{ title: 'Hello' },
|
||||
undefined
|
||||
);
|
||||
|
@ -168,77 +174,15 @@ describe('RPC -> create()', () => {
|
|||
).rejects.toEqual(new Error('Content [unknown] is not registered.'));
|
||||
});
|
||||
|
||||
test('should enforce a schema for the data', () => {
|
||||
const { ctx } = setup({ contentSchemas: {} as any });
|
||||
expect(() => fn(ctx, { contentTypeId: FOO_CONTENT_ID, data: {} })).rejects.toEqual(
|
||||
new Error('Schema missing for rpc procedure [create.in.data].')
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate the data sent in input - missing field', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() => fn(ctx, { contentTypeId: FOO_CONTENT_ID, data: {} })).rejects.toEqual(
|
||||
new Error('[title]: expected value of type [string] but got [undefined]')
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate the data sent in input - unknown field', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
data: { title: 'Hello', unknownField: 'Hello' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[unknownField]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should enforce a schema for options if options are passed', () => {
|
||||
test('should throw if the request version is higher than the registered version', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
data: { title: 'Hello' },
|
||||
options: { foo: 'bar' },
|
||||
version: 'v7',
|
||||
})
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [create.in.options].'));
|
||||
});
|
||||
|
||||
test('should validate the options', () => {
|
||||
const { ctx } = setup({
|
||||
contentSchemas: {
|
||||
create: {
|
||||
in: {
|
||||
data: fooDataSchema,
|
||||
options: schema.object({ validOption: schema.maybe(schema.boolean()) }),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
data: { title: 'Hello' },
|
||||
options: { foo: 'bar' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[foo]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should validate the result if schema is provided', () => {
|
||||
const { ctx, storage } = setup({
|
||||
contentSchemas: {
|
||||
create: {
|
||||
in: { data: fooDataSchema },
|
||||
out: { result: schema.object({ validField: schema.maybe(schema.boolean()) }) },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const invalidResult = { wrongField: 'bad' };
|
||||
storage.create.mockResolvedValueOnce(invalidResult);
|
||||
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, data: { title: 'Hello' } })
|
||||
).rejects.toEqual(new Error('[wrongField]: definition for this key is missing'));
|
||||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,55 +11,24 @@ import type { CreateIn } from '../../../common';
|
|||
import type { StorageContext, ContentCrud } from '../../core';
|
||||
import type { ProcedureDefinition } from '../rpc_service';
|
||||
import type { Context } from '../types';
|
||||
import { validate } from '../../utils';
|
||||
import { validateRequestVersion } from './utils';
|
||||
|
||||
export const create: ProcedureDefinition<Context, CreateIn<string>> = {
|
||||
schemas: rpcSchemas.create,
|
||||
fn: async (ctx, input) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(input.contentTypeId);
|
||||
const { create: schemas } = contentDefinition.schemas.content;
|
||||
|
||||
// Validate data to be stored
|
||||
if (schemas?.in?.data) {
|
||||
const error = validate(input.data, schemas.in.data);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [create.in.data].');
|
||||
}
|
||||
|
||||
// Validate the possible options
|
||||
if (input.options) {
|
||||
if (!schemas.in?.options) {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [create.in.options].');
|
||||
}
|
||||
const error = validate(input.options, schemas.in.options);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
fn: async (ctx, { contentTypeId, version: _version, data, options }) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId);
|
||||
const version = validateRequestVersion(_version, contentDefinition.version.latest);
|
||||
|
||||
// Execute CRUD
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(input.contentTypeId);
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(contentTypeId);
|
||||
const storageContext: StorageContext = {
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.create(storageContext, input.data, input.options);
|
||||
|
||||
// Validate result
|
||||
const resultSchema = schemas.out?.result;
|
||||
if (resultSchema) {
|
||||
const error = validate(result.result, resultSchema);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const result = await crudInstance.create(storageContext, data, options);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import type { RpcSchemas } from '../../core';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { deleteProc } from './delete';
|
||||
|
||||
|
@ -31,25 +31,31 @@ const FOO_CONTENT_ID = 'foo';
|
|||
|
||||
describe('RPC -> delete()', () => {
|
||||
describe('Input/Output validation', () => {
|
||||
/**
|
||||
* These tests are for the procedure call itself. Every RPC needs to declare in/out schema
|
||||
* We will test _specific_ validation schema inside the procedure in separate tests.
|
||||
*/
|
||||
const validInput = { contentTypeId: 'foo', id: '123', version: 'v1' };
|
||||
|
||||
test('should validate that a contentTypeId and an id is passed', () => {
|
||||
[
|
||||
{ input: { contentTypeId: 'foo', id: '123' } },
|
||||
{ input: validInput },
|
||||
{
|
||||
input: { id: '777' }, // contentTypeId missing
|
||||
input: omit(validInput, 'contentTypeId'),
|
||||
expectedError: '[contentTypeId]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '123', unknown: 'foo' },
|
||||
input: { ...validInput, unknown: 'foo' },
|
||||
expectedError: '[unknown]: definition for this key is missing',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '' }, // id must have min 1 char
|
||||
input: { ...validInput, id: '' }, // id must have min 1 char
|
||||
expectedError: '[id]: value has length [0] but it must have a minimum length of [1].',
|
||||
},
|
||||
{
|
||||
input: omit(validInput, 'version'),
|
||||
expectedError: '[version]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { ...validInput, version: '1' }, // invalid version format
|
||||
expectedError: '[version]: must follow the pattern [v${number}]',
|
||||
},
|
||||
].forEach(({ input, expectedError }) => {
|
||||
const error = validate(input, inputSchema);
|
||||
|
||||
|
@ -70,6 +76,7 @@ describe('RPC -> delete()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
options: { any: 'object' },
|
||||
},
|
||||
inputSchema
|
||||
|
@ -81,6 +88,7 @@ describe('RPC -> delete()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
options: 123, // Not an object
|
||||
},
|
||||
inputSchema
|
||||
|
@ -108,18 +116,14 @@ describe('RPC -> delete()', () => {
|
|||
});
|
||||
|
||||
describe('procedure', () => {
|
||||
const createSchemas = (): RpcSchemas => {
|
||||
return {} as any;
|
||||
};
|
||||
|
||||
const setup = ({ contentSchemas = createSchemas() } = {}) => {
|
||||
const setup = () => {
|
||||
const contentRegistry = new ContentRegistry(new EventBus());
|
||||
const storage = createMockedStorage();
|
||||
contentRegistry.register({
|
||||
id: FOO_CONTENT_ID,
|
||||
storage,
|
||||
schemas: {
|
||||
content: contentSchemas,
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -135,7 +139,7 @@ describe('RPC -> delete()', () => {
|
|||
const expected = 'DeleteResult';
|
||||
storage.delete.mockResolvedValueOnce(expected);
|
||||
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234' });
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, version: 'v1', id: '1234' });
|
||||
|
||||
expect(result).toEqual({
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
|
@ -143,7 +147,13 @@ describe('RPC -> delete()', () => {
|
|||
});
|
||||
|
||||
expect(storage.delete).toHaveBeenCalledWith(
|
||||
{ requestHandlerContext: ctx.requestHandlerContext },
|
||||
{
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
},
|
||||
'1234',
|
||||
undefined
|
||||
);
|
||||
|
@ -157,43 +167,15 @@ describe('RPC -> delete()', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should enforce a schema for options if options are passed', () => {
|
||||
test('should throw if the request version is higher than the registered version', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', options: { foo: 'bar' } })
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [delete.in.options].'));
|
||||
});
|
||||
|
||||
test('should validate the options', () => {
|
||||
const { ctx } = setup({
|
||||
contentSchemas: {
|
||||
delete: {
|
||||
in: {
|
||||
options: schema.object({ validOption: schema.maybe(schema.boolean()) }),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', options: { foo: 'bar' } })
|
||||
).rejects.toEqual(new Error('[foo]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should validate the result if schema is provided', () => {
|
||||
const { ctx, storage } = setup({
|
||||
contentSchemas: {
|
||||
delete: {
|
||||
out: { result: schema.object({ validField: schema.maybe(schema.boolean()) }) },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const invalidResult = { wrongField: 'bad' };
|
||||
storage.delete.mockResolvedValueOnce(invalidResult);
|
||||
|
||||
expect(() => fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234' })).rejects.toEqual(
|
||||
new Error('[wrongField]: definition for this key is missing')
|
||||
);
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '1234',
|
||||
version: 'v7',
|
||||
})
|
||||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,43 +10,25 @@ import type { DeleteIn } from '../../../common';
|
|||
import type { StorageContext, ContentCrud } from '../../core';
|
||||
import type { ProcedureDefinition } from '../rpc_service';
|
||||
import type { Context } from '../types';
|
||||
import { validate } from '../../utils';
|
||||
import { validateRequestVersion } from './utils';
|
||||
|
||||
export const deleteProc: ProcedureDefinition<Context, DeleteIn<string>> = {
|
||||
schemas: rpcSchemas.delete,
|
||||
fn: async (ctx, { contentTypeId, id, options }) => {
|
||||
fn: async (ctx, { contentTypeId, id, version: _version, options }) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId);
|
||||
const { delete: schemas } = contentDefinition.schemas.content;
|
||||
|
||||
if (options) {
|
||||
// Validate the options provided
|
||||
if (!schemas?.in?.options) {
|
||||
throw new Error(`Schema missing for rpc procedure [delete.in.options].`);
|
||||
}
|
||||
const error = validate(options, schemas.in.options);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const version = validateRequestVersion(_version, contentDefinition.version.latest);
|
||||
|
||||
// Execute CRUD
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(contentTypeId);
|
||||
const storageContext: StorageContext = {
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.delete(storageContext, id, options);
|
||||
|
||||
// Validate result
|
||||
const resultSchema = schemas?.out?.result;
|
||||
if (resultSchema) {
|
||||
const error = validate(result.result, resultSchema);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import type { RpcSchemas } from '../../core';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { get } from './get';
|
||||
|
||||
|
@ -31,25 +31,31 @@ const FOO_CONTENT_ID = 'foo';
|
|||
|
||||
describe('RPC -> get()', () => {
|
||||
describe('Input/Output validation', () => {
|
||||
/**
|
||||
* These tests are for the procedure call itself. Every RPC needs to declare in/out schema
|
||||
* We will test _specific_ validation schema inside the procedure in separate tests.
|
||||
*/
|
||||
const validInput = { contentTypeId: 'foo', id: '123', version: 'v1' };
|
||||
|
||||
test('should validate that a contentTypeId and an id is passed', () => {
|
||||
[
|
||||
{ input: { contentTypeId: 'foo', id: '123' } },
|
||||
{ input: validInput },
|
||||
{
|
||||
input: { id: '777' }, // contentTypeId missing
|
||||
input: omit(validInput, 'contentTypeId'),
|
||||
expectedError: '[contentTypeId]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '123', unknown: 'foo' },
|
||||
input: { ...validInput, unknown: 'foo' },
|
||||
expectedError: '[unknown]: definition for this key is missing',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '' }, // id must have min 1 char
|
||||
input: { ...validInput, id: '' }, // id must have min 1 char
|
||||
expectedError: '[id]: value has length [0] but it must have a minimum length of [1].',
|
||||
},
|
||||
{
|
||||
input: omit(validInput, 'version'),
|
||||
expectedError: '[version]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { ...validInput, version: '1' }, // invalid version format
|
||||
expectedError: '[version]: must follow the pattern [v${number}]',
|
||||
},
|
||||
].forEach(({ input, expectedError }) => {
|
||||
const error = validate(input, inputSchema);
|
||||
|
||||
|
@ -70,6 +76,7 @@ describe('RPC -> get()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
options: { any: 'object' },
|
||||
},
|
||||
inputSchema
|
||||
|
@ -81,6 +88,7 @@ describe('RPC -> get()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
options: 123, // Not an object
|
||||
},
|
||||
inputSchema
|
||||
|
@ -108,18 +116,14 @@ describe('RPC -> get()', () => {
|
|||
});
|
||||
|
||||
describe('procedure', () => {
|
||||
const createSchemas = (): RpcSchemas => {
|
||||
return {} as any;
|
||||
};
|
||||
|
||||
const setup = ({ contentSchemas = createSchemas() } = {}) => {
|
||||
const setup = () => {
|
||||
const contentRegistry = new ContentRegistry(new EventBus());
|
||||
const storage = createMockedStorage();
|
||||
contentRegistry.register({
|
||||
id: FOO_CONTENT_ID,
|
||||
storage,
|
||||
schemas: {
|
||||
content: contentSchemas,
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -135,7 +139,7 @@ describe('RPC -> get()', () => {
|
|||
const expected = 'GetResult';
|
||||
storage.get.mockResolvedValueOnce(expected);
|
||||
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234' });
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', version: 'v1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
|
@ -143,7 +147,13 @@ describe('RPC -> get()', () => {
|
|||
});
|
||||
|
||||
expect(storage.get).toHaveBeenCalledWith(
|
||||
{ requestHandlerContext: ctx.requestHandlerContext },
|
||||
{
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
},
|
||||
'1234',
|
||||
undefined
|
||||
);
|
||||
|
@ -157,43 +167,15 @@ describe('RPC -> get()', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('should enforce a schema for options if options are passed', () => {
|
||||
test('should throw if the request version is higher than the registered version', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', options: { foo: 'bar' } })
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [get.in.options].'));
|
||||
});
|
||||
|
||||
test('should validate the options', () => {
|
||||
const { ctx } = setup({
|
||||
contentSchemas: {
|
||||
get: {
|
||||
in: {
|
||||
options: schema.object({ validOption: schema.maybe(schema.boolean()) }),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234', options: { foo: 'bar' } })
|
||||
).rejects.toEqual(new Error('[foo]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should validate the result if schema is provided', () => {
|
||||
const { ctx, storage } = setup({
|
||||
contentSchemas: {
|
||||
get: {
|
||||
out: { result: schema.object({ validField: schema.maybe(schema.boolean()) }) },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const invalidResult = { wrongField: 'bad' };
|
||||
storage.get.mockResolvedValueOnce(invalidResult);
|
||||
|
||||
expect(() => fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '1234' })).rejects.toEqual(
|
||||
new Error('[wrongField]: definition for this key is missing')
|
||||
);
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '1234',
|
||||
version: 'v7',
|
||||
})
|
||||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,44 +9,26 @@
|
|||
import { rpcSchemas } from '../../../common/schemas';
|
||||
import type { GetIn } from '../../../common';
|
||||
import type { ContentCrud, StorageContext } from '../../core';
|
||||
import { validate } from '../../utils';
|
||||
import type { ProcedureDefinition } from '../rpc_service';
|
||||
import type { Context } from '../types';
|
||||
import { validateRequestVersion } from './utils';
|
||||
|
||||
export const get: ProcedureDefinition<Context, GetIn<string>> = {
|
||||
schemas: rpcSchemas.get,
|
||||
fn: async (ctx, input) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(input.contentTypeId);
|
||||
const { get: schemas } = contentDefinition.schemas.content;
|
||||
|
||||
if (input.options) {
|
||||
// Validate the options provided
|
||||
if (!schemas?.in?.options) {
|
||||
throw new Error(`Schema missing for rpc procedure [get.in.options].`);
|
||||
}
|
||||
const error = validate(input.options, schemas.in.options);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
fn: async (ctx, { contentTypeId, id, version: _version, options }) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId);
|
||||
const version = validateRequestVersion(_version, contentDefinition.version.latest);
|
||||
|
||||
// Execute CRUD
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(input.contentTypeId);
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(contentTypeId);
|
||||
const storageContext: StorageContext = {
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.get(storageContext, input.id, input.options);
|
||||
|
||||
// Validate result
|
||||
const resultSchema = schemas?.out?.result;
|
||||
if (resultSchema) {
|
||||
const error = validate(result.item, resultSchema);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const result = await crudInstance.get(storageContext, id, options);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import type { RpcSchemas } from '../../core';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { search } from './search';
|
||||
|
||||
|
@ -28,33 +28,37 @@ if (!outputSchema) {
|
|||
}
|
||||
|
||||
const FOO_CONTENT_ID = 'foo';
|
||||
const fooDataSchema = schema.object({ title: schema.string() }, { unknowns: 'forbid' });
|
||||
|
||||
describe('RPC -> search()', () => {
|
||||
describe('Input/Output validation', () => {
|
||||
/**
|
||||
* These tests are for the procedure call itself. Every RPC needs to declare in/out schema
|
||||
* We will test _specific_ validation schema inside the procedure in separate tests.
|
||||
*/
|
||||
test('should validate that a contentTypeId and "query" object is passed', () => {
|
||||
const query = { title: 'hello' };
|
||||
const query = { title: 'hello' };
|
||||
const validInput = { contentTypeId: 'foo', version: 'v1', query };
|
||||
|
||||
test('should validate that a contentTypeId and "query" object is passed', () => {
|
||||
[
|
||||
{ input: { contentTypeId: 'foo', query } },
|
||||
{ input: validInput },
|
||||
{
|
||||
input: { query }, // contentTypeId missing
|
||||
expectedError: '[contentTypeId]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo' }, // query missing
|
||||
input: omit(validInput, 'version'),
|
||||
expectedError: '[version]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { ...validInput, version: '1' }, // invalid version format
|
||||
expectedError: '[version]: must follow the pattern [v${number}]',
|
||||
},
|
||||
{
|
||||
input: omit(validInput, 'query'),
|
||||
expectedError: '[query]: expected value of type [object] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', query: 123 }, // query is not an object
|
||||
input: { ...validInput, query: 123 }, // query is not an object
|
||||
expectedError: '[query]: expected value of type [object] but got [number]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', query, unknown: 'foo' },
|
||||
input: { ...validInput, unknown: 'foo' },
|
||||
expectedError: '[unknown]: definition for this key is missing',
|
||||
},
|
||||
].forEach(({ input, expectedError }) => {
|
||||
|
@ -77,6 +81,7 @@ describe('RPC -> search()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
query: { title: 'hello' },
|
||||
version: 'v1',
|
||||
options: { any: 'object' },
|
||||
},
|
||||
inputSchema
|
||||
|
@ -87,6 +92,7 @@ describe('RPC -> search()', () => {
|
|||
error = validate(
|
||||
{
|
||||
contentTypeId: 'foo',
|
||||
version: 'v1',
|
||||
query: { title: 'hello' },
|
||||
options: 123, // Not an object
|
||||
},
|
||||
|
@ -129,24 +135,14 @@ describe('RPC -> search()', () => {
|
|||
});
|
||||
|
||||
describe('procedure', () => {
|
||||
const createSchemas = (): RpcSchemas => {
|
||||
return {
|
||||
search: {
|
||||
in: {
|
||||
query: fooDataSchema,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const setup = ({ contentSchemas = createSchemas() } = {}) => {
|
||||
const setup = () => {
|
||||
const contentRegistry = new ContentRegistry(new EventBus());
|
||||
const storage = createMockedStorage();
|
||||
contentRegistry.register({
|
||||
id: FOO_CONTENT_ID,
|
||||
storage,
|
||||
schemas: {
|
||||
content: contentSchemas,
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -162,7 +158,11 @@ describe('RPC -> search()', () => {
|
|||
const expected = 'SearchResult';
|
||||
storage.search.mockResolvedValueOnce(expected);
|
||||
|
||||
const result = await fn(ctx, { contentTypeId: FOO_CONTENT_ID, query: { title: 'Hello' } });
|
||||
const result = await fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
version: 'v1', // version in request
|
||||
query: { title: 'Hello' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
|
@ -170,7 +170,13 @@ describe('RPC -> search()', () => {
|
|||
});
|
||||
|
||||
expect(storage.search).toHaveBeenCalledWith(
|
||||
{ requestHandlerContext: ctx.requestHandlerContext },
|
||||
{
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
},
|
||||
{ title: 'Hello' },
|
||||
undefined
|
||||
);
|
||||
|
@ -184,77 +190,15 @@ describe('RPC -> search()', () => {
|
|||
).rejects.toEqual(new Error('Content [unknown] is not registered.'));
|
||||
});
|
||||
|
||||
test('should enforce a schema for the query', () => {
|
||||
const { ctx } = setup({ contentSchemas: {} as any });
|
||||
expect(() => fn(ctx, { contentTypeId: FOO_CONTENT_ID, query: {} })).rejects.toEqual(
|
||||
new Error('Schema missing for rpc procedure [search.in.query].')
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate the query sent in input - missing field', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() => fn(ctx, { contentTypeId: FOO_CONTENT_ID, query: {} })).rejects.toEqual(
|
||||
new Error('[title]: expected value of type [string] but got [undefined]')
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate the query sent in input - unknown field', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
query: { title: 'Hello', unknownField: 'Hello' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[unknownField]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should enforce a schema for options if options are passed', () => {
|
||||
test('should throw if the request version is higher than the registered version', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
query: { title: 'Hello' },
|
||||
options: { foo: 'bar' },
|
||||
version: 'v7',
|
||||
})
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [search.in.options].'));
|
||||
});
|
||||
|
||||
test('should validate the options', () => {
|
||||
const { ctx } = setup({
|
||||
contentSchemas: {
|
||||
search: {
|
||||
in: {
|
||||
query: fooDataSchema,
|
||||
options: schema.object({ validOption: schema.maybe(schema.boolean()) }),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
query: { title: 'Hello' },
|
||||
options: { foo: 'bar' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[foo]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should validate the result if schema is provided', () => {
|
||||
const { ctx, storage } = setup({
|
||||
contentSchemas: {
|
||||
search: {
|
||||
in: { query: fooDataSchema },
|
||||
out: { result: schema.object({ validField: schema.maybe(schema.boolean()) }) },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const invalidResult = { wrongField: 'bad' };
|
||||
storage.search.mockResolvedValueOnce(invalidResult);
|
||||
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, query: { title: 'Hello' } })
|
||||
).rejects.toEqual(new Error('[wrongField]: definition for this key is missing'));
|
||||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,56 +11,25 @@ import type { SearchIn } from '../../../common';
|
|||
import type { StorageContext, ContentCrud } from '../../core';
|
||||
import type { ProcedureDefinition } from '../rpc_service';
|
||||
import type { Context } from '../types';
|
||||
import { validate } from '../../utils';
|
||||
import { validateRequestVersion } from './utils';
|
||||
|
||||
export const search: ProcedureDefinition<Context, SearchIn<string>> = {
|
||||
schemas: rpcSchemas.search,
|
||||
fn: async (ctx, { contentTypeId, query, options }) => {
|
||||
fn: async (ctx, { contentTypeId, version: _version, query, options }) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId);
|
||||
const { search: schemas } = contentDefinition.schemas.content;
|
||||
|
||||
// Validate query to execute
|
||||
if (schemas?.in?.query) {
|
||||
const error = validate(query, schemas.in.query);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [search.in.query].');
|
||||
}
|
||||
|
||||
// Validate the possible options
|
||||
if (options) {
|
||||
if (!schemas.in?.options) {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [search.in.options].');
|
||||
}
|
||||
const error = validate(options, schemas.in.options);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const version = validateRequestVersion(_version, contentDefinition.version.latest);
|
||||
|
||||
// Execute CRUD
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(contentTypeId);
|
||||
const storageContext: StorageContext = {
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.search(storageContext, query, options);
|
||||
|
||||
// Validate result
|
||||
const resultSchema = schemas.out?.result;
|
||||
if (resultSchema) {
|
||||
const error = validate(result.result, resultSchema);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { validate } from '../../utils';
|
||||
import { ContentRegistry } from '../../core/registry';
|
||||
import { createMockedStorage } from '../../core/mocks';
|
||||
import type { RpcSchemas } from '../../core';
|
||||
import { EventBus } from '../../core/event_bus';
|
||||
import { update } from './update';
|
||||
|
||||
|
@ -28,41 +28,45 @@ if (!outputSchema) {
|
|||
}
|
||||
|
||||
const FOO_CONTENT_ID = 'foo';
|
||||
const fooDataSchema = schema.object({ title: schema.string() }, { unknowns: 'forbid' });
|
||||
|
||||
describe('RPC -> update()', () => {
|
||||
describe('Input/Output validation', () => {
|
||||
/**
|
||||
* These tests are for the procedure call itself. Every RPC needs to declare in/out schema
|
||||
* We will test _specific_ validation schema inside the procedure suite below.
|
||||
*/
|
||||
test('should validate that a "contentTypeId", an "id" and "data" object is passed', () => {
|
||||
const data = { title: 'hello' };
|
||||
const data = { title: 'hello' };
|
||||
const validInput = { contentTypeId: 'foo', id: '123', version: 'v1', data };
|
||||
|
||||
test('should validate that a "contentTypeId", an "id" and "data" object is passed', () => {
|
||||
[
|
||||
{ input: { contentTypeId: 'foo', id: '123', data } },
|
||||
{ input: validInput },
|
||||
{
|
||||
input: { id: '123', data }, // contentTypeId missing
|
||||
input: omit(validInput, 'contentTypeId'),
|
||||
expectedError: '[contentTypeId]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', data }, // id missing
|
||||
input: omit(validInput, 'id'),
|
||||
expectedError: '[id]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '' }, // id must have min 1 char
|
||||
input: { ...validInput, id: '' }, // id must have min 1 char
|
||||
expectedError: '[id]: value has length [0] but it must have a minimum length of [1].',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '123' }, // data missing
|
||||
input: omit(validInput, 'version'),
|
||||
expectedError: '[version]: expected value of type [string] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { ...validInput, version: '1' }, // invalid version format
|
||||
expectedError: '[version]: must follow the pattern [v${number}]',
|
||||
},
|
||||
{
|
||||
input: omit(validInput, 'data'),
|
||||
expectedError: '[data]: expected value of type [object] but got [undefined]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '123', data: 123 }, // data is not an object
|
||||
input: { ...validInput, data: 123 }, // data is not an object
|
||||
expectedError: '[data]: expected value of type [object] but got [number]',
|
||||
},
|
||||
{
|
||||
input: { contentTypeId: 'foo', id: '123', data, unknown: 'foo' },
|
||||
input: { ...validInput, unknown: 'foo' },
|
||||
expectedError: '[unknown]: definition for this key is missing',
|
||||
},
|
||||
].forEach(({ input, expectedError }) => {
|
||||
|
@ -81,6 +85,7 @@ describe('RPC -> update()', () => {
|
|||
{
|
||||
contentTypeId: 'foo',
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
data: { title: 'hello' },
|
||||
options: { any: 'object' },
|
||||
},
|
||||
|
@ -94,6 +99,7 @@ describe('RPC -> update()', () => {
|
|||
contentTypeId: 'foo',
|
||||
data: { title: 'hello' },
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
options: 123, // Not an object
|
||||
},
|
||||
inputSchema
|
||||
|
@ -121,24 +127,14 @@ describe('RPC -> update()', () => {
|
|||
});
|
||||
|
||||
describe('procedure', () => {
|
||||
const createSchemas = (): RpcSchemas => {
|
||||
return {
|
||||
update: {
|
||||
in: {
|
||||
data: fooDataSchema,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
const setup = ({ contentSchemas = createSchemas() } = {}) => {
|
||||
const setup = () => {
|
||||
const contentRegistry = new ContentRegistry(new EventBus());
|
||||
const storage = createMockedStorage();
|
||||
contentRegistry.register({
|
||||
id: FOO_CONTENT_ID,
|
||||
storage,
|
||||
schemas: {
|
||||
content: contentSchemas,
|
||||
version: {
|
||||
latest: 'v2',
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -157,6 +153,7 @@ describe('RPC -> update()', () => {
|
|||
const result = await fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '123',
|
||||
version: 'v1',
|
||||
data: { title: 'Hello' },
|
||||
});
|
||||
|
||||
|
@ -166,7 +163,13 @@ describe('RPC -> update()', () => {
|
|||
});
|
||||
|
||||
expect(storage.update).toHaveBeenCalledWith(
|
||||
{ requestHandlerContext: ctx.requestHandlerContext },
|
||||
{
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: 'v1',
|
||||
latest: 'v2', // from the registry
|
||||
},
|
||||
},
|
||||
'123',
|
||||
{ title: 'Hello' },
|
||||
undefined
|
||||
|
@ -181,82 +184,16 @@ describe('RPC -> update()', () => {
|
|||
).rejects.toEqual(new Error('Content [unknown] is not registered.'));
|
||||
});
|
||||
|
||||
test('should enforce a schema for the data', () => {
|
||||
const { ctx } = setup({ contentSchemas: {} as any });
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '123', data: {} })
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [update.in.data].'));
|
||||
});
|
||||
|
||||
test('should validate the data sent in input - missing field', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '123', data: {} })
|
||||
).rejects.toEqual(
|
||||
new Error('[title]: expected value of type [string] but got [undefined]')
|
||||
);
|
||||
});
|
||||
|
||||
test('should validate the data sent in input - unknown field', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '123',
|
||||
data: { title: 'Hello', unknownField: 'Hello' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[unknownField]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should enforce a schema for options if options are passed', () => {
|
||||
test('should throw if the request version is higher than the registered version', () => {
|
||||
const { ctx } = setup();
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '123',
|
||||
data: { title: 'Hello' },
|
||||
options: { foo: 'bar' },
|
||||
version: 'v7',
|
||||
})
|
||||
).rejects.toEqual(new Error('Schema missing for rpc procedure [update.in.options].'));
|
||||
});
|
||||
|
||||
test('should validate the options', () => {
|
||||
const { ctx } = setup({
|
||||
contentSchemas: {
|
||||
update: {
|
||||
in: {
|
||||
data: fooDataSchema,
|
||||
options: schema.object({ validOption: schema.maybe(schema.boolean()) }),
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
expect(() =>
|
||||
fn(ctx, {
|
||||
contentTypeId: FOO_CONTENT_ID,
|
||||
id: '123',
|
||||
data: { title: 'Hello' },
|
||||
options: { foo: 'bar' },
|
||||
})
|
||||
).rejects.toEqual(new Error('[foo]: definition for this key is missing'));
|
||||
});
|
||||
|
||||
test('should validate the result if schema is provided', () => {
|
||||
const { ctx, storage } = setup({
|
||||
contentSchemas: {
|
||||
update: {
|
||||
in: { data: fooDataSchema },
|
||||
out: { result: schema.object({ validField: schema.maybe(schema.boolean()) }) },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const invalidResult = { wrongField: 'bad' };
|
||||
storage.update.mockResolvedValueOnce(invalidResult);
|
||||
|
||||
expect(() =>
|
||||
fn(ctx, { contentTypeId: FOO_CONTENT_ID, id: '123', data: { title: 'Hello' } })
|
||||
).rejects.toEqual(new Error('[wrongField]: definition for this key is missing'));
|
||||
).rejects.toEqual(new Error('Invalid version. Latest version is [v2].'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,56 +10,25 @@ import type { UpdateIn } from '../../../common';
|
|||
import type { StorageContext, ContentCrud } from '../../core';
|
||||
import type { ProcedureDefinition } from '../rpc_service';
|
||||
import type { Context } from '../types';
|
||||
import { validate } from '../../utils';
|
||||
import { validateRequestVersion } from './utils';
|
||||
|
||||
export const update: ProcedureDefinition<Context, UpdateIn<string>> = {
|
||||
schemas: rpcSchemas.update,
|
||||
fn: async (ctx, { contentTypeId, id, data, options }) => {
|
||||
fn: async (ctx, { contentTypeId, id, version: _version, data, options }) => {
|
||||
const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId);
|
||||
const { update: schemas } = contentDefinition.schemas.content;
|
||||
|
||||
// Validate data to be stored
|
||||
if (schemas?.in?.data) {
|
||||
const error = validate(data, schemas.in.data);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [update.in.data].');
|
||||
}
|
||||
|
||||
// Validate the possible options
|
||||
if (options) {
|
||||
if (!schemas.in?.options) {
|
||||
// TODO: Improve error handling
|
||||
throw new Error('Schema missing for rpc procedure [update.in.options].');
|
||||
}
|
||||
const error = validate(options, schemas.in.options);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const version = validateRequestVersion(_version, contentDefinition.version.latest);
|
||||
|
||||
// Execute CRUD
|
||||
const crudInstance: ContentCrud = ctx.contentRegistry.getCrud(contentTypeId);
|
||||
const storageContext: StorageContext = {
|
||||
requestHandlerContext: ctx.requestHandlerContext,
|
||||
version: {
|
||||
request: version,
|
||||
latest: contentDefinition.version.latest,
|
||||
},
|
||||
};
|
||||
const result = await crudInstance.update(storageContext, id, data, options);
|
||||
|
||||
// Validate result
|
||||
const resultSchema = schemas.out?.result;
|
||||
if (resultSchema) {
|
||||
const error = validate(result.result, resultSchema);
|
||||
if (error) {
|
||||
// TODO: Improve error handling
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 '../../../common/utils';
|
||||
import type { Version } from '../../../common';
|
||||
|
||||
export const validateRequestVersion = (
|
||||
requestVersion: Version | undefined,
|
||||
latestVersion: Version
|
||||
): Version => {
|
||||
if (requestVersion === undefined) {
|
||||
// this should never happen as we have schema in place at the route level
|
||||
throw new Error('Request version missing');
|
||||
}
|
||||
|
||||
const requestVersionNumber = validateVersion(requestVersion);
|
||||
const latestVersionNumber = parseInt(latestVersion.substring(1), 10);
|
||||
|
||||
if (requestVersionNumber > latestVersionNumber) {
|
||||
throw new Error(`Invalid version. Latest version is [${latestVersion}].`);
|
||||
}
|
||||
|
||||
return requestVersion;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue