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/**/*",