[Content management] BWC - versioned content (#152729)

This commit is contained in:
Sébastien Loix 2023-03-10 14:36:17 +00:00 committed by GitHub
parent e36ed9658d
commit 206a9d114d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 715 additions and 804 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/** 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;
};

View file

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

View file

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

View file

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

View file

@ -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), {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()', () => {

View file

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

View file

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

View file

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

View file

@ -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'),

View file

@ -21,7 +21,7 @@ import {
ContentManagementServerStart,
SetupDependencies,
} from './types';
import { procedureNames } from '../common';
import { procedureNames } from '../common/rpc';
type CreateRouterFn = CoreSetup['http']['createRouter'];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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