From 97ebed051f26ab262a8563d5d0fd322f1015bdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 5 Feb 2024 11:37:32 +0000 Subject: [PATCH] [Content Management] Server side client (#175968) --- .../content_management_services_versioning.ts | 6 +- .../content_management/common/rpc/types.ts | 1 + .../content_client/content_client.test.ts | 264 ++++++++++++++ .../server/content_client/content_client.ts | 57 +++ .../content_client/content_client_factory.ts | 102 ++++++ .../{utils.ts => content_client/index.ts} | 11 +- .../server/content_client/types.ts | 53 +++ .../server/core/core.test.ts | 342 ++++++++++++++++-- .../content_management/server/core/core.ts | 87 ++++- .../content_management/server/core/crud.ts | 6 +- .../server/core/mocks/in_memory_storage.ts | 24 +- .../server/core/registry.ts | 11 + .../content_management/server/core/types.ts | 14 + .../content_management/server/plugin.test.ts | 2 +- .../server/rpc/procedures/bulk_get.test.ts | 8 +- .../server/rpc/procedures/bulk_get.ts | 13 +- .../server/rpc/procedures/create.test.ts | 8 +- .../server/rpc/procedures/create.ts | 14 +- .../server/rpc/procedures/delete.test.ts | 8 +- .../server/rpc/procedures/delete.ts | 13 +- .../server/rpc/procedures/get.test.ts | 8 +- .../server/rpc/procedures/get.ts | 13 +- .../server/rpc/procedures/msearch.test.ts | 7 +- .../server/rpc/procedures/msearch.ts | 26 +- .../server/rpc/procedures/search.test.ts | 8 +- .../server/rpc/procedures/search.ts | 13 +- .../server/rpc/procedures/update.test.ts | 8 +- .../server/rpc/procedures/update.ts | 14 +- .../server/rpc/routes/routes.ts | 3 +- .../content_management/server/rpc/types.ts | 10 +- .../content_management/server/types.ts | 9 +- .../content_management/server/utils/index.ts | 14 + .../services_transforms_factory.ts | 17 +- .../server/{rpc/procedures => utils}/utils.ts | 22 +- 34 files changed, 1045 insertions(+), 171 deletions(-) create mode 100644 src/plugins/content_management/server/content_client/content_client.test.ts create mode 100644 src/plugins/content_management/server/content_client/content_client.ts create mode 100644 src/plugins/content_management/server/content_client/content_client_factory.ts rename src/plugins/content_management/server/{utils.ts => content_client/index.ts} (57%) create mode 100644 src/plugins/content_management/server/content_client/types.ts create mode 100644 src/plugins/content_management/server/utils/index.ts rename src/plugins/content_management/server/{rpc => utils}/services_transforms_factory.ts (88%) rename src/plugins/content_management/server/{rpc/procedures => utils}/utils.ts (73%) diff --git a/packages/kbn-object-versioning/lib/content_management_services_versioning.ts b/packages/kbn-object-versioning/lib/content_management_services_versioning.ts index eb38de643f630..9808ee96d32e5 100644 --- a/packages/kbn-object-versioning/lib/content_management_services_versioning.ts +++ b/packages/kbn-object-versioning/lib/content_management_services_versioning.ts @@ -69,18 +69,20 @@ const validateServiceDefinitions = (definitions: ServiceDefinitionVersioned) => * ```ts * From this * { + * // Service definition version 1 * 1: { * get: { * in: { - * options: { up: () => {} } // 1 + * options: { up: () => {} } * } * }, * ... * }, + * // Service definition version 2 * 2: { * get: { * in: { - * options: { up: () => {} } // 2 + * options: { up: () => {} } * } * }, * } diff --git a/src/plugins/content_management/common/rpc/types.ts b/src/plugins/content_management/common/rpc/types.ts index 781ba7abc20cf..8b93d8246f339 100644 --- a/src/plugins/content_management/common/rpc/types.ts +++ b/src/plugins/content_management/common/rpc/types.ts @@ -15,6 +15,7 @@ export interface ProcedureSchemas { export type ItemResult = M extends void ? { item: T; + meta?: never; } : { item: T; diff --git a/src/plugins/content_management/server/content_client/content_client.test.ts b/src/plugins/content_management/server/content_client/content_client.test.ts new file mode 100644 index 0000000000000..f88e4f5d3a892 --- /dev/null +++ b/src/plugins/content_management/server/content_client/content_client.test.ts @@ -0,0 +1,264 @@ +/* + * 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 { ContentCrud } from '../core/crud'; +import { EventBus } from '../core/event_bus'; +import { createMemoryStorage, type FooContent } from '../core/mocks'; +import { ContentClient } from './content_client'; + +describe('ContentClient', () => { + const setup = ({ + contentTypeId = 'foo', + }: { + contentTypeId?: string; + } = {}) => { + const storage = createMemoryStorage(); + const eventBus = new EventBus(); + const crudInstance = new ContentCrud(contentTypeId, storage, { eventBus }); + + const contentClient = ContentClient.create(contentTypeId, { + crudInstance, + storageContext: {} as any, + }); + + return { contentClient }; + }; + + describe('instance', () => { + test('should throw an Error if instantiate using constructor', () => { + const expectToThrow = () => { + new ContentClient(Symbol('foo'), 'foo', {} as any); + }; + expect(expectToThrow).toThrowError('Use ContentClient.create() instead'); + }); + + test('should have contentTypeId', () => { + const { contentClient } = setup({ contentTypeId: 'hellooo' }); + expect(contentClient.contentTypeId).toBe('hellooo'); + }); + + test('should throw if crudInstance is not an instance of ContentCrud', () => { + const expectToThrow = () => { + ContentClient.create('foo', { + crudInstance: {} as any, + storageContext: {} as any, + }); + }; + // With this test and runtime check we can rely on all the existing tests of the Content Crud. + // e.g. the tests about events being dispatched, etc. + expect(expectToThrow).toThrowError('Crud instance missing or not an instance of ContentCrud'); + }); + }); + + describe('Crud', () => { + describe('create()', () => { + test('should create an item', async () => { + const { contentClient } = setup(); + const itemCreated = await contentClient.create({ foo: 'bar' }); + const { id } = itemCreated.result.item; + const res = await contentClient.get(id); + expect(res.result.item).toEqual({ foo: 'bar', id }); + }); + + test('should pass the options to the storage', async () => { + const { contentClient } = setup(); + + const options = { forwardInResponse: { option1: 'foo' } }; + const res = await contentClient.create({ field1: 123 }, options); + expect(res.result.item).toEqual({ + field1: 123, + id: expect.any(String), + options: { option1: 'foo' }, // the options have correctly been passed to the storage + }); + }); + }); + + describe('get()', () => { + // Note: we test the client get() method in multiple other tests for + // the "create()" and "update()" methods, no need for extended tests here. + test('should return undefined if no item was found', async () => { + const { contentClient } = setup(); + const res = await contentClient.get('hello'); + expect(res.result.item).toBeUndefined(); + }); + + test('should pass the options to the storage', async () => { + const { contentClient } = setup(); + + const options = { forwardInResponse: { foo: 'bar' } }; + const res = await contentClient.get('hello', options); + + expect(res.result.item).toEqual({ + // the options have correctly been passed to the storage + options: { foo: 'bar' }, + }); + }); + }); + + describe('bulkGet()', () => { + test('should return multiple items', async () => { + const { contentClient } = setup(); + + const item1 = await contentClient.create({ name: 'item1' }); + const item2 = await contentClient.create({ name: 'item2' }); + const ids = [item1.result.item.id, item2.result.item.id]; + + const res = await contentClient.bulkGet(ids); + expect(res.result.hits).toEqual([ + { + item: { + name: 'item1', + id: expect.any(String), + }, + }, + { + item: { + name: 'item2', + id: expect.any(String), + }, + }, + ]); + }); + + test('should pass the options to the storage', async () => { + const { contentClient } = setup(); + + const item1 = await contentClient.create({ name: 'item1' }); + const item2 = await contentClient.create({ name: 'item2' }); + const ids = [item1.result.item.id, item2.result.item.id]; + + const options = { forwardInResponse: { foo: 'bar' } }; + const res = await contentClient.bulkGet(ids, options); + + expect(res.result.hits).toEqual([ + { + item: { + name: 'item1', + id: expect.any(String), + options: { foo: 'bar' }, // the options have correctly been passed to the storage + }, + }, + { + item: { + name: 'item2', + id: expect.any(String), + options: { foo: 'bar' }, // the options have correctly been passed to the storage + }, + }, + ]); + }); + }); + + describe('update()', () => { + test('should update an item', async () => { + const { contentClient } = setup(); + const itemCreated = await contentClient.create({ foo: 'bar' }); + const { id } = itemCreated.result.item; + + await contentClient.update(id, { foo: 'changed' }); + + const res = await contentClient.get(id); + expect(res.result.item).toEqual({ foo: 'changed', id }); + }); + + test('should pass the options to the storage', async () => { + const { contentClient } = setup(); + const itemCreated = await contentClient.create({ field1: 'bar' }); + const { id } = itemCreated.result.item; + + const options = { forwardInResponse: { option1: 'foo' } }; + const res = await contentClient.update(id, { field1: 'changed' }, options); + + expect(res.result.item).toEqual({ + field1: 'changed', + id, + options: { option1: 'foo' }, // the options have correctly been passed to the storage + }); + }); + }); + + describe('delete()', () => { + test('should delete an item', async () => { + const { contentClient } = setup(); + const itemCreated = await contentClient.create({ foo: 'bar' }); + const { id } = itemCreated.result.item; + + { + const res = await contentClient.get(id); + expect(res.result.item).not.toBeUndefined(); + } + + await contentClient.delete(id); + + { + const res = await contentClient.get(id); + expect(res.result.item).toBeUndefined(); + } + }); + + test('should pass the options to the storage', async () => { + const { contentClient } = setup(); + const itemCreated = await contentClient.create({ field1: 'bar' }); + const { id } = itemCreated.result.item; + + const options = { forwardInResponse: { option1: 'foo' } }; + + const res = await contentClient.delete(id, options); + + expect(res.result).toEqual({ + success: true, + options: { option1: 'foo' }, // the options have correctly been passed to the storage + }); + }); + }); + + describe('search()', () => { + test('should find an item', async () => { + const { contentClient } = setup(); + + await contentClient.create({ title: 'hello' }); + + const res = await contentClient.search({ text: 'hello' }); + + expect(res.result).toEqual({ + hits: [ + { + id: expect.any(String), + title: 'hello', + }, + ], + pagination: { + cursor: '', + total: 1, + }, + }); + }); + + test('should pass the options to the storage', async () => { + const { contentClient } = setup(); + await contentClient.create({ title: 'hello' }); + + const options = { forwardInResponse: { option1: 'foo' } }; + const res = await contentClient.search({ text: 'hello' }, options); + + expect(res.result).toEqual({ + hits: [ + { + id: expect.any(String), + title: 'hello', + options: { option1: 'foo' }, // the options have correctly been passed to the storage + }, + ], + pagination: { + cursor: '', + total: 1, + }, + }); + }); + }); + }); +}); diff --git a/src/plugins/content_management/server/content_client/content_client.ts b/src/plugins/content_management/server/content_client/content_client.ts new file mode 100644 index 0000000000000..4cf7691059408 --- /dev/null +++ b/src/plugins/content_management/server/content_client/content_client.ts @@ -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 type { StorageContext } from '../core'; +import { ContentCrud } from '../core/crud'; +import type { IContentClient } from './types'; + +interface Context { + crudInstance: ContentCrud; + storageContext: StorageContext; +} + +const secretToken = Symbol('secretToken'); + +export class ContentClient implements IContentClient { + static create(contentTypeId: string, ctx: Context): IContentClient { + return new ContentClient(secretToken, contentTypeId, ctx); + } + + constructor(token: symbol, public contentTypeId: string, private readonly ctx: Context) { + if (token !== secretToken) { + throw new Error('Use ContentClient.create() instead'); + } + + if (ctx.crudInstance instanceof ContentCrud === false) { + throw new Error('Crud instance missing or not an instance of ContentCrud'); + } + } + + get(id: string, options: object) { + return this.ctx.crudInstance.get(this.ctx.storageContext, id, options); + } + + bulkGet(ids: string[], options: object) { + return this.ctx.crudInstance.bulkGet(this.ctx.storageContext, ids, options); + } + + create(data: object, options?: object) { + return this.ctx.crudInstance.create(this.ctx.storageContext, data, options); + } + + update(id: string, data: object, options?: object) { + return this.ctx.crudInstance.update(this.ctx.storageContext, id, data, options); + } + + delete(id: string, options?: object) { + return this.ctx.crudInstance.delete(this.ctx.storageContext, id, options); + } + + search(query: object, options?: object) { + return this.ctx.crudInstance.search(this.ctx.storageContext, query, options); + } +} diff --git a/src/plugins/content_management/server/content_client/content_client_factory.ts b/src/plugins/content_management/server/content_client/content_client_factory.ts new file mode 100644 index 0000000000000..e60f81fddfac8 --- /dev/null +++ b/src/plugins/content_management/server/content_client/content_client_factory.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import { Version } from '@kbn/object-versioning'; + +import type { MSearchIn, MSearchOut } from '../../common'; +import type { ContentRegistry } from '../core'; +import { MSearchService } from '../core/msearch'; +import { getServiceObjectTransformFactory, getStorageContext } from '../utils'; +import { ContentClient } from './content_client'; + +export const getContentClientFactory = + ({ contentRegistry }: { contentRegistry: ContentRegistry }) => + (contentTypeId: string) => { + const getForRequest = ({ + requestHandlerContext, + version, + }: { + requestHandlerContext: RequestHandlerContext; + request: KibanaRequest; + version?: Version; + }) => { + const contentDefinition = contentRegistry.getDefinition(contentTypeId); + + const storageContext = getStorageContext({ + contentTypeId, + version: version ?? contentDefinition.version.latest, + ctx: { + contentRegistry, + requestHandlerContext, + getTransformsFactory: getServiceObjectTransformFactory, + }, + }); + + const crudInstance = contentRegistry.getCrud(contentTypeId); + + return ContentClient.create(contentTypeId, { + storageContext, + crudInstance, + }); + }; + + return { + /** + * Client getter to interact with the registered content type. + */ + getForRequest, + }; + }; + +export const getMSearchClientFactory = + ({ + contentRegistry, + mSearchService, + }: { + contentRegistry: ContentRegistry; + mSearchService: MSearchService; + }) => + ({ + requestHandlerContext, + }: { + requestHandlerContext: RequestHandlerContext; + request: KibanaRequest; + }) => { + const msearch = async ({ contentTypes, query }: MSearchIn): Promise => { + const contentTypesWithStorageContext = contentTypes.map(({ contentTypeId, version }) => { + const contentDefinition = contentRegistry.getDefinition(contentTypeId); + + const storageContext = getStorageContext({ + contentTypeId, + version: version ?? contentDefinition.version.latest, + ctx: { + contentRegistry, + requestHandlerContext, + getTransformsFactory: getServiceObjectTransformFactory, + }, + }); + + return { + contentTypeId, + ctx: storageContext, + }; + }); + + const result = await mSearchService.search(contentTypesWithStorageContext, query); + + return { + contentTypes, + result, + }; + }; + + return { + msearch, + }; + }; diff --git a/src/plugins/content_management/server/utils.ts b/src/plugins/content_management/server/content_client/index.ts similarity index 57% rename from src/plugins/content_management/server/utils.ts rename to src/plugins/content_management/server/content_client/index.ts index cd001f77350a2..24e5daa1a88bb 100644 --- a/src/plugins/content_management/server/utils.ts +++ b/src/plugins/content_management/server/content_client/index.ts @@ -6,13 +6,6 @@ * Side Public License, v 1. */ -import { Type, ValidationError } from '@kbn/config-schema'; +export { getContentClientFactory, getMSearchClientFactory } from './content_client_factory'; -export const validate = (input: unknown, schema: Type): ValidationError | null => { - try { - schema.validate(input); - return null; - } catch (e: any) { - return e as ValidationError; - } -}; +export type { IContentClient } from './types'; diff --git a/src/plugins/content_management/server/content_client/types.ts b/src/plugins/content_management/server/content_client/types.ts new file mode 100644 index 0000000000000..bc7c0c61dccef --- /dev/null +++ b/src/plugins/content_management/server/content_client/types.ts @@ -0,0 +1,53 @@ +/* + * 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 { ContentCrud } from '../core/crud'; + +type CrudGetParameters = Parameters['get']>; +export type GetParameters = [CrudGetParameters[1], CrudGetParameters[2]?]; + +type CrudBulkGetParameters = Parameters['bulkGet']>; +export type BulkGetParameters = [ + CrudBulkGetParameters[1], + CrudBulkGetParameters[2]? +]; + +type CrudCreateParameters = Parameters['create']>; +export type CreateParameters = [ + CrudCreateParameters[1], + CrudCreateParameters[2]? +]; + +type CrudUpdateParameters = Parameters['update']>; +export type UpdateParameters = [ + CrudUpdateParameters[1], + CrudUpdateParameters[2], + CrudUpdateParameters[3]? +]; + +type CrudDeleteParameters = Parameters['delete']>; +export type DeleteParameters = [ + CrudDeleteParameters[1], + CrudDeleteParameters[2]? +]; + +type CrudSearchParameters = Parameters['search']>; +export type SearchParameters = [ + CrudSearchParameters[1], + CrudSearchParameters[2]? +]; + +export interface IContentClient { + contentTypeId: string; + get(...params: GetParameters): ReturnType['get']>; + bulkGet(...params: BulkGetParameters): ReturnType['bulkGet']>; + create(...params: CreateParameters): ReturnType['create']>; + update(...params: UpdateParameters): ReturnType['update']>; + delete(...params: DeleteParameters): ReturnType['delete']>; + search(...params: SearchParameters): ReturnType['search']>; +} diff --git a/src/plugins/content_management/server/core/core.test.ts b/src/plugins/content_management/server/core/core.test.ts index 15671b42f4159..2eafd1dc4df91 100644 --- a/src/plugins/content_management/server/core/core.test.ts +++ b/src/plugins/content_management/server/core/core.test.ts @@ -7,8 +7,12 @@ */ import { loggingSystemMock } from '@kbn/core/server/mocks'; + +import { until } from '../event_stream/tests/util'; +import { setupEventStreamService } from '../event_stream/tests/setup_event_stream_service'; +import { ContentClient } from '../content_client/content_client'; import { Core } from './core'; -import { createMemoryStorage } from './mocks'; +import { createMemoryStorage, createMockedStorage } from './mocks'; import { ContentRegistry } from './registry'; import type { ContentCrud } from './crud'; import type { @@ -31,19 +35,37 @@ import type { SearchItemSuccess, SearchItemError, } from './event_types'; -import { ContentTypeDefinition, StorageContext } from './types'; -import { until } from '../event_stream/tests/util'; -import { setupEventStreamService } from '../event_stream/tests/setup_event_stream_service'; +import { ContentStorage, ContentTypeDefinition, StorageContext } from './types'; + +const spyMsearch = jest.fn(); +const getmSearchSpy = () => spyMsearch; + +jest.mock('./msearch', () => { + const original = jest.requireActual('./msearch'); + class MSearchService { + search(...args: any[]) { + getmSearchSpy()(...args); + } + } + return { + ...original, + MSearchService, + }; +}); const logger = loggingSystemMock.createLogger(); const FOO_CONTENT_ID = 'foo'; -const setup = ({ registerFooType = false }: { registerFooType?: boolean } = {}) => { +const setup = ({ + registerFooType = false, + storage = createMemoryStorage(), + latestVersion = 2, +}: { registerFooType?: boolean; storage?: ContentStorage; latestVersion?: number } = {}) => { const ctx: StorageContext = { requestHandlerContext: {} as any, version: { - latest: 1, + latest: latestVersion, request: 1, }, utils: { @@ -60,9 +82,9 @@ const setup = ({ registerFooType = false }: { registerFooType?: boolean } = {}) const contentDefinition: ContentTypeDefinition = { id: FOO_CONTENT_ID, - storage: createMemoryStorage(), + storage, version: { - latest: 2, + latest: latestVersion, }, }; const cleanUp = () => { @@ -94,7 +116,12 @@ describe('Content Core', () => { const { coreSetup, cleanUp } = setup(); expect(coreSetup.contentRegistry).toBeInstanceOf(ContentRegistry); - expect(Object.keys(coreSetup.api).sort()).toEqual(['crud', 'eventBus', 'register']); + expect(Object.keys(coreSetup.api).sort()).toEqual([ + 'contentClient', + 'crud', + 'eventBus', + 'register', + ]); cleanUp(); }); @@ -158,6 +185,52 @@ describe('Content Core', () => { cleanUp(); }); + + test('should return a contentClient when registering', async () => { + const storage = createMockedStorage(); + const latestVersion = 11; + + const { coreSetup, cleanUp, contentDefinition } = setup({ latestVersion, storage }); + + const { contentClient } = coreSetup.api.register(contentDefinition); + + { + const client = contentClient.getForRequest({ + requestHandlerContext: {} as any, + request: {} as any, + version: 2, + }); + + await client.get('1234'); + expect(storage.get).toHaveBeenCalledTimes(1); + + const [storageContext] = storage.get.mock.calls[0]; + expect(storageContext.version).toEqual({ + latest: latestVersion, + request: 2, // version passed in the request should be used + }); + } + + storage.get.mockReset(); + + { + // If no request version is passed, the latest version should be used + const client = contentClient.getForRequest({ + requestHandlerContext: {} as any, + request: {} as any, + }); + + await client.get('1234'); + + const [storageContext] = storage.get.mock.calls[0]; + expect(storageContext.version).toEqual({ + latest: latestVersion, + request: latestVersion, // latest version should be used + }); + } + + cleanUp(); + }); }); describe('crud()', () => { @@ -849,38 +922,239 @@ describe('Content Core', () => { }); }); - describe('eventStream', () => { - test('stores "delete" events', async () => { - const { fooContentCrud, ctx, eventStream } = setup({ registerFooType: true }); + describe('contentClient', () => { + describe('single content type', () => { + test('should return a ClientContent instance for a specific request', () => { + const { coreSetup, cleanUp } = setup({ registerFooType: true }); - await fooContentCrud!.create(ctx, { title: 'Hello' }, { id: '1234' }); - await fooContentCrud!.delete(ctx, '1234'); + const { + api: { contentClient }, + } = coreSetup; - const findEvent = async () => { - const tail = await eventStream.tail(); - - for (const event of tail) { - if ( - event.predicate[0] === 'delete' && - event.object && - event.object[0] === 'foo' && - event.object[1] === '1234' - ) { - return event; - } - } + const client = contentClient + .getForRequest({ + requestHandlerContext: {} as any, + request: {} as any, + }) + .for(FOO_CONTENT_ID); - return null; - }; + expect(client).toBeInstanceOf(ContentClient); + + cleanUp(); + }); + + test('should automatically set the content version to the latest version if not provided', async () => { + const storage = createMockedStorage(); + const latestVersion = 7; + + const { coreSetup, cleanUp } = setup({ + registerFooType: true, + latestVersion, + storage, + }); + + const requestHandlerContext = {} as any; + const client = coreSetup.api.contentClient + .getForRequest({ + requestHandlerContext, + request: {} as any, + }) + .for(FOO_CONTENT_ID); + + const options = { foo: 'bar' }; + await client.get('1234', options); + + const storageContext = { + requestHandlerContext, + utils: { getTransforms: expect.any(Function) }, + version: { + latest: latestVersion, + request: latestVersion, // Request version should be set to the latest version + }, + }; + + expect(storage.get).toHaveBeenCalledWith(storageContext, '1234', options); + + cleanUp(); + }); + + test('should pass the provided content version', async () => { + const storage = createMockedStorage(); + const latestVersion = 7; + const requestVersion = 2; + + const { coreSetup, cleanUp } = setup({ + registerFooType: true, + latestVersion, + storage, + }); + + const requestHandlerContext = {} as any; + + const client = coreSetup.api.contentClient + .getForRequest({ + requestHandlerContext, + request: {} as any, + }) + .for(FOO_CONTENT_ID, requestVersion); - await until(async () => !!(await findEvent()), 100); + await client.get('1234'); - const event = await findEvent(); + const storageContext = { + requestHandlerContext, + utils: { getTransforms: expect.any(Function) }, + version: { + latest: latestVersion, + request: requestVersion, // The requested version should be used + }, + }; + + expect(storage.get).toHaveBeenCalledWith(storageContext, '1234', undefined); + + cleanUp(); + }); + + test('should throw if the contentTypeId is not registered', () => { + const { coreSetup, cleanUp } = setup(); + + const { + api: { contentClient }, + } = coreSetup; + + expect(() => { + contentClient + .getForRequest({ + requestHandlerContext: {} as any, + request: {} as any, + }) + .for(FOO_CONTENT_ID); + }).toThrowError('Content [foo] is not registered.'); + + cleanUp(); + }); + }); + + describe('multiple content types', () => { + const storage = createMockedStorage(); + + beforeEach(() => { + spyMsearch.mockReset(); + }); + + test('should automatically set the content version to the latest version if not provided', async () => { + const { coreSetup, cleanUp } = setup(); + + coreSetup.api.register({ + id: 'foo', + storage, + version: { + latest: 9, // Needs to be automatically passed to the mSearch service + }, + }); + + coreSetup.api.register({ + id: 'bar', + storage, + version: { + latest: 11, // Needs to be automatically passed to the mSearch service + }, + }); + + const client = coreSetup.api.contentClient.getForRequest({ + requestHandlerContext: {} as any, + request: {} as any, + }); + + await client.msearch({ + // We don't pass the version here + contentTypes: [{ contentTypeId: 'foo' }, { contentTypeId: 'bar' }], + query: { text: 'Hello' }, + }); + + const [contentTypes] = spyMsearch.mock.calls[0]; + expect(contentTypes[0].contentTypeId).toBe('foo'); + expect(contentTypes[0].ctx.version).toEqual({ latest: 9, request: 9 }); + + expect(contentTypes[1].contentTypeId).toBe('bar'); + expect(contentTypes[1].ctx.version).toEqual({ latest: 11, request: 11 }); - expect(event).toMatchObject({ - predicate: ['delete'], - object: ['foo', '1234'], + cleanUp(); }); + + test('should use the request version if provided', async () => { + const { coreSetup, cleanUp } = setup(); + + coreSetup.api.register({ + id: 'foo', + storage, + version: { + latest: 9, // Needs to be automatically passed to the mSearch service + }, + }); + + coreSetup.api.register({ + id: 'bar', + storage, + version: { + latest: 11, // Needs to be automatically passed to the mSearch service + }, + }); + + const client = coreSetup.api.contentClient.getForRequest({ + requestHandlerContext: {} as any, + request: {} as any, + }); + + await client.msearch({ + // We don't pass the version here + contentTypes: [ + { contentTypeId: 'foo', version: 2 }, + { contentTypeId: 'bar', version: 3 }, + ], + query: { text: 'Hello' }, + }); + + const [contentTypes] = spyMsearch.mock.calls[0]; + expect(contentTypes[0].ctx.version).toEqual({ latest: 9, request: 2 }); + expect(contentTypes[1].ctx.version).toEqual({ latest: 11, request: 3 }); + + cleanUp(); + }); + }); + }); + }); + + describe('eventStream', () => { + test('stores "delete" events', async () => { + const { fooContentCrud, ctx, eventStream } = setup({ registerFooType: true }); + + await fooContentCrud!.create(ctx, { title: 'Hello' }, { id: '1234' }); + await fooContentCrud!.delete(ctx, '1234'); + + const findEvent = async () => { + const tail = await eventStream.tail(); + + for (const event of tail) { + if ( + event.predicate[0] === 'delete' && + event.object && + event.object[0] === 'foo' && + event.object[1] === '1234' + ) { + return event; + } + } + + return null; + }; + + await until(async () => !!(await findEvent()), 100); + + const event = await findEvent(); + + expect(event).toMatchObject({ + predicate: ['delete'], + object: ['foo', '1234'], }); }); }); diff --git a/src/plugins/content_management/server/core/core.ts b/src/plugins/content_management/server/core/core.ts index 4d055ed6783f4..9686d6638a3ee 100644 --- a/src/plugins/content_management/server/core/core.ts +++ b/src/plugins/content_management/server/core/core.ts @@ -6,11 +6,27 @@ * Side Public License, v 1. */ -import { Logger } from '@kbn/core/server'; +import type { Logger, KibanaRequest } from '@kbn/core/server'; +import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; +import type { Version } from '@kbn/object-versioning'; +import { LISTING_LIMIT_SETTING, PER_PAGE_SETTING } from '@kbn/saved-objects-settings'; + +import type { MSearchIn, MSearchOut } from '../../common'; +import { + getContentClientFactory, + getMSearchClientFactory, + IContentClient, +} from '../content_client'; import { EventStreamService } from '../event_stream'; import { ContentCrud } from './crud'; import { EventBus } from './event_bus'; import { ContentRegistry } from './registry'; +import { MSearchService } from './msearch'; + +export interface GetContentClientForRequestDependencies { + requestHandlerContext: RequestHandlerContext; + request: KibanaRequest; +} export interface CoreApi { /** @@ -24,6 +40,14 @@ export interface CoreApi { crud: (contentType: string) => ContentCrud; /** Content management event bus */ eventBus: EventBus; + /** Client getters to interact with registered content types. */ + contentClient: { + /** Client getter to interact with registered content types for the current HTTP request. */ + getForRequest(deps: GetContentClientForRequestDependencies): { + for: (contentTypeId: string, version?: Version) => IContentClient; + msearch(args: MSearchIn): Promise; + }; + }; } export interface CoreInitializerContext { @@ -52,13 +76,18 @@ export class Core { setup(): CoreSetup { this.setupEventStream(); + const coreApi: CoreApi = { + register: this.contentRegistry.register.bind(this.contentRegistry), + crud: this.contentRegistry.getCrud.bind(this.contentRegistry), + eventBus: this.eventBus, + contentClient: { + getForRequest: this.getContentClientForRequest.bind(this), + }, + }; + return { contentRegistry: this.contentRegistry, - api: { - register: this.contentRegistry.register.bind(this.contentRegistry), - crud: this.contentRegistry.getCrud.bind(this.contentRegistry), - eventBus: this.eventBus, - }, + api: coreApi, }; } @@ -79,4 +108,50 @@ export class Core { }); } } + + private getContentClientForRequest({ + requestHandlerContext, + request, + }: { + request: KibanaRequest; + requestHandlerContext: RequestHandlerContext; + }) { + /** Handler to return a ContentClient for a specific content type Id and request version */ + const forFn = (contentTypeId: string, version?: Version) => { + const contentDefinition = this.contentRegistry.getDefinition(contentTypeId); + const clientFactory = getContentClientFactory({ + contentRegistry: this.contentRegistry, + }); + const contentClient = clientFactory(contentTypeId); + + return contentClient.getForRequest({ + requestHandlerContext, + request, + version: version ?? contentDefinition.version.latest, + }); + }; + + const mSearchService = new MSearchService({ + getSavedObjectsClient: async () => (await requestHandlerContext.core).savedObjects.client, + contentRegistry: this.contentRegistry, + getConfig: { + listingLimit: async () => + (await requestHandlerContext.core).uiSettings.client.get(LISTING_LIMIT_SETTING), + perPage: async () => + (await requestHandlerContext.core).uiSettings.client.get(PER_PAGE_SETTING), + }, + }); + + const msearchClientFactory = getMSearchClientFactory({ + contentRegistry: this.contentRegistry, + mSearchService, + }); + + const msearchClient = msearchClientFactory({ requestHandlerContext, request }); + + return { + for: forFn, + msearch: msearchClient.msearch, + }; + } } diff --git a/src/plugins/content_management/server/core/crud.ts b/src/plugins/content_management/server/core/crud.ts index 21e13c1e6a698..bd06bb0c4ad3f 100644 --- a/src/plugins/content_management/server/core/crud.ts +++ b/src/plugins/content_management/server/core/crud.ts @@ -79,17 +79,17 @@ export class ContentCrud { }); try { - const item = await this.storage.get(ctx, contentId, options); + const result = await this.storage.get(ctx, contentId, options); this.eventBus.emit({ type: 'getItemSuccess', contentId, contentTypeId: this.contentTypeId, - data: item, + data: result, options, }); - return { contentTypeId: this.contentTypeId, result: item }; + return { contentTypeId: this.contentTypeId, result }; } catch (e) { this.eventBus.emit({ type: 'getItemError', diff --git a/src/plugins/content_management/server/core/mocks/in_memory_storage.ts b/src/plugins/content_management/server/core/mocks/in_memory_storage.ts index fd3a7a125ce45..de4ed69856ccc 100644 --- a/src/plugins/content_management/server/core/mocks/in_memory_storage.ts +++ b/src/plugins/content_management/server/core/mocks/in_memory_storage.ts @@ -64,7 +64,11 @@ class InMemoryStorage implements ContentStorage { async create( ctx: StorageContext, data: Omit, - { id: _id, errorToThrow }: { id?: string; errorToThrow?: string } = {} + { + id: _id, + forwardInResponse, + errorToThrow, + }: { id?: string; errorToThrow?: string; forwardInResponse?: object } = {} ) { // This allows us to test that proper error events are thrown when the storage layer op fails if (errorToThrow) { @@ -81,6 +85,16 @@ class InMemoryStorage implements ContentStorage { this.db.set(id, content); + if (forwardInResponse) { + // We add this so we can test that options are passed down to the storage layer + return { + item: { + ...content, + options: forwardInResponse, + }, + }; + } + return { item: content, }; @@ -159,7 +173,7 @@ class InMemoryStorage implements ContentStorage { async search( ctx: StorageContext, query: { text: string }, - { errorToThrow }: { errorToThrow?: string } = {} + { errorToThrow, forwardInResponse }: { errorToThrow?: string; forwardInResponse?: object } = {} ) { // This allows us to test that proper error events are thrown when the storage layer op fails if (errorToThrow) { @@ -181,7 +195,7 @@ class InMemoryStorage implements ContentStorage { return title.match(rgx); }); return { - hits, + hits: forwardInResponse ? hits.map((hit) => ({ ...hit, options: forwardInResponse })) : hits, pagination: { total: hits.length, cursor: '', @@ -190,8 +204,8 @@ class InMemoryStorage implements ContentStorage { } } -export const createMemoryStorage = () => { - return new InMemoryStorage(); +export const createMemoryStorage = (): ContentStorage => { + return new InMemoryStorage() as ContentStorage; }; export const createMockedStorage = (): jest.Mocked => ({ diff --git a/src/plugins/content_management/server/core/registry.ts b/src/plugins/content_management/server/core/registry.ts index 00adb4b04a403..77618b786c409 100644 --- a/src/plugins/content_management/server/core/registry.ts +++ b/src/plugins/content_management/server/core/registry.ts @@ -7,6 +7,8 @@ */ import { validateVersion } from '@kbn/object-versioning/lib/utils'; + +import { getContentClientFactory } from '../content_client'; import { ContentType } from './content_type'; import { EventBus } from './event_bus'; import type { ContentStorage, ContentTypeDefinition, MSearchConfig } from './types'; @@ -45,6 +47,15 @@ export class ContentRegistry { ); this.types.set(contentType.id, contentType); + + const contentClient = getContentClientFactory({ contentRegistry: this })(contentType.id); + + return { + /** + * Client getters to interact with the registered content type. + */ + contentClient, + }; } getContentType(id: string): ContentType { diff --git a/src/plugins/content_management/server/core/types.ts b/src/plugins/content_management/server/core/types.ts index d26c6ac72fa41..ed649a8bafdcb 100644 --- a/src/plugins/content_management/server/core/types.ts +++ b/src/plugins/content_management/server/core/types.ts @@ -31,12 +31,26 @@ export type StorageContextGetTransformFn = ( /** Context that is sent to all storage instance methods */ export interface StorageContext { + /** The Core HTTP request handler context */ requestHandlerContext: RequestHandlerContext; version: { + /** + * The content type version for the request. It usually is the latest version although in some + * cases the client (browser) might still be on an older version and make requests with that version. + */ request: Version; + /** + * The latest version of the content type. This is the version that the content type is currently on + * after updating the Kibana server. + */ latest: Version; }; utils: { + /** + * Get the transforms handlers for the content type. + * The transforms are used to transform the content object to the latest schema (up) and back + * to a previous schema (down). + */ getTransforms: StorageContextGetTransformFn; }; } diff --git a/src/plugins/content_management/server/plugin.test.ts b/src/plugins/content_management/server/plugin.test.ts index 2e4eaf8122b61..aef15dada6449 100644 --- a/src/plugins/content_management/server/plugin.test.ts +++ b/src/plugins/content_management/server/plugin.test.ts @@ -138,8 +138,8 @@ describe('ContentManagementPlugin', () => { // Each procedure has been called with the context and input const context = { requestHandlerContext: mockedRequestHandlerContext, + request: expect.any(Object), contentRegistry: 'mockedContentRegistry', - getTransformsFactory: expect.any(Function), mSearchService: expect.any(MSearchService), }; expect(mockGet).toHaveBeenCalledWith(context, input); diff --git a/src/plugins/content_management/server/rpc/procedures/bulk_get.test.ts b/src/plugins/content_management/server/rpc/procedures/bulk_get.test.ts index 040ab6191bddc..baeb9c08471c7 100644 --- a/src/plugins/content_management/server/rpc/procedures/bulk_get.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/bulk_get.test.ts @@ -9,14 +9,14 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; -import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; -import { validate } from '../../utils'; +import type { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { bulkGet } from './bulk_get'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -191,8 +191,6 @@ describe('RPC -> bulkGet()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), }; return { ctx, storage }; diff --git a/src/plugins/content_management/server/rpc/procedures/bulk_get.ts b/src/plugins/content_management/server/rpc/procedures/bulk_get.ts index d3f66a5962193..7ea19ebb49759 100644 --- a/src/plugins/content_management/server/rpc/procedures/bulk_get.ts +++ b/src/plugins/content_management/server/rpc/procedures/bulk_get.ts @@ -11,18 +11,19 @@ import type { BulkGetIn } from '../../../common'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; import { BulkGetResponse } from '../../core/crud'; -import { getStorageContext } from './utils'; +import { getContentClientFactory } from '../../content_client'; export const bulkGet: ProcedureDefinition, BulkGetResponse> = { schemas: rpcSchemas.bulkGet, fn: async (ctx, { contentTypeId, version, ids, options }) => { - const storageContext = getStorageContext({ - contentTypeId, + const clientFactory = getContentClientFactory({ + contentRegistry: ctx.contentRegistry, + }); + const client = clientFactory(contentTypeId).getForRequest({ + ...ctx, version, - ctx, }); - const crudInstance = ctx.contentRegistry.getCrud(contentTypeId); - return crudInstance.bulkGet(storageContext, ids, options); + return client.bulkGet(ids, options); }, }; diff --git a/src/plugins/content_management/server/rpc/procedures/create.test.ts b/src/plugins/content_management/server/rpc/procedures/create.test.ts index 4e923f7cf695a..ec877d2ea0d0d 100644 --- a/src/plugins/content_management/server/rpc/procedures/create.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/create.test.ts @@ -9,14 +9,14 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; -import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; -import { validate } from '../../utils'; +import type { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { create } from './create'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -164,8 +164,6 @@ describe('RPC -> create()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), }; return { ctx, storage }; diff --git a/src/plugins/content_management/server/rpc/procedures/create.ts b/src/plugins/content_management/server/rpc/procedures/create.ts index a4313b2095f3a..b8646e150597a 100644 --- a/src/plugins/content_management/server/rpc/procedures/create.ts +++ b/src/plugins/content_management/server/rpc/procedures/create.ts @@ -8,21 +8,21 @@ import { rpcSchemas } from '../../../common/schemas'; import type { CreateIn } from '../../../common'; +import { getContentClientFactory } from '../../content_client'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; -import { getStorageContext } from './utils'; export const create: ProcedureDefinition> = { schemas: rpcSchemas.create, fn: async (ctx, { contentTypeId, version, data, options }) => { - const storageContext = getStorageContext({ - contentTypeId, + const clientFactory = getContentClientFactory({ + contentRegistry: ctx.contentRegistry, + }); + const client = clientFactory(contentTypeId).getForRequest({ + ...ctx, version, - ctx, }); - const crudInstance = ctx.contentRegistry.getCrud(contentTypeId); - - return crudInstance.create(storageContext, data, options); + return client.create(data, options); }, }; diff --git a/src/plugins/content_management/server/rpc/procedures/delete.test.ts b/src/plugins/content_management/server/rpc/procedures/delete.test.ts index 612732788a64f..5becd91a7b7d4 100644 --- a/src/plugins/content_management/server/rpc/procedures/delete.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/delete.test.ts @@ -8,15 +8,15 @@ import { omit } from 'lodash'; -import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; +import type { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning'; import { schema } from '@kbn/config-schema'; -import { validate } from '../../utils'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { deleteProc } from './delete'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -150,8 +150,6 @@ describe('RPC -> delete()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), }; return { ctx, storage }; diff --git a/src/plugins/content_management/server/rpc/procedures/delete.ts b/src/plugins/content_management/server/rpc/procedures/delete.ts index 698df0764a76d..3e948730faba8 100644 --- a/src/plugins/content_management/server/rpc/procedures/delete.ts +++ b/src/plugins/content_management/server/rpc/procedures/delete.ts @@ -7,20 +7,21 @@ */ import { rpcSchemas } from '../../../common/schemas'; import type { DeleteIn } from '../../../common'; +import { getContentClientFactory } from '../../content_client'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; -import { getStorageContext } from './utils'; export const deleteProc: ProcedureDefinition> = { schemas: rpcSchemas.delete, fn: async (ctx, { contentTypeId, id, version, options }) => { - const storageContext = getStorageContext({ - contentTypeId, + const clientFactory = getContentClientFactory({ + contentRegistry: ctx.contentRegistry, + }); + const client = clientFactory(contentTypeId).getForRequest({ + ...ctx, version, - ctx, }); - const crudInstance = ctx.contentRegistry.getCrud(contentTypeId); - return crudInstance.delete(storageContext, id, options); + return client.delete(id, options); }, }; diff --git a/src/plugins/content_management/server/rpc/procedures/get.test.ts b/src/plugins/content_management/server/rpc/procedures/get.test.ts index 4670b7f542027..83affb9e725ae 100644 --- a/src/plugins/content_management/server/rpc/procedures/get.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/get.test.ts @@ -9,14 +9,14 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; -import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; -import { validate } from '../../utils'; +import type { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { get } from './get'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -157,8 +157,6 @@ describe('RPC -> get()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), }; return { ctx, storage }; diff --git a/src/plugins/content_management/server/rpc/procedures/get.ts b/src/plugins/content_management/server/rpc/procedures/get.ts index eec993cc27ea0..a4e27847feeda 100644 --- a/src/plugins/content_management/server/rpc/procedures/get.ts +++ b/src/plugins/content_management/server/rpc/procedures/get.ts @@ -8,20 +8,21 @@ import { rpcSchemas } from '../../../common/schemas'; import type { GetIn } from '../../../common'; +import { getContentClientFactory } from '../../content_client'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; -import { getStorageContext } from './utils'; export const get: ProcedureDefinition> = { schemas: rpcSchemas.get, fn: async (ctx, { contentTypeId, id, version, options }) => { - const storageContext = getStorageContext({ - contentTypeId, + const clientFactory = getContentClientFactory({ + contentRegistry: ctx.contentRegistry, + }); + const client = clientFactory(contentTypeId).getForRequest({ + ...ctx, version, - ctx, }); - const crudInstance = ctx.contentRegistry.getCrud(contentTypeId); - return crudInstance.get(storageContext, id, options); + return client.get(id, options); }, }; diff --git a/src/plugins/content_management/server/rpc/procedures/msearch.test.ts b/src/plugins/content_management/server/rpc/procedures/msearch.test.ts index 6513682f58a89..7b28fae6d3b49 100644 --- a/src/plugins/content_management/server/rpc/procedures/msearch.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/msearch.test.ts @@ -6,18 +6,17 @@ * Side Public License, v 1. */ -import type { Version } from '@kbn/object-versioning'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { MSearchIn, MSearchQuery } from '../../../common'; -import { validate } from '../../utils'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; import { MSearchService } from '../../core/msearch'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { mSearch } from './msearch'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -151,8 +150,6 @@ describe('RPC -> mSearch()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), mSearchService, }; diff --git a/src/plugins/content_management/server/rpc/procedures/msearch.ts b/src/plugins/content_management/server/rpc/procedures/msearch.ts index 68408ae525071..dea79b1f5b0bd 100644 --- a/src/plugins/content_management/server/rpc/procedures/msearch.ts +++ b/src/plugins/content_management/server/rpc/procedures/msearch.ts @@ -10,29 +10,17 @@ import { rpcSchemas } from '../../../common/schemas'; import type { MSearchIn, MSearchOut } from '../../../common'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; -import { getStorageContext } from './utils'; +import { getMSearchClientFactory } from '../../content_client'; export const mSearch: ProcedureDefinition = { schemas: rpcSchemas.mSearch, - fn: async (ctx, { contentTypes: contentTypes, query }) => { - const contentTypesWithStorageContext = contentTypes.map(({ contentTypeId, version }) => { - const storageContext = getStorageContext({ - contentTypeId, - version, - ctx, - }); - - return { - contentTypeId, - ctx: storageContext, - }; + fn: async (ctx, { contentTypes, query }) => { + const clientFactory = getMSearchClientFactory({ + contentRegistry: ctx.contentRegistry, + mSearchService: ctx.mSearchService, }); + const mSearchClient = clientFactory(ctx); - const result = await ctx.mSearchService.search(contentTypesWithStorageContext, query); - - return { - contentTypes, - result, - }; + return mSearchClient.msearch({ contentTypes, query }); }, }; diff --git a/src/plugins/content_management/server/rpc/procedures/search.test.ts b/src/plugins/content_management/server/rpc/procedures/search.test.ts index cd0b2f2f18c89..2d7ef1501d102 100644 --- a/src/plugins/content_management/server/rpc/procedures/search.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/search.test.ts @@ -8,16 +8,16 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; -import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; +import type { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning'; import type { SearchQuery } from '../../../common'; -import { validate } from '../../utils'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { search } from './search'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -177,8 +177,6 @@ describe('RPC -> search()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), }; return { ctx, storage }; diff --git a/src/plugins/content_management/server/rpc/procedures/search.ts b/src/plugins/content_management/server/rpc/procedures/search.ts index 74d8c51425a58..a726eb244f381 100644 --- a/src/plugins/content_management/server/rpc/procedures/search.ts +++ b/src/plugins/content_management/server/rpc/procedures/search.ts @@ -8,20 +8,21 @@ import { rpcSchemas } from '../../../common/schemas'; import type { SearchIn } from '../../../common'; +import { getContentClientFactory } from '../../content_client'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; -import { getStorageContext } from './utils'; export const search: ProcedureDefinition> = { schemas: rpcSchemas.search, fn: async (ctx, { contentTypeId, version, query, options }) => { - const storageContext = getStorageContext({ - contentTypeId, + const clientFactory = getContentClientFactory({ + contentRegistry: ctx.contentRegistry, + }); + const client = clientFactory(contentTypeId).getForRequest({ + ...ctx, version, - ctx, }); - const crudInstance = ctx.contentRegistry.getCrud(contentTypeId); - return crudInstance.search(storageContext, query, options); + return client.search(query, options); }, }; diff --git a/src/plugins/content_management/server/rpc/procedures/update.test.ts b/src/plugins/content_management/server/rpc/procedures/update.test.ts index 81b6ac9e421fe..ad721d045be1d 100644 --- a/src/plugins/content_management/server/rpc/procedures/update.test.ts +++ b/src/plugins/content_management/server/rpc/procedures/update.test.ts @@ -8,15 +8,15 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; -import type { ContentManagementServiceDefinitionVersioned, Version } from '@kbn/object-versioning'; +import type { ContentManagementServiceDefinitionVersioned } from '@kbn/object-versioning'; -import { validate } from '../../utils'; +import { validate, disableTransformsCache } from '../../utils'; import { ContentRegistry } from '../../core/registry'; import { createMockedStorage } from '../../core/mocks'; import { EventBus } from '../../core/event_bus'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import { update } from './update'; +disableTransformsCache(); const storageContextGetTransforms = jest.fn(); const spy = () => storageContextGetTransforms; @@ -171,8 +171,6 @@ describe('RPC -> update()', () => { const ctx: any = { contentRegistry, requestHandlerContext, - getTransformsFactory: (contentTypeId: string, version: Version) => - getServiceObjectTransformFactory(contentTypeId, version, { cacheEnabled: false }), }; return { ctx, storage }; diff --git a/src/plugins/content_management/server/rpc/procedures/update.ts b/src/plugins/content_management/server/rpc/procedures/update.ts index 95e4c5e24194a..99cd3d44c95e1 100644 --- a/src/plugins/content_management/server/rpc/procedures/update.ts +++ b/src/plugins/content_management/server/rpc/procedures/update.ts @@ -7,19 +7,21 @@ */ import { rpcSchemas } from '../../../common/schemas'; import type { UpdateIn } from '../../../common'; +import { getContentClientFactory } from '../../content_client'; import type { ProcedureDefinition } from '../rpc_service'; import type { Context } from '../types'; -import { getStorageContext } from './utils'; export const update: ProcedureDefinition> = { schemas: rpcSchemas.update, fn: async (ctx, { contentTypeId, id, version, data, options }) => { - const storageContext = getStorageContext({ - contentTypeId, + const clientFactory = getContentClientFactory({ + contentRegistry: ctx.contentRegistry, + }); + const client = clientFactory(contentTypeId).getForRequest({ + ...ctx, version, - ctx, }); - const crudInstance = ctx.contentRegistry.getCrud(contentTypeId); - return crudInstance.update(storageContext, id, data, options); + + return client.update(id, data, options); }, }; diff --git a/src/plugins/content_management/server/rpc/routes/routes.ts b/src/plugins/content_management/server/rpc/routes/routes.ts index ea529aae11188..7b109eb78b51e 100644 --- a/src/plugins/content_management/server/rpc/routes/routes.ts +++ b/src/plugins/content_management/server/rpc/routes/routes.ts @@ -14,7 +14,6 @@ import type { ContentRegistry } from '../../core'; import { MSearchService } from '../../core/msearch'; import type { RpcService } from '../rpc_service'; -import { getServiceObjectTransformFactory } from '../services_transforms_factory'; import type { Context as RpcContext } from '../types'; import { wrapError } from './error_wrapper'; @@ -56,7 +55,7 @@ export function initRpcRoutes( const context: RpcContext = { contentRegistry, requestHandlerContext, - getTransformsFactory: getServiceObjectTransformFactory, + request, mSearchService: new MSearchService({ getSavedObjectsClient: async () => (await requestHandlerContext.core).savedObjects.client, diff --git a/src/plugins/content_management/server/rpc/types.ts b/src/plugins/content_management/server/rpc/types.ts index 4e5bdbd5f240f..9c79643a52de8 100644 --- a/src/plugins/content_management/server/rpc/types.ts +++ b/src/plugins/content_management/server/rpc/types.ts @@ -6,17 +6,13 @@ * Side Public License, v 1. */ import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; -import type { Version } from '@kbn/object-versioning'; -import type { ContentRegistry, StorageContextGetTransformFn } from '../core'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { ContentRegistry } from '../core'; import type { MSearchService } from '../core/msearch'; export interface Context { contentRegistry: ContentRegistry; requestHandlerContext: RequestHandlerContext; - getTransformsFactory: ( - contentTypeId: string, - requestVersion: Version, - options?: { cacheEnabled?: boolean } - ) => StorageContextGetTransformFn; + request: KibanaRequest; mSearchService: MSearchService; } diff --git a/src/plugins/content_management/server/types.ts b/src/plugins/content_management/server/types.ts index 79251838b1a56..dbc401807975d 100644 --- a/src/plugins/content_management/server/types.ts +++ b/src/plugins/content_management/server/types.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { CoreApi } from './core'; +import type { Version } from '@kbn/object-versioning'; +import type { CoreApi, StorageContextGetTransformFn } from './core'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ContentManagementServerSetupDependencies {} @@ -19,3 +20,9 @@ export interface ContentManagementServerSetup extends CoreApi {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ContentManagementServerStart {} + +export type GetTransformsFactoryFn = ( + contentTypeId: string, + requestVersion: Version, + options?: { cacheEnabled?: boolean } +) => StorageContextGetTransformFn; diff --git a/src/plugins/content_management/server/utils/index.ts b/src/plugins/content_management/server/utils/index.ts new file mode 100644 index 0000000000000..1e4a503a515e9 --- /dev/null +++ b/src/plugins/content_management/server/utils/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { getStorageContext, validate } from './utils'; + +export { + getServiceObjectTransformFactory, + disableCache as disableTransformsCache, +} from './services_transforms_factory'; diff --git a/src/plugins/content_management/server/rpc/services_transforms_factory.ts b/src/plugins/content_management/server/utils/services_transforms_factory.ts similarity index 88% rename from src/plugins/content_management/server/rpc/services_transforms_factory.ts rename to src/plugins/content_management/server/utils/services_transforms_factory.ts index 9dc96226ce4dc..de0fab28467d5 100644 --- a/src/plugins/content_management/server/rpc/services_transforms_factory.ts +++ b/src/plugins/content_management/server/utils/services_transforms_factory.ts @@ -15,6 +15,13 @@ import { } from '@kbn/object-versioning'; import type { StorageContextGetTransformFn } from '../core'; +let isCacheEnabled = true; + +// This is used in tests to disable the cache +export const disableCache = () => { + isCacheEnabled = false; +}; + /** * We keep a cache of compiled service definition to avoid unnecessary recompile on every request. */ @@ -32,15 +39,11 @@ const compiledCache = new LRUCache + (contentTypeId: string, _requestVersion: Version): StorageContextGetTransformFn => (definitions: ContentManagementServiceDefinitionVersioned, requestVersionOverride?: Version) => { const requestVersion = requestVersionOverride ?? _requestVersion; - if (cacheEnabled) { + if (isCacheEnabled) { const compiledFromCache = compiledCache.get(contentTypeId); if (compiledFromCache) { @@ -54,7 +57,7 @@ export const getServiceObjectTransformFactory = const compiled = compileServiceDefinitions(definitions); - if (cacheEnabled) { + if (isCacheEnabled) { compiledCache.set(contentTypeId, compiled); } diff --git a/src/plugins/content_management/server/rpc/procedures/utils.ts b/src/plugins/content_management/server/utils/utils.ts similarity index 73% rename from src/plugins/content_management/server/rpc/procedures/utils.ts rename to src/plugins/content_management/server/utils/utils.ts index b1c291ae586d9..d873c349bc8f6 100644 --- a/src/plugins/content_management/server/rpc/procedures/utils.ts +++ b/src/plugins/content_management/server/utils/utils.ts @@ -6,10 +6,22 @@ * Side Public License, v 1. */ +import { Type, ValidationError } from '@kbn/config-schema'; +import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; import { validateVersion } from '@kbn/object-versioning/lib/utils'; import type { Version } from '@kbn/object-versioning'; -import type { StorageContext } from '../../core'; -import type { Context as RpcContext } from '../types'; + +import type { ContentRegistry, StorageContext } from '../core'; +import type { GetTransformsFactoryFn } from '../types'; + +export const validate = (input: unknown, schema: Type): ValidationError | null => { + try { + schema.validate(input); + return null; + } catch (e: any) { + return e as ValidationError; + } +}; const validateRequestVersion = ( requestVersion: Version | undefined, @@ -40,7 +52,11 @@ export const getStorageContext = ({ }: { contentTypeId: string; version?: number; - ctx: RpcContext; + ctx: { + contentRegistry: ContentRegistry; + requestHandlerContext: RequestHandlerContext; + getTransformsFactory: GetTransformsFactoryFn; + }; }): StorageContext => { const contentDefinition = contentRegistry.getDefinition(contentTypeId); const version = validateRequestVersion(_version, contentDefinition.version.latest);