/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF 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.
*/
package org.apache.zeppelin.elasticsearch;
import com.github.wnameless.json.flattener.JsonFlattener;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import org.apache.commons.lang.StringUtils;
import org.apache.zeppelin.interpreter.Interpreter;
import org.apache.zeppelin.interpreter.InterpreterContext;
import org.apache.zeppelin.interpreter.InterpreterPropertyBuilder;
import org.apache.zeppelin.interpreter.InterpreterResult;
import org.elasticsearch.action.delete.DeleteResponse;
import org.elasticsearch.action.get.GetResponse;
import org.elasticsearch.action.index.IndexResponse;
import org.elasticsearch.action.search.SearchAction;
import org.elasticsearch.action.search.SearchRequestBuilder;
import org.elasticsearch.action.search.SearchResponse;
import org.elasticsearch.client.Client;
import org.elasticsearch.client.transport.TransportClient;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.transport.InetSocketTransportAddress;
import org.elasticsearch.common.xcontent.XContentHelper;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.SearchHit;
import org.elasticsearch.search.aggregations.Aggregation;
import org.elasticsearch.search.aggregations.Aggregations;
import org.elasticsearch.search.aggregations.InternalMultiBucketAggregation;
import org.elasticsearch.search.aggregations.bucket.InternalSingleBucketAggregation;
import org.elasticsearch.search.aggregations.bucket.MultiBucketsAggregation;
import org.elasticsearch.search.aggregations.metrics.InternalMetricsAggregation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.InetAddress;
import java.util.*;
/**
* Elasticsearch Interpreter for Zeppelin.
*/
public class ElasticsearchInterpreter extends Interpreter {
private static Logger logger = LoggerFactory.getLogger(ElasticsearchInterpreter.class);
private static final String HELP = "Elasticsearch interpreter:\n"
+ "General format: <command> /<indices>/<types>/<id> <option> <JSON>\n"
+ " - indices: list of indices separated by commas (depends on the command)\n"
+ " - types: list of document types separated by commas (depends on the command)\n"
+ "Commands:\n"
+ " - search /indices/types <query>\n"
+ " . indices and types can be omitted (at least, you have to provide '/')\n"
+ " . a query is either a JSON-formatted query, nor a lucene query\n"
+ " - size <value>\n"
+ " . defines the size of the result set (default value is in the config)\n"
+ " . if used, this command must be declared before a search command\n"
+ " - count /indices/types <query>\n"
+ " . same comments as for the search\n"
+ " - get /index/type/id\n"
+ " - delete /index/type/id\n"
+ " - index /ndex/type/id <json-formatted document>\n"
+ " . the id can be omitted, elasticsearch will generate one";
private static final List<String> COMMANDS = Arrays.asList(
"count", "delete", "get", "help", "index", "search");
public static final String ELASTICSEARCH_HOST = "elasticsearch.host";
public static final String ELASTICSEARCH_PORT = "elasticsearch.port";
public static final String ELASTICSEARCH_CLUSTER_NAME = "elasticsearch.cluster.name";
public static final String ELASTICSEARCH_RESULT_SIZE = "elasticsearch.result.size";
static {
Interpreter.register(
"elasticsearch",
"elasticsearch",
ElasticsearchInterpreter.class.getName(),
new InterpreterPropertyBuilder()
.add(ELASTICSEARCH_HOST, "localhost", "The host for Elasticsearch")
.add(ELASTICSEARCH_PORT, "9300", "The port for Elasticsearch")
.add(ELASTICSEARCH_CLUSTER_NAME, "elasticsearch", "The cluster name for Elasticsearch")
.add(ELASTICSEARCH_RESULT_SIZE, "10", "The size of the result set of a search query")
.build());
}
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
private Client client;
private String host = "localhost";
private int port = 9300;
private String clusterName = "elasticsearch";
private int resultSize = 10;
public ElasticsearchInterpreter(Properties property) {
super(property);
this.host = getProperty(ELASTICSEARCH_HOST);
this.port = Integer.parseInt(getProperty(ELASTICSEARCH_PORT));
this.clusterName = getProperty(ELASTICSEARCH_CLUSTER_NAME);
this.resultSize = Integer.parseInt(getProperty(ELASTICSEARCH_RESULT_SIZE));
}
@Override
public void open() {
try {
logger.info("prop={}", getProperty());
final Settings settings = Settings.settingsBuilder()
.put("cluster.name", clusterName)
.put(getProperty())
.build();
client = TransportClient.builder().settings(settings).build()
.addTransportAddress(new InetSocketTransportAddress(InetAddress.getByName(host), port));
}
catch (IOException e) {
logger.error("Open connection with Elasticsearch", e);
}
}
@Override
public void close() {
if (client != null) {
client.close();
}
}
@Override
public InterpreterResult interpret(String cmd, InterpreterContext interpreterContext) {
logger.info("Run Elasticsearch command '" + cmd + "'");
int currentResultSize = resultSize;
if (client == null) {
return new InterpreterResult(InterpreterResult.Code.ERROR,
"Problem with the Elasticsearch client, please check your configuration (host, port,...)");
}
String[] items = StringUtils.split(cmd.trim(), " ", 3);
// Process some specific commands (help, size, ...)
if ("help".equalsIgnoreCase(items[0])) {
return processHelp(InterpreterResult.Code.SUCCESS, null);
}
if ("size".equalsIgnoreCase(items[0])) {
// In this case, the line with size must be followed by a search,
// so we will continue with the next lines
final String[] lines = StringUtils.split(cmd.trim(), "\n", 2);
if (lines.length < 2) {
return processHelp(InterpreterResult.Code.ERROR,
"Size cmd must be followed by a search");
}
final String[] sizeLine = StringUtils.split(lines[0], " ", 2);
if (sizeLine.length != 2) {
return processHelp(InterpreterResult.Code.ERROR, "Right format is : size <value>");
}
currentResultSize = Integer.parseInt(sizeLine[1]);
items = StringUtils.split(lines[1].trim(), " ", 3);
}
if (items.length < 2) {
return processHelp(InterpreterResult.Code.ERROR, "Arguments missing");
}
final String method = items[0];
final String url = items[1];
final String data = items.length > 2 ? items[2].trim() : null;
final String[] urlItems = StringUtils.split(url.trim(), "/");
try {
if ("get".equalsIgnoreCase(method)) {
return processGet(urlItems);
}
else if ("count".equalsIgnoreCase(method)) {
return processCount(urlItems, data);
}
else if ("search".equalsIgnoreCase(method)) {
return processSearch(urlItems, data, currentResultSize);
}
else if ("index".equalsIgnoreCase(method)) {
return processIndex(urlItems, data);
}
else if ("delete".equalsIgnoreCase(method)) {
return processDelete(urlItems);
}
return processHelp(InterpreterResult.Code.ERROR, "Unknown command");
}
catch (Exception e) {
return new InterpreterResult(InterpreterResult.Code.ERROR, "Error : " + e.getMessage());
}
}
@Override
public void cancel(InterpreterContext interpreterContext) {
// Nothing to do
}
@Override
public FormType getFormType() {
return FormType.SIMPLE;
}
@Override
public int getProgress(InterpreterContext interpreterContext) {
return 0;
}
@Override
public List<String> completion(String s, int i) {
final List<String> suggestions = new ArrayList<>();
if (StringUtils.isEmpty(s)) {
suggestions.addAll(COMMANDS);
}
else {
for (String cmd : COMMANDS) {
if (cmd.toLowerCase().contains(s)) {
suggestions.add(cmd);
}
}
}
return suggestions;
}
private InterpreterResult processHelp(InterpreterResult.Code code, String additionalMessage) {
final StringBuffer buffer = new StringBuffer();
if (additionalMessage != null) {
buffer.append(additionalMessage).append("\n");
}
buffer.append(HELP).append("\n");
return new InterpreterResult(code, InterpreterResult.Type.TEXT, buffer.toString());
}
/**
* Processes a "get" request.
*
* @param urlItems Items of the URL
* @return Result of the get request, it contains a JSON-formatted string
*/
private InterpreterResult processGet(String[] urlItems) {
if (urlItems.length != 3
|| StringUtils.isEmpty(urlItems[0])
|| StringUtils.isEmpty(urlItems[1])
|| StringUtils.isEmpty(urlItems[2])) {
return new InterpreterResult(InterpreterResult.Code.ERROR,
"Bad URL (it should be /index/type/id)");
}
final GetResponse response = client
.prepareGet(urlItems[0], urlItems[1], urlItems[2])
.get();
if (response.isExists()) {
final String json = gson.toJson(response.getSource());
return new InterpreterResult(
InterpreterResult.Code.SUCCESS,
InterpreterResult.Type.TEXT,
json);
}
return new InterpreterResult(InterpreterResult.Code.ERROR, "Document not found");
}
/**
* Processes a "count" request.
*
* @param urlItems Items of the URL
* @param data May contains the JSON of the request
* @return Result of the count request, it contains the total hits
*/
private InterpreterResult processCount(String[] urlItems, String data) {
if (urlItems.length > 2) {
return new InterpreterResult(InterpreterResult.Code.ERROR,
"Bad URL (it should be /index1,index2,.../type1,type2,...)");
}
final SearchResponse response = searchData(urlItems, data, 0);
return new InterpreterResult(
InterpreterResult.Code.SUCCESS,
InterpreterResult.Type.TEXT,
"" + response.getHits().getTotalHits());
}
/**
* Processes a "search" request.
*
* @param urlItems Items of the URL
* @param data May contains the JSON of the request
* @param size Limit of result set
* @return Result of the search request, it contains a tab-formatted string of the matching hits
*/
private InterpreterResult processSearch(String[] urlItems, String data, int size) {
if (urlItems.length > 2) {
return new InterpreterResult(InterpreterResult.Code.ERROR,
"Bad URL (it should be /index1,index2,.../type1,type2,...)");
}
final SearchResponse response = searchData(urlItems, data, size);
return buildResponseMessage(response);
}
/**
* Processes a "index" request.
*
* @param urlItems Items of the URL
* @param data JSON to be indexed
* @return Result of the index request, it contains the id of the document
*/
private InterpreterResult processIndex(String[] urlItems, String data) {
if (urlItems.length < 2 || urlItems.length > 3) {
return new InterpreterResult(InterpreterResult.Code.ERROR,
"Bad URL (it should be /index/type or /index/type/id)");
}
final IndexResponse response = client
.prepareIndex(urlItems[0], urlItems[1], urlItems.length == 2 ? null : urlItems[2])
.setSource(data)
.get();
return new InterpreterResult(
InterpreterResult.Code.SUCCESS,
InterpreterResult.Type.TEXT,
response.getId());
}
/**
* Processes a "delete" request.
*
* @param urlItems Items of the URL
* @return Result of the delete request, it contains the id of the deleted document
*/
private InterpreterResult processDelete(String[] urlItems) {
if (urlItems.length != 3
|| StringUtils.isEmpty(urlItems[0])
|| StringUtils.isEmpty(urlItems[1])
|| StringUtils.isEmpty(urlItems[2])) {
return new InterpreterResult(InterpreterResult.Code.ERROR,
"Bad URL (it should be /index/type/id)");
}
final DeleteResponse response = client
.prepareDelete(urlItems[0], urlItems[1], urlItems[2])
.get();
if (response.isFound()) {
return new InterpreterResult(
InterpreterResult.Code.SUCCESS,
InterpreterResult.Type.TEXT,
response.getId());
}
return new InterpreterResult(InterpreterResult.Code.ERROR, "Document not found");
}
private SearchResponse searchData(String[] urlItems, String query, int size) {
final SearchRequestBuilder reqBuilder = new SearchRequestBuilder(
client, SearchAction.INSTANCE);
reqBuilder.setIndices();
if (urlItems.length >= 1) {
reqBuilder.setIndices(StringUtils.split(urlItems[0], ","));
}
if (urlItems.length > 1) {
reqBuilder.setTypes(StringUtils.split(urlItems[1], ","));
}
if (!StringUtils.isEmpty(query)) {
// The query can be either JSON-formatted, nor a Lucene query
// So, try to parse as a JSON => if there is an error, consider the query a Lucene one
try {
final Map source = gson.fromJson(query, Map.class);
reqBuilder.setExtraSource(source);
}
catch (JsonParseException e) {
// This is not a JSON (or maybe not well formatted...)
reqBuilder.setQuery(QueryBuilders.queryStringQuery(query).analyzeWildcard(true));
}
}
reqBuilder.setSize(size);
final SearchResponse response = reqBuilder.get();
return response;
}
private InterpreterResult buildAggResponseMessage(Aggregations aggregations) {
// Only the result of the first aggregation is returned
//
final Aggregation agg = aggregations.asList().get(0);
InterpreterResult.Type resType = InterpreterResult.Type.TEXT;
String resMsg = "";
if (agg instanceof InternalMetricsAggregation) {
resMsg = XContentHelper.toString((InternalMetricsAggregation) agg).toString();
}
else if (agg instanceof InternalSingleBucketAggregation) {
resMsg = XContentHelper.toString((InternalSingleBucketAggregation) agg).toString();
}
else if (agg instanceof InternalMultiBucketAggregation) {
final StringBuffer buffer = new StringBuffer("key\tdoc_count");
final InternalMultiBucketAggregation multiBucketAgg = (InternalMultiBucketAggregation) agg;
for (MultiBucketsAggregation.Bucket bucket : multiBucketAgg.getBuckets()) {
buffer.append("\n")
.append(bucket.getKeyAsString())
.append("\t")
.append(bucket.getDocCount());
}
resType = InterpreterResult.Type.TABLE;
resMsg = buffer.toString();
}
return new InterpreterResult(InterpreterResult.Code.SUCCESS, resType, resMsg);
}
private String buildSearchHitsResponseMessage(SearchHit[] hits) {
if (hits == null || hits.length == 0) {
return "";
}
//First : get all the keys in order to build an ordered list of the values for each hit
//
final List<Map<String, Object>> flattenHits = new LinkedList<>();
final Set<String> keys = new TreeSet<>();
for (SearchHit hit : hits) {
final String json = hit.getSourceAsString();
final Map<String, Object> flattenMap = JsonFlattener.flattenAsMap(json);
flattenHits.add(flattenMap);
for (String key : flattenMap.keySet()) {
keys.add(key);
}
}
// Next : build the header of the table
//
final StringBuffer buffer = new StringBuffer();
for (String key : keys) {
buffer.append(key).append('\t');
}
buffer.replace(buffer.lastIndexOf("\t"), buffer.lastIndexOf("\t") + 1, "\n");
// Finally : build the result by using the key set
//
for (Map<String, Object> hit : flattenHits) {
for (String key : keys) {
final Object val = hit.get(key);
if (val != null) {
buffer.append(val);
}
buffer.append('\t');
}
buffer.replace(buffer.lastIndexOf("\t"), buffer.lastIndexOf("\t") + 1, "\n");
}
return buffer.toString();
}
private InterpreterResult buildResponseMessage(SearchResponse response) {
final Aggregations aggregations = response.getAggregations();
if (aggregations != null && aggregations.asList().size() > 0) {
return buildAggResponseMessage(aggregations);
}
return new InterpreterResult(
InterpreterResult.Code.SUCCESS,
InterpreterResult.Type.TABLE,
buildSearchHitsResponseMessage(response.getHits().getHits()));
}
}