mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Add globalSearch
x-pack plugin (#66293)
* add skeleton for global_search plugin * base implementation of the server-side service * add utils tests * add server-side mocks * move take_in_array to common folder * implements base of client-side plugin * add tests for server-side service * fix server plugin tests * implement `navigateToUrl` core API * extract processResults for the client-side * fetch server results from the client side * factorize process_results * fix plugin start params * move things around * move all server types to single file * fix types imports * add basic FTR tests * add client-side service tests * add tests for addNavigate * add getDefaultPreference & tests * use optional for RequestHandlerContext * add registerRoutes test * add base test for context * resolve TODO * common nits/doc * common nits/doc on public * update CODEOWNERS * add import for declare statement * add license check on the server-side * add license check on the client-side * eslint * address some review comments * use properly typed errors for obs * add integration tests for the find endpoint * fix unit tests * use licensing start contract * translate the error message * fix eslint rule for test_utils * fix test_utils imports * remove NavigableGlobalSearchResult, use `application.navigateToUrl` instead. * use coreProvider plugin in FTR tests * nits * fix service start params * fix service start params, bis * I really need to fix this typecheck oom error * add README, update missing jsdoc * nits on doc
This commit is contained in:
parent
3d2c3f1eac
commit
c5546f4a39
81 changed files with 3294 additions and 66 deletions
|
@ -225,7 +225,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}',
|
||||
|
|
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -139,6 +139,7 @@
|
|||
/config/kibana.yml @elastic/kibana-platform
|
||||
/x-pack/plugins/features/ @elastic/kibana-platform
|
||||
/x-pack/plugins/licensing/ @elastic/kibana-platform
|
||||
/x-pack/plugins/global_search/ @elastic/kibana-platform
|
||||
/x-pack/plugins/cloud/ @elastic/kibana-platform
|
||||
/packages/kbn-config-schema/ @elastic/kibana-platform
|
||||
/src/legacy/server/config/ @elastic/kibana-platform
|
||||
|
|
|
@ -194,7 +194,7 @@ Notes:
|
|||
|
||||
### Plugin API
|
||||
|
||||
#### server API
|
||||
#### Common types
|
||||
|
||||
```ts
|
||||
/**
|
||||
|
@ -208,6 +208,21 @@ type GlobalSearchResult = Omit<GlobalSearchProviderResult, 'url'> & {
|
|||
url: string;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Response returned from the {@link GlobalSearchServiceStart | global search service}'s `find` API
|
||||
*/
|
||||
type GlobalSearchBatchedResults = {
|
||||
/**
|
||||
* Results for this batch
|
||||
*/
|
||||
results: GlobalSearchResult[];
|
||||
};
|
||||
```
|
||||
|
||||
#### server API
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Options for the server-side {@link GlobalSearchServiceStart.find | find API}
|
||||
*/
|
||||
|
@ -226,16 +241,6 @@ interface GlobalSearchFindOptions {
|
|||
aborted$?: Observable<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response returned from the server-side {@link GlobalSearchServiceStart | global search service}'s `find` API
|
||||
*/
|
||||
type GlobalSearchBatchedResults = {
|
||||
/**
|
||||
* Results for this batch
|
||||
*/
|
||||
results: GlobalSearchResult[];
|
||||
};
|
||||
|
||||
/** @public */
|
||||
interface GlobalSearchPluginSetup {
|
||||
registerResultProvider(provider: GlobalSearchResultProvider);
|
||||
|
@ -265,28 +270,6 @@ interface GlobalSearchFindOptions {
|
|||
aborted$?: Observable<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced {@link GlobalSearchResult | result type} for the client-side,
|
||||
* to allow navigating to a given result.
|
||||
*/
|
||||
interface NavigableGlobalSearchResult extends GlobalSearchResult {
|
||||
/**
|
||||
* Navigate to this result's associated url. If the result is on this kibana instance, user will be redirected to it
|
||||
* in a SPA friendly way using `application.navigateToApp`, else, a full page refresh will be performed.
|
||||
*/
|
||||
navigate: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response returned from the client-side {@link GlobalSearchServiceStart | global search service}'s `find` API
|
||||
*/
|
||||
type GlobalSearchBatchedResults = {
|
||||
/**
|
||||
* Results for this batch
|
||||
*/
|
||||
results: NavigableGlobalSearchResult[];
|
||||
};
|
||||
|
||||
/** @public */
|
||||
interface GlobalSearchPluginSetup {
|
||||
registerResultProvider(provider: GlobalSearchResultProvider);
|
||||
|
@ -304,9 +287,6 @@ Notes:
|
|||
- The `registerResultProvider` setup APIs share the same signature, however the input `GlobalSearchResultProvider`
|
||||
types are different on the client and server.
|
||||
- The `find` start API signature got a `KibanaRequest` for `server`, when this parameter is not present for `public`.
|
||||
- The `find` API returns a observable of `NavigableGlobalSearchResult` instead of plain `GlobalSearchResult`. This type
|
||||
is here to enhance results with a `navigate` method to let the `GlobalSearch` plugin handle the navigation logic, which is
|
||||
non-trivial. See the [Redirecting to a result](#redirecting-to-a-result) section for more info.
|
||||
|
||||
#### http API
|
||||
|
||||
|
@ -395,14 +375,11 @@ In current specification, the only conversion step is to transform the `result.u
|
|||
|
||||
#### redirecting to a result
|
||||
|
||||
Parsing a relative or absolute result url to perform SPA navigation can be non trivial, and should remains the responsibility
|
||||
of the GlobalSearch plugin API.
|
||||
Parsing a relative or absolute result url to perform SPA navigation can be non trivial. This is why `ApplicationService.navigateToUrl` has been introduced on the client-side core API
|
||||
|
||||
This is why `NavigableGlobalSearchResult.navigate` has been introduced on the client-side version of the `find` API
|
||||
When using `navigateToUrl` with the url of a result instance, the following logic will be executed:
|
||||
|
||||
When using `navigate` from a result instance, the following logic will be executed:
|
||||
|
||||
If all these criteria are true for `result.url`:
|
||||
If all these criteria are true for `url`:
|
||||
|
||||
- (only for absolute URLs) The origin of the URL matches the origin of the browser's current location
|
||||
- The pathname of the URL starts with the current basePath (eg. /mybasepath/s/my-space)
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock;
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
@ -19,3 +19,4 @@
|
|||
|
||||
export { createHttpServer } from './http/test_utils';
|
||||
export { ServiceStatusLevelSnapshotSerializer } from './status/test_utils';
|
||||
export { setupServer } from './saved_objects/routes/test_utils';
|
||||
|
|
|
@ -26,3 +26,4 @@ export * from './capabilities';
|
|||
export * from './app_category';
|
||||
export * from './ui_settings';
|
||||
export * from './saved_objects';
|
||||
export * from './serializable';
|
||||
|
|
32
src/core/types/serializable.ts
Normal file
32
src/core/types/serializable.ts
Normal file
|
@ -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<Serializable> {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface SerializableRecord extends Record<string, Serializable> {}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
49
x-pack/plugins/global_search/README.md
Normal file
49
x-pack/plugins/global_search/README.md
Normal file
|
@ -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.
|
22
x-pack/plugins/global_search/common/errors.test.ts
Normal file
22
x-pack/plugins/global_search/common/errors.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
27
x-pack/plugins/global_search/common/errors.ts
Normal file
27
x-pack/plugins/global_search/common/errors.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
24
x-pack/plugins/global_search/common/license_checker.mock.ts
Normal file
24
x-pack/plugins/global_search/common/license_checker.mock.ts
Normal file
|
@ -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<ILicenseChecker> => {
|
||||
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,
|
||||
};
|
63
x-pack/plugins/global_search/common/license_checker.test.ts
Normal file
63
x-pack/plugins/global_search/common/license_checker.test.ts
Normal file
|
@ -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<ILicense> => 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<ILicense>(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);
|
||||
});
|
||||
});
|
49
x-pack/plugins/global_search/common/license_checker.ts
Normal file
49
x-pack/plugins/global_search/common/license_checker.ts
Normal file
|
@ -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<LicenseChecker>;
|
||||
|
||||
export class LicenseChecker {
|
||||
private subscription: Subscription;
|
||||
private state: LicenseState = { valid: false, message: 'unknown' };
|
||||
|
||||
constructor(license$: Observable<ILicense>) {
|
||||
this.subscription = license$.subscribe((license) => {
|
||||
this.state = checkLicense(license);
|
||||
});
|
||||
}
|
||||
|
||||
public getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
public clean() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
7
x-pack/plugins/global_search/common/operators/index.ts
Normal file
7
x-pack/plugins/global_search/common/operators/index.ts
Normal file
|
@ -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';
|
|
@ -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"`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<T>(count: number): MonoTypeOperatorFunction<T[]> {
|
||||
return function takeLastOperatorFunction(source: Observable<T[]>): Observable<T[]> {
|
||||
if (count === 0) {
|
||||
return EMPTY;
|
||||
} else {
|
||||
return source.lift(new TakeInArray(count));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class TakeInArray<T> implements Operator<T[], T[]> {
|
||||
constructor(private total: number) {
|
||||
if (this.total < 0) {
|
||||
throw new Error('Cannot take a negative number of items');
|
||||
}
|
||||
}
|
||||
|
||||
call(subscriber: Subscriber<T[]>, source: any): TeardownLogic {
|
||||
return source.subscribe(new TakeInArraySubscriber(subscriber, this.total));
|
||||
}
|
||||
}
|
||||
|
||||
class TakeInArraySubscriber<T> extends Subscriber<T[]> {
|
||||
private current: number = 0;
|
||||
|
||||
constructor(destination: Subscriber<T>, 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}));
|
73
x-pack/plugins/global_search/common/process_result.test.ts
Normal file
73
x-pack/plugins/global_search/common/process_result.test.ts
Normal file
|
@ -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>): GlobalSearchProviderResult => ({
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
type: 'type',
|
||||
icon: 'icon',
|
||||
url: '/foo/bar',
|
||||
score: 42,
|
||||
meta: { foo: 'bar' },
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('processProviderResult', () => {
|
||||
let basePath: jest.Mocked<IBasePath>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
22
x-pack/plugins/global_search/common/process_result.ts
Normal file
22
x-pack/plugins/global_search/common/process_result.ts
Normal file
|
@ -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),
|
||||
};
|
||||
};
|
89
x-pack/plugins/global_search/common/types.ts
Normal file
89
x-pack/plugins/global_search/common/types.ts
Normal file
|
@ -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<void>;
|
||||
/**
|
||||
* 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<string, Serializable>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Representation of a result returned by the {@link GlobalSearchPluginStart.find | `find` API}
|
||||
*/
|
||||
export type GlobalSearchResult = Omit<GlobalSearchProviderResult, 'url'> & {
|
||||
/**
|
||||
* 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[];
|
||||
}
|
67
x-pack/plugins/global_search/common/utils.test.ts
Normal file
67
x-pack/plugins/global_search/common/utils.test.ts
Normal file
|
@ -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<typeof createBasePath>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
35
x-pack/plugins/global_search/common/utils.ts
Normal file
35
x-pack/plugins/global_search/common/utils.ts
Normal file
|
@ -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;
|
||||
};
|
10
x-pack/plugins/global_search/kibana.json
Normal file
10
x-pack/plugins/global_search/kibana.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "globalSearch",
|
||||
"version": "8.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"server": true,
|
||||
"ui": true,
|
||||
"requiredPlugins": ["licensing"],
|
||||
"optionalPlugins": [],
|
||||
"configPath": ["xpack", "global_search"]
|
||||
}
|
12
x-pack/plugins/global_search/public/config.ts
Normal file
12
x-pack/plugins/global_search/public/config.ts
Normal file
|
@ -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;
|
||||
}
|
34
x-pack/plugins/global_search/public/index.ts
Normal file
34
x-pack/plugins/global_search/public/index.ts
Normal file
|
@ -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';
|
29
x-pack/plugins/global_search/public/mocks.ts
Normal file
29
x-pack/plugins/global_search/public/mocks.ts
Normal file
|
@ -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<GlobalSearchPluginSetup> => {
|
||||
const searchMock = searchServiceMock.createSetupContract();
|
||||
|
||||
return {
|
||||
registerResultProvider: searchMock.registerResultProvider,
|
||||
};
|
||||
};
|
||||
|
||||
const createStartMock = (): jest.Mocked<GlobalSearchPluginStart> => {
|
||||
const searchMock = searchServiceMock.createStartContract();
|
||||
|
||||
return {
|
||||
find: searchMock.find,
|
||||
};
|
||||
};
|
||||
|
||||
export const globalSearchPluginMock = {
|
||||
createSetupContract: createSetupMock,
|
||||
createStartContract: createStartMock,
|
||||
};
|
63
x-pack/plugins/global_search/public/plugin.ts
Normal file
63
x-pack/plugins/global_search/public/plugin.ts
Normal file
|
@ -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<GlobalSearchClientConfigType>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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> = {}): GlobalSearchResult => ({
|
||||
id,
|
||||
title: id,
|
||||
type: 'type',
|
||||
url: `/path/to/${id}`,
|
||||
score: 100,
|
||||
...parts,
|
||||
});
|
||||
|
||||
describe('fetchServerResults', () => {
|
||||
let http: ReturnType<typeof httpServiceMock.createStartContract>;
|
||||
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<GlobalSearchResult[]> => {
|
||||
let controller: AbortController | undefined;
|
||||
if (aborted$) {
|
||||
controller = new AbortController();
|
||||
aborted$.subscribe(() => {
|
||||
controller!.abort();
|
||||
});
|
||||
}
|
||||
return from(
|
||||
http.post<ServerFetchResponse>('/internal/global_search/find', {
|
||||
body: JSON.stringify({ term, options: { preference } }),
|
||||
signal: controller?.signal,
|
||||
})
|
||||
).pipe(
|
||||
takeUntil(aborted$ ?? EMPTY),
|
||||
map((response) => response.results)
|
||||
);
|
||||
};
|
8
x-pack/plugins/global_search/public/services/index.ts
Normal file
8
x-pack/plugins/global_search/public/services/index.ts
Normal file
|
@ -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';
|
|
@ -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<SearchServiceSetup> => {
|
||||
return {
|
||||
registerResultProvider: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const createStartMock = (): jest.Mocked<SearchServiceStart> => {
|
||||
const mock = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
mock.find.mockReturnValue(of({ results: [] }));
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
export const searchServiceMock = {
|
||||
createSetupContract: createSetupMock,
|
||||
createStartContract: createStartMock,
|
||||
};
|
|
@ -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,
|
||||
}));
|
|
@ -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<typeof httpServiceMock.createStartContract>;
|
||||
let licenseChecker: ReturnType<typeof licenseCheckerMock.create>;
|
||||
|
||||
const createConfig = (timeoutMs: number = 30000): GlobalSearchClientConfigType => {
|
||||
return {
|
||||
search_timeout: duration(timeoutMs).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
const startDeps = () => ({
|
||||
http: httpStart,
|
||||
licenseChecker,
|
||||
});
|
||||
|
||||
const createProvider = (
|
||||
id: string,
|
||||
source: Observable<GlobalSearchProviderResult[]> = of([])
|
||||
): jest.Mocked<GlobalSearchResultProvider> => ({
|
||||
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> = {}
|
||||
): GlobalSearchProviderResult => ({
|
||||
title: id,
|
||||
type: 'test',
|
||||
url: '/foo/bar',
|
||||
score: 100,
|
||||
...parts,
|
||||
id,
|
||||
});
|
||||
|
||||
const serverResult = (
|
||||
id: string,
|
||||
parts: Partial<GlobalSearchResult> = {}
|
||||
): 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'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
164
x-pack/plugins/global_search/public/services/search_service.ts
Normal file
164
x-pack/plugins/global_search/public/services/search_service.ts
Normal file
|
@ -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<GlobalSearchBatchedResults>;
|
||||
}
|
||||
|
||||
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<string, GlobalSearchResultProvider>();
|
||||
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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
27
x-pack/plugins/global_search/public/services/types.ts
Normal file
27
x-pack/plugins/global_search/public/services/types.ts
Normal file
|
@ -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<void>;
|
||||
}
|
49
x-pack/plugins/global_search/public/services/utils.test.ts
Normal file
49
x-pack/plugins/global_search/public/services/utils.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
24
x-pack/plugins/global_search/public/services/utils.ts
Normal file
24
x-pack/plugins/global_search/public/services/utils.ts
Normal file
|
@ -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;
|
||||
};
|
43
x-pack/plugins/global_search/public/types.ts
Normal file
43
x-pack/plugins/global_search/public/types.ts
Normal file
|
@ -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<SearchServiceSetup, 'registerResultProvider'>;
|
||||
export type GlobalSearchPluginStart = Pick<SearchServiceStart, 'find'>;
|
||||
|
||||
/**
|
||||
* 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<GlobalSearchProviderResult[]>;
|
||||
}
|
21
x-pack/plugins/global_search/server/config.ts
Normal file
21
x-pack/plugins/global_search/server/config.ts
Normal file
|
@ -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<typeof configSchema>;
|
||||
|
||||
export const config: PluginConfigDescriptor<GlobalSearchConfigType> = {
|
||||
schema: configSchema,
|
||||
exposeToBrowser: {
|
||||
search_timeout: true,
|
||||
},
|
||||
};
|
38
x-pack/plugins/global_search/server/index.ts
Normal file
38
x-pack/plugins/global_search/server/index.ts
Normal file
|
@ -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';
|
45
x-pack/plugins/global_search/server/mocks.ts
Normal file
45
x-pack/plugins/global_search/server/mocks.ts
Normal file
|
@ -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<GlobalSearchPluginSetup> => {
|
||||
const searchMock = searchServiceMock.createSetupContract();
|
||||
|
||||
return {
|
||||
registerResultProvider: searchMock.registerResultProvider,
|
||||
};
|
||||
};
|
||||
|
||||
const createStartMock = (): jest.Mocked<GlobalSearchPluginStart> => {
|
||||
const searchMock = searchServiceMock.createStartContract();
|
||||
|
||||
return {
|
||||
find: searchMock.find,
|
||||
};
|
||||
};
|
||||
|
||||
const createRouteHandlerContextMock = (): jest.Mocked<RouteHandlerGlobalSearchContext> => {
|
||||
const contextMock = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
contextMock.find.mockReturnValue(of([]));
|
||||
|
||||
return contextMock;
|
||||
};
|
||||
|
||||
export const globalSearchPluginMock = {
|
||||
createSetupContract: createSetupMock,
|
||||
createStartContract: createStartMock,
|
||||
createRouteHandlerContext: createRouteHandlerContextMock,
|
||||
};
|
10
x-pack/plugins/global_search/server/plugin.test.mocks.ts
Normal file
10
x-pack/plugins/global_search/server/plugin.test.mocks.ts
Normal file
|
@ -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,
|
||||
}));
|
33
x-pack/plugins/global_search/server/plugin.test.ts
Normal file
33
x-pack/plugins/global_search/server/plugin.test.ts
Normal file
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
86
x-pack/plugins/global_search/server/plugin.ts
Normal file
86
x-pack/plugins/global_search/server/plugin.ts
Normal file
|
@ -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<GlobalSearchConfigType>;
|
||||
private readonly searchService = new SearchService();
|
||||
private searchServiceStart?: SearchServiceStart;
|
||||
private licenseChecker?: ILicenseChecker;
|
||||
|
||||
constructor(context: PluginInitializerContext) {
|
||||
this.config$ = context.config.create<GlobalSearchConfigType>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
50
x-pack/plugins/global_search/server/routes/find.ts
Normal file
50
x-pack/plugins/global_search/server/routes/find.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
29
x-pack/plugins/global_search/server/routes/index.test.ts
Normal file
29
x-pack/plugins/global_search/server/routes/index.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
12
x-pack/plugins/global_search/server/routes/index.ts
Normal file
12
x-pack/plugins/global_search/server/routes/index.ts
Normal file
|
@ -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);
|
||||
};
|
|
@ -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<ReturnType<typeof setupServer>>;
|
||||
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<typeof globalSearchPluginMock.createRouteHandlerContext>;
|
||||
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
38
x-pack/plugins/global_search/server/services/context.test.ts
Normal file
38
x-pack/plugins/global_search/server/services/context.test.ts
Normal file
|
@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
35
x-pack/plugins/global_search/server/services/context.ts
Normal file
35
x-pack/plugins/global_search/server/services/context.ts
Normal file
|
@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
7
x-pack/plugins/global_search/server/services/index.ts
Normal file
7
x-pack/plugins/global_search/server/services/index.ts
Normal file
|
@ -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';
|
|
@ -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<SearchServiceSetup> => {
|
||||
return {
|
||||
registerResultProvider: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const createStartMock = (): jest.Mocked<SearchServiceStart> => {
|
||||
const mock = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
mock.find.mockReturnValue(of({ results: [] }));
|
||||
|
||||
return mock;
|
||||
};
|
||||
|
||||
export const searchServiceMock = {
|
||||
createSetupContract: createSetupMock,
|
||||
createStartContract: createStartMock,
|
||||
};
|
|
@ -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<typeof httpServiceMock.createBasePath>;
|
||||
let coreStart: ReturnType<typeof coreMock.createStart>;
|
||||
let licenseChecker: ReturnType<typeof licenseCheckerMock.create>;
|
||||
let request: ReturnType<typeof httpServerMock.createKibanaRequest>;
|
||||
|
||||
const createConfig = (timeoutMs: number = 30000): GlobalSearchConfigType => {
|
||||
return {
|
||||
search_timeout: duration(timeoutMs),
|
||||
};
|
||||
};
|
||||
|
||||
const createProvider = (
|
||||
id: string,
|
||||
source: Observable<GlobalSearchProviderResult[]> = of([])
|
||||
): jest.Mocked<GlobalSearchResultProvider> => ({
|
||||
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> = {}
|
||||
): 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'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
162
x-pack/plugins/global_search/server/services/search_service.ts
Normal file
162
x-pack/plugins/global_search/server/services/search_service.ts
Normal file
|
@ -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<GlobalSearchBatchedResults>;
|
||||
}
|
||||
|
||||
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<string, GlobalSearchResultProvider>();
|
||||
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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
110
x-pack/plugins/global_search/server/types.ts
Normal file
110
x-pack/plugins/global_search/server/types.ts
Normal file
|
@ -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<SearchServiceSetup, 'registerResultProvider'>;
|
||||
export type GlobalSearchPluginStart = Pick<SearchServiceStart, 'find'>;
|
||||
|
||||
/**
|
||||
* globalSearch route handler context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RouteHandlerGlobalSearchContext {
|
||||
/**
|
||||
* See {@link SearchServiceStart.find | the find API}
|
||||
*/
|
||||
find(term: string, options: GlobalSearchFindOptions): Observable<GlobalSearchBatchedResults>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<GlobalSearchProviderResult[]>;
|
||||
}
|
|
@ -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 '..';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
): GlobalSearchProviderResult => ({
|
||||
id: 'test',
|
||||
title: 'test result',
|
||||
type: 'test_type',
|
||||
url: '/some-url',
|
||||
score: 100,
|
||||
...parts,
|
||||
});
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"id": "globalSearchTest",
|
||||
"version": "1.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "global_search_test"],
|
||||
"requiredPlugins": ["globalSearch"],
|
||||
"server": true,
|
||||
"ui": true
|
||||
}
|
|
@ -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';
|
|
@ -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<GlobalSearchResult[]>;
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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();
|
|
@ -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 {};
|
||||
}
|
||||
}
|
|
@ -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<GlobalSearchResult[]> => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -9,6 +9,7 @@ import { RESERVED_DIR_JEST_INTEGRATION_TESTS } from '../../../src/dev/constants'
|
|||
export default {
|
||||
rootDir: '../../',
|
||||
roots: [
|
||||
'<rootDir>/plugins',
|
||||
'<rootDir>/legacy/plugins',
|
||||
'<rootDir>/legacy/server',
|
||||
'<rootDir>/legacy/common',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue