diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/pluginExceptions/StaleConnectionException.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/pluginExceptions/StaleConnectionException.java index 2c17c9acac..26a46913bf 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/pluginExceptions/StaleConnectionException.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/external/pluginExceptions/StaleConnectionException.java @@ -1,4 +1,14 @@ package com.appsmith.external.pluginExceptions; public class StaleConnectionException extends RuntimeException { + public StaleConnectionException() { + } + + public StaleConnectionException(String message) { + super(message); + } + + public StaleConnectionException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/app/server/appsmith-plugins/pom.xml b/app/server/appsmith-plugins/pom.xml index 4673fe878b..032f02dd81 100644 --- a/app/server/appsmith-plugins/pom.xml +++ b/app/server/appsmith-plugins/pom.xml @@ -9,7 +9,6 @@ 4.0.0 - com.appsmith appsmith-plugins 1.0-SNAPSHOT pom @@ -22,6 +21,6 @@ mysqlPlugin elasticSearchPlugin dynamoPlugin + redisPlugin - - + \ No newline at end of file diff --git a/app/server/appsmith-plugins/redisPlugin/plugin.properties b/app/server/appsmith-plugins/redisPlugin/plugin.properties new file mode 100644 index 0000000000..382cc2fbe9 --- /dev/null +++ b/app/server/appsmith-plugins/redisPlugin/plugin.properties @@ -0,0 +1,5 @@ +plugin.id=redis-plugin +plugin.class=com.external.plugins.RedisPlugin +plugin.version=1.0-SNAPSHOT +plugin.provider=tech@appsmith.com +plugin.dependencies= \ No newline at end of file diff --git a/app/server/appsmith-plugins/redisPlugin/pom.xml b/app/server/appsmith-plugins/redisPlugin/pom.xml new file mode 100644 index 0000000000..2be6893d04 --- /dev/null +++ b/app/server/appsmith-plugins/redisPlugin/pom.xml @@ -0,0 +1,115 @@ + + + + 4.0.0 + + com.external.plugins + redisPlugin + 1.0-SNAPSHOT + + redisPlugin + + + UTF-8 + 11 + ${java.version} + ${java.version} + redis-plugin + com.external.plugins.RedisPlugin + 1.0-SNAPSHOT + tech@appsmith.com + + + + + + + org.pf4j + pf4j-spring + 0.6.0 + provided + + + + com.appsmith + interfaces + 1.0-SNAPSHOT + provided + + + + org.projectlombok + lombok + 1.18.8 + provided + + + + redis.clients + jedis + 3.3.0 + + + + + junit + junit + 4.11 + test + + + + io.projectreactor + reactor-test + 3.2.11.RELEASE + test + + + org.mockito + mockito-core + 3.1.0 + test + + + org.testcontainers + testcontainers + 1.14.1 + test + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + false + + + + ${plugin.id} + ${plugin.class} + ${plugin.version} + ${plugin.provider} + + + + + + + package + + shade + + + + + + + + diff --git a/app/server/appsmith-plugins/redisPlugin/src/main/java/com/external/plugins/RedisPlugin.java b/app/server/appsmith-plugins/redisPlugin/src/main/java/com/external/plugins/RedisPlugin.java new file mode 100644 index 0000000000..96695c737f --- /dev/null +++ b/app/server/appsmith-plugins/redisPlugin/src/main/java/com/external/plugins/RedisPlugin.java @@ -0,0 +1,164 @@ +package com.external.plugins; + +import com.appsmith.external.models.*; +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.commons.lang.ObjectUtils; +import org.pf4j.Extension; +import org.pf4j.PluginWrapper; +import org.pf4j.util.StringUtils; +import org.springframework.util.CollectionUtils; +import reactor.core.publisher.Mono; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.Protocol; +import redis.clients.jedis.exceptions.JedisConnectionException; +import redis.clients.jedis.util.SafeEncoder; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public class RedisPlugin extends BasePlugin { + private static final Integer DEFAULT_PORT = 6379; + + public RedisPlugin(PluginWrapper wrapper) { + super(wrapper); + } + + @Slf4j + @Extension + public static class RedisPluginExecutor implements PluginExecutor { + @Override + public Mono execute(Jedis jedis, + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + String body = actionConfiguration.getBody(); + if (StringUtils.isNullOrEmpty(body)) { + return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, + String.format("Body is null or empty [%s]", body))); + } + + // First value will be the redis command and others are arguments for that command + String[] bodySplitted = body.trim().split("\\s+"); + + Protocol.Command command; + try { + // Commands are in upper case + command = Protocol.Command.valueOf(bodySplitted[0].toUpperCase()); + } catch (IllegalArgumentException exc) { + return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, + String.format("Not a valid Redis command:%s", bodySplitted[0]))); + } + + Object commandOutput; + if (bodySplitted.length > 1) { + commandOutput = jedis.sendCommand(command, Arrays.copyOfRange(bodySplitted, 1, bodySplitted.length)); + } else { + commandOutput = jedis.sendCommand(command); + } + + ActionExecutionResult actionExecutionResult = new ActionExecutionResult(); + actionExecutionResult.setBody(processCommandOutput(commandOutput)); + + return Mono.just(actionExecutionResult); + } + + // This will be updated as we encounter different outputs. + private String processCommandOutput(Object commandOutput) { + if (commandOutput == null) { + return "null"; + } else if (commandOutput instanceof byte[]) { + return SafeEncoder.encode((byte[]) commandOutput); + } else { + return String.valueOf(commandOutput); + } + } + + @Override + public Mono datasourceCreate(DatasourceConfiguration datasourceConfiguration) { + if (datasourceConfiguration.getEndpoints().isEmpty()) { + return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "No endpoint(s) configured")); + } + + Endpoint endpoint = datasourceConfiguration.getEndpoints().get(0); + Integer port = (int) (long) ObjectUtils.defaultIfNull(endpoint.getPort(), DEFAULT_PORT); + Jedis jedis = new Jedis(endpoint.getHost(), port); + + AuthenticationDTO auth = datasourceConfiguration.getAuthentication(); + if (auth != null && AuthenticationDTO.Type.USERNAME_PASSWORD.equals(auth.getAuthType())) { + jedis.auth(auth.getUsername(), auth.getPassword()); + } + + return Mono.just(jedis); + } + + @Override + public void datasourceDestroy(Jedis jedis) { + try { + if (jedis != null) { + jedis.close(); + } + } catch (JedisConnectionException exc) { + log.error("Error closing Redis connection"); + } + } + + @Override + public Set validateDatasource(DatasourceConfiguration datasourceConfiguration) { + Set invalids = new HashSet<>(); + + if (CollectionUtils.isEmpty(datasourceConfiguration.getEndpoints())) { + invalids.add("Missing endpoint(s)"); + } else { + Endpoint endpoint = datasourceConfiguration.getEndpoints().get(0); + if (StringUtils.isNullOrEmpty(endpoint.getHost())) { + invalids.add("Missing host for endpoint"); + } + } + + AuthenticationDTO auth = datasourceConfiguration.getAuthentication(); + if (auth != null && AuthenticationDTO.Type.USERNAME_PASSWORD.equals(auth.getAuthType())) { + if (StringUtils.isNullOrEmpty(datasourceConfiguration.getAuthentication().getUsername())) { + invalids.add("Missing username for authentication."); + } + + if (StringUtils.isNullOrEmpty(datasourceConfiguration.getAuthentication().getPassword())) { + invalids.add("Missing password for authentication."); + } + } + + return invalids; + } + + private Mono verifyPing(Jedis jedis) { + String pingResponse; + try { + pingResponse = jedis.ping(); + } catch (Exception exc) { + return Mono.error(exc); + } + + if (!"PONG".equals(pingResponse)) { + return Mono.error(new RuntimeException( + String.format("Expected PONG in response of PING but got %s", pingResponse))); + } + + return Mono.empty(); + } + + @Override + public Mono testDatasource(DatasourceConfiguration datasourceConfiguration) { + return datasourceCreate(datasourceConfiguration). + map(jedis -> { + verifyPing(jedis).block(); + datasourceDestroy(jedis); + return new DatasourceTestResult(); + }). + onErrorResume(error -> Mono.just(new DatasourceTestResult(error.getMessage()))); + } + + } +} diff --git a/app/server/appsmith-plugins/redisPlugin/src/test/java/com/external/plugins/RedisPluginTest.java b/app/server/appsmith-plugins/redisPlugin/src/test/java/com/external/plugins/RedisPluginTest.java new file mode 100644 index 0000000000..8ec264ca6c --- /dev/null +++ b/app/server/appsmith-plugins/redisPlugin/src/test/java/com/external/plugins/RedisPluginTest.java @@ -0,0 +1,212 @@ +package com.external.plugins; + +import com.appsmith.external.models.*; +import com.appsmith.external.pluginExceptions.AppsmithPluginException; +import lombok.extern.slf4j.Slf4j; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import redis.clients.jedis.Jedis; + +import java.util.Collections; +import java.util.Set; + +@Slf4j +public class RedisPluginTest { + @ClassRule + public static final GenericContainer redis = new GenericContainer("redis:5.0.3-alpine") + .withExposedPorts(6379); + private static String host; + private static Integer port; + + private RedisPlugin.RedisPluginExecutor pluginExecutor = new RedisPlugin.RedisPluginExecutor(); + + @BeforeClass + public static void setup() { + host = redis.getContainerIpAddress(); + port = redis.getFirstMappedPort(); + } + + private DatasourceConfiguration createDatasourceConfiguration() { + Endpoint endpoint = new Endpoint(); + endpoint.setHost(host); + endpoint.setPort(Long.valueOf(port)); + + DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration(); + datasourceConfiguration.setEndpoints(Collections.singletonList(endpoint)); + + return datasourceConfiguration; + } + + @Test + public void itShouldCreateDatasource() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + Mono jedisMono = pluginExecutor.datasourceCreate(datasourceConfiguration); + + StepVerifier.create(jedisMono) + .assertNext(Assert::assertNotNull) + .verifyComplete(); + + pluginExecutor.datasourceDestroy(jedisMono.block()); + } + + @Test + public void itShouldValidateDatasourceWithNoEndpoints() { + DatasourceConfiguration invalidDatasourceConfiguration = new DatasourceConfiguration(); + + Assert.assertEquals(pluginExecutor.validateDatasource(invalidDatasourceConfiguration), Set.of("Missing endpoint(s)")); + } + + @Test + public void itShouldValidateDatasourceWithInvalidEndpoint() { + DatasourceConfiguration invalidDatasourceConfiguration = new DatasourceConfiguration(); + + Endpoint endpoint = new Endpoint(); + invalidDatasourceConfiguration.setEndpoints(Collections.singletonList(endpoint)); + + Assert.assertEquals(pluginExecutor.validateDatasource(invalidDatasourceConfiguration), Set.of("Missing host for endpoint")); + } + + @Test + public void itShouldValidateDatasourceWithInvalidAuth() { + DatasourceConfiguration invalidDatasourceConfiguration = new DatasourceConfiguration(); + + Endpoint endpoint = new Endpoint(); + endpoint.setHost("test-host"); + + AuthenticationDTO invalidAuth = new AuthenticationDTO(); + invalidAuth.setAuthType(AuthenticationDTO.Type.USERNAME_PASSWORD); + + invalidDatasourceConfiguration.setAuthentication(invalidAuth); + invalidDatasourceConfiguration.setEndpoints(Collections.singletonList(endpoint)); + + Assert.assertEquals(pluginExecutor.validateDatasource(invalidDatasourceConfiguration), + Set.of("Missing username for authentication.", "Missing password for authentication.") + ); + } + + @Test + public void itShouldValidateDatasource() { + DatasourceConfiguration datasourceConfiguration = new DatasourceConfiguration(); + + AuthenticationDTO auth = new AuthenticationDTO(); + auth.setAuthType(AuthenticationDTO.Type.USERNAME_PASSWORD); + auth.setUsername("test-username"); + auth.setPassword("test-password"); + + Endpoint endpoint = new Endpoint(); + endpoint.setHost("test-host"); + + datasourceConfiguration.setAuthentication(auth); + datasourceConfiguration.setEndpoints(Collections.singletonList(endpoint)); + + Assert.assertTrue(pluginExecutor.validateDatasource(datasourceConfiguration).isEmpty()); + } + + @Test + public void itShouldTestDatasource() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + Mono datasourceTestResultMono = pluginExecutor.testDatasource(datasourceConfiguration); + + StepVerifier.create(datasourceTestResultMono) + .assertNext(datasourceTestResult -> { + Assert.assertNotNull(datasourceTestResult); + Assert.assertTrue(datasourceTestResult.isSuccess()); + }) + .verifyComplete(); + } + + @Test + public void itShouldThrowErrorIfEmptyBody() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + Mono jedisMono = pluginExecutor.datasourceCreate(datasourceConfiguration); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + + Mono actionExecutionResultMono = jedisMono + .flatMap(jedis -> pluginExecutor.execute(jedis, datasourceConfiguration, actionConfiguration)); + + StepVerifier.create(actionExecutionResultMono) + .expectError(AppsmithPluginException.class) + .verify(); + } + + @Test + public void itShouldThrowErrorIfInvalidRedisCommand() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + Mono jedisMono = pluginExecutor.datasourceCreate(datasourceConfiguration); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setBody("LOL"); + + Mono actionExecutionResultMono = jedisMono + .flatMap(jedis -> pluginExecutor.execute(jedis, datasourceConfiguration, actionConfiguration)); + + StepVerifier.create(actionExecutionResultMono) + .expectError(AppsmithPluginException.class) + .verify(); + } + + @Test + public void itShouldExecuteCommandWithoutArgs() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + Mono jedisMono = pluginExecutor.datasourceCreate(datasourceConfiguration); + + ActionConfiguration actionConfiguration = new ActionConfiguration(); + actionConfiguration.setBody("PING"); + + Mono actionExecutionResultMono = jedisMono + .flatMap(jedis -> pluginExecutor.execute(jedis, datasourceConfiguration, actionConfiguration)); + + StepVerifier.create(actionExecutionResultMono) + .assertNext(actionExecutionResult -> { + Assert.assertNotNull(actionExecutionResult); + Assert.assertNotNull(actionExecutionResult.getBody()); + Assert.assertEquals(actionExecutionResult.getBody(), "PONG"); + }).verifyComplete(); + } + + @Test + public void itShouldExecuteCommandWithArgs() { + DatasourceConfiguration datasourceConfiguration = createDatasourceConfiguration(); + Mono jedisMono = pluginExecutor.datasourceCreate(datasourceConfiguration); + + // Getting a non-existent key + ActionConfiguration getActionConfiguration = new ActionConfiguration(); + getActionConfiguration.setBody("GET key"); + Mono actionExecutionResultMono = jedisMono + .flatMap(jedis -> pluginExecutor.execute(jedis, datasourceConfiguration, getActionConfiguration)); + StepVerifier.create(actionExecutionResultMono) + .assertNext(actionExecutionResult -> { + Assert.assertNotNull(actionExecutionResult); + Assert.assertNotNull(actionExecutionResult.getBody()); + Assert.assertEquals(actionExecutionResult.getBody(), "null"); + }).verifyComplete(); + + // Setting a key + ActionConfiguration setActionConfiguration = new ActionConfiguration(); + setActionConfiguration.setBody("SET key value"); + actionExecutionResultMono = jedisMono + .flatMap(jedis -> pluginExecutor.execute(jedis, datasourceConfiguration, setActionConfiguration)); + StepVerifier.create(actionExecutionResultMono) + .assertNext(actionExecutionResult -> { + Assert.assertNotNull(actionExecutionResult); + Assert.assertNotNull(actionExecutionResult.getBody()); + Assert.assertEquals(actionExecutionResult.getBody(), "OK"); + }).verifyComplete(); + + // Getting the key + actionExecutionResultMono = jedisMono + .flatMap(jedis -> pluginExecutor.execute(jedis, datasourceConfiguration, getActionConfiguration)); + StepVerifier.create(actionExecutionResultMono) + .assertNext(actionExecutionResult -> { + Assert.assertNotNull(actionExecutionResult); + Assert.assertNotNull(actionExecutionResult.getBody()); + Assert.assertEquals(actionExecutionResult.getBody(), "value"); + }).verifyComplete(); + } +}