diff --git a/.eslintrc.js b/.eslintrc.js index a7058254f58e22..9090538d5dbdb3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -231,7 +231,7 @@ module.exports = { '!src/core/server/index.ts', // relative import '!src/core/server/mocks{,.ts}', '!src/core/server/types{,.ts}', - '!src/core/server/test_utils', + '!src/core/server/test_utils{,.ts}', // for absolute imports until fixed in // https://github.com/elastic/kibana/issues/36096 '!src/core/server/*.test.mocks{,.ts}', diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts index 5b52665b6268e8..28afdefe1413fa 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_create.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerBulkCreateRoute } from '../bulk_create'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts index 845bae47b41f2c..521e62e16b1d81 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_get.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerBulkGetRoute } from '../bulk_get'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts index 6356fc787a8d84..9c888406b0c96a 100644 --- a/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/bulk_update.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerBulkUpdateRoute } from '../bulk_update'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/create.test.ts b/src/core/server/saved_objects/routes/integration_tests/create.test.ts index 5a53a302092818..ba3d620f8fdb5b 100644 --- a/src/core/server/saved_objects/routes/integration_tests/create.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/create.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerCreateRoute } from '../create'; import { savedObjectsClientMock } from '../../service/saved_objects_client.mock'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index d4ce4d421dde1a..652d267f08fe74 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerDeleteRoute } from '../delete'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index bdb2e23f0826d6..7b342dde2febe7 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -27,7 +27,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { SavedObjectConfig } from '../../saved_objects_config'; import { registerExportRoute } from '../export'; -import { setupServer, createExportableType } from './test_utils'; +import { setupServer, createExportableType } from '../test_utils'; type setupServerReturn = UnwrapPromise>; const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index 7916100e468317..31bda1d6b9cbd2 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -23,7 +23,7 @@ import querystring from 'querystring'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerFindRoute } from '../find'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index c4a03a0e2e7d2c..c4e304a3f892f8 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -22,7 +22,7 @@ import { UnwrapPromise } from '@kbn/utility-types'; import { registerImportRoute } from '../import'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; import { SavedObjectConfig } from '../../saved_objects_config'; -import { setupServer, createExportableType } from './test_utils'; +import { setupServer, createExportableType } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts index 4bbe3271e0232d..0fe07245dda202 100644 --- a/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/log_legacy_import.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerLogLegacyImportRoute } from '../log_legacy_import'; import { loggingServiceMock } from '../../../logging/logging_service.mock'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index a36f246f9dbc55..27750ec692e5ad 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerResolveImportErrorsRoute } from '../resolve_import_errors'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer, createExportableType } from './test_utils'; +import { setupServer, createExportableType } from '../test_utils'; import { SavedObjectConfig } from '../../saved_objects_config'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/update.test.ts b/src/core/server/saved_objects/routes/integration_tests/update.test.ts index b0c3d68090db6a..eb6eb1cdb6bd95 100644 --- a/src/core/server/saved_objects/routes/integration_tests/update.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/update.test.ts @@ -21,7 +21,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; import { registerUpdateRoute } from '../update'; import { savedObjectsClientMock } from '../../../../../core/server/mocks'; -import { setupServer } from './test_utils'; +import { setupServer } from '../test_utils'; type setupServerReturn = UnwrapPromise>; diff --git a/src/core/server/saved_objects/routes/integration_tests/test_utils.ts b/src/core/server/saved_objects/routes/test_utils.ts similarity index 83% rename from src/core/server/saved_objects/routes/integration_tests/test_utils.ts rename to src/core/server/saved_objects/routes/test_utils.ts index 23e0285201dc72..a2227a8033dbdb 100644 --- a/src/core/server/saved_objects/routes/integration_tests/test_utils.ts +++ b/src/core/server/saved_objects/routes/test_utils.ts @@ -17,14 +17,14 @@ * under the License. */ -import { ContextService } from '../../../context'; -import { createHttpServer, createCoreContext } from '../../../http/test_utils'; -import { coreMock } from '../../../mocks'; -import { SavedObjectsType } from '../../types'; +import { ContextService } from '../../context'; +import { createHttpServer, createCoreContext } from '../../http/test_utils'; +import { coreMock } from '../../mocks'; +import { SavedObjectsType } from '../types'; -const coreId = Symbol('core'); +const defaultCoreId = Symbol('core'); -export const setupServer = async () => { +export const setupServer = async (coreId: symbol = defaultCoreId) => { const coreContext = createCoreContext({ coreId }); const contextService = new ContextService(coreContext); diff --git a/src/core/server/test_utils.ts b/src/core/server/test_utils.ts index f7e6fbcd0c131e..6b16fe3bdef616 100644 --- a/src/core/server/test_utils.ts +++ b/src/core/server/test_utils.ts @@ -19,3 +19,4 @@ export { createHttpServer } from './http/test_utils'; export { ServiceStatusLevelSnapshotSerializer } from './status/test_utils'; +export { setupServer } from './saved_objects/routes/test_utils'; diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 346b4cfce70c1a..07d7789d235ecb 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -26,3 +26,4 @@ export * from './capabilities'; export * from './app_category'; export * from './ui_settings'; export * from './saved_objects'; +export * from './serializable'; diff --git a/src/core/types/serializable.ts b/src/core/types/serializable.ts new file mode 100644 index 00000000000000..9e8ea123bea91d --- /dev/null +++ b/src/core/types/serializable.ts @@ -0,0 +1,32 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type Serializable = + | string + | number + | boolean + | null + | SerializableArray + | SerializableRecord; + +// we need interfaces instead of types here to allow cyclic references +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SerializableArray extends Array {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SerializableRecord extends Record {} diff --git a/test/plugin_functional/plugins/core_provider_plugin/kibana.json b/test/plugin_functional/plugins/core_provider_plugin/kibana.json index 1d5c5824d6b970..8d9b30acab8935 100644 --- a/test/plugin_functional/plugins/core_provider_plugin/kibana.json +++ b/test/plugin_functional/plugins/core_provider_plugin/kibana.json @@ -2,7 +2,7 @@ "id": "core_provider_plugin", "version": "0.0.1", "kibanaVersion": "kibana", - "optionalPlugins": ["core_plugin_a", "core_plugin_b", "licensing"], + "optionalPlugins": ["core_plugin_a", "core_plugin_b", "licensing", "globalSearchTest"], "server": false, "ui": true } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 48dfa341f63853..66e7eec23959da 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -18,6 +18,7 @@ "xpack.endpoint": "plugins/endpoint", "xpack.features": "plugins/features", "xpack.fileUpload": "plugins/file_upload", + "xpack.globalSearch": ["plugins/global_search"], "xpack.graph": ["plugins/graph"], "xpack.grokDebugger": "plugins/grokdebugger", "xpack.idxMgmt": "plugins/index_management", diff --git a/x-pack/plugins/global_search/README.md b/x-pack/plugins/global_search/README.md new file mode 100644 index 00000000000000..d47e0bd696fd85 --- /dev/null +++ b/x-pack/plugins/global_search/README.md @@ -0,0 +1,49 @@ +# Kibana GlobalSearch plugin + +The GlobalSearch plugin provides an easy way to search for various objects, such as applications +or dashboards from the Kibana instance, from both server and client-side plugins + +## Consuming the globalSearch API + +```ts +startDeps.globalSearch.find('some term').subscribe({ + next: ({ results }) => { + addNewResultsToList(results); + }, + error: () => {}, + complete: () => { + showAsyncSearchIndicator(false); + } +}); +``` + +## Registering custom result providers + +The GlobalSearch API allows to extend provided results by registering your own provider. + +```ts +setupDeps.globalSearch.registerResultProvider({ + id: 'my_provider', + find: (term, options, context) => { + const resultPromise = myService.search(term, context.core.savedObjects.client); + return from(resultPromise).pipe(takeUntil(options.aborted$); + }, +}); +``` + +## Known limitations + +### Client-side registered providers + +Results from providers registered from the client-side `registerResultProvider` API will +not be available when performing a search from the server-side. For this reason, prefer +registering providers using the server-side API when possible. + +Refer to the [RFC](rfcs/text/0011_global_search.md#result_provider_registration) for more details + +### Search completion cause + +There is currently no way to identify `globalSearch.find` observable completion cause: +searches completing because all providers returned all their results and searches +completing because the consumer aborted the search using the `aborted$` option or because +the internal timout period has been reaches will both complete the same way. diff --git a/x-pack/plugins/global_search/common/errors.test.ts b/x-pack/plugins/global_search/common/errors.test.ts new file mode 100644 index 00000000000000..949795abd701a2 --- /dev/null +++ b/x-pack/plugins/global_search/common/errors.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GlobalSearchFindError } from './errors'; + +describe('GlobalSearchFindError', () => { + describe('#invalidLicense', () => { + it('create an error with the correct `type`', () => { + const error = GlobalSearchFindError.invalidLicense('foobar'); + expect(error.message).toBe('foobar'); + expect(error.type).toBe('invalid-license'); + }); + + it('can be identified via instanceof', () => { + const error = GlobalSearchFindError.invalidLicense('foo'); + expect(error instanceof GlobalSearchFindError).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/global_search/common/errors.ts b/x-pack/plugins/global_search/common/errors.ts new file mode 100644 index 00000000000000..15bc0958cb8aae --- /dev/null +++ b/x-pack/plugins/global_search/common/errors.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// only one type for now, but already present for future-proof reasons +export type GlobalSearchFindErrorType = 'invalid-license'; + +/** + * Error thrown from the {@link GlobalSearchPluginStart.find | GlobalSearch find API}'s result observable + * + * @public + */ +export class GlobalSearchFindError extends Error { + public static invalidLicense(message: string) { + return new GlobalSearchFindError('invalid-license', message); + } + + private constructor(public readonly type: GlobalSearchFindErrorType, message: string) { + super(message); + + // Set the prototype explicitly, see: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + Object.setPrototypeOf(this, GlobalSearchFindError.prototype); + } +} diff --git a/x-pack/plugins/global_search/common/license_checker.mock.ts b/x-pack/plugins/global_search/common/license_checker.mock.ts new file mode 100644 index 00000000000000..e19a2562e53d8c --- /dev/null +++ b/x-pack/plugins/global_search/common/license_checker.mock.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILicenseChecker } from './license_checker'; + +const createLicenseCheckerMock = (): jest.Mocked => { + const mock = { + getState: jest.fn(), + getLicense: jest.fn(), + clean: jest.fn(), + }; + + mock.getLicense.mockReturnValue(undefined); + mock.getState.mockReturnValue({ valid: true }); + + return mock; +}; + +export const licenseCheckerMock = { + create: createLicenseCheckerMock, +}; diff --git a/x-pack/plugins/global_search/common/license_checker.test.ts b/x-pack/plugins/global_search/common/license_checker.test.ts new file mode 100644 index 00000000000000..47a0d41016d71f --- /dev/null +++ b/x-pack/plugins/global_search/common/license_checker.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, of, BehaviorSubject } from 'rxjs'; +import { licenseMock } from '../../licensing/common/licensing.mock'; +import { ILicense, LicenseCheck } from '../../licensing/common/types'; +import { LicenseChecker } from './license_checker'; + +describe('LicenseChecker', () => { + const createLicense = (check: LicenseCheck): ILicense => { + const license = licenseMock.createLicenseMock(); + license.check.mockReturnValue(check); + return license; + }; + + const createLicense$ = (check: LicenseCheck): Observable => of(createLicense(check)); + + it('returns the correct state of the license', () => { + let checker = new LicenseChecker(createLicense$({ state: 'valid' })); + expect(checker.getState()).toEqual({ valid: true }); + + checker = new LicenseChecker(createLicense$({ state: 'expired' })); + expect(checker.getState()).toEqual({ valid: false, message: 'expired' }); + + checker = new LicenseChecker(createLicense$({ state: 'invalid' })); + expect(checker.getState()).toEqual({ valid: false, message: 'invalid' }); + + checker = new LicenseChecker(createLicense$({ state: 'unavailable' })); + expect(checker.getState()).toEqual({ valid: false, message: 'unavailable' }); + }); + + it('updates the state when the license changes', () => { + const license$ = new BehaviorSubject(createLicense({ state: 'valid' })); + + const checker = new LicenseChecker(license$); + expect(checker.getState()).toEqual({ valid: true }); + + license$.next(createLicense({ state: 'expired' })); + expect(checker.getState()).toEqual({ valid: false, message: 'expired' }); + + license$.next(createLicense({ state: 'valid' })); + expect(checker.getState()).toEqual({ valid: true }); + }); + + it('removes the subscription when calling `clean`', () => { + const mockUnsubscribe = jest.fn(); + const mockObs = { + subscribe: jest.fn().mockReturnValue({ unsubscribe: mockUnsubscribe }), + }; + + const checker = new LicenseChecker(mockObs as any); + + expect(mockObs.subscribe).toHaveBeenCalledTimes(1); + expect(mockUnsubscribe).not.toHaveBeenCalled(); + + checker.clean(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/global_search/common/license_checker.ts b/x-pack/plugins/global_search/common/license_checker.ts new file mode 100644 index 00000000000000..d201b31802b32f --- /dev/null +++ b/x-pack/plugins/global_search/common/license_checker.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, Subscription } from 'rxjs'; +import { ILicense } from '../../licensing/common/types'; + +export type LicenseState = { valid: false; message: string } | { valid: true }; + +export type CheckLicense = (license: ILicense) => LicenseState; + +const checkLicense: CheckLicense = (license) => { + const check = license.check('globalSearch', 'basic'); + switch (check.state) { + case 'expired': + return { valid: false, message: 'expired' }; + case 'invalid': + return { valid: false, message: 'invalid' }; + case 'unavailable': + return { valid: false, message: 'unavailable' }; + case 'valid': + return { valid: true }; + default: + throw new Error(`Invalid license state: ${check.state}`); + } +}; + +export type ILicenseChecker = PublicMethodsOf; + +export class LicenseChecker { + private subscription: Subscription; + private state: LicenseState = { valid: false, message: 'unknown' }; + + constructor(license$: Observable) { + this.subscription = license$.subscribe((license) => { + this.state = checkLicense(license); + }); + } + + public getState() { + return this.state; + } + + public clean() { + this.subscription.unsubscribe(); + } +} diff --git a/x-pack/plugins/global_search/common/operators/index.ts b/x-pack/plugins/global_search/common/operators/index.ts new file mode 100644 index 00000000000000..2a0cf066a04aa1 --- /dev/null +++ b/x-pack/plugins/global_search/common/operators/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { takeInArray } from './take_in_array'; diff --git a/x-pack/plugins/global_search/common/operators/take_in_array.test.ts b/x-pack/plugins/global_search/common/operators/take_in_array.test.ts new file mode 100644 index 00000000000000..b73ee20c9889a0 --- /dev/null +++ b/x-pack/plugins/global_search/common/operators/take_in_array.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TestScheduler } from 'rxjs/testing'; +import { takeInArray } from './take_in_array'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +describe('takeInArray', () => { + it('only emits a given `count` of items from an array observable', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + const source = hot('a-b-c', { a: [1], b: [2], c: [3] }); + const expected = 'a-(b|)'; + + expectObservable(source.pipe(takeInArray(2))).toBe(expected, { + a: [1], + b: [2], + }); + }); + }); + + it('completes if the source completes before reaching the given `count`', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + const source = hot('a-b-c-|', { a: [1, 2], b: [3, 4], c: [5] }); + const expected = 'a-b-c-|'; + + expectObservable(source.pipe(takeInArray(10))).toBe(expected, { + a: [1, 2], + b: [3, 4], + c: [5], + }); + }); + }); + + it('split the emission if `count` is reached in a given emission', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + const source = hot('a-b-c', { a: [1, 2, 3], b: [4, 5, 6], c: [7, 8] }); + const expected = 'a-(b|)'; + + expectObservable(source.pipe(takeInArray(5))).toBe(expected, { + a: [1, 2, 3], + b: [4, 5], + }); + }); + }); + + it('throws when trying to take a negative number of items', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + const source = hot('a-b-c', { a: [1, 2, 3], b: [4, 5, 6], c: [7, 8] }); + + expect(() => { + source.pipe(takeInArray(-4)).subscribe(() => undefined); + }).toThrowErrorMatchingInlineSnapshot(`"Cannot take a negative number of items"`); + }); + }); +}); diff --git a/x-pack/plugins/global_search/common/operators/take_in_array.ts b/x-pack/plugins/global_search/common/operators/take_in_array.ts new file mode 100644 index 00000000000000..7d041d3c2bab03 --- /dev/null +++ b/x-pack/plugins/global_search/common/operators/take_in_array.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line max-classes-per-file +import { + EMPTY, + MonoTypeOperatorFunction, + Observable, + Operator, + Subscriber, + TeardownLogic, +} from 'rxjs'; + +/** + * Emits only the first `count` items from the arrays emitted by the source Observable. The limit + * is global to all emitted values, and not per emission. + * + * @example + * ```ts + * const source = of([1, 2], [3, 4], [5, 6]); + * const takeThreeInArray = source.pipe(takeInArray(3)); + * takeThreeInArray.subscribe(x => console.log(x)); + * + * // Logs: + * // [1,2] + * // [3] + * ``` + * + * @param count The total maximum number of value to keep from the emitted arrays + */ +export function takeInArray(count: number): MonoTypeOperatorFunction { + return function takeLastOperatorFunction(source: Observable): Observable { + if (count === 0) { + return EMPTY; + } else { + return source.lift(new TakeInArray(count)); + } + }; +} + +class TakeInArray implements Operator { + constructor(private total: number) { + if (this.total < 0) { + throw new Error('Cannot take a negative number of items'); + } + } + + call(subscriber: Subscriber, source: any): TeardownLogic { + return source.subscribe(new TakeInArraySubscriber(subscriber, this.total)); + } +} + +class TakeInArraySubscriber extends Subscriber { + private current: number = 0; + + constructor(destination: Subscriber, private total: number) { + super(destination); + } + + protected _next(value: T[]): void { + const remaining = this.total - this.current; + if (remaining > value.length) { + this.destination.next!(value); + this.current += value.length; + } else { + this.destination.next!(value.slice(0, remaining)); + this.destination.complete!(); + this.unsubscribe(); + } + } +} diff --git a/x-pack/plugins/global_search/common/process_result.test.mocks.ts b/x-pack/plugins/global_search/common/process_result.test.mocks.ts new file mode 100644 index 00000000000000..718ac7a1a6a52b --- /dev/null +++ b/x-pack/plugins/global_search/common/process_result.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const convertResultUrlMock = jest.fn().mockReturnValue('converted-url'); +jest.doMock('./utils', () => ({ + convertResultUrl: convertResultUrlMock, +})); diff --git a/x-pack/plugins/global_search/common/process_result.test.ts b/x-pack/plugins/global_search/common/process_result.test.ts new file mode 100644 index 00000000000000..723f21a24f5525 --- /dev/null +++ b/x-pack/plugins/global_search/common/process_result.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { convertResultUrlMock } from './process_result.test.mocks'; + +import { IBasePath } from './utils'; +import { GlobalSearchProviderResult } from './types'; +import { processProviderResult } from './process_result'; + +const createResult = (parts: Partial): GlobalSearchProviderResult => ({ + id: 'id', + title: 'title', + type: 'type', + icon: 'icon', + url: '/foo/bar', + score: 42, + meta: { foo: 'bar' }, + ...parts, +}); + +describe('processProviderResult', () => { + let basePath: jest.Mocked; + + beforeEach(() => { + basePath = { + prepend: jest.fn(), + }; + + convertResultUrlMock.mockClear(); + }); + + it('returns all properties unchanged except `url`', () => { + const r1 = createResult({ + id: '1', + type: 'test', + url: '/url-1', + title: 'title 1', + icon: 'foo', + score: 69, + meta: { hello: 'dolly' }, + }); + + expect(processProviderResult(r1, basePath)).toEqual({ + ...r1, + url: expect.any(String), + }); + }); + + it('converts the url using `convertResultUrl`', () => { + const r1 = createResult({ id: '1', url: '/url-1' }); + const r2 = createResult({ id: '2', url: '/url-2' }); + + convertResultUrlMock.mockReturnValueOnce('/url-A'); + convertResultUrlMock.mockReturnValueOnce('/url-B'); + + expect(convertResultUrlMock).not.toHaveBeenCalled(); + + const g1 = processProviderResult(r1, basePath); + + expect(g1.url).toEqual('/url-A'); + expect(convertResultUrlMock).toHaveBeenCalledTimes(1); + expect(convertResultUrlMock).toHaveBeenCalledWith(r1.url, basePath); + + const g2 = processProviderResult(r2, basePath); + + expect(g2.url).toEqual('/url-B'); + expect(convertResultUrlMock).toHaveBeenCalledTimes(2); + expect(convertResultUrlMock).toHaveBeenCalledWith(r2.url, basePath); + }); +}); diff --git a/x-pack/plugins/global_search/common/process_result.ts b/x-pack/plugins/global_search/common/process_result.ts new file mode 100644 index 00000000000000..fed6dc14f066bb --- /dev/null +++ b/x-pack/plugins/global_search/common/process_result.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GlobalSearchProviderResult, GlobalSearchResult } from './types'; +import { convertResultUrl, IBasePath } from './utils'; + +/** + * Convert a {@link GlobalSearchProviderResult | provider result} + * to a {@link GlobalSearchResult | service result} + */ +export const processProviderResult = ( + result: GlobalSearchProviderResult, + basePath: IBasePath +): GlobalSearchResult => { + return { + ...result, + url: convertResultUrl(result.url, basePath), + }; +}; diff --git a/x-pack/plugins/global_search/common/types.ts b/x-pack/plugins/global_search/common/types.ts new file mode 100644 index 00000000000000..26940806a4ecd2 --- /dev/null +++ b/x-pack/plugins/global_search/common/types.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { Serializable } from 'src/core/types'; + +/** + * Options provided to {@link GlobalSearchResultProvider | a result provider}'s `find` method. + */ +export interface GlobalSearchProviderFindOptions { + /** + * A custom preference token associated with a search 'session' that should be used to get consistent scoring + * when performing calls to ES. Can also be used as a 'session' token for providers returning data from elsewhere + * than an elasticsearch cluster. + */ + preference: string; + /** + * Observable that emits once if and when the `find` call has been aborted, either manually by the consumer, + * or when the internal timeout period as been reached. + * + * When a `find` request is effectively aborted, the service will stop emitting any new result to the consumer anyway, but + * this can (and should) be used to cancel any pending asynchronous task and complete the result observable from within the provider. + */ + aborted$: Observable; + /** + * The total maximum number of results (including all batches, not per emission) that should be returned by the provider for a given `find` request. + * Any result emitted exceeding this quota will be ignored by the service and not emitted to the consumer. + */ + maxResults: number; +} + +/** + * Structured type for the {@link GlobalSearchProviderResult.url | provider result's url property} + */ +export type GlobalSearchProviderResultUrl = string | { path: string; prependBasePath: boolean }; + +/** + * Representation of a result returned by a {@link GlobalSearchResultProvider | result provider} + */ +export interface GlobalSearchProviderResult { + /** an id that should be unique for an individual provider's results */ + id: string; + /** the title/label of the result */ + title: string; + /** the type of result */ + type: string; + /** an optional EUI icon name to associate with the search result */ + icon?: string; + /** + * The url associated with this result. + * This can be either an absolute url, a path relative to the basePath, or a structure specifying if the basePath should be prepended. + * + * @example + * `result.url = 'https://kibana-instance:8080/base-path/app/my-app/my-result-type/id';` + * `result.url = '/app/my-app/my-result-type/id';` + * `result.url = { path: '/base-path/app/my-app/my-result-type/id', prependBasePath: false };` + */ + url: GlobalSearchProviderResultUrl; + /** the score of the result, from 1 (lowest) to 100 (highest) */ + score: number; + /** an optional record of metadata for this result */ + meta?: Record; +} + +/** + * Representation of a result returned by the {@link GlobalSearchPluginStart.find | `find` API} + */ +export type GlobalSearchResult = Omit & { + /** + * The url associated with this result. + * This can be either an absolute url, or a relative path including the basePath + */ + url: string; +}; + +/** + * Response returned from the {@link GlobalSearchPluginStart | global search service}'s `find` API + * + * @public + */ +export interface GlobalSearchBatchedResults { + /** + * Results for this batch + */ + results: GlobalSearchResult[]; +} diff --git a/x-pack/plugins/global_search/common/utils.test.ts b/x-pack/plugins/global_search/common/utils.test.ts new file mode 100644 index 00000000000000..27f1ce99a58cc5 --- /dev/null +++ b/x-pack/plugins/global_search/common/utils.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { convertResultUrl } from './utils'; + +const createBasePath = () => ({ + prepend: jest.fn(), +}); + +describe('convertResultUrl', () => { + let basePath: ReturnType; + + beforeEach(() => { + basePath = createBasePath(); + basePath.prepend.mockImplementation((path) => `/base-path${path}`); + }); + + describe('when the url is a string', () => { + it('does not convert absolute urls', () => { + expect(convertResultUrl('http://kibana:8080/foo/bar', basePath)).toEqual( + 'http://kibana:8080/foo/bar' + ); + expect(convertResultUrl('https://localhost/path/to/thing', basePath)).toEqual( + 'https://localhost/path/to/thing' + ); + expect(basePath.prepend).toHaveBeenCalledTimes(0); + }); + + it('prepends the base path to relative urls', () => { + expect(convertResultUrl('/app/my-app/foo', basePath)).toEqual('/base-path/app/my-app/foo'); + expect(basePath.prepend).toHaveBeenCalledTimes(1); + expect(basePath.prepend).toHaveBeenCalledWith('/app/my-app/foo'); + + expect(convertResultUrl('/some-path', basePath)).toEqual('/base-path/some-path'); + expect(basePath.prepend).toHaveBeenCalledTimes(2); + expect(basePath.prepend).toHaveBeenCalledWith('/some-path'); + }); + }); + + describe('when the url is an object', () => { + it('converts the path if `prependBasePath` is true', () => { + expect(convertResultUrl({ path: '/app/my-app', prependBasePath: true }, basePath)).toEqual( + '/base-path/app/my-app' + ); + expect(basePath.prepend).toHaveBeenCalledTimes(1); + expect(basePath.prepend).toHaveBeenCalledWith('/app/my-app'); + + expect(convertResultUrl({ path: '/some-path', prependBasePath: true }, basePath)).toEqual( + '/base-path/some-path' + ); + expect(basePath.prepend).toHaveBeenCalledTimes(2); + expect(basePath.prepend).toHaveBeenCalledWith('/some-path'); + }); + it('does not convert the path if `prependBasePath` is false', () => { + expect(convertResultUrl({ path: '/app/my-app', prependBasePath: false }, basePath)).toEqual( + '/app/my-app' + ); + expect(convertResultUrl({ path: '/some-path', prependBasePath: false }, basePath)).toEqual( + '/some-path' + ); + expect(basePath.prepend).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/x-pack/plugins/global_search/common/utils.ts b/x-pack/plugins/global_search/common/utils.ts new file mode 100644 index 00000000000000..46648319458de6 --- /dev/null +++ b/x-pack/plugins/global_search/common/utils.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GlobalSearchProviderResultUrl } from './types'; + +// interface matching both the server and client-side implementation of IBasePath for our needs +// used to avoid duplicating `convertResultUrl` in server and client code due to different signatures. +export interface IBasePath { + prepend(path: string): string; +} + +/** + * Convert a {@link GlobalSearchProviderResultUrl | provider result's url} to an absolute or relative url + * usable in {@link GlobalSearchResult | service results} + */ +export const convertResultUrl = ( + url: GlobalSearchProviderResultUrl, + basePath: IBasePath +): string => { + if (typeof url === 'string') { + // relative path + if (url.startsWith('/')) { + return basePath.prepend(url); + } + // absolute url + return url; + } + if (url.prependBasePath) { + return basePath.prepend(url.path); + } + return url.path; +}; diff --git a/x-pack/plugins/global_search/kibana.json b/x-pack/plugins/global_search/kibana.json new file mode 100644 index 00000000000000..c94e080a8c589c --- /dev/null +++ b/x-pack/plugins/global_search/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "globalSearch", + "version": "8.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["licensing"], + "optionalPlugins": [], + "configPath": ["xpack", "global_search"] +} diff --git a/x-pack/plugins/global_search/public/config.ts b/x-pack/plugins/global_search/public/config.ts new file mode 100644 index 00000000000000..a3969bef287b2c --- /dev/null +++ b/x-pack/plugins/global_search/public/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface GlobalSearchClientConfigType { + // is a string because the server-side counterpart is a duration + // which is serialized to string when sent to the client + // should be parsed using moment.duration(config.search_timeout) + search_timeout: string; +} diff --git a/x-pack/plugins/global_search/public/index.ts b/x-pack/plugins/global_search/public/index.ts new file mode 100644 index 00000000000000..18483cea725402 --- /dev/null +++ b/x-pack/plugins/global_search/public/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/public'; +import { + GlobalSearchPlugin, + GlobalSearchPluginSetupDeps, + GlobalSearchPluginStartDeps, +} from './plugin'; +import { GlobalSearchPluginSetup, GlobalSearchPluginStart } from './types'; + +export const plugin: PluginInitializer< + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + GlobalSearchPluginSetupDeps, + GlobalSearchPluginStartDeps +> = (context) => new GlobalSearchPlugin(context); + +export { + GlobalSearchBatchedResults, + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, + GlobalSearchProviderResultUrl, + GlobalSearchResult, +} from '../common/types'; +export { + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + GlobalSearchResultProvider, +} from './types'; +export { GlobalSearchFindOptions } from './services/types'; diff --git a/x-pack/plugins/global_search/public/mocks.ts b/x-pack/plugins/global_search/public/mocks.ts new file mode 100644 index 00000000000000..97dc01e92dbfef --- /dev/null +++ b/x-pack/plugins/global_search/public/mocks.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GlobalSearchPluginSetup, GlobalSearchPluginStart } from './types'; +import { searchServiceMock } from './services/search_service.mock'; + +const createSetupMock = (): jest.Mocked => { + const searchMock = searchServiceMock.createSetupContract(); + + return { + registerResultProvider: searchMock.registerResultProvider, + }; +}; + +const createStartMock = (): jest.Mocked => { + const searchMock = searchServiceMock.createStartContract(); + + return { + find: searchMock.find, + }; +}; + +export const globalSearchPluginMock = { + createSetupContract: createSetupMock, + createStartContract: createStartMock, +}; diff --git a/x-pack/plugins/global_search/public/plugin.ts b/x-pack/plugins/global_search/public/plugin.ts new file mode 100644 index 00000000000000..6af8ec32a581d4 --- /dev/null +++ b/x-pack/plugins/global_search/public/plugin.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { LicensingPluginStart } from '../../licensing/public'; +import { LicenseChecker, ILicenseChecker } from '../common/license_checker'; +import { GlobalSearchPluginSetup, GlobalSearchPluginStart } from './types'; +import { GlobalSearchClientConfigType } from './config'; +import { SearchService } from './services'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface GlobalSearchPluginSetupDeps {} +export interface GlobalSearchPluginStartDeps { + licensing: LicensingPluginStart; +} + +export class GlobalSearchPlugin + implements + Plugin< + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + GlobalSearchPluginSetupDeps, + GlobalSearchPluginStartDeps + > { + private readonly config: GlobalSearchClientConfigType; + private licenseChecker?: ILicenseChecker; + private readonly searchService = new SearchService(); + + constructor(context: PluginInitializerContext) { + this.config = context.config.get(); + } + + setup(core: CoreSetup<{}, GlobalSearchPluginStart>) { + const { registerResultProvider } = this.searchService.setup({ + config: this.config, + }); + + return { + registerResultProvider, + }; + } + + start({ http }: CoreStart, { licensing }: GlobalSearchPluginStartDeps) { + this.licenseChecker = new LicenseChecker(licensing.license$); + const { find } = this.searchService.start({ + http, + licenseChecker: this.licenseChecker, + }); + + return { + find, + }; + } + + public stop() { + if (this.licenseChecker) { + this.licenseChecker.clean(); + } + } +} diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts new file mode 100644 index 00000000000000..f62acd08633ff1 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TestScheduler } from 'rxjs/testing'; +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { GlobalSearchResult } from '../../common/types'; +import { fetchServerResults } from './fetch_server_results'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +const createResult = (id: string, parts: Partial = {}): GlobalSearchResult => ({ + id, + title: id, + type: 'type', + url: `/path/to/${id}`, + score: 100, + ...parts, +}); + +describe('fetchServerResults', () => { + let http: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createStartContract(); + }); + + it('perform a POST request to the endpoint with valid options', () => { + http.post.mockResolvedValue({ results: [] }); + + fetchServerResults(http, 'some term', { preference: 'pref' }); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(http.post).toHaveBeenCalledWith('/internal/global_search/find', { + body: JSON.stringify({ term: 'some term', options: { preference: 'pref' } }), + }); + }); + + it('returns the results from the server', async () => { + const resultA = createResult('A'); + const resultB = createResult('B'); + + http.post.mockResolvedValue({ results: [resultA, resultB] }); + + const results = await fetchServerResults(http, 'some term', { preference: 'pref' }).toPromise(); + + expect(http.post).toHaveBeenCalledTimes(1); + expect(results).toHaveLength(2); + expect(results[0]).toEqual(resultA); + expect(results[1]).toEqual(resultB); + }); + + describe('returns an observable that', () => { + // NOTE: test scheduler do not properly work with promises because of their asynchronous nature. + // we are cheating here by having `http.post` return an observable instead of a promise. + // this still allows more finely grained testing about timing, and asserting that the method + // works properly when `post` returns a real promise is handled in other tests of this suite + + it('emits when the response is received', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); + + const results = fetchServerResults(http, 'term', {}); + + expectObservable(results).toBe('---(a|)', { + a: [], + }); + }); + }); + + it('completes without returning results if aborted$ emits before the response', () => { + getTestScheduler().run(({ expectObservable, hot }) => { + http.post.mockReturnValue(hot('---(a|)', { a: { results: [] } }) as any); + const aborted$ = hot('-(a|)', { a: undefined }); + const results = fetchServerResults(http, 'term', { aborted$ }); + + expectObservable(results).toBe('-|', { + a: [], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/global_search/public/services/fetch_server_results.ts b/x-pack/plugins/global_search/public/services/fetch_server_results.ts new file mode 100644 index 00000000000000..3c06dfab9f50e3 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/fetch_server_results.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, from, EMPTY } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; +import { HttpStart } from 'src/core/public'; +import { GlobalSearchResult } from '../../common/types'; +import { GlobalSearchFindOptions } from './types'; + +interface ServerFetchResponse { + results: GlobalSearchResult[]; +} + +/** + * Fetch the server-side results from the GS internal HTTP API. + * + * @remarks + * Though this function returns an Observable, the current implementation is not streaming + * results from the server. All results will be returned in a single batch when + * all server-side providers are completed. + */ +export const fetchServerResults = ( + http: HttpStart, + term: string, + { preference, aborted$ }: GlobalSearchFindOptions +): Observable => { + let controller: AbortController | undefined; + if (aborted$) { + controller = new AbortController(); + aborted$.subscribe(() => { + controller!.abort(); + }); + } + return from( + http.post('/internal/global_search/find', { + body: JSON.stringify({ term, options: { preference } }), + signal: controller?.signal, + }) + ).pipe( + takeUntil(aborted$ ?? EMPTY), + map((response) => response.results) + ); +}; diff --git a/x-pack/plugins/global_search/public/services/index.ts b/x-pack/plugins/global_search/public/services/index.ts new file mode 100644 index 00000000000000..8d3cb860434324 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SearchService, SearchServiceSetup, SearchServiceStart } from './search_service'; +export { GlobalSearchFindOptions } from './types'; diff --git a/x-pack/plugins/global_search/public/services/search_service.mock.ts b/x-pack/plugins/global_search/public/services/search_service.mock.ts new file mode 100644 index 00000000000000..eca69148288b9c --- /dev/null +++ b/x-pack/plugins/global_search/public/services/search_service.mock.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchServiceSetup, SearchServiceStart } from './search_service'; +import { of } from 'rxjs'; + +const createSetupMock = (): jest.Mocked => { + return { + registerResultProvider: jest.fn(), + }; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + find: jest.fn(), + }; + mock.find.mockReturnValue(of({ results: [] })); + + return mock; +}; + +export const searchServiceMock = { + createSetupContract: createSetupMock, + createStartContract: createStartMock, +}; diff --git a/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts new file mode 100644 index 00000000000000..ce406e27c4a72c --- /dev/null +++ b/x-pack/plugins/global_search/public/services/search_service.test.mocks.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const fetchServerResultsMock = jest.fn(); +jest.doMock('./fetch_server_results', () => ({ + fetchServerResults: fetchServerResultsMock, +})); + +export const getDefaultPreferenceMock = jest.fn(); +jest.doMock('./utils', () => ({ + ...jest.requireActual('./utils'), + getDefaultPreference: getDefaultPreferenceMock, +})); diff --git a/x-pack/plugins/global_search/public/services/search_service.test.ts b/x-pack/plugins/global_search/public/services/search_service.test.ts new file mode 100644 index 00000000000000..350547a928fe4b --- /dev/null +++ b/x-pack/plugins/global_search/public/services/search_service.test.ts @@ -0,0 +1,436 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fetchServerResultsMock, getDefaultPreferenceMock } from './search_service.test.mocks'; + +import { Observable, of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; +import { duration } from 'moment'; +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { licenseCheckerMock } from '../../common/license_checker.mock'; +import { GlobalSearchProviderResult, GlobalSearchResult } from '../../common/types'; +import { GlobalSearchFindError } from '../../common/errors'; +import { GlobalSearchClientConfigType } from '../config'; +import { GlobalSearchResultProvider } from '../types'; +import { SearchService } from './search_service'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +describe('SearchService', () => { + let service: SearchService; + let httpStart: ReturnType; + let licenseChecker: ReturnType; + + const createConfig = (timeoutMs: number = 30000): GlobalSearchClientConfigType => { + return { + search_timeout: duration(timeoutMs).toString(), + }; + }; + + const startDeps = () => ({ + http: httpStart, + licenseChecker, + }); + + const createProvider = ( + id: string, + source: Observable = of([]) + ): jest.Mocked => ({ + id, + find: jest.fn().mockImplementation((term, options, context) => source), + }); + + const expectedResult = (id: string) => expect.objectContaining({ id }); + + const expectedBatch = (...ids: string[]) => ({ + results: ids.map((id) => expectedResult(id)), + }); + + const providerResult = ( + id: string, + parts: Partial = {} + ): GlobalSearchProviderResult => ({ + title: id, + type: 'test', + url: '/foo/bar', + score: 100, + ...parts, + id, + }); + + const serverResult = ( + id: string, + parts: Partial = {} + ): GlobalSearchResult => ({ + title: id, + type: 'test', + url: '/foo/bar', + score: 100, + ...parts, + id, + }); + + beforeEach(() => { + service = new SearchService(); + httpStart = httpServiceMock.createStartContract({ basePath: '/base-path' }); + licenseChecker = licenseCheckerMock.create(); + + fetchServerResultsMock.mockClear(); + fetchServerResultsMock.mockReturnValue(of()); + + getDefaultPreferenceMock.mockClear(); + getDefaultPreferenceMock.mockReturnValue('default_pref'); + }); + + describe('#setup()', () => { + describe('#registerResultProvider()', () => { + it('throws when trying to register the same provider twice', () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider = createProvider('A'); + registerResultProvider(provider); + expect(() => { + registerResultProvider(provider); + }).toThrowErrorMatchingInlineSnapshot(`"trying to register duplicate provider: A"`); + }); + }); + }); + + describe('#start()', () => { + describe('#find()', () => { + it('calls the provider with the correct parameters', () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider = createProvider('A'); + registerResultProvider(provider); + + const { find } = service.start(startDeps()); + find('foobar', { preference: 'pref' }); + + expect(provider.find).toHaveBeenCalledTimes(1); + expect(provider.find).toHaveBeenCalledWith( + 'foobar', + expect.objectContaining({ preference: 'pref' }) + ); + }); + + it('calls `fetchServerResults` with the correct parameters', () => { + service.setup({ config: createConfig() }); + + const { find } = service.start(startDeps()); + find('foobar', { preference: 'pref' }); + + expect(fetchServerResultsMock).toHaveBeenCalledTimes(1); + expect(fetchServerResultsMock).toHaveBeenCalledWith( + httpStart, + 'foobar', + expect.objectContaining({ preference: 'pref', aborted$: expect.any(Object) }) + ); + }); + + it('calls `getDefaultPreference` when `preference` is not specified', () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const provider = createProvider('A'); + registerResultProvider(provider); + + const { find } = service.start(startDeps()); + find('foobar', { preference: 'pref' }); + + expect(getDefaultPreferenceMock).not.toHaveBeenCalled(); + + expect(provider.find).toHaveBeenNthCalledWith( + 1, + 'foobar', + expect.objectContaining({ + preference: 'pref', + }) + ); + + find('foobar', {}); + + expect(getDefaultPreferenceMock).toHaveBeenCalledTimes(1); + + expect(provider.find).toHaveBeenNthCalledWith( + 2, + 'foobar', + expect.objectContaining({ + preference: 'default_pref', + }) + ); + }); + + it('return the results from the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('a-b-|', { + a: [providerResult('1')], + b: [providerResult('2')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe('a-b-|', { + a: expectedBatch('1'), + b: expectedBatch('2'), + }); + }); + }); + + it('return the results from the server', async () => { + service.setup({ config: createConfig() }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const serverResults = hot('a-b-|', { + a: [serverResult('1')], + b: [serverResult('2')], + }); + + fetchServerResultsMock.mockReturnValue(serverResults); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe('a-b-|', { + a: expectedBatch('1'), + b: expectedBatch('2'), + }); + }); + }); + + it('handles multiple providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + registerResultProvider( + createProvider( + 'A', + hot('a---d-|', { + a: [providerResult('A1'), providerResult('A2')], + d: [providerResult('A3')], + }) + ) + ); + registerResultProvider( + createProvider( + 'B', + hot('-b-c| ', { + b: [providerResult('B1')], + c: [providerResult('B2'), providerResult('B3')], + }) + ) + ); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe('ab-cd-|', { + a: expectedBatch('A1', 'A2'), + b: expectedBatch('B1'), + c: expectedBatch('B2', 'B3'), + d: expectedBatch('A3'), + }); + }); + }); + + it('return mixed server/client providers results', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + fetchServerResultsMock.mockReturnValue( + hot('-----(c|)', { + c: [serverResult('S1'), serverResult('S2')], + }) + ); + + registerResultProvider( + createProvider( + 'A', + hot('a-b-|', { + a: [providerResult('P1')], + b: [providerResult('P2')], + }) + ) + ); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe('a-b--(c|)', { + a: expectedBatch('P1'), + b: expectedBatch('P2'), + c: expectedBatch('S1', 'S2'), + }); + }); + }); + + it('handles the `aborted$` option', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('--a---(b|)', { + a: [providerResult('1')], + b: [providerResult('2')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const aborted$ = hot('----a--|', { a: undefined }); + + const { find } = service.start(startDeps()); + const results = find('foo', { aborted$ }); + + expectObservable(results).toBe('--a-|', { + a: expectedBatch('1'), + }); + }); + }); + + it('respects the timeout duration', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(100), + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('a 24ms b 100ms (c|)', { + a: [providerResult('1')], + b: [providerResult('2')], + c: [providerResult('3')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe('a 24ms b 74ms |', { + a: expectedBatch('1'), + b: expectedBatch('2'), + }); + }); + }); + + it('only returns a given maximum number of results per provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(100), + maxProviderResults: 2, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + registerResultProvider( + createProvider( + 'A', + hot('a---d-|', { + a: [providerResult('A1'), providerResult('A2')], + d: [providerResult('A3')], + }) + ) + ); + registerResultProvider( + createProvider( + 'B', + hot('-b-c| ', { + b: [providerResult('B1')], + c: [providerResult('B2'), providerResult('B3')], + }) + ) + ); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe('ab-(c|)', { + a: expectedBatch('A1', 'A2'), + b: expectedBatch('B1'), + c: expectedBatch('B2'), + }); + }); + }); + + it('process the results before returning them', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + const resultA = providerResult('A', { + type: 'application', + icon: 'appIcon', + score: 42, + title: 'foo', + url: '/foo/bar', + }); + const resultB = providerResult('B', { + type: 'dashboard', + score: 69, + title: 'bar', + url: { path: '/foo', prependBasePath: false }, + }); + + const provider = createProvider('A', of([resultA, resultB])); + registerResultProvider(provider); + + const { find } = service.start(startDeps()); + const batch = await find('foo', {}).pipe(take(1)).toPromise(); + + expect(batch.results).toHaveLength(2); + expect(batch.results[0]).toEqual({ + ...resultA, + url: '/base-path/foo/bar', + }); + expect(batch.results[1]).toEqual({ + ...resultB, + url: '/foo', + }); + }); + + it('emits an error when the license is invalid', async () => { + licenseChecker.getState.mockReturnValue({ valid: false, message: 'expired' }); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('a-b-|', { + a: [providerResult('1')], + b: [providerResult('2')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const { find } = service.start(startDeps()); + const results = find('foo', {}); + + expectObservable(results).toBe( + '#', + {}, + GlobalSearchFindError.invalidLicense( + 'GlobalSearch API is disabled because of invalid license state: expired' + ) + ); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts new file mode 100644 index 00000000000000..68970b75ad9750 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { merge, Observable, timer, throwError } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; +import { duration } from 'moment'; +import { i18n } from '@kbn/i18n'; +import { HttpStart } from 'src/core/public'; +import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { GlobalSearchFindError } from '../../common/errors'; +import { takeInArray } from '../../common/operators'; +import { processProviderResult } from '../../common/process_result'; +import { ILicenseChecker } from '../../common/license_checker'; +import { GlobalSearchResultProvider } from '../types'; +import { GlobalSearchClientConfigType } from '../config'; +import { GlobalSearchFindOptions } from './types'; +import { getDefaultPreference } from './utils'; +import { fetchServerResults } from './fetch_server_results'; + +/** @public */ +export interface SearchServiceSetup { + /** + * Register a result provider to be used by the search service. + * + * @example + * ```ts + * setupDeps.globalSearch.registerResultProvider({ + * id: 'my_provider', + * find: (term, options) => { + * const resultPromise = myService.search(term, options); + * return from(resultPromise).pipe(takeUntil(options.aborted$); + * }, + * }); + * ``` + * + * @remarks + * As results from providers registered from the client-side API will not be available from the server's `find` API, + * registering result providers from the client should only be done when returning results that would not be retrievable + * from the server-side. In any other situation, prefer registering your provider from the server-side instead. + */ + registerResultProvider(provider: GlobalSearchResultProvider): void; +} + +/** @public */ +export interface SearchServiceStart { + /** + * Perform a search for given `term` and {@link GlobalSearchFindOptions | options}. + * + * @example + * ```ts + * startDeps.globalSearch.find('some term').subscribe({ + * next: ({ results }) => { + * addNewResultsToList(results); + * }, + * error: () => {}, + * complete: () => { + * showAsyncSearchIndicator(false); + * } + * }); + * ``` + * + * @remarks + * Emissions from the resulting observable will only contains **new** results. It is the consumer's + * responsibility to aggregate the emission and sort the results if required. + */ + find(term: string, options: GlobalSearchFindOptions): Observable; +} + +interface SetupDeps { + config: GlobalSearchClientConfigType; + maxProviderResults?: number; +} + +interface StartDeps { + http: HttpStart; + licenseChecker: ILicenseChecker; +} + +const defaultMaxProviderResults = 20; +const mapToUndefined = () => undefined; + +/** @internal */ +export class SearchService { + private readonly providers = new Map(); + private config?: GlobalSearchClientConfigType; + private http?: HttpStart; + private maxProviderResults = defaultMaxProviderResults; + private licenseChecker?: ILicenseChecker; + + setup({ config, maxProviderResults = defaultMaxProviderResults }: SetupDeps): SearchServiceSetup { + this.config = config; + + this.maxProviderResults = maxProviderResults; + + return { + registerResultProvider: (provider) => { + if (this.providers.has(provider.id)) { + throw new Error(`trying to register duplicate provider: ${provider.id}`); + } + this.providers.set(provider.id, provider); + }, + }; + } + + start({ http, licenseChecker }: StartDeps): SearchServiceStart { + this.http = http; + this.licenseChecker = licenseChecker; + + return { + find: (term, options) => this.performFind(term, options), + }; + } + + private performFind(term: string, options: GlobalSearchFindOptions) { + const licenseState = this.licenseChecker!.getState(); + if (!licenseState.valid) { + return throwError( + GlobalSearchFindError.invalidLicense( + i18n.translate('xpack.globalSearch.find.invalidLicenseError', { + defaultMessage: `GlobalSearch API is disabled because of invalid license state: {errorMessage}`, + values: { errorMessage: licenseState.message }, + }) + ) + ); + } + + const timeout = duration(this.config!.search_timeout).asMilliseconds(); + const timeout$ = timer(timeout).pipe(map(mapToUndefined)); + const aborted$ = options.aborted$ ? merge(options.aborted$, timeout$) : timeout$; + const preference = options.preference ?? getDefaultPreference(); + + const providerOptions = { + ...options, + preference, + maxResults: this.maxProviderResults, + aborted$, + }; + + const processResult = (result: GlobalSearchProviderResult) => + processProviderResult(result, this.http!.basePath); + + const serverResults$ = fetchServerResults(this.http!, term, { + preference, + aborted$, + }); + + const providersResults$ = [...this.providers.values()].map((provider) => + provider.find(term, providerOptions).pipe( + takeInArray(this.maxProviderResults), + takeUntil(aborted$), + map((results) => results.map((r) => processResult(r))) + ) + ); + + return merge(...providersResults$, serverResults$).pipe( + map((results) => ({ + results, + })) + ); + } +} diff --git a/x-pack/plugins/global_search/public/services/types.ts b/x-pack/plugins/global_search/public/services/types.ts new file mode 100644 index 00000000000000..fcaa8f0545a6e6 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; + +/** + * Options for the server-side {@link GlobalSearchPluginStart.find | find API} + */ +export interface GlobalSearchFindOptions { + /** + * A custom preference token associated with a search 'session' that should be used to get consistent scoring + * when performing calls to ES. Can also be used as a 'session' token for providers returning data from elsewhere + * than an elasticsearch cluster. + * + * If not specified, a random token will be generated and used. The token is stored in the sessionStorage and is guaranteed + * to be consistent during a given http 'session' + */ + preference?: string; + /** + * Optional observable to notify that the associated `find` call should be canceled. + * If/when provided and emitting, the result observable will be completed and no further result emission will be performed. + */ + aborted$?: Observable; +} diff --git a/x-pack/plugins/global_search/public/services/utils.test.ts b/x-pack/plugins/global_search/public/services/utils.test.ts new file mode 100644 index 00000000000000..f69fb1d2fd825a --- /dev/null +++ b/x-pack/plugins/global_search/public/services/utils.test.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { StubBrowserStorage } from '../../../../../src/test_utils/public/stub_browser_storage'; +import { getDefaultPreference } from './utils'; + +describe('getDefaultPreference', () => { + let storage: Storage; + let getItemSpy: jest.SpyInstance; + let setItemSpy: jest.SpyInstance; + + beforeEach(() => { + storage = new StubBrowserStorage(); + getItemSpy = jest.spyOn(storage, 'getItem'); + setItemSpy = jest.spyOn(storage, 'setItem'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the value in storage when available', () => { + getItemSpy.mockReturnValue('foo_pref'); + + const pref = getDefaultPreference(storage); + + expect(pref).toEqual('foo_pref'); + expect(getItemSpy).toHaveBeenCalledTimes(1); + expect(setItemSpy).not.toHaveBeenCalled(); + }); + + it('sets the value to the storage and return it when not already present', () => { + getItemSpy.mockReturnValue(null); + + const returnedPref = getDefaultPreference(storage); + + expect(getItemSpy).toHaveBeenCalledTimes(1); + expect(setItemSpy).toHaveBeenCalledTimes(1); + + const storedPref = setItemSpy.mock.calls[0][1]; + + expect(storage.length).toBe(1); + expect(storage.key(0)).toBe('globalSearch:defaultPref'); + expect(storedPref).toEqual(returnedPref); + }); +}); diff --git a/x-pack/plugins/global_search/public/services/utils.ts b/x-pack/plugins/global_search/public/services/utils.ts new file mode 100644 index 00000000000000..45d2ba7d7c2102 --- /dev/null +++ b/x-pack/plugins/global_search/public/services/utils.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; + +const defaultPrefStorageKey = 'globalSearch:defaultPref'; + +/** + * Returns the default {@link GlobalSearchFindOptions.preference | preference} value. + * + * The implementation is based on the sessionStorage, which ensure the default value for a session/tab will remain the same. + */ +export const getDefaultPreference = (storage: Storage = window.sessionStorage): string => { + let pref = storage.getItem(defaultPrefStorageKey); + if (pref) { + return pref; + } + pref = uuid.v4(); + storage.setItem(defaultPrefStorageKey, pref); + return pref; +}; diff --git a/x-pack/plugins/global_search/public/types.ts b/x-pack/plugins/global_search/public/types.ts new file mode 100644 index 00000000000000..42ef234504d12b --- /dev/null +++ b/x-pack/plugins/global_search/public/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { GlobalSearchProviderFindOptions, GlobalSearchProviderResult } from '../common/types'; +import { SearchServiceSetup, SearchServiceStart } from './services'; + +export type GlobalSearchPluginSetup = Pick; +export type GlobalSearchPluginStart = Pick; + +/** + * GlobalSearch result provider, to be registered using the {@link GlobalSearchPluginSetup | global search API} + */ +export interface GlobalSearchResultProvider { + /** + * id of the provider + */ + id: string; + /** + * Method that should return an observable used to emit new results from the provider. + * + * See {@GlobalSearchProviderResult | the result type} for the expected result structure. + * + * @example + * ```ts + * // returning all results in a single batch + * setupDeps.globalSearch.registerResultProvider({ + * id: 'my_provider', + * find: (term, { aborted$, preference, maxResults }, context) => { + * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); + * return from(resultPromise).pipe(takeUntil(aborted$)); + * }, + * }); + * ``` + */ + find( + term: string, + options: GlobalSearchProviderFindOptions + ): Observable; +} diff --git a/x-pack/plugins/global_search/server/config.ts b/x-pack/plugins/global_search/server/config.ts new file mode 100644 index 00000000000000..33ff45595b9122 --- /dev/null +++ b/x-pack/plugins/global_search/server/config.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'kibana/server'; + +const configSchema = schema.object({ + search_timeout: schema.duration({ defaultValue: '30s' }), +}); + +export type GlobalSearchConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + search_timeout: true, + }, +}; diff --git a/x-pack/plugins/global_search/server/index.ts b/x-pack/plugins/global_search/server/index.ts new file mode 100644 index 00000000000000..82f7c80dca552c --- /dev/null +++ b/x-pack/plugins/global_search/server/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/server'; +import { + GlobalSearchPlugin, + GlobalSearchPluginSetupDeps, + GlobalSearchPluginStartDeps, +} from './plugin'; +import { GlobalSearchPluginSetup, GlobalSearchPluginStart } from './types'; + +export const plugin: PluginInitializer< + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + GlobalSearchPluginSetupDeps, + GlobalSearchPluginStartDeps +> = (context) => new GlobalSearchPlugin(context); + +export { config } from './config'; + +export { + GlobalSearchBatchedResults, + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, + GlobalSearchProviderResultUrl, + GlobalSearchResult, +} from '../common/types'; +export { + GlobalSearchFindOptions, + GlobalSearchProviderContext, + GlobalSearchPluginStart, + GlobalSearchPluginSetup, + GlobalSearchResultProvider, + RouteHandlerGlobalSearchContext, +} from './types'; diff --git a/x-pack/plugins/global_search/server/mocks.ts b/x-pack/plugins/global_search/server/mocks.ts new file mode 100644 index 00000000000000..8a189a57017088 --- /dev/null +++ b/x-pack/plugins/global_search/server/mocks.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { of } from 'rxjs'; +import { + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + RouteHandlerGlobalSearchContext, +} from './types'; +import { searchServiceMock } from './services/search_service.mock'; + +const createSetupMock = (): jest.Mocked => { + const searchMock = searchServiceMock.createSetupContract(); + + return { + registerResultProvider: searchMock.registerResultProvider, + }; +}; + +const createStartMock = (): jest.Mocked => { + const searchMock = searchServiceMock.createStartContract(); + + return { + find: searchMock.find, + }; +}; + +const createRouteHandlerContextMock = (): jest.Mocked => { + const contextMock = { + find: jest.fn(), + }; + + contextMock.find.mockReturnValue(of([])); + + return contextMock; +}; + +export const globalSearchPluginMock = { + createSetupContract: createSetupMock, + createStartContract: createStartMock, + createRouteHandlerContext: createRouteHandlerContextMock, +}; diff --git a/x-pack/plugins/global_search/server/plugin.test.mocks.ts b/x-pack/plugins/global_search/server/plugin.test.mocks.ts new file mode 100644 index 00000000000000..1223b1ec203897 --- /dev/null +++ b/x-pack/plugins/global_search/server/plugin.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const registerRoutesMock = jest.fn(); +jest.doMock('./routes', () => ({ + registerRoutes: registerRoutesMock, +})); diff --git a/x-pack/plugins/global_search/server/plugin.test.ts b/x-pack/plugins/global_search/server/plugin.test.ts new file mode 100644 index 00000000000000..e654dbfdc158ab --- /dev/null +++ b/x-pack/plugins/global_search/server/plugin.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { registerRoutesMock } from './plugin.test.mocks'; + +import { coreMock } from '../../../../src/core/server/mocks'; +import { GlobalSearchPlugin } from './plugin'; + +describe('GlobalSearchPlugin', () => { + let plugin: GlobalSearchPlugin; + + beforeEach(() => { + plugin = new GlobalSearchPlugin(coreMock.createPluginInitializerContext()); + }); + + it('registers routes during `setup`', async () => { + await plugin.setup(coreMock.createSetup()); + expect(registerRoutesMock).toHaveBeenCalledTimes(1); + }); + + it('registers the globalSearch route handler context', async () => { + const coreSetup = coreMock.createSetup(); + await plugin.setup(coreSetup); + expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledTimes(1); + expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledWith( + 'globalSearch', + expect.any(Function) + ); + }); +}); diff --git a/x-pack/plugins/global_search/server/plugin.ts b/x-pack/plugins/global_search/server/plugin.ts new file mode 100644 index 00000000000000..87e7f96b34c0c5 --- /dev/null +++ b/x-pack/plugins/global_search/server/plugin.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; +import { LicensingPluginStart } from '../../licensing/server'; +import { LicenseChecker, ILicenseChecker } from '../common/license_checker'; +import { SearchService, SearchServiceStart } from './services'; +import { registerRoutes } from './routes'; +import { + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + RouteHandlerGlobalSearchContext, +} from './types'; +import { GlobalSearchConfigType } from './config'; + +declare module 'src/core/server' { + interface RequestHandlerContext { + globalSearch?: RouteHandlerGlobalSearchContext; + } +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface GlobalSearchPluginSetupDeps {} +export interface GlobalSearchPluginStartDeps { + licensing: LicensingPluginStart; +} + +export class GlobalSearchPlugin + implements + Plugin< + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + GlobalSearchPluginSetupDeps, + GlobalSearchPluginStartDeps + > { + private readonly config$: Observable; + private readonly searchService = new SearchService(); + private searchServiceStart?: SearchServiceStart; + private licenseChecker?: ILicenseChecker; + + constructor(context: PluginInitializerContext) { + this.config$ = context.config.create(); + } + + public async setup(core: CoreSetup<{}, GlobalSearchPluginStart>) { + const config = await this.config$.pipe(take(1)).toPromise(); + const { registerResultProvider } = this.searchService.setup({ + basePath: core.http.basePath, + config, + }); + + registerRoutes(core.http.createRouter()); + + core.http.registerRouteHandlerContext('globalSearch', (_, req) => { + return { + find: (term, options) => this.searchServiceStart!.find(term, options, req), + }; + }); + + return { + registerResultProvider, + }; + } + + public start(core: CoreStart, { licensing }: GlobalSearchPluginStartDeps) { + this.licenseChecker = new LicenseChecker(licensing.license$); + this.searchServiceStart = this.searchService.start({ + core, + licenseChecker: this.licenseChecker, + }); + return { + find: this.searchServiceStart.find, + }; + } + + public stop() { + if (this.licenseChecker) { + this.licenseChecker.clean(); + } + } +} diff --git a/x-pack/plugins/global_search/server/routes/find.ts b/x-pack/plugins/global_search/server/routes/find.ts new file mode 100644 index 00000000000000..a9063abda0e3eb --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/find.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { reduce, map } from 'rxjs/operators'; +import { schema } from '@kbn/config-schema'; +import { IRouter } from 'src/core/server'; +import { GlobalSearchFindError } from '../../common/errors'; + +export const registerInternalFindRoute = (router: IRouter) => { + router.post( + { + path: '/internal/global_search/find', + validate: { + body: schema.object({ + term: schema.string(), + options: schema.maybe( + schema.object({ + preference: schema.maybe(schema.string()), + }) + ), + }), + }, + }, + async (ctx, req, res) => { + const { term, options } = req.body; + try { + const allResults = await ctx + .globalSearch!.find(term, { ...options, aborted$: req.events.aborted$ }) + .pipe( + map((batch) => batch.results), + reduce((acc, results) => [...acc, ...results]) + ) + .toPromise(); + return res.ok({ + body: { + results: allResults, + }, + }); + } catch (e) { + if (e instanceof GlobalSearchFindError && e.type === 'invalid-license') { + return res.forbidden({ body: e.message }); + } + throw e; + } + } + ); +}; diff --git a/x-pack/plugins/global_search/server/routes/index.test.ts b/x-pack/plugins/global_search/server/routes/index.test.ts new file mode 100644 index 00000000000000..64675bc13cb1c6 --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/index.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from '../../../../../src/core/server/mocks'; +import { registerRoutes } from './index'; + +describe('registerRoutes', () => { + it('foo', () => { + const router = httpServiceMock.createRouter(); + + registerRoutes(router); + + expect(router.post).toHaveBeenCalledTimes(1); + + expect(router.post).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/internal/global_search/find', + }), + expect.any(Function) + ); + + expect(router.get).toHaveBeenCalledTimes(0); + expect(router.delete).toHaveBeenCalledTimes(0); + expect(router.put).toHaveBeenCalledTimes(0); + }); +}); diff --git a/x-pack/plugins/global_search/server/routes/index.ts b/x-pack/plugins/global_search/server/routes/index.ts new file mode 100644 index 00000000000000..7840b95614993f --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { registerInternalFindRoute } from './find'; + +export const registerRoutes = (router: IRouter) => { + registerInternalFindRoute(router); +}; diff --git a/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts new file mode 100644 index 00000000000000..878e4ac896b96d --- /dev/null +++ b/x-pack/plugins/global_search/server/routes/integration_tests/find.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { of, throwError } from 'rxjs'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from '../../../../../../src/core/server/test_utils'; +import { GlobalSearchResult, GlobalSearchBatchedResults } from '../../../common/types'; +import { GlobalSearchFindError } from '../../../common/errors'; +import { globalSearchPluginMock } from '../../mocks'; +import { registerInternalFindRoute } from '../find'; + +type setupServerReturn = UnwrapPromise>; +const pluginId = Symbol('globalSearch'); + +const createResult = (id: string): GlobalSearchResult => ({ + id, + title: id, + type: 'test', + url: `/app/test/${id}`, + score: 42, +}); + +const createBatch = (...ids: string[]): GlobalSearchBatchedResults => ({ + results: ids.map(createResult), +}); + +const expectedResults = (...ids: string[]) => ids.map((id) => expect.objectContaining({ id })); + +describe('POST /internal/global_search/find', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let globalSearchHandlerContext: ReturnType; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(pluginId)); + + globalSearchHandlerContext = globalSearchPluginMock.createRouteHandlerContext(); + httpSetup.registerRouteHandlerContext( + pluginId, + 'globalSearch', + () => globalSearchHandlerContext + ); + + const router = httpSetup.createRouter('/'); + + registerInternalFindRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('calls the handler context with correct parameters', async () => { + await supertest(httpSetup.server.listener) + .post('/internal/global_search/find') + .send({ + term: 'search', + options: { + preference: 'custom-pref', + }, + }) + .expect(200); + + expect(globalSearchHandlerContext.find).toHaveBeenCalledTimes(1); + expect(globalSearchHandlerContext.find).toHaveBeenCalledWith('search', { + preference: 'custom-pref', + aborted$: expect.any(Object), + }); + }); + + it('returns all the results returned from the service', async () => { + globalSearchHandlerContext.find.mockReturnValue( + of(createBatch('1', '2'), createBatch('3', '4')) + ); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/find') + .send({ + term: 'search', + }) + .expect(200); + + expect(response.body).toEqual({ + results: expectedResults('1', '2', '3', '4'), + }); + }); + + it('returns a 403 when the observable throws an invalid-license error', async () => { + globalSearchHandlerContext.find.mockReturnValue( + throwError(GlobalSearchFindError.invalidLicense('invalid-license-message')) + ); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/find') + .send({ + term: 'search', + }) + .expect(403); + + expect(response.body).toEqual( + expect.objectContaining({ + message: 'invalid-license-message', + statusCode: 403, + }) + ); + }); + + it('returns the default error when the observable throws any other error', async () => { + globalSearchHandlerContext.find.mockReturnValue(throwError('any-error')); + + const response = await supertest(httpSetup.server.listener) + .post('/internal/global_search/find') + .send({ + term: 'search', + }) + .expect(500); + + expect(response.body).toEqual( + expect.objectContaining({ + message: 'An internal server error occurred.', + statusCode: 500, + }) + ); + }); +}); diff --git a/x-pack/plugins/global_search/server/services/context.test.ts b/x-pack/plugins/global_search/server/services/context.test.ts new file mode 100644 index 00000000000000..397a1ea1703490 --- /dev/null +++ b/x-pack/plugins/global_search/server/services/context.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServerMock, coreMock } from '../../../../../src/core/server/mocks'; +import { getContextFactory } from './context'; + +describe('getContextFactory', () => { + it('returns a GlobalSearchProviderContext bound to the request', () => { + const coreStart = coreMock.createStart(); + const request = httpServerMock.createKibanaRequest(); + + const factory = getContextFactory(coreStart); + const context = factory(request); + + expect(coreStart.savedObjects.getScopedClient).toHaveBeenCalledTimes(1); + expect(coreStart.savedObjects.getScopedClient).toHaveBeenCalledWith(request); + + expect(coreStart.savedObjects.getTypeRegistry).toHaveBeenCalledTimes(1); + + expect(coreStart.elasticsearch.legacy.client.asScoped).toHaveBeenCalledTimes(1); + expect(coreStart.elasticsearch.legacy.client.asScoped).toHaveBeenCalledWith(request); + + const soClient = coreStart.savedObjects.getScopedClient.mock.results[0].value; + expect(coreStart.uiSettings.asScopedToClient).toHaveBeenCalledTimes(1); + expect(coreStart.uiSettings.asScopedToClient).toHaveBeenCalledWith(soClient); + + expect(context).toEqual({ + core: { + savedObjects: expect.any(Object), + elasticsearch: expect.any(Object), + uiSettings: expect.any(Object), + }, + }); + }); +}); diff --git a/x-pack/plugins/global_search/server/services/context.ts b/x-pack/plugins/global_search/server/services/context.ts new file mode 100644 index 00000000000000..b15deccaae018f --- /dev/null +++ b/x-pack/plugins/global_search/server/services/context.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, KibanaRequest } from 'src/core/server'; +import { GlobalSearchProviderContext } from '../types'; + +export type GlobalSearchContextFactory = (request: KibanaRequest) => GlobalSearchProviderContext; + +/** + * {@link GlobalSearchProviderContext | context} factory + */ +export const getContextFactory = (coreStart: CoreStart) => ( + request: KibanaRequest +): GlobalSearchProviderContext => { + const soClient = coreStart.savedObjects.getScopedClient(request); + return { + core: { + savedObjects: { + client: soClient, + typeRegistry: coreStart.savedObjects.getTypeRegistry(), + }, + elasticsearch: { + legacy: { + client: coreStart.elasticsearch.legacy.client.asScoped(request), + }, + }, + uiSettings: { + client: coreStart.uiSettings.asScopedToClient(soClient), + }, + }, + }; +}; diff --git a/x-pack/plugins/global_search/server/services/index.ts b/x-pack/plugins/global_search/server/services/index.ts new file mode 100644 index 00000000000000..cee5b24d2f5883 --- /dev/null +++ b/x-pack/plugins/global_search/server/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SearchService, SearchServiceSetup, SearchServiceStart } from './search_service'; diff --git a/x-pack/plugins/global_search/server/services/search_service.mock.ts b/x-pack/plugins/global_search/server/services/search_service.mock.ts new file mode 100644 index 00000000000000..eca69148288b9c --- /dev/null +++ b/x-pack/plugins/global_search/server/services/search_service.mock.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchServiceSetup, SearchServiceStart } from './search_service'; +import { of } from 'rxjs'; + +const createSetupMock = (): jest.Mocked => { + return { + registerResultProvider: jest.fn(), + }; +}; + +const createStartMock = (): jest.Mocked => { + const mock = { + find: jest.fn(), + }; + mock.find.mockReturnValue(of({ results: [] })); + + return mock; +}; + +export const searchServiceMock = { + createSetupContract: createSetupMock, + createStartContract: createStartMock, +}; diff --git a/x-pack/plugins/global_search/server/services/search_service.test.ts b/x-pack/plugins/global_search/server/services/search_service.test.ts new file mode 100644 index 00000000000000..fd705b4286680a --- /dev/null +++ b/x-pack/plugins/global_search/server/services/search_service.test.ts @@ -0,0 +1,323 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { TestScheduler } from 'rxjs/testing'; +import { duration } from 'moment'; +import { httpServiceMock, httpServerMock, coreMock } from '../../../../../src/core/server/mocks'; +import { licenseCheckerMock } from '../../common/license_checker.mock'; +import { GlobalSearchProviderResult } from '../../common/types'; +import { GlobalSearchFindError } from '../../common/errors'; +import { GlobalSearchConfigType } from '../config'; +import { GlobalSearchResultProvider } from '../types'; +import { SearchService } from './search_service'; + +const getTestScheduler = () => + new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + +describe('SearchService', () => { + let service: SearchService; + let basePath: ReturnType; + let coreStart: ReturnType; + let licenseChecker: ReturnType; + let request: ReturnType; + + const createConfig = (timeoutMs: number = 30000): GlobalSearchConfigType => { + return { + search_timeout: duration(timeoutMs), + }; + }; + + const createProvider = ( + id: string, + source: Observable = of([]) + ): jest.Mocked => ({ + id, + find: jest.fn().mockImplementation((term, options, context) => source), + }); + + const expectedResult = (id: string) => expect.objectContaining({ id }); + + const expectedBatch = (...ids: string[]) => ({ + results: ids.map((id) => expectedResult(id)), + }); + + const result = ( + id: string, + parts: Partial = {} + ): GlobalSearchProviderResult => ({ + title: id, + type: 'test', + url: '/foo/bar', + score: 100, + ...parts, + id, + }); + + beforeEach(() => { + service = new SearchService(); + basePath = httpServiceMock.createBasePath(); + basePath.prepend.mockImplementation((path) => `/base-path${path}`); + coreStart = coreMock.createStart(); + licenseChecker = licenseCheckerMock.create(); + }); + + describe('#setup()', () => { + describe('#registerResultProvider()', () => { + it('throws when trying to register the same provider twice', () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A'); + registerResultProvider(provider); + expect(() => { + registerResultProvider(provider); + }).toThrowErrorMatchingInlineSnapshot(`"trying to register duplicate provider: A"`); + }); + }); + }); + + describe('#start()', () => { + describe('#find()', () => { + it('calls the provider with the correct parameters', () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const provider = createProvider('A'); + registerResultProvider(provider); + + const { find } = service.start({ core: coreStart, licenseChecker }); + find('foobar', { preference: 'pref' }, request); + + expect(provider.find).toHaveBeenCalledTimes(1); + expect(provider.find).toHaveBeenCalledWith( + 'foobar', + expect.objectContaining({ preference: 'pref' }), + expect.objectContaining({ core: expect.any(Object) }) + ); + }); + + it('return the results from the provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('a-b-|', { + a: [result('1')], + b: [result('2')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const results = find('foo', {}, request); + + expectObservable(results).toBe('a-b-|', { + a: expectedBatch('1'), + b: expectedBatch('2'), + }); + }); + }); + + it('handles multiple providers', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + registerResultProvider( + createProvider( + 'A', + hot('a---d-|', { + a: [result('A1'), result('A2')], + d: [result('A3')], + }) + ) + ); + registerResultProvider( + createProvider( + 'B', + hot('-b-c| ', { + b: [result('B1')], + c: [result('B2'), result('B3')], + }) + ) + ); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const results = find('foo', {}, request); + + expectObservable(results).toBe('ab-cd-|', { + a: expectedBatch('A1', 'A2'), + b: expectedBatch('B1'), + c: expectedBatch('B2', 'B3'), + d: expectedBatch('A3'), + }); + }); + }); + + it('handles the `aborted$` option', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('--a---(b|)', { + a: [result('1')], + b: [result('2')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const aborted$ = hot('----a--|', { a: undefined }); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const results = find('foo', { aborted$ }, request); + + expectObservable(results).toBe('--a-|', { + a: expectedBatch('1'), + }); + }); + }); + + it('respects the timeout duration', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(100), + basePath, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('a 24ms b 100ms (c|)', { + a: [result('1')], + b: [result('2')], + c: [result('3')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const results = find('foo', {}, request); + + expectObservable(results).toBe('a 24ms b 74ms |', { + a: expectedBatch('1'), + b: expectedBatch('2'), + }); + }); + }); + + it('only returns a given maximum number of results per provider', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(100), + basePath, + maxProviderResults: 2, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + registerResultProvider( + createProvider( + 'A', + hot('a---d-|', { + a: [result('A1'), result('A2')], + d: [result('A3')], + }) + ) + ); + registerResultProvider( + createProvider( + 'B', + hot('-b-c| ', { + b: [result('B1')], + c: [result('B2'), result('B3')], + }) + ) + ); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const results = find('foo', {}, request); + + expectObservable(results).toBe('ab-(c|)', { + a: expectedBatch('A1', 'A2'), + b: expectedBatch('B1'), + c: expectedBatch('B2'), + }); + }); + }); + + it('process the results before returning them', async () => { + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + const resultA = result('A', { + type: 'application', + icon: 'appIcon', + score: 42, + title: 'foo', + url: '/foo/bar', + }); + const resultB = result('B', { + type: 'dashboard', + score: 69, + title: 'bar', + url: { path: '/foo', prependBasePath: false }, + }); + + const provider = createProvider('A', of([resultA, resultB])); + registerResultProvider(provider); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const batch = await find('foo', {}, request).pipe(take(1)).toPromise(); + + expect(batch.results).toHaveLength(2); + expect(batch.results[0]).toEqual({ + ...resultA, + url: '/base-path/foo/bar', + }); + expect(batch.results[1]).toEqual({ + ...resultB, + url: '/foo', + }); + }); + + it('emits an error when the license is invalid', async () => { + licenseChecker.getState.mockReturnValue({ valid: false, message: 'expired' }); + + const { registerResultProvider } = service.setup({ + config: createConfig(), + basePath, + }); + + getTestScheduler().run(({ expectObservable, hot }) => { + const providerResults = hot('a-b-|', { + a: [result('1')], + b: [result('2')], + }); + registerResultProvider(createProvider('A', providerResults)); + + const { find } = service.start({ core: coreStart, licenseChecker }); + const results = find('foo', {}, request); + + expectObservable(results).toBe( + '#', + {}, + GlobalSearchFindError.invalidLicense( + 'GlobalSearch API is disabled because of invalid license state: expired' + ) + ); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts new file mode 100644 index 00000000000000..12eada2a1385ec --- /dev/null +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable, timer, merge, throwError } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; +import { i18n } from '@kbn/i18n'; +import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; +import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; +import { GlobalSearchFindError } from '../../common/errors'; +import { takeInArray } from '../../common/operators'; +import { ILicenseChecker } from '../../common/license_checker'; + +import { processProviderResult } from '../../common/process_result'; +import { GlobalSearchConfigType } from '../config'; +import { getContextFactory, GlobalSearchContextFactory } from './context'; +import { GlobalSearchResultProvider, GlobalSearchFindOptions } from '../types'; + +/** @public */ +export interface SearchServiceSetup { + /** + * Register a result provider to be used by the search service. + * + * @example + * ```ts + * setupDeps.globalSearch.registerResultProvider({ + * id: 'my_provider', + * find: (term, options, context) => { + * const resultPromise = myService.search(term, options, context.core.savedObjects.client); + * return from(resultPromise).pipe(takeUntil(options.aborted$); + * }, + * }); + * ``` + */ + registerResultProvider(provider: GlobalSearchResultProvider): void; +} + +/** @public */ +export interface SearchServiceStart { + /** + * Perform a search for given `term` and {@link GlobalSearchFindOptions | options}. + * + * @example + * ```ts + * startDeps.globalSearch.find('some term').subscribe({ + * next: ({ results }) => { + * addNewResultsToList(results); + * }, + * error: () => {}, + * complete: () => { + * showAsyncSearchIndicator(false); + * } + * }); + * ``` + * + * @remarks + * - Emissions from the resulting observable will only contains **new** results. It is the consumer + * responsibility to aggregate the emission and sort the results if required. + * - Results from the client-side registered providers will not available when performing a search + * from the server-side `find` API. + */ + find( + term: string, + options: GlobalSearchFindOptions, + request: KibanaRequest + ): Observable; +} + +interface SetupDeps { + basePath: IBasePath; + config: GlobalSearchConfigType; + maxProviderResults?: number; +} + +interface StartDeps { + core: CoreStart; + licenseChecker: ILicenseChecker; +} + +const defaultMaxProviderResults = 20; +const mapToUndefined = () => undefined; + +/** @internal */ +export class SearchService { + private readonly providers = new Map(); + private basePath?: IBasePath; + private config?: GlobalSearchConfigType; + private contextFactory?: GlobalSearchContextFactory; + private licenseChecker?: ILicenseChecker; + private maxProviderResults = defaultMaxProviderResults; + + setup({ + basePath, + config, + maxProviderResults = defaultMaxProviderResults, + }: SetupDeps): SearchServiceSetup { + this.basePath = basePath; + this.config = config; + this.maxProviderResults = maxProviderResults; + + return { + registerResultProvider: (provider) => { + if (this.providers.has(provider.id)) { + throw new Error(`trying to register duplicate provider: ${provider.id}`); + } + this.providers.set(provider.id, provider); + }, + }; + } + + start({ core, licenseChecker }: StartDeps): SearchServiceStart { + this.licenseChecker = licenseChecker; + this.contextFactory = getContextFactory(core); + return { + find: (term, options, request) => this.performFind(term, options, request), + }; + } + + private performFind(term: string, options: GlobalSearchFindOptions, request: KibanaRequest) { + const licenseState = this.licenseChecker!.getState(); + if (!licenseState.valid) { + return throwError( + GlobalSearchFindError.invalidLicense( + i18n.translate('xpack.globalSearch.find.invalidLicenseError', { + defaultMessage: `GlobalSearch API is disabled because of invalid license state: {errorMessage}`, + values: { errorMessage: licenseState.message }, + }) + ) + ); + } + + const context = this.contextFactory!(request); + + const timeout$ = timer(this.config!.search_timeout.asMilliseconds()).pipe(map(mapToUndefined)); + const aborted$ = options.aborted$ ? merge(options.aborted$, timeout$) : timeout$; + const providerOptions = { + ...options, + preference: options.preference ?? 'default', + maxResults: this.maxProviderResults, + aborted$, + }; + + const processResult = (result: GlobalSearchProviderResult) => + processProviderResult(result, this.basePath!); + + const providersResults$ = [...this.providers.values()].map((provider) => + provider.find(term, providerOptions, context).pipe( + takeInArray(this.maxProviderResults), + takeUntil(aborted$), + map((results) => results.map((r) => processResult(r))) + ) + ); + + return merge(...providersResults$).pipe( + map((results) => ({ + results, + })) + ); + } +} diff --git a/x-pack/plugins/global_search/server/types.ts b/x-pack/plugins/global_search/server/types.ts new file mode 100644 index 00000000000000..eca4aff366883d --- /dev/null +++ b/x-pack/plugins/global_search/server/types.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { + ISavedObjectTypeRegistry, + IScopedClusterClient, + IUiSettingsClient, + SavedObjectsClientContract, +} from 'src/core/server'; +import { + GlobalSearchBatchedResults, + GlobalSearchProviderFindOptions, + GlobalSearchProviderResult, +} from '../common/types'; +import { SearchServiceSetup, SearchServiceStart } from './services'; + +export type GlobalSearchPluginSetup = Pick; +export type GlobalSearchPluginStart = Pick; + +/** + * globalSearch route handler context. + * + * @public + */ +export interface RouteHandlerGlobalSearchContext { + /** + * See {@link SearchServiceStart.find | the find API} + */ + find(term: string, options: GlobalSearchFindOptions): Observable; +} + +/** + * Context passed to server-side {@GlobalSearchResultProvider | result provider}'s `find` method. + * + * @public + */ +export interface GlobalSearchProviderContext { + core: { + savedObjects: { + client: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; + }; + elasticsearch: { + legacy: { + client: IScopedClusterClient; + }; + }; + uiSettings: { + client: IUiSettingsClient; + }; + }; +} + +/** + * Options for the server-side {@link GlobalSearchPluginStart.find | find API} + * + * @public + */ +export interface GlobalSearchFindOptions { + /** + * A custom preference token associated with a search 'session' that should be used to get consistent scoring + * when performing calls to ES. Can also be used as a 'session' token for providers returning data from elsewhere + * than an elasticsearch cluster. + * If not specified, a random token will be generated and used. + */ + preference?: string; + /** + * Optional observable to notify that the associated `find` call should be canceled. + * If/when provided and emitting, no further result emission will be performed and the result observable will be completed. + */ + aborted$?: Observable; +} + +/** + * GlobalSearch result provider, to be registered using the {@link GlobalSearchPluginSetup | global search API} + * + * @public + */ +export interface GlobalSearchResultProvider { + /** + * id of the provider + */ + id: string; + /** + * Method that should return an observable used to emit new results from the provider. + * + * See {@GlobalSearchProviderResult | the result type} for the expected result structure. + * + * @example + * ```ts + * // returning all results in a single batch + * setupDeps.globalSearch.registerResultProvider({ + * id: 'my_provider', + * find: (term, { aborted$, preference, maxResults }, context) => { + * const resultPromise = myService.search(term, { preference, maxResults }, context.core.savedObjects.client); + * return from(resultPromise).pipe(takeUntil(aborted$)); + * }, + * }); + * ``` + */ + find( + term: string, + options: GlobalSearchProviderFindOptions, + context: GlobalSearchProviderContext + ): Observable; +} diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index eb75109c704c7e..f9b3e5446cfce6 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -6,8 +6,7 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@kbn/utility-types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { setupServer } from 'src/core/server/saved_objects/routes/integration_tests/test_utils'; +import { setupServer } from 'src/core/server/test_utils'; import { registerJobGenerationRoutes } from './generation'; import { createMockReportingCore } from '../test_helpers'; import { ReportingCore } from '..'; diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index d13b3e72ca8e7e..22d60d62d5fdb5 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -6,8 +6,7 @@ import { UnwrapPromise } from '@kbn/utility-types'; import { of } from 'rxjs'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { setupServer } from 'src/core/server/saved_objects/routes/integration_tests/test_utils'; +import { setupServer } from 'src/core/server/test_utils'; import supertest from 'supertest'; import { ReportingCore } from '..'; import { ReportingInternalSetup } from '../core'; diff --git a/x-pack/test/plugin_functional/config.ts b/x-pack/test/plugin_functional/config.ts index bc9569968bf16a..9a0519960f850e 100644 --- a/x-pack/test/plugin_functional/config.ts +++ b/x-pack/test/plugin_functional/config.ts @@ -5,6 +5,7 @@ */ import { resolve } from 'path'; import fs from 'fs'; +import { KIBANA_ROOT } from '@kbn/test'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; import { pageObjects } from './page_objects'; @@ -26,7 +27,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { // list paths to the files that contain your plugins tests - testFiles: [resolve(__dirname, './test_suites/resolver')], + testFiles: [ + resolve(__dirname, './test_suites/resolver'), + resolve(__dirname, './test_suites/global_search'), + ], services, pageObjects, @@ -40,6 +44,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), ...plugins.map((pluginDir) => `--plugin-path=${resolve(__dirname, 'plugins', pluginDir)}`), + `--plugin-path=${resolve( + KIBANA_ROOT, + 'test/plugin_functional/plugins/core_provider_plugin' + )}`, // Required to load new platform plugins via `--plugin-path` flag. '--env.name=development', ], diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/common/utils.ts b/x-pack/test/plugin_functional/plugins/global_search_test/common/utils.ts new file mode 100644 index 00000000000000..c1be54ac49153b --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/common/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GlobalSearchProviderResult } from '../../../../../plugins/global_search/common/types'; + +export const createResult = ( + parts: Partial +): GlobalSearchProviderResult => ({ + id: 'test', + title: 'test result', + type: 'test_type', + url: '/some-url', + score: 100, + ...parts, +}); diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json new file mode 100644 index 00000000000000..934c6cce633870 --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "globalSearchTest", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "global_search_test"], + "requiredPlugins": ["globalSearch"], + "server": true, + "ui": true +} diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/public/index.ts b/x-pack/test/plugin_functional/plugins/global_search_test/public/index.ts new file mode 100644 index 00000000000000..ff2783f4fd4fa2 --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/public/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/public'; +import { + GlobalSearchTestPlugin, + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps, +} from './plugin'; + +export const plugin: PluginInitializer< + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps +> = () => new GlobalSearchTestPlugin(); + +export { + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps, +} from './plugin'; diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts new file mode 100644 index 00000000000000..27434202d77f1c --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/public/plugin.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { of } from 'rxjs'; +import { map, reduce } from 'rxjs/operators'; +import { Plugin, CoreSetup, CoreStart, AppMountParameters } from 'kibana/public'; +import { + GlobalSearchPluginSetup, + GlobalSearchPluginStart, + GlobalSearchResult, +} from '../../../../../plugins/global_search/public'; +import { createResult } from '../common/utils'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface GlobalSearchTestPluginSetup {} +export interface GlobalSearchTestPluginStart { + findAll: (term: string) => Promise; +} + +export interface GlobalSearchTestPluginSetupDeps { + globalSearch: GlobalSearchPluginSetup; +} +export interface GlobalSearchTestPluginStartDeps { + globalSearch: GlobalSearchPluginStart; +} + +export class GlobalSearchTestPlugin + implements + Plugin< + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps + > { + public setup( + { application, getStartServices }: CoreSetup, + { globalSearch }: GlobalSearchTestPluginSetupDeps + ) { + application.register({ + id: 'globalSearchTestApp', + title: 'GlobalSearch test', + mount: (params: AppMountParameters) => { + return () => undefined; + }, + }); + + globalSearch.registerResultProvider({ + id: 'gs_test_client', + find: (term, options) => { + if (term.includes('client')) { + return of([ + createResult({ + id: 'client1', + type: 'test_client_type', + }), + createResult({ + id: 'client2', + type: 'test_client_type', + }), + ]); + } + return of([]); + }, + }); + + return {}; + } + + public start( + {}: CoreStart, + { globalSearch }: GlobalSearchTestPluginStartDeps + ): GlobalSearchTestPluginStart { + return { + findAll: (term) => + globalSearch + .find(term, {}) + .pipe( + map((batch) => batch.results), + // restrict to test type to avoid failure when real providers are present + map((results) => results.filter((r) => r.type.startsWith('test_'))), + reduce((memo, results) => [...memo, ...results]) + ) + .toPromise(), + }; + } +} diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/public/types.ts b/x-pack/test/plugin_functional/plugins/global_search_test/public/types.ts new file mode 100644 index 00000000000000..02969e97b6c8a3 --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GlobalSearchTestPluginStart } from './plugin'; + +export type GlobalSearchTestApi = GlobalSearchTestPluginStart; diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts new file mode 100644 index 00000000000000..7f9cdf423718b2 --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializer } from 'src/core/server'; +import { + GlobalSearchTestPlugin, + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps, +} from './plugin'; + +export const plugin: PluginInitializer< + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps +> = () => new GlobalSearchTestPlugin(); diff --git a/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts b/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts new file mode 100644 index 00000000000000..d8ad94ab74207e --- /dev/null +++ b/x-pack/test/plugin_functional/plugins/global_search_test/server/plugin.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { of } from 'rxjs'; +import { Plugin, CoreSetup, CoreStart } from 'kibana/server'; +import { + GlobalSearchPluginSetup, + GlobalSearchPluginStart, +} from '../../../../../plugins/global_search/server'; +import { createResult } from '../common/utils'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface GlobalSearchTestPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface GlobalSearchTestPluginStart {} + +export interface GlobalSearchTestPluginSetupDeps { + globalSearch: GlobalSearchPluginSetup; +} +export interface GlobalSearchTestPluginStartDeps { + globalSearch: GlobalSearchPluginStart; +} + +export class GlobalSearchTestPlugin + implements + Plugin< + GlobalSearchTestPluginSetup, + GlobalSearchTestPluginStart, + GlobalSearchTestPluginSetupDeps, + GlobalSearchTestPluginStartDeps + > { + public setup(core: CoreSetup, { globalSearch }: GlobalSearchTestPluginSetupDeps) { + globalSearch.registerResultProvider({ + id: 'gs_test_server', + find: (term, options, context) => { + if (term.includes('server')) { + return of([ + createResult({ + id: 'server1', + type: 'test_server_type', + }), + createResult({ + id: 'server2', + type: 'test_server_type', + }), + ]); + } + return of([]); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } +} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts b/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.ts new file mode 100644 index 00000000000000..4cc056fd51c2aa --- /dev/null +++ b/x-pack/test/plugin_functional/test_suites/global_search/global_search_api.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { GlobalSearchResult } from '../../../../plugins/global_search/common/types'; +import { GlobalSearchTestApi } from '../../plugins/global_search_test/public/types'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const pageObjects = getPageObjects(['common']); + const browser = getService('browser'); + + const findResultsWithAPI = async (t: string): Promise => { + return browser.executeAsync(async (term: string, cb: Function) => { + const { start } = window.__coreProvider; + const globalSearchTestApi: GlobalSearchTestApi = start.plugins.globalSearchTest; + globalSearchTestApi.findAll(term).then((results) => { + cb(results); + }); + }, t) as any; // executeAsync signature is broken. return type should be inferred from the cb param. + }; + + describe('GlobalSearch API', function () { + beforeEach(async function () { + await pageObjects.common.navigateToApp('globalSearchTestApp'); + }); + + it('return no results when no provider return results', async () => { + const results = await findResultsWithAPI('no_match'); + expect(results.length).to.be(0); + }); + it('return results from the client provider', async () => { + const results = await findResultsWithAPI('client'); + expect(results.length).to.be(2); + expect(results.map((r) => r.id)).to.eql(['client1', 'client2']); + }); + it('return results from the server provider', async () => { + const results = await findResultsWithAPI('server'); + expect(results.length).to.be(2); + expect(results.map((r) => r.id)).to.eql(['server1', 'server2']); + }); + it('return mixed results from both client and server providers', async () => { + const results = await findResultsWithAPI('server+client'); + expect(results.length).to.be(4); + expect(results.map((r) => r.id)).to.eql(['client1', 'client2', 'server1', 'server2']); + }); + }); +} diff --git a/x-pack/test/plugin_functional/test_suites/global_search/index.ts b/x-pack/test/plugin_functional/test_suites/global_search/index.ts new file mode 100644 index 00000000000000..1e5a765612f45d --- /dev/null +++ b/x-pack/test/plugin_functional/test_suites/global_search/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('GlobalSearch API', function () { + this.tags('ciGroup7'); + loadTestFile(require.resolve('./global_search_api')); + }); +} diff --git a/x-pack/test_utils/jest/config.js b/x-pack/test_utils/jest/config.js index 66b88cbdeba174..deee585b91fe4a 100644 --- a/x-pack/test_utils/jest/config.js +++ b/x-pack/test_utils/jest/config.js @@ -9,6 +9,7 @@ import { RESERVED_DIR_JEST_INTEGRATION_TESTS } from '../../../src/dev/constants' export default { rootDir: '../../', roots: [ + '/plugins', '/legacy/plugins', '/legacy/server', '/legacy/common',