Add ElasticSearch integration (#1181)

* add elasticSearchPlugin

* Fix container startup in tests

* Add elasticsearch dependency

* Get plugin to a base working state

* Add templates and tests for all Document APIs

* Add support for bulk queries

* Add test and template for bulk operations

* Use rich form for action configuration

* Add test API for ElasticSearch

* Use rich form's values for plugin execution

* Add authorization header support

* Fix tests after config object use changes

* Add test for bulk requests with nd-json body

* Remove templates and minor refactoring

* Fix potential NPE with null body

Co-authored-by: Trisha Anand <trisha@appsmith.com>

* Add datasource validation for endpoint

* Wrap errors in AppsmithPluginException

Co-authored-by: Suman Patra <spatra@akamai.com>
Co-authored-by: Trisha Anand <trisha@appsmith.com>
This commit is contained in:
Shrikant Sharat Kandula 2020-10-21 15:34:29 +05:30 committed by GitHub
parent 81a92d40cb
commit fa1a0549ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 663 additions and 2 deletions

View File

@ -0,0 +1,5 @@
plugin.id=elasticsearch-plugin
plugin.class=com.external.plugins.ElasticSearchPlugin
plugin.version=1.0-SNAPSHOT
plugin.provider=tech@appsmith.com
plugin.dependencies=

View File

@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.external.plugins</groupId>
<artifactId>elasticSearchPlugin</artifactId>
<version>1.0-SNAPSHOT</version>
<name>elasticSearchPlugin</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>11</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<plugin.id>elasticsearch-plugin</plugin.id>
<plugin.class>com.external.plugins.ElasticSearchPlugin</plugin.class>
<plugin.version>1.0-SNAPSHOT</plugin.version>
<plugin.provider>tech@appsmith.com</plugin.provider>
<plugin.dependencies/>
</properties>
<dependencies>
<dependency>
<groupId>org.pf4j</groupId>
<artifactId>pf4j-spring</artifactId>
<version>0.6.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.appsmith</groupId>
<artifactId>interfaces</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.8</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-client</artifactId>
<version>7.9.2</version>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<version>3.3.5.RELEASE</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.15.0-rc2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>elasticsearch</artifactId>
<version>1.15.0-rc2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<configuration>
<minimizeJar>false</minimizeJar>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Plugin-Id>${plugin.id}</Plugin-Id>
<Plugin-Class>${plugin.class}</Plugin-Class>
<Plugin-Version>${plugin.version}</Plugin-Version>
<Plugin-Provider>${plugin.provider}</Plugin-Provider>
</manifestEntries>
</transformer>
</transformers>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<includeScope>runtime</includeScope>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,202 @@
package com.external.plugins;
import com.appsmith.external.models.ActionConfiguration;
import com.appsmith.external.models.ActionExecutionResult;
import com.appsmith.external.models.AuthenticationDTO;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.DatasourceTestResult;
import com.appsmith.external.models.Endpoint;
import com.appsmith.external.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.plugins.BasePlugin;
import com.appsmith.external.plugins.PluginExecutor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.StatusLine;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.message.BasicHeader;
import org.apache.http.nio.entity.NStringEntity;
import org.elasticsearch.client.Request;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.pf4j.Extension;
import org.pf4j.PluginWrapper;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class ElasticSearchPlugin extends BasePlugin {
public ElasticSearchPlugin(PluginWrapper wrapper) {
super(wrapper);
}
@Slf4j
@Extension
public static class ElasticSearchPluginExecutor implements PluginExecutor<RestClient> {
@Override
public Mono<ActionExecutionResult> execute(RestClient client,
DatasourceConfiguration datasourceConfiguration,
ActionConfiguration actionConfiguration) {
final ActionExecutionResult result = new ActionExecutionResult();
String body = actionConfiguration.getBody();
final String path = actionConfiguration.getPath();
final Request request = new Request(actionConfiguration.getHttpMethod().toString(), path);
ContentType contentType = ContentType.APPLICATION_JSON;
if (isBulkQuery(path)) {
contentType = ContentType.create("application/x-ndjson");
// If body is a JSON Array, convert it to an ND-JSON string.
if (body != null && body.trim().startsWith("[")) {
final StringBuilder ndJsonBuilder = new StringBuilder();
try {
List<Object> commands = objectMapper.readValue(body, ArrayList.class);
for (Object object : commands) {
ndJsonBuilder.append(objectMapper.writeValueAsString(object)).append("\n");
}
} catch (IOException e) {
final String message = "Error converting array to ND-JSON: " + e.getMessage();
log.warn(message, e);
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, message));
}
body = ndJsonBuilder.toString();
}
}
if (body != null) {
request.setEntity(new NStringEntity(body, contentType));
}
try {
final String responseBody = new String(
client.performRequest(request).getEntity().getContent().readAllBytes());
result.setBody(objectMapper.readValue(responseBody, HashMap.class));
} catch (IOException e) {
final String message = "Error performing request: " + e.getMessage();
log.warn(message, e);
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, message));
}
result.setIsExecutionSuccess(true);
return Mono.just(result);
}
private static boolean isBulkQuery(String path) {
return path.split("\\?", 1)[0].matches(".*\\b_bulk$");
}
@Override
public Mono<RestClient> datasourceCreate(DatasourceConfiguration datasourceConfiguration) {
final List<HttpHost> hosts = new ArrayList<>();
for (Endpoint endpoint : datasourceConfiguration.getEndpoints()) {
hosts.add(new HttpHost(endpoint.getHost(), endpoint.getPort().intValue(), "http"));
}
final RestClientBuilder clientBuilder = RestClient.builder(hosts.toArray(new HttpHost[]{}));
final AuthenticationDTO authentication = datasourceConfiguration.getAuthentication();
if (authentication != null
&& !StringUtils.isEmpty(authentication.getUsername())
&& !StringUtils.isEmpty(authentication.getPassword())) {
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials(authentication.getUsername(), authentication.getPassword())
);
clientBuilder
.setHttpClientConfigCallback(
httpClientBuilder -> httpClientBuilder
.setDefaultCredentialsProvider(credentialsProvider)
);
}
if (!CollectionUtils.isEmpty(datasourceConfiguration.getHeaders())) {
clientBuilder.setDefaultHeaders(
(Header[]) datasourceConfiguration.getHeaders()
.stream()
.map(h -> new BasicHeader(h.getKey(), h.getValue()))
.toArray()
);
}
return Mono.just(clientBuilder.build());
}
@Override
public void datasourceDestroy(RestClient client) {
try {
client.close();
} catch (IOException e) {
log.warn("Error closing connection to ElasticSearch.", e);
}
}
@Override
public Set<String> validateDatasource(DatasourceConfiguration datasourceConfiguration) {
Set<String> invalids = new HashSet<>();
if (CollectionUtils.isEmpty(datasourceConfiguration.getEndpoints())) {
invalids.add("No endpoint provided. Please provide a host:port where ElasticSearch is reachable.");
}
return invalids;
}
@Override
public Mono<DatasourceTestResult> testDatasource(DatasourceConfiguration datasourceConfiguration) {
return datasourceCreate(datasourceConfiguration)
.map(client -> {
if (client == null) {
return new DatasourceTestResult("Null client object to ElasticSearch.");
}
// This HEAD request is to check if an index exists. It response with 200 if the index exists,
// 404 if it doesn't. We just check for either of these two.
// Ref: https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-exists.html
Request request = new Request("HEAD", "/potentially-missing-index?local=true");
final Response response;
try {
response = client.performRequest(request);
} catch (IOException e) {
return new DatasourceTestResult("Error running HEAD request: " + e.getMessage());
}
final StatusLine statusLine = response.getStatusLine();
try {
client.close();
} catch (IOException e) {
log.warn("Error closing ElasticSearch client that was made for testing.", e);
}
if (statusLine.getStatusCode() != 404 && statusLine.getStatusCode() != 200) {
return new DatasourceTestResult(
"Unexpected response from ElasticSearch: " + statusLine);
}
return new DatasourceTestResult();
})
.onErrorResume(error -> Mono.just(new DatasourceTestResult(error.getMessage())));
}
}
}

View File

@ -0,0 +1,45 @@
{
"editor": [
{
"sectionName": "",
"id": 1,
"children": [
{
"label": "Method",
"configProperty": "actionConfiguration.httpMethod",
"controlType": "DROP_DOWN",
"isRequired": true,
"initialValue": "GET",
"options": [
{
"label": "GET",
"value": "GET"
},
{
"label": "POST",
"value": "POST"
},
{
"label": "PUT",
"value": "PUT"
},
{
"label": "DELETE",
"value": "DELETE"
}
]
},
{
"label": "Path",
"configProperty": "actionConfiguration.path",
"controlType": "INPUT_TEXT"
},
{
"label": "Body",
"configProperty": "actionConfiguration.body",
"controlType": "QUERY_DYNAMIC_TEXT"
}
]
}
]
}

View File

@ -0,0 +1,52 @@
{
"form": [
{
"sectionName": "Connection",
"children": [
{
"sectionName": null,
"children": [
{
"label": "Host Address",
"configProperty": "datasourceConfiguration.endpoints[*].host",
"controlType": "KEYVALUE_ARRAY",
"validationMessage": "Please enter a valid host",
"validationRegex": "^((?![/:]).)*$"
},
{
"label": "Port",
"configProperty": "datasourceConfiguration.endpoints[*].port",
"dataType": "NUMBER",
"controlType": "KEYVALUE_ARRAY"
}
]
}
]
},
{
"sectionName": "Authentication",
"children": [
{
"label": "Username for Basic Auth",
"configProperty": "datasourceConfiguration.authentication.username",
"controlType": "INPUT_TEXT",
"placeholderText": "Username"
},
{
"label": "Password for Basic Auth",
"configProperty": "datasourceConfiguration.authentication.password",
"dataType": "PASSWORD",
"controlType": "INPUT_TEXT",
"placeholderText": "Password"
},
{
"label": "Authorization Header (if username, password are not set)",
"configProperty": "datasourceConfiguration.headers[0]",
"controlType": "FIXED_KEY_INPUT",
"fixedKey": "Authorization",
"placeholderText": "Authorization Header"
}
]
}
]
}

View File

@ -0,0 +1,206 @@
package com.external.plugins;
import com.appsmith.external.models.ActionConfiguration;
import com.appsmith.external.models.ActionExecutionResult;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.Endpoint;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.elasticsearch.client.Request;
import org.elasticsearch.client.RestClient;
import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test;
import org.springframework.http.HttpMethod;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@Slf4j
public class ElasticSearchPluginTest {
ElasticSearchPlugin.ElasticSearchPluginExecutor pluginExecutor = new ElasticSearchPlugin.ElasticSearchPluginExecutor();
@ClassRule
public static final ElasticsearchContainer container =
new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:6.4.1")
.withEnv("discovery.type", "single-node");
private static final DatasourceConfiguration dsConfig = new DatasourceConfiguration();
@BeforeClass
public static void setUp() throws IOException {
final Integer port = container.getMappedPort(9200);
final RestClient client = RestClient.builder(
new HttpHost("localhost", port, "http")
).build();
Request request;
request = new Request("PUT", "/planets/doc/id1");
request.setJsonEntity("{\"name\": \"Mercury\"}");
client.performRequest(request);
request = new Request("PUT", "/planets/doc/id2");
request.setJsonEntity("{\"name\": \"Venus\"}");
client.performRequest(request);
request = new Request("PUT", "/planets/doc/id3");
request.setJsonEntity("{\"name\": \"Earth\"}");
client.performRequest(request);
client.close();
dsConfig.setEndpoints(List.of(new Endpoint("localhost", port.longValue())));
}
private Mono<ActionExecutionResult> execute(HttpMethod method, String path, String body) {
final ActionConfiguration actionConfiguration = new ActionConfiguration();
actionConfiguration.setHttpMethod(method);
actionConfiguration.setPath(path);
actionConfiguration.setBody(body);
return pluginExecutor
.datasourceCreate(dsConfig)
.flatMap(conn -> pluginExecutor.execute(conn, dsConfig, actionConfiguration));
}
@Test
public void testGet() {
StepVerifier.create(execute(HttpMethod.GET, "/planets/doc/id1", null))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final Map<String, Object> resultBody = (Map) result.getBody();
assertEquals("Mercury", ((Map<String, String>) resultBody.get("_source")).get("name"));
})
.verifyComplete();
}
@Test
public void testMultiGet() {
final String contentJson = "{\n" +
" \"docs\": [\n" +
" {\n" +
" \"_index\": \"planets\",\n" +
" \"_id\": \"id1\"\n" +
" },\n" +
" {\n" +
" \"_index\": \"planets\",\n" +
" \"_id\": \"id2\"\n" +
" }\n" +
" ]\n" +
"}";
StepVerifier.create(execute(HttpMethod.GET, "/planets/_mget", contentJson))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final List<Map> docs = ((Map<String, List<Map>>) result.getBody()).get("docs");
assertEquals(2, docs.size());
})
.verifyComplete();
}
@Test
public void testPutCreate() {
final String contentJson = "{\"name\": \"Pluto\"}";
StepVerifier.create(execute(HttpMethod.PUT, "/planets/doc/id9", contentJson))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final Map<String, Object> resultBody = (Map) result.getBody();
assertEquals("created", resultBody.get("result"));
assertEquals("id9", resultBody.get("_id"));
})
.verifyComplete();
}
@Test
public void testPutUpdate() {
final String contentJson = "{\"name\": \"New Venus\"}";
StepVerifier.create(execute(HttpMethod.PUT, "/planets/doc/id2", contentJson))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final Map<String, Object> resultBody = (Map) result.getBody();
assertEquals("updated", resultBody.get("result"));
assertEquals("id2", resultBody.get("_id"));
})
.verifyComplete();
}
@Test
public void testDelete() {
StepVerifier.create(execute(HttpMethod.DELETE, "/planets/doc/id3", null))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final Map<String, Object> resultBody = (Map) result.getBody();
assertEquals("deleted", resultBody.get("result"));
assertEquals("id3", resultBody.get("_id"));
})
.verifyComplete();
}
@Test
public void testBulkWithArrayBody() {
final String contentJson = "[\n" +
" { \"index\" : { \"_index\" : \"test1\", \"_type\": \"doc\", \"_id\" : \"1\" } },\n" +
" { \"field1\" : \"value1\" },\n" +
" { \"delete\" : { \"_index\" : \"test1\", \"_type\": \"doc\", \"_id\" : \"2\" } },\n" +
" { \"create\" : { \"_index\" : \"test1\", \"_type\": \"doc\", \"_id\" : \"3\" } },\n" +
" { \"field1\" : \"value3\" },\n" +
" { \"update\" : {\"_id\" : \"1\", \"_type\": \"doc\", \"_index\" : \"test1\"} },\n" +
" { \"doc\" : {\"field2\" : \"value2\"} }\n" +
"]";
StepVerifier.create(execute(HttpMethod.POST, "/_bulk", contentJson))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final Map<String, Object> resultBody = (Map) result.getBody();
assertFalse((Boolean) resultBody.get("errors"));
assertEquals(4, ((List) resultBody.get("items")).size());
})
.verifyComplete();
}
@Test
public void testBulkWithDirectBody() {
final String contentJson =
"{ \"index\" : { \"_index\" : \"test2\", \"_type\": \"doc\", \"_id\" : \"1\" } }\n" +
"{ \"field1\" : \"value1\" }\n" +
"{ \"delete\" : { \"_index\" : \"test2\", \"_type\": \"doc\", \"_id\" : \"2\" } }\n" +
"{ \"create\" : { \"_index\" : \"test2\", \"_type\": \"doc\", \"_id\" : \"3\" } }\n" +
"{ \"field1\" : \"value3\" }\n" +
"{ \"update\" : {\"_id\" : \"1\", \"_type\": \"doc\", \"_index\" : \"test2\"} }\n" +
"{ \"doc\" : {\"field2\" : \"value2\"} }\n";
StepVerifier.create(execute(HttpMethod.POST, "/_bulk", contentJson))
.assertNext(result -> {
assertNotNull(result);
assertTrue(result.getIsExecutionSuccess());
assertNotNull(result.getBody());
final Map<String, Object> resultBody = (Map) result.getBody();
assertFalse((Boolean) resultBody.get("errors"));
assertEquals(4, ((List) resultBody.get("items")).size());
})
.verifyComplete();
}
}

View File

@ -20,6 +20,7 @@
<module>mongoPlugin</module>
<module>rapidApiPlugin</module>
<module>mysqlPlugin</module>
<module>elasticSearchPlugin</module>
</modules>
</project>

View File

@ -936,6 +936,26 @@ public class DatabaseChangelog {
);
}
@ChangeSet(order = "027", id = "add-elastic-search-plugin", author = "")
public void addElasticSearchPlugin(MongoTemplate mongoTemplate) {
Plugin plugin1 = new Plugin();
plugin1.setName("ElasticSearch");
plugin1.setType(PluginType.DB);
plugin1.setPackageName("elasticsearch-plugin");
plugin1.setUiComponent("DbEditorForm");
plugin1.setResponseType(Plugin.ResponseType.JSON);
plugin1.setIconLocation("https://s3.us-east-2.amazonaws.com/assets.appsmith.com/ElasticSearch.jpg");
plugin1.setDocumentationLink("https://docs.appsmith.com/core-concepts/connecting-to-databases/querying-elasticsearch");
plugin1.setDefaultInstall(true);
try {
mongoTemplate.insert(plugin1);
} catch (DuplicateKeyException e) {
log.warn(plugin1.getPackageName() + " already present in database.");
}
installPluginToAllOrganizations(mongoTemplate, plugin1.getId());
}
private void installPluginToAllOrganizations(MongoTemplate mongoTemplate, String pluginId) {
for (Organization organization : mongoTemplate.findAll(Organization.class)) {
if (CollectionUtils.isEmpty(organization.getPlugins())) {
@ -954,6 +974,4 @@ public class DatabaseChangelog {
}
}
}