Skip to content

Commit

Permalink
[Content Management] Cross Type Search (savedObjects.find() based) (#…
Browse files Browse the repository at this point in the history
…154464)

## Summary

Partially addresses #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 #153304)
- Documentation (will add into
#154453)
- Add rxjs and hook method
  • Loading branch information
Dosant authored Apr 6, 2023
1 parent 6a99c46 commit 0936601
Show file tree
Hide file tree
Showing 24 changed files with 715 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/content_management/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ export type {
SearchIn,
SearchQuery,
SearchResult,
MSearchIn,
MSearchQuery,
MSearchResult,
MSearchOut,
} from './rpc';
10 changes: 9 additions & 1 deletion src/plugins/content_management/common/rpc/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down
1 change: 1 addition & 0 deletions src/plugins/content_management/common/rpc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
51 changes: 51 additions & 0 deletions src/plugins/content_management/common/rpc/msearch.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
2 changes: 2 additions & 0 deletions src/plugins/content_management/common/rpc/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,4 +25,5 @@ export const schemas: {
update: updateSchemas,
delete: deleteSchemas,
search: searchSchemas,
mSearch: mSearchSchemas,
};
49 changes: 29 additions & 20 deletions src/plugins/content_management/common/rpc/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,47 @@ 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' }
),
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' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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: {},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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>>;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
* 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>;
create(input: CreateIn): Promise<unknown>;
update(input: UpdateIn): Promise<unknown>;
delete(input: DeleteIn): Promise<unknown>;
search(input: SearchIn): Promise<unknown>;
mSearch?(input: MSearchIn): Promise<unknown>;
}
8 changes: 4 additions & 4 deletions src/plugins/content_management/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import type {
DeleteIn,
SearchIn,
ProcedureName,
MSearchIn,
MSearchOut,
MSearchResult,
} from '../../common';
import type { CrudClient } from '../crud_client/crud_client';
import type {
Expand Down Expand Up @@ -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),
Expand Down
Loading

0 comments on commit 0936601

Please sign in to comment.