[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<Map> {
  // ... 
  mSearch: {
          savedObjectType: 'maps',
          toItemResult: (ctx: StorageContext, mapsSavedObject: SavedObject<MapsAttributes>) => 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
This commit is contained in:
Anton Dosov 2023-04-06 17:15:56 +02:00 committed by GitHub
parent 6a99c46108
commit 0936601686
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 715 additions and 30 deletions

View file

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

View file

@ -24,4 +24,8 @@ export type {
SearchIn,
SearchQuery,
SearchResult,
MSearchIn,
MSearchQuery,
MSearchResult,
MSearchOut,
} from './rpc';

View file

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

View file

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

View file

@ -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<T = unknown> = SearchResult<T>;
export interface MSearchOut<T = unknown> {
contentTypes: Array<{ contentTypeId: string; version?: Version }>;
result: MSearchResult<T>;
}

View file

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

View file

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

View file

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

View file

@ -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<typeof createQueryOptionBuilder>;
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<I, O>(addVersion(input, this.contentTypeRegistry))
);
}
mSearch<T = unknown>(input: MSearchIn): Promise<MSearchResult<T>> {
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<MSearchResult<T>>;
}
}

View file

@ -15,6 +15,7 @@ export const createCrudClientMock = (): jest.Mocked<CrudClient> => {
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;
};

View file

@ -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<unknown>;
@ -14,4 +14,5 @@ export interface CrudClient {
update(input: UpdateIn): Promise<unknown>;
delete(input: DeleteIn): Promise<unknown>;
search(input: SearchIn): Promise<unknown>;
mSearch?(input: MSearchIn): Promise<unknown>;
}

View file

@ -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: {

View file

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

View file

@ -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<SearchResponse<O>>('search', input).then((r) => r.result);
}
public mSearch<T = unknown>(input: MSearchIn): Promise<MSearchResult<T>> {
return this.sendMessage<MSearchOut<T>>('mSearch', input).then((r) => r.result);
}
private sendMessage = async <O = unknown>(name: ProcedureName, input: any): Promise<O> => {
const { result } = await this.http.post<{ result: O }>(`${API_ENDPOINT}/${name}`, {
body: JSON.stringify(input),

View file

@ -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> = {}): 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"`);
});

View file

@ -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<SavedObjectsClientContract>;
contentRegistry: ContentRegistry;
}
) {}
async search(
contentTypes: Array<{ contentTypeId: string; ctx: StorageContext }>,
query: SearchQuery
): Promise<MSearchResult> {
// 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<string>();
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,
},
};
}
}

View file

@ -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<T = unknown, U = T> {
/** Search items */
search(ctx: StorageContext, query: SearchQuery, options?: object): Promise<SearchResult<T>>;
/**
* 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<T>;
}
export interface ContentTypeDefinition<S extends ContentStorage = ContentStorage> {
@ -65,3 +72,29 @@ export interface ContentTypeDefinition<S extends ContentStorage = ContentStorage
latest: Version;
};
}
/**
* A configuration for multi-type search.
* By configuring a content type with a `MSearchConfig`, it can be searched in the multi-type search.
* Underneath content management is using the `savedObjects.find` API to search the saved objects.
*/
export interface MSearchConfig<T = unknown, SavedObjectAttributes = unknown> {
/**
* 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<SavedObjectAttributes>
) => 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[];
}

View file

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

View file

@ -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<Context, any, any> } = {
get,
@ -22,4 +23,5 @@ export const procedures: { [key in ProcedureName]: ProcedureDefinition<Context,
update,
delete: deleteProc,
search,
mSearch,
};

View file

@ -0,0 +1,218 @@
/*
* 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 { validate } from '../../utils';
import { ContentRegistry } from '../../core/registry';
import { createMockedStorage } from '../../core/mocks';
import { EventBus } from '../../core/event_bus';
import { MSearchIn, MSearchQuery } from '../../../common';
import { mSearch } from './msearch';
import { getServiceObjectTransformFactory } from '../services_transforms_factory';
import { MSearchService } from '../../core/msearch';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
const { fn, schemas } = mSearch;
const inputSchema = schemas?.in;
const outputSchema = schemas?.out;
if (!inputSchema) {
throw new Error(`Input schema missing for [mSearch] procedure.`);
}
if (!outputSchema) {
throw new Error(`Output schema missing for [mSearch] procedure.`);
}
describe('RPC -> 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].'));
});
});
});
});

View file

@ -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<Context, MSearchIn, MSearchOut> = {
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,
};
},
};

View file

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

View file

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

View file

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