From a193909a7f831f27efe6eee58ddeba6df38932af Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 16 Dec 2019 23:00:07 +0300 Subject: [PATCH] value suggestions server route -> data plugin Closes #52842 --- src/legacy/core_plugins/kibana/index.js | 2 - .../suggestions/register_value_suggestions.js | 117 --------------- .../data/common/index_patterns/types.ts | 1 + .../autocomplete/autocomplete_service.ts | 29 ++++ .../data/server/autocomplete/index.ts} | 6 +- .../data/server/autocomplete/routes.ts | 43 ++++++ .../autocomplete/value_suggestions_route.ts | 138 ++++++++++++++++++ src/plugins/data/server/index.ts | 1 + .../data/server/index_patterns/index.ts | 2 + .../data/server/index_patterns/utils.ts | 51 +++++++ src/plugins/data/server/plugin.ts | 7 + 11 files changed, 273 insertions(+), 124 deletions(-) delete mode 100644 src/legacy/core_plugins/kibana/server/routes/api/suggestions/register_value_suggestions.js create mode 100644 src/plugins/data/server/autocomplete/autocomplete_service.ts rename src/{legacy/core_plugins/kibana/server/routes/api/suggestions/index.js => plugins/data/server/autocomplete/index.ts} (83%) create mode 100644 src/plugins/data/server/autocomplete/routes.ts create mode 100644 src/plugins/data/server/autocomplete/value_suggestions_route.ts create mode 100644 src/plugins/data/server/index_patterns/utils.ts diff --git a/src/legacy/core_plugins/kibana/index.js b/src/legacy/core_plugins/kibana/index.js index 93167cb0e6bf922..4d492aae7fede4a 100644 --- a/src/legacy/core_plugins/kibana/index.js +++ b/src/legacy/core_plugins/kibana/index.js @@ -28,7 +28,6 @@ import { exportApi } from './server/routes/api/export'; import { homeApi } from './server/routes/api/home'; import { managementApi } from './server/routes/api/management'; import { scriptsApi } from './server/routes/api/scripts'; -import { registerSuggestionsApi } from './server/routes/api/suggestions'; import { registerKqlTelemetryApi } from './server/routes/api/kql_telemetry'; import { registerFieldFormats } from './server/field_formats/register'; import { registerTutorials } from './server/tutorials/register'; @@ -334,7 +333,6 @@ export default function(kibana) { exportApi(server); homeApi(server); managementApi(server); - registerSuggestionsApi(server); registerKqlTelemetryApi(server); registerFieldFormats(server); registerTutorials(server); diff --git a/src/legacy/core_plugins/kibana/server/routes/api/suggestions/register_value_suggestions.js b/src/legacy/core_plugins/kibana/server/routes/api/suggestions/register_value_suggestions.js deleted file mode 100644 index 6a6276234e55031..000000000000000 --- a/src/legacy/core_plugins/kibana/server/routes/api/suggestions/register_value_suggestions.js +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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. - */ - -import { get, map } from 'lodash'; -import { abortableRequestHandler } from '../../../../../elasticsearch/lib/abortable_request_handler'; - -export function registerValueSuggestions(server) { - const serverConfig = server.config(); - const autocompleteTerminateAfter = serverConfig.get('kibana.autocompleteTerminateAfter'); - const autocompleteTimeout = serverConfig.get('kibana.autocompleteTimeout'); - server.route({ - path: '/api/kibana/suggestions/values/{index}', - method: ['POST'], - handler: abortableRequestHandler(async function(signal, req) { - const { index } = req.params; - const { field: fieldName, query, boolFilter } = req.payload; - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - - const savedObjectsClient = req.getSavedObjectsClient(); - const savedObjectsResponse = await savedObjectsClient.find({ - type: 'index-pattern', - fields: ['fields'], - search: `"${index}"`, - searchFields: ['title'], - }); - const indexPattern = - savedObjectsResponse.total > 0 ? savedObjectsResponse.saved_objects[0] : null; - const fields = indexPattern ? JSON.parse(indexPattern.attributes.fields) : null; - const field = fields ? fields.find(field => field.name === fieldName) : fieldName; - - const body = getBody( - { field, query, boolFilter }, - autocompleteTerminateAfter, - autocompleteTimeout - ); - - try { - const response = await callWithRequest(req, 'search', { index, body }, { signal }); - const buckets = - get(response, 'aggregations.suggestions.buckets') || - get(response, 'aggregations.nestedSuggestions.suggestions.buckets') || - []; - const suggestions = map(buckets, 'key'); - return suggestions; - } catch (error) { - throw server.plugins.elasticsearch.handleESError(error); - } - }), - }); -} - -function getBody({ field, query, boolFilter = [] }, terminateAfter, timeout) { - // Helps ensure that the regex is not evaluated eagerly against the terms dictionary - const executionHint = 'map'; - - // We don't care about the accuracy of the counts, just the content of the terms, so this reduces - // the amount of information that needs to be transmitted to the coordinating node - const shardSize = 10; - - const body = { - size: 0, - timeout: `${timeout}ms`, - terminate_after: terminateAfter, - query: { - bool: { - filter: boolFilter, - }, - }, - aggs: { - suggestions: { - terms: { - field: field.name || field, - include: `${getEscapedQuery(query)}.*`, - execution_hint: executionHint, - shard_size: shardSize, - }, - }, - }, - }; - - if (field.subType && field.subType.nested) { - return { - ...body, - aggs: { - nestedSuggestions: { - nested: { - path: field.subType.nested.path, - }, - aggs: body.aggs, - }, - }, - }; - } - - return body; -} - -function getEscapedQuery(query = '') { - // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators - return query.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, match => `\\${match}`); -} diff --git a/src/plugins/data/common/index_patterns/types.ts b/src/plugins/data/common/index_patterns/types.ts index 0a1ba4834224429..98cdd20ea4b8463 100644 --- a/src/plugins/data/common/index_patterns/types.ts +++ b/src/plugins/data/common/index_patterns/types.ts @@ -20,6 +20,7 @@ import { IFieldType } from './fields'; export interface IIndexPattern { + [key: string]: any; fields: IFieldType[]; title: string; id?: string; diff --git a/src/plugins/data/server/autocomplete/autocomplete_service.ts b/src/plugins/data/server/autocomplete/autocomplete_service.ts new file mode 100644 index 000000000000000..1b85321aa2185e3 --- /dev/null +++ b/src/plugins/data/server/autocomplete/autocomplete_service.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +import { CoreSetup, Plugin } from 'kibana/server'; +import { registerRoutes } from './routes'; + +export class AutocompleteService implements Plugin { + public setup(core: CoreSetup) { + registerRoutes(core); + } + + public start() {} +} diff --git a/src/legacy/core_plugins/kibana/server/routes/api/suggestions/index.js b/src/plugins/data/server/autocomplete/index.ts similarity index 83% rename from src/legacy/core_plugins/kibana/server/routes/api/suggestions/index.js rename to src/plugins/data/server/autocomplete/index.ts index 15110b192ee33c1..6c10a8c98bdbf7f 100644 --- a/src/legacy/core_plugins/kibana/server/routes/api/suggestions/index.js +++ b/src/plugins/data/server/autocomplete/index.ts @@ -17,8 +17,4 @@ * under the License. */ -import { registerValueSuggestions } from './register_value_suggestions'; - -export function registerSuggestionsApi(server) { - registerValueSuggestions(server); -} +export { AutocompleteService } from './autocomplete_service'; diff --git a/src/plugins/data/server/autocomplete/routes.ts b/src/plugins/data/server/autocomplete/routes.ts new file mode 100644 index 000000000000000..bee189491ecdd3b --- /dev/null +++ b/src/plugins/data/server/autocomplete/routes.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +import { first } from 'rxjs/operators'; +import { + APICaller, + CallAPIOptions, + CoreSetup, + ElasticsearchServiceSetup, + KibanaRequest, +} from 'kibana/server'; +import { registerValueSuggestionsRoute } from './value_suggestions_route'; + +const getAPICallerFn = (elasticsearch: ElasticsearchServiceSetup) => async ( + request: KibanaRequest +): Promise => { + const client = await elasticsearch.dataClient$.pipe(first()).toPromise(); + + return (endpoint: string, params?: Record, options?: CallAPIOptions) => + client.asScoped(request).callAsCurrentUser(endpoint, params, options); +}; + +export function registerRoutes({ http, elasticsearch }: CoreSetup): void { + const router = http.createRouter(); + + registerValueSuggestionsRoute(router, getAPICallerFn(elasticsearch)); +} diff --git a/src/plugins/data/server/autocomplete/value_suggestions_route.ts b/src/plugins/data/server/autocomplete/value_suggestions_route.ts new file mode 100644 index 000000000000000..8deff3d307e1983 --- /dev/null +++ b/src/plugins/data/server/autocomplete/value_suggestions_route.ts @@ -0,0 +1,138 @@ +/* + * 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. + */ + +import { get, map } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { APICaller, IRouter, KibanaRequest } from 'kibana/server'; + +import { IFieldType, indexPatternsUtils, esFilters } from '../index'; + +export function registerValueSuggestionsRoute( + router: IRouter, + apiCaller: (request: KibanaRequest) => Promise +) { + router.post( + { + path: '/api/kibana/suggestions/values/{index}', + validate: { + params: schema.object( + { + index: schema.string(), + }, + { allowUnknowns: false } + ), + body: schema.object( + { + field: schema.string(), + query: schema.string(), + boolFilter: schema.maybe(schema.any()), + }, + { allowUnknowns: false } + ), + }, + }, + async (context, request, response) => { + const { client: uiSettings } = context.core.uiSettings; + const { field: fieldName, query, boolFilter } = request.body; + const { index } = request.params; + + const autocompleteSearchOptions = { + timeout: await uiSettings.get('kibana.autocompleteTimeout'), + terminate_after: await uiSettings.get('kibana.autocompleteTerminateAfter'), + }; + + const indexPattern = await indexPatternsUtils.findIndexPatternById( + context.core.savedObjects.client, + index + ); + + const field = indexPatternsUtils.getFieldByName(fieldName, indexPattern); + const body = await getBody(autocompleteSearchOptions, field || fieldName, query, boolFilter); + + try { + const callCluster = await apiCaller(request); + const result = await callCluster('search', { index, body }); + + const buckets: any[] = + get(result, 'aggregations.suggestions.buckets') || + get(result, 'aggregations.nestedSuggestions.suggestions.buckets'); + + return response.ok({ body: map(buckets || [], 'key') }); + } catch (error) { + return response.internalError({ body: error }); + } + } + ); +} + +async function getBody( + { timeout, terminate_after }: Record, + field: IFieldType | string, + query: string, + boolFilter: esFilters.Filter[] = [] +) { + const isFieldObject = (f: any): f is IFieldType => Boolean(f && f.name); + + // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators + const getEscapedQuery = (q: string = '') => + q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, match => `\\${match}`); + + // Helps ensure that the regex is not evaluated eagerly against the terms dictionary + const executionHint = 'map'; + + // We don't care about the accuracy of the counts, just the content of the terms, so this reduces + // the amount of information that needs to be transmitted to the coordinating node + const shardSize = 10; + const body = { + size: 0, + timeout, + terminate_after, + query: { + bool: { + filter: boolFilter, + }, + }, + aggs: { + suggestions: { + terms: { + field: isFieldObject(field) ? field.name : field, + include: `${getEscapedQuery(query)}.*`, + execution_hint: executionHint, + shard_size: shardSize, + }, + }, + }, + }; + + if (isFieldObject(field) && field.subType && field.subType.nested) { + return { + ...body, + aggs: { + nestedSuggestions: { + nested: { + path: field.subType.nested.path, + }, + aggs: body.aggs, + }, + }, + }; + } + + return body; +} diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 022eb0ae5029587..986cf0f21bffda1 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -57,6 +57,7 @@ export { IndexPatternsFetcher, FieldDescriptor, shouldReadFieldFromDocValues, + indexPatternsUtils, } from './index_patterns'; export * from './search'; export { diff --git a/src/plugins/data/server/index_patterns/index.ts b/src/plugins/data/server/index_patterns/index.ts index 6937fa22c4e5d12..c20bb719612326b 100644 --- a/src/plugins/data/server/index_patterns/index.ts +++ b/src/plugins/data/server/index_patterns/index.ts @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ +import * as indexPatternsUtils from './utils'; export { IndexPatternsFetcher, FieldDescriptor, shouldReadFieldFromDocValues } from './fetcher'; export { IndexPatternsService } from './index_patterns_service'; +export { indexPatternsUtils }; diff --git a/src/plugins/data/server/index_patterns/utils.ts b/src/plugins/data/server/index_patterns/utils.ts new file mode 100644 index 000000000000000..937d8c67db96365 --- /dev/null +++ b/src/plugins/data/server/index_patterns/utils.ts @@ -0,0 +1,51 @@ +/* + * 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. + */ + +import { Server } from 'kibana'; +import { IIndexPattern, IFieldType } from '../../common'; + +export const getFieldByName = ( + fieldName: string, + indexPattern?: IIndexPattern +): IFieldType | undefined => { + if (indexPattern) { + const fields: IFieldType[] = indexPattern && JSON.parse(indexPattern.attributes.fields); + const field = fields && fields.find(f => f.name === fieldName); + + if (field) { + return field; + } + } +}; + +export const findIndexPatternById = async ( + savedObjectsClient: Server.SavedObjectsClientContract, + index: string +): Promise => { + const savedObjectsResponse = await savedObjectsClient.find({ + type: 'index-pattern', + fields: ['fields'], + search: `"${index}"`, + searchFields: ['title'], + }); + + if (savedObjectsResponse.total > 0) { + return (savedObjectsResponse.saved_objects[0] as unknown) as IIndexPattern; + } +}; diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index e81250e653ebd3f..6df0a11e7538dd6 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -21,18 +21,25 @@ import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '../../.. import { IndexPatternsService } from './index_patterns'; import { ISearchSetup } from './search'; import { SearchService } from './search/search_service'; +import { AutocompleteService } from './autocomplete'; export interface DataPluginSetup { search: ISearchSetup; } + export class DataServerPlugin implements Plugin { private readonly searchService: SearchService; + private readonly autocompleteService = new AutocompleteService(); private readonly indexPatterns = new IndexPatternsService(); + constructor(initializerContext: PluginInitializerContext) { this.searchService = new SearchService(initializerContext); } + public setup(core: CoreSetup) { this.indexPatterns.setup(core); + this.autocompleteService.setup(core); + return { search: this.searchService.setup(core), };