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:
Pierre Gayvallet 2020-06-04 16:18:02 +02:00 committed by GitHub
parent 3d2c3f1eac
commit c5546f4a39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
81 changed files with 3294 additions and 66 deletions

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,3 +19,4 @@
export { createHttpServer } from './http/test_utils';
export { ServiceStatusLevelSnapshotSerializer } from './status/test_utils';
export { setupServer } from './saved_objects/routes/test_utils';

View file

@ -26,3 +26,4 @@ export * from './capabilities';
export * from './app_category';
export * from './ui_settings';
export * from './saved_objects';
export * from './serializable';

View 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> {}

View file

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

View file

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

View 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.

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

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

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

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

View 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();
}
}

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

View file

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

View file

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

View 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 convertResultUrlMock = jest.fn().mockReturnValue('converted-url');
jest.doMock('./utils', () => ({
convertResultUrl: convertResultUrlMock,
}));

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

View 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),
};
};

View 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[];
}

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

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

View file

@ -0,0 +1,10 @@
{
"id": "globalSearch",
"version": "8.0.0",
"kibanaVersion": "kibana",
"server": true,
"ui": true,
"requiredPlugins": ["licensing"],
"optionalPlugins": [],
"configPath": ["xpack", "global_search"]
}

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

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

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

View 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();
}
}
}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View 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,
}))
);
}
}

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

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

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

View 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[]>;
}

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

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

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

View 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,
}));

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

View 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();
}
}
}

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

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

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

View file

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

View 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),
},
});
});
});

View 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),
},
},
};
};

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

View file

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

View file

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

View 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,
}))
);
}
}

View 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[]>;
}

View file

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

View file

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

View file

@ -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',
],

View file

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

View file

@ -0,0 +1,9 @@
{
"id": "globalSearchTest",
"version": "1.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "global_search_test"],
"requiredPlugins": ["globalSearch"],
"server": true,
"ui": true
}

View file

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

View 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 { 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(),
};
}
}

View file

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

View 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 { PluginInitializer } from 'src/core/server';
import {
GlobalSearchTestPlugin,
GlobalSearchTestPluginSetup,
GlobalSearchTestPluginStart,
GlobalSearchTestPluginSetupDeps,
GlobalSearchTestPluginStartDeps,
} from './plugin';
export const plugin: PluginInitializer<
GlobalSearchTestPluginSetup,
GlobalSearchTestPluginStart,
GlobalSearchTestPluginSetupDeps,
GlobalSearchTestPluginStartDeps
> = () => new GlobalSearchTestPlugin();

View file

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

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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']);
});
});
}

View file

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

View file

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