From 09366016865cfa05773e8969999d7925fa8fb6bd Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 6 Apr 2023 17:15:56 +0200 Subject: [PATCH] [Content Management] Cross Type Search (`savedObjects.find()` based) (#154464) ## Summary Partially addresses https://github.com/elastic/kibana/issues/152224 [Tech Doc (private)](https://docs.google.com/document/d/1ssYmqSEUPrsuCR4iz8DohkEWekoYrm2yL4QR_fVxXLg/edit#heading=h.6sj4n6bjcgp5) Introduce `mSearch` - temporary cross-content type search layer for content management backed by `savedObjects.find` Until we have [a dedicated search layer in CM services](https://docs.google.com/document/d/1mTa1xJIr8ttRnhkHcbdyLSpNJDL1FPJ3OyK4xnOsmnQ/edit), we want to provide a temporary solution to replace client-side or naive server proxy usages of `savedObjects.find()` across multiple types with Content Management API to prepare these places for backward compatibility compliance. Later we plan to use the new API together with shared components that work across multiple types like `SavedObjectFinder` or `TableListView` The api would only work with content types that use saved objects as a backend. To opt-in a saved object backed content type to `mSearch` need to provide `MSearchConfig` on `ContentStorage`: ``` export class MapsStorage implements ContentStorage { // ... mSearch: { savedObjectType: 'maps', toItemResult: (ctx: StorageContext, mapsSavedObject: SavedObject) => toMap(ctx,mapsSavedObject), // transform, validate, version additionalSearchFields: ['something-maps-specific'], } } ``` *Out of scope of this PR:* - tags search (will follow up shortly) - pagination (as described in [the doc]([Tech Doc](https://docs.google.com/document/d/1ssYmqSEUPrsuCR4iz8DohkEWekoYrm2yL4QR_fVxXLg/edit#heading=h.6sj4n6bjcgp5)) server-side pagination is not needed for the first consumers, but will follow up shortly) - end-to-end / integration testing (don't want to introduce a dummy saved object for testing this, instead plan to wait for maps CM onboard and test using maps https://github.com/elastic/kibana/pull/153304) - Documentation (will add into https://github.com/elastic/kibana/pull/154453) - Add rxjs and hook method --- .../examples/todos/stories/todo.stories.tsx | 2 +- .../content_management/common/index.ts | 4 + .../common/rpc/constants.ts | 10 +- .../content_management/common/rpc/index.ts | 1 + .../content_management/common/rpc/msearch.ts | 51 ++++ .../content_management/common/rpc/rpc.ts | 2 + .../content_management/common/rpc/search.ts | 49 ++-- .../content_client/content_client.test.ts | 17 +- .../public/content_client/content_client.tsx | 26 ++- .../public/crud_client/crud_client.mock.ts | 1 + .../public/crud_client/crud_client.ts | 3 +- .../content_management/public/plugin.ts | 8 +- .../public/rpc_client/rpc_client.test.ts | 1 + .../public/rpc_client/rpc_client.ts | 7 + .../server/core/msearch.test.ts | 163 +++++++++++++ .../content_management/server/core/msearch.ts | 84 +++++++ .../content_management/server/core/types.ts | 33 +++ .../content_management/server/plugin.test.ts | 5 + .../server/rpc/procedures/all_procedures.ts | 2 + .../server/rpc/procedures/msearch.test.ts | 218 ++++++++++++++++++ .../server/rpc/procedures/msearch.ts | 48 ++++ .../server/rpc/routes/routes.ts | 6 + .../content_management/server/rpc/types.ts | 2 + src/plugins/content_management/tsconfig.json | 2 + 24 files changed, 715 insertions(+), 30 deletions(-) create mode 100644 src/plugins/content_management/common/rpc/msearch.ts create mode 100644 src/plugins/content_management/server/core/msearch.test.ts create mode 100644 src/plugins/content_management/server/core/msearch.ts create mode 100644 src/plugins/content_management/server/rpc/procedures/msearch.test.ts create mode 100644 src/plugins/content_management/server/rpc/procedures/msearch.ts diff --git a/examples/content_management_examples/public/examples/todos/stories/todo.stories.tsx b/examples/content_management_examples/public/examples/todos/stories/todo.stories.tsx index 9a37ac2816f6c..8d458bc3e8cf7 100644 --- a/examples/content_management_examples/public/examples/todos/stories/todo.stories.tsx +++ b/examples/content_management_examples/public/examples/todos/stories/todo.stories.tsx @@ -24,7 +24,7 @@ const todosClient = new TodosClient(); const contentTypeRegistry = new ContentTypeRegistry(); contentTypeRegistry.register({ id: 'todos', version: { latest: 1 } }); -const contentClient = new ContentClient((contentType: string) => { +const contentClient = new ContentClient((contentType?: string) => { switch (contentType) { case 'todos': return todosClient; diff --git a/src/plugins/content_management/common/index.ts b/src/plugins/content_management/common/index.ts index 147c3a8e53c38..998f4a56715f4 100644 --- a/src/plugins/content_management/common/index.ts +++ b/src/plugins/content_management/common/index.ts @@ -24,4 +24,8 @@ export type { SearchIn, SearchQuery, SearchResult, + MSearchIn, + MSearchQuery, + MSearchResult, + MSearchOut, } from './rpc'; diff --git a/src/plugins/content_management/common/rpc/constants.ts b/src/plugins/content_management/common/rpc/constants.ts index e88ab0f6df689..18df2fb7e22db 100644 --- a/src/plugins/content_management/common/rpc/constants.ts +++ b/src/plugins/content_management/common/rpc/constants.ts @@ -8,7 +8,15 @@ import { schema } from '@kbn/config-schema'; import { validateVersion } from '@kbn/object-versioning/lib/utils'; -export const procedureNames = ['get', 'bulkGet', 'create', 'update', 'delete', 'search'] as const; +export const procedureNames = [ + 'get', + 'bulkGet', + 'create', + 'update', + 'delete', + 'search', + 'mSearch', +] as const; export type ProcedureName = typeof procedureNames[number]; diff --git a/src/plugins/content_management/common/rpc/index.ts b/src/plugins/content_management/common/rpc/index.ts index 859264bc7c67f..3ab4e1919e748 100644 --- a/src/plugins/content_management/common/rpc/index.ts +++ b/src/plugins/content_management/common/rpc/index.ts @@ -15,5 +15,6 @@ export type { CreateIn, CreateResult } from './create'; export type { UpdateIn, UpdateResult } from './update'; export type { DeleteIn, DeleteResult } from './delete'; export type { SearchIn, SearchQuery, SearchResult } from './search'; +export type { MSearchIn, MSearchQuery, MSearchOut, MSearchResult } from './msearch'; export type { ProcedureSchemas } from './types'; export type { ProcedureName } from './constants'; diff --git a/src/plugins/content_management/common/rpc/msearch.ts b/src/plugins/content_management/common/rpc/msearch.ts new file mode 100644 index 0000000000000..9ba1fc81c65bf --- /dev/null +++ b/src/plugins/content_management/common/rpc/msearch.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { schema } from '@kbn/config-schema'; +import type { Version } from '@kbn/object-versioning'; +import { versionSchema } from './constants'; +import { searchQuerySchema, searchResultSchema, SearchQuery, SearchResult } from './search'; + +import type { ProcedureSchemas } from './types'; + +export const mSearchSchemas: ProcedureSchemas = { + in: schema.object( + { + contentTypes: schema.arrayOf( + schema.object({ contentTypeId: schema.string(), version: versionSchema }), + { + minSize: 1, + } + ), + query: searchQuerySchema, + }, + { unknowns: 'forbid' } + ), + out: schema.object( + { + contentTypes: schema.arrayOf( + schema.object({ contentTypeId: schema.string(), version: versionSchema }) + ), + result: searchResultSchema, + }, + { unknowns: 'forbid' } + ), +}; + +export type MSearchQuery = SearchQuery; + +export interface MSearchIn { + contentTypes: Array<{ contentTypeId: string; version?: Version }>; + query: MSearchQuery; +} + +export type MSearchResult = SearchResult; + +export interface MSearchOut { + contentTypes: Array<{ contentTypeId: string; version?: Version }>; + result: MSearchResult; +} diff --git a/src/plugins/content_management/common/rpc/rpc.ts b/src/plugins/content_management/common/rpc/rpc.ts index 4b497d4e7cc25..0d9d472785a38 100644 --- a/src/plugins/content_management/common/rpc/rpc.ts +++ b/src/plugins/content_management/common/rpc/rpc.ts @@ -14,6 +14,7 @@ import { createSchemas } from './create'; import { updateSchemas } from './update'; import { deleteSchemas } from './delete'; import { searchSchemas } from './search'; +import { mSearchSchemas } from './msearch'; export const schemas: { [key in ProcedureName]: ProcedureSchemas; @@ -24,4 +25,5 @@ export const schemas: { update: updateSchemas, delete: deleteSchemas, search: searchSchemas, + mSearch: mSearchSchemas, }; diff --git a/src/plugins/content_management/common/rpc/search.ts b/src/plugins/content_management/common/rpc/search.ts index c53914c8af357..8958697df9c0b 100644 --- a/src/plugins/content_management/common/rpc/search.ts +++ b/src/plugins/content_management/common/rpc/search.ts @@ -11,24 +11,39 @@ import { versionSchema } from './constants'; import type { ProcedureSchemas } from './types'; +export const searchQuerySchema = schema.oneOf([ + schema.object( + { + text: schema.maybe(schema.string()), + tags: schema.maybe( + schema.object({ + included: schema.maybe(schema.arrayOf(schema.string())), + excluded: schema.maybe(schema.arrayOf(schema.string())), + }) + ), + limit: schema.maybe(schema.number()), + cursor: schema.maybe(schema.string()), + }, + { + unknowns: 'forbid', + } + ), +]); + +export const searchResultSchema = schema.object({ + hits: schema.arrayOf(schema.any()), + pagination: schema.object({ + total: schema.number(), + cursor: schema.maybe(schema.string()), + }), +}); + export const searchSchemas: ProcedureSchemas = { in: schema.object( { contentTypeId: schema.string(), version: versionSchema, - query: schema.oneOf([ - schema.object( - { - text: schema.maybe(schema.string()), - tags: schema.maybe(schema.arrayOf(schema.arrayOf(schema.string()), { maxSize: 2 })), - limit: schema.maybe(schema.number()), - cursor: schema.maybe(schema.string()), - }, - { - unknowns: 'forbid', - } - ), - ]), + query: searchQuerySchema, options: schema.maybe(schema.object({}, { unknowns: 'allow' })), }, { unknowns: 'forbid' } @@ -36,13 +51,7 @@ export const searchSchemas: ProcedureSchemas = { out: schema.object( { contentTypeId: schema.string(), - result: schema.object({ - hits: schema.arrayOf(schema.any()), - pagination: schema.object({ - total: schema.number(), - cursor: schema.maybe(schema.string()), - }), - }), + result: searchResultSchema, meta: schema.maybe(schema.object({}, { unknowns: 'allow' })), }, { unknowns: 'forbid' } diff --git a/src/plugins/content_management/public/content_client/content_client.test.ts b/src/plugins/content_management/public/content_client/content_client.test.ts index a654314fd1533..8fcd19f7865c9 100644 --- a/src/plugins/content_management/public/content_client/content_client.test.ts +++ b/src/plugins/content_management/public/content_client/content_client.test.ts @@ -10,7 +10,7 @@ import { lastValueFrom } from 'rxjs'; 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 type { GetIn, CreateIn, UpdateIn, DeleteIn, SearchIn, MSearchIn } from '../../common'; import { ContentTypeRegistry } from '../registry'; const setup = () => { @@ -182,3 +182,18 @@ describe('#search', () => { expect(loadedState.data).toEqual(output); }); }); + +describe('#mSearch', () => { + it('calls rpcClient.mSearch with input and returns output', async () => { + const { crudClient, contentClient } = setup(); + const input: MSearchIn = { contentTypes: [{ contentTypeId: 'testType' }], query: {} }; + const output = { hits: [{ id: 'test' }] }; + // @ts-ignore + crudClient.mSearch.mockResolvedValueOnce(output); + expect(await contentClient.mSearch(input)).toEqual(output); + expect(crudClient.mSearch).toBeCalledWith({ + contentTypes: [{ contentTypeId: 'testType', version: 3 }], // latest version added + query: {}, + }); + }); +}); diff --git a/src/plugins/content_management/public/content_client/content_client.tsx b/src/plugins/content_management/public/content_client/content_client.tsx index 939212f26a597..e1d148b74760a 100644 --- a/src/plugins/content_management/public/content_client/content_client.tsx +++ b/src/plugins/content_management/public/content_client/content_client.tsx @@ -11,7 +11,15 @@ import { validateVersion } from '@kbn/object-versioning/lib/utils'; import type { Version } from '@kbn/object-versioning'; 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, + MSearchIn, + MSearchResult, +} from '../../common'; import type { ContentTypeRegistry } from '../registry'; export const queryKeyBuilder = { @@ -85,7 +93,7 @@ export class ContentClient { readonly queryOptionBuilder: ReturnType; constructor( - private readonly crudClientProvider: (contentType: string) => CrudClient, + private readonly crudClientProvider: (contentType?: string) => CrudClient, private readonly contentTypeRegistry: ContentTypeRegistry ) { this.queryClient = new QueryClient(); @@ -133,4 +141,18 @@ export class ContentClient { this.queryOptionBuilder.search(addVersion(input, this.contentTypeRegistry)) ); } + + mSearch(input: MSearchIn): Promise> { + const crudClient = this.crudClientProvider(); + if (!crudClient.mSearch) { + throw new Error('mSearch is not supported by provided crud client'); + } + + return crudClient.mSearch({ + ...input, + contentTypes: input.contentTypes.map((contentType) => + addVersion(contentType, this.contentTypeRegistry) + ), + }) as Promise>; + } } diff --git a/src/plugins/content_management/public/crud_client/crud_client.mock.ts b/src/plugins/content_management/public/crud_client/crud_client.mock.ts index 2b2bead4ea462..d50ae23edcda6 100644 --- a/src/plugins/content_management/public/crud_client/crud_client.mock.ts +++ b/src/plugins/content_management/public/crud_client/crud_client.mock.ts @@ -15,6 +15,7 @@ export const createCrudClientMock = (): jest.Mocked => { update: jest.fn((input) => Promise.resolve({} as any)), delete: jest.fn((input) => Promise.resolve({} as any)), search: jest.fn((input) => Promise.resolve({ hits: [] } as any)), + mSearch: jest.fn((input) => Promise.resolve({ hits: [] } as any)), }; return mock; }; diff --git a/src/plugins/content_management/public/crud_client/crud_client.ts b/src/plugins/content_management/public/crud_client/crud_client.ts index 4976c7937dc4d..4f4bbadf00077 100644 --- a/src/plugins/content_management/public/crud_client/crud_client.ts +++ b/src/plugins/content_management/public/crud_client/crud_client.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { GetIn, CreateIn, UpdateIn, DeleteIn, SearchIn } from '../../common'; +import type { GetIn, CreateIn, UpdateIn, DeleteIn, SearchIn, MSearchIn } from '../../common'; export interface CrudClient { get(input: GetIn): Promise; @@ -14,4 +14,5 @@ export interface CrudClient { update(input: UpdateIn): Promise; delete(input: DeleteIn): Promise; search(input: SearchIn): Promise; + mSearch?(input: MSearchIn): Promise; } diff --git a/src/plugins/content_management/public/plugin.ts b/src/plugins/content_management/public/plugin.ts index 38b5bcb3283f5..9a20459fec2cb 100644 --- a/src/plugins/content_management/public/plugin.ts +++ b/src/plugins/content_management/public/plugin.ts @@ -43,10 +43,10 @@ export class ContentManagementPlugin public start(core: CoreStart, deps: StartDependencies) { const rpcClient = new RpcClient(core.http); - const contentClient = new ContentClient( - (contentType) => this.contentTypeRegistry.get(contentType)?.crud ?? rpcClient, - this.contentTypeRegistry - ); + const contentClient = new ContentClient((contentType) => { + if (!contentType) return rpcClient; + return this.contentTypeRegistry.get(contentType)?.crud ?? rpcClient; + }, this.contentTypeRegistry); return { client: contentClient, registry: { diff --git a/src/plugins/content_management/public/rpc_client/rpc_client.test.ts b/src/plugins/content_management/public/rpc_client/rpc_client.test.ts index c0d832919cf03..323f822ad885e 100644 --- a/src/plugins/content_management/public/rpc_client/rpc_client.test.ts +++ b/src/plugins/content_management/public/rpc_client/rpc_client.test.ts @@ -45,6 +45,7 @@ describe('RpcClient', () => { await rpcClient.update({ contentTypeId: 'foo', id: '123', data: {} }); await rpcClient.delete({ contentTypeId: 'foo', id: '123' }); await rpcClient.search({ contentTypeId: 'foo', query: {} }); + await rpcClient.mSearch({ contentTypes: [{ contentTypeId: 'foo' }], query: {} }); Object.values(proceduresSpys).forEach(({ name, spy }) => { expect(spy).toHaveBeenCalledWith(`${API_ENDPOINT}/${name}`, { body: expect.any(String) }); diff --git a/src/plugins/content_management/public/rpc_client/rpc_client.ts b/src/plugins/content_management/public/rpc_client/rpc_client.ts index 08b9cf8a767b3..17ea6a1391a59 100644 --- a/src/plugins/content_management/public/rpc_client/rpc_client.ts +++ b/src/plugins/content_management/public/rpc_client/rpc_client.ts @@ -16,6 +16,9 @@ import type { DeleteIn, SearchIn, ProcedureName, + MSearchIn, + MSearchOut, + MSearchResult, } from '../../common'; import type { CrudClient } from '../crud_client/crud_client'; import type { @@ -54,6 +57,10 @@ export class RpcClient implements CrudClient { return this.sendMessage>('search', input).then((r) => r.result); } + public mSearch(input: MSearchIn): Promise> { + return this.sendMessage>('mSearch', input).then((r) => r.result); + } + private sendMessage = async (name: ProcedureName, input: any): Promise => { const { result } = await this.http.post<{ result: O }>(`${API_ENDPOINT}/${name}`, { body: JSON.stringify(input), diff --git a/src/plugins/content_management/server/core/msearch.test.ts b/src/plugins/content_management/server/core/msearch.test.ts new file mode 100644 index 0000000000000..1c6597030f554 --- /dev/null +++ b/src/plugins/content_management/server/core/msearch.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { EventBus } from './event_bus'; +import { MSearchService } from './msearch'; +import { ContentRegistry } from './registry'; +import { createMockedStorage } from './mocks'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { StorageContext } from '.'; + +const setup = () => { + const contentRegistry = new ContentRegistry(new EventBus()); + + contentRegistry.register({ + id: `foo`, + storage: { + ...createMockedStorage(), + mSearch: { + savedObjectType: 'foo-type', + toItemResult: (ctx, so) => ({ itemFoo: so }), + additionalSearchFields: ['special-foo-field'], + }, + }, + version: { + latest: 1, + }, + }); + + contentRegistry.register({ + id: `bar`, + storage: { + ...createMockedStorage(), + mSearch: { + savedObjectType: 'bar-type', + toItemResult: (ctx, so) => ({ itemBar: so }), + additionalSearchFields: ['special-bar-field'], + }, + }, + version: { + latest: 1, + }, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + const mSearchService = new MSearchService({ + getSavedObjectsClient: async () => savedObjectsClient, + contentRegistry, + }); + + return { mSearchService, savedObjectsClient, contentRegistry }; +}; + +const mockStorageContext = (ctx: Partial = {}): StorageContext => { + return { + requestHandlerContext: 'mockRequestHandlerContext' as any, + utils: 'mockUtils' as any, + version: { + latest: 1, + request: 1, + }, + ...ctx, + }; +}; + +test('should cross-content search using saved objects api', async () => { + const { savedObjectsClient, mSearchService } = setup(); + + const soResultFoo = { + id: 'fooid', + score: 0, + type: 'foo-type', + references: [], + attributes: { + title: 'foo', + }, + }; + + const soResultBar = { + id: 'barid', + score: 0, + type: 'bar-type', + references: [], + attributes: { + title: 'bar', + }, + }; + + savedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [soResultFoo, soResultBar], + total: 2, + page: 1, + per_page: 10, + }); + + const result = await mSearchService.search( + [ + { contentTypeId: 'foo', ctx: mockStorageContext() }, + { contentTypeId: 'bar', ctx: mockStorageContext() }, + ], + { + text: 'search text', + } + ); + + expect(savedObjectsClient.find).toHaveBeenCalledWith({ + defaultSearchOperator: 'AND', + search: 'search text', + searchFields: ['title^3', 'description', 'special-foo-field', 'special-bar-field'], + type: ['foo-type', 'bar-type'], + }); + + expect(result).toEqual({ + hits: [{ itemFoo: soResultFoo }, { itemBar: soResultBar }], + pagination: { + total: 2, + }, + }); +}); + +test('should error if content is not registered', async () => { + const { mSearchService } = setup(); + + await expect( + mSearchService.search( + [ + { contentTypeId: 'foo', ctx: mockStorageContext() }, + { contentTypeId: 'foo-fake', ctx: mockStorageContext() }, + ], + { + text: 'foo', + } + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Content [foo-fake] is not registered."`); +}); + +test('should error if content is registered, but no mSearch support', async () => { + const { mSearchService, contentRegistry } = setup(); + + contentRegistry.register({ + id: `foo2`, + storage: createMockedStorage(), + version: { + latest: 1, + }, + }); + + await expect( + mSearchService.search( + [ + { contentTypeId: 'foo', ctx: mockStorageContext() }, + { contentTypeId: 'foo2', ctx: mockStorageContext() }, + ], + { + text: 'foo', + } + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Content type foo2 does not support mSearch"`); +}); diff --git a/src/plugins/content_management/server/core/msearch.ts b/src/plugins/content_management/server/core/msearch.ts new file mode 100644 index 0000000000000..879a233c289f5 --- /dev/null +++ b/src/plugins/content_management/server/core/msearch.ts @@ -0,0 +1,84 @@ +/* + * 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 { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { MSearchResult, SearchQuery } from '../../common'; +import { ContentRegistry } from './registry'; +import { StorageContext } from './types'; + +export class MSearchService { + constructor( + private readonly deps: { + getSavedObjectsClient: () => Promise; + contentRegistry: ContentRegistry; + } + ) {} + + async search( + contentTypes: Array<{ contentTypeId: string; ctx: StorageContext }>, + query: SearchQuery + ): Promise { + // Map: contentTypeId -> StorageContext + const contentTypeToCtx = new Map(contentTypes.map((ct) => [ct.contentTypeId, ct.ctx])); + + // Map: contentTypeId -> MSearchConfig + const contentTypeToMSearchConfig = new Map( + contentTypes.map((ct) => { + const mSearchConfig = this.deps.contentRegistry.getDefinition(ct.contentTypeId).storage + .mSearch; + if (!mSearchConfig) { + throw new Error(`Content type ${ct.contentTypeId} does not support mSearch`); + } + return [ct.contentTypeId, mSearchConfig]; + }) + ); + + // Map: Saved object type -> [contentTypeId, MSearchConfig] + const soTypeToMSearchConfig = new Map( + Array.from(contentTypeToMSearchConfig.entries()).map(([ct, mSearchConfig]) => { + return [mSearchConfig.savedObjectType, [ct, mSearchConfig] as const]; + }) + ); + + const mSearchConfigs = Array.from(contentTypeToMSearchConfig.values()); + const soSearchTypes = mSearchConfigs.map((mSearchConfig) => mSearchConfig.savedObjectType); + + const additionalSearchFields = new Set(); + mSearchConfigs.forEach((mSearchConfig) => { + if (mSearchConfig.additionalSearchFields) { + mSearchConfig.additionalSearchFields.forEach((f) => additionalSearchFields.add(f)); + } + }); + + const savedObjectsClient = await this.deps.getSavedObjectsClient(); + const soResult = await savedObjectsClient.find({ + type: soSearchTypes, + search: query.text, + searchFields: [`title^3`, `description`, ...additionalSearchFields], + defaultSearchOperator: 'AND', + // TODO: tags + // TODO: pagination + // TODO: sort + }); + + const contentItemHits = soResult.saved_objects.map((savedObject) => { + const [ct, mSearchConfig] = soTypeToMSearchConfig.get(savedObject.type) ?? []; + if (!ct || !mSearchConfig) + throw new Error(`Saved object type ${savedObject.type} does not support mSearch`); + + return mSearchConfig.toItemResult(contentTypeToCtx.get(ct)!, savedObject); + }); + + return { + hits: contentItemHits, + pagination: { + total: soResult.total, + }, + }; + } +} diff --git a/src/plugins/content_management/server/core/types.ts b/src/plugins/content_management/server/core/types.ts index 71fc3c5b6d090..941281a6b4a6e 100644 --- a/src/plugins/content_management/server/core/types.ts +++ b/src/plugins/content_management/server/core/types.ts @@ -8,6 +8,7 @@ import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; import type { ContentManagementGetTransformsFn, Version } from '@kbn/object-versioning'; +import type { SavedObjectsFindResult } from '@kbn/core-saved-objects-api-server'; import type { GetResult, @@ -54,6 +55,12 @@ export interface ContentStorage { /** Search items */ search(ctx: StorageContext, query: SearchQuery, options?: object): Promise>; + + /** + * Opt-in to multi-type search. + * Can only be supported if the content type is backed by a saved object since `mSearch` is using the `savedObjects.find` API. + **/ + mSearch?: MSearchConfig; } export interface ContentTypeDefinition { @@ -65,3 +72,29 @@ export interface ContentTypeDefinition { + /** + * The saved object type that corresponds to this content type. + */ + savedObjectType: string; + + /** + * Mapper function that transforms the saved object into the content item result. + */ + toItemResult: ( + ctx: StorageContext, + savedObject: SavedObjectsFindResult + ) => T; + + /** + * Additional fields to search on. These fields will be added to the search query. + * By default, only `title` and `description` are searched. + */ + additionalSearchFields?: string[]; +} diff --git a/src/plugins/content_management/server/plugin.test.ts b/src/plugins/content_management/server/plugin.test.ts index fbf0e632d2552..85e9459fa1909 100644 --- a/src/plugins/content_management/server/plugin.test.ts +++ b/src/plugins/content_management/server/plugin.test.ts @@ -11,6 +11,7 @@ import { ContentManagementPlugin } from './plugin'; import { IRouter } from '@kbn/core/server'; import type { ProcedureName } from '../common'; import { procedureNames } from '../common/rpc'; +import { MSearchService } from './core/msearch'; jest.mock('./core', () => ({ ...jest.requireActual('./core'), @@ -36,6 +37,7 @@ const mockCreate = jest.fn().mockResolvedValue('createMocked'); const mockUpdate = jest.fn().mockResolvedValue('updateMocked'); const mockDelete = jest.fn().mockResolvedValue('deleteMocked'); const mockSearch = jest.fn().mockResolvedValue('searchMocked'); +const mockMSearch = jest.fn().mockResolvedValue('mSearchMocked'); jest.mock('./rpc/procedures/all_procedures', () => { const mockedProcedure = (spyGetter: () => jest.Mock) => ({ @@ -54,6 +56,7 @@ jest.mock('./rpc/procedures/all_procedures', () => { update: mockedProcedure(() => mockUpdate), delete: mockedProcedure(() => mockDelete), search: mockedProcedure(() => mockSearch), + mSearch: mockedProcedure(() => mockMSearch), }; return { @@ -137,12 +140,14 @@ describe('ContentManagementPlugin', () => { requestHandlerContext: mockedRequestHandlerContext, contentRegistry: 'mockedContentRegistry', getTransformsFactory: expect.any(Function), + mSearchService: expect.any(MSearchService), }; expect(mockGet).toHaveBeenCalledWith(context, input); expect(mockCreate).toHaveBeenCalledWith(context, input); expect(mockUpdate).toHaveBeenCalledWith(context, input); expect(mockDelete).toHaveBeenCalledWith(context, input); expect(mockSearch).toHaveBeenCalledWith(context, input); + expect(mockMSearch).toHaveBeenCalledWith(context, input); }); test('should return error in custom error format', async () => { diff --git a/src/plugins/content_management/server/rpc/procedures/all_procedures.ts b/src/plugins/content_management/server/rpc/procedures/all_procedures.ts index 6b177debf11df..e0f065ce429d4 100644 --- a/src/plugins/content_management/server/rpc/procedures/all_procedures.ts +++ b/src/plugins/content_management/server/rpc/procedures/all_procedures.ts @@ -14,6 +14,7 @@ import { create } from './create'; import { update } from './update'; import { deleteProc } from './delete'; import { search } from './search'; +import { mSearch } from './msearch'; export const procedures: { [key in ProcedureName]: ProcedureDefinition } = { get, @@ -22,4 +23,5 @@ export const procedures: { [key in ProcedureName]: ProcedureDefinition mSearch()', () => { + describe('Input/Output validation', () => { + const query: MSearchQuery = { text: 'hello' }; + const validInput: MSearchIn = { + contentTypes: [ + { contentTypeId: 'foo', version: 1 }, + { contentTypeId: 'bar', version: 2 }, + ], + query, + }; + + test('should validate contentTypes and query', () => { + [ + { input: validInput }, + { + input: { query }, // contentTypes missing + expectedError: '[contentTypes]: expected value of type [array] but got [undefined]', + }, + { + input: { ...validInput, contentTypes: [] }, // contentTypes is empty + expectedError: '[contentTypes]: array size is [0], but cannot be smaller than [1]', + }, + { + input: { ...validInput, contentTypes: [{ contentTypeId: 'foo' }] }, // contentTypes has no version + expectedError: + '[contentTypes.0.version]: expected value of type [number] but got [undefined]', + }, + { + input: { ...validInput, query: 123 }, // query is not an object + expectedError: '[query]: expected a plain object value, but found [number] instead.', + }, + { + input: { ...validInput, unknown: 'foo' }, + expectedError: '[unknown]: definition for this key is missing', + }, + ].forEach(({ input, expectedError }) => { + const error = validate(input, inputSchema); + if (!expectedError) { + try { + expect(error).toBe(null); + } catch (e) { + throw new Error(`Expected no error but got [{${error?.message}}].`); + } + } else { + expect(error?.message).toBe(expectedError); + } + }); + }); + + test('should validate the response format with "hits" and "pagination"', () => { + let error = validate( + { + contentTypes: validInput.contentTypes, + result: { + hits: [], + pagination: { + total: 0, + cursor: '', + }, + }, + }, + outputSchema + ); + + expect(error).toBe(null); + + error = validate(123, outputSchema); + + expect(error?.message).toContain( + 'expected a plain object value, but found [number] instead.' + ); + }); + }); + + describe('procedure', () => { + const setup = () => { + const contentRegistry = new ContentRegistry(new EventBus()); + const storage = createMockedStorage(); + storage.mSearch = { + savedObjectType: 'foo-type', + toItemResult: (ctx, so) => ({ item: so }), + }; + contentRegistry.register({ + id: `foo`, + storage, + version: { + latest: 2, + }, + }); + + const savedObjectsClient = savedObjectsClientMock.create(); + const mSearchService = new MSearchService({ + getSavedObjectsClient: async () => savedObjectsClient, + contentRegistry, + }); + + const mSearchSpy = jest.spyOn(mSearchService, 'search'); + + const requestHandlerContext = 'mockedRequestHandlerContext'; + const ctx: any = { + contentRegistry, + requestHandlerContext, + getTransformsFactory: getServiceObjectTransformFactory, + mSearchService, + }; + + return { ctx, storage, savedObjectsClient, mSearchSpy }; + }; + + test('should return so find result mapped through toItemResult', async () => { + const { ctx, savedObjectsClient, mSearchSpy } = setup(); + + const soResult = { + id: 'fooid', + score: 0, + type: 'foo-type', + references: [], + attributes: { + title: 'foo', + }, + }; + + savedObjectsClient.find.mockResolvedValueOnce({ + saved_objects: [soResult], + total: 1, + page: 1, + per_page: 10, + }); + + const result = await fn(ctx, { + contentTypes: [{ contentTypeId: 'foo', version: 1 }], + query: { text: 'Hello' }, + }); + + expect(result).toEqual({ + contentTypes: [{ contentTypeId: 'foo', version: 1 }], + result: { + hits: [{ item: soResult }], + pagination: { + total: 1, + }, + }, + }); + + expect(mSearchSpy).toHaveBeenCalledWith( + [ + { + contentTypeId: 'foo', + ctx: { + requestHandlerContext: ctx.requestHandlerContext, + version: { + request: 1, + latest: 2, // from the registry + }, + utils: { + getTransforms: expect.any(Function), + }, + }, + }, + ], + { text: 'Hello' } + ); + }); + + describe('validation', () => { + test('should validate that content type definition exist', () => { + const { ctx } = setup(); + expect(() => + fn(ctx, { + contentTypes: [{ contentTypeId: 'unknown', version: 1 }], + query: { text: 'Hello' }, + }) + ).rejects.toEqual(new Error('Content [unknown] is not registered.')); + }); + + test('should throw if the request version is higher than the registered version', () => { + const { ctx } = setup(); + expect(() => + fn(ctx, { + contentTypes: [{ contentTypeId: 'foo', version: 7 }], + query: { text: 'Hello' }, + }) + ).rejects.toEqual(new Error('Invalid version. Latest version is [2].')); + }); + }); + }); +}); diff --git a/src/plugins/content_management/server/rpc/procedures/msearch.ts b/src/plugins/content_management/server/rpc/procedures/msearch.ts new file mode 100644 index 0000000000000..754dd378291d8 --- /dev/null +++ b/src/plugins/content_management/server/rpc/procedures/msearch.ts @@ -0,0 +1,48 @@ +/* + * 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 { rpcSchemas } from '../../../common/schemas'; +import type { MSearchIn, MSearchOut } from '../../../common'; +import type { StorageContext } from '../../core'; +import type { ProcedureDefinition } from '../rpc_service'; +import type { Context } from '../types'; +import { validateRequestVersion } from './utils'; + +export const mSearch: ProcedureDefinition = { + schemas: rpcSchemas.mSearch, + fn: async (ctx, { contentTypes: contentTypes, query }) => { + const contentTypesWithStorageContext = contentTypes.map( + ({ contentTypeId, version: _version }) => { + const contentDefinition = ctx.contentRegistry.getDefinition(contentTypeId); + const version = validateRequestVersion(_version, contentDefinition.version.latest); + const storageContext: StorageContext = { + requestHandlerContext: ctx.requestHandlerContext, + version: { + request: version, + latest: contentDefinition.version.latest, + }, + utils: { + getTransforms: ctx.getTransformsFactory(contentTypeId), + }, + }; + + return { + contentTypeId, + ctx: storageContext, + }; + } + ); + + const result = await ctx.mSearchService.search(contentTypesWithStorageContext, query); + + return { + contentTypes, + result, + }; + }, +}; diff --git a/src/plugins/content_management/server/rpc/routes/routes.ts b/src/plugins/content_management/server/rpc/routes/routes.ts index e89fbc6ae8953..392d01549f9ad 100644 --- a/src/plugins/content_management/server/rpc/routes/routes.ts +++ b/src/plugins/content_management/server/rpc/routes/routes.ts @@ -10,6 +10,7 @@ import type { IRouter } from '@kbn/core/server'; import { ProcedureName } from '../../../common'; import type { ContentRegistry } from '../../core'; +import { MSearchService } from '../../core/msearch'; import type { RpcService } from '../rpc_service'; import { getServiceObjectTransformFactory } from '../services_transforms_factory'; @@ -55,6 +56,11 @@ export function initRpcRoutes( contentRegistry, requestHandlerContext, getTransformsFactory: getServiceObjectTransformFactory, + mSearchService: new MSearchService({ + getSavedObjectsClient: async () => + (await requestHandlerContext.core).savedObjects.client, + contentRegistry, + }), }; const { name } = request.params as { name: ProcedureName }; diff --git a/src/plugins/content_management/server/rpc/types.ts b/src/plugins/content_management/server/rpc/types.ts index e12ae82f1691c..862ac56d67268 100644 --- a/src/plugins/content_management/server/rpc/types.ts +++ b/src/plugins/content_management/server/rpc/types.ts @@ -8,9 +8,11 @@ import type { RequestHandlerContext } from '@kbn/core-http-request-handler-context-server'; import type { ContentManagementGetTransformsFn } from '@kbn/object-versioning'; import type { ContentRegistry } from '../core'; +import type { MSearchService } from '../core/msearch'; export interface Context { contentRegistry: ContentRegistry; requestHandlerContext: RequestHandlerContext; getTransformsFactory: (contentTypeId: string) => ContentManagementGetTransformsFn; + mSearchService: MSearchService; } diff --git a/src/plugins/content_management/tsconfig.json b/src/plugins/content_management/tsconfig.json index ead8bd9af5a06..5d306e1a21f4b 100644 --- a/src/plugins/content_management/tsconfig.json +++ b/src/plugins/content_management/tsconfig.json @@ -12,6 +12,8 @@ "@kbn/core-test-helpers-kbn-server", "@kbn/bfetch-plugin", "@kbn/object-versioning", + "@kbn/core-saved-objects-api-server-mocks", + "@kbn/core-saved-objects-api-server", ], "exclude": [ "target/**/*",