fix: remove RapidAPI plugin src code from API server (#16501)

* remove RapidApi plugin related code from API server
* mark RapidApi plugin related data as deleted in DB (soft delete)
This commit is contained in:
Sumit Kumar 2022-09-23 13:20:03 +05:30 committed by GitHub
parent 995847cdef
commit b8f30a8f10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 110 additions and 465 deletions

View File

@ -64,7 +64,6 @@
<module>postgresPlugin</module>
<module>restApiPlugin</module>
<module>mongoPlugin</module>
<module>rapidApiPlugin</module>
<module>mysqlPlugin</module>
<module>elasticSearchPlugin</module>
<module>dynamoPlugin</module>

View File

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

View File

@ -1,92 +0,0 @@
<?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>
<parent>
<groupId>com.appsmith</groupId>
<artifactId>appsmith-plugins</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<groupId>com.external.plugins</groupId>
<artifactId>rapidApiPlugin</artifactId>
<version>1.0-SNAPSHOT</version>
<name>rapidApiPlugin</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>rapidapi-plugin</plugin.id>
<plugin.class>com.external.plugins.RapidApiPlugin</plugin.class>
<plugin.version>1.0-SNAPSHOT</plugin.version>
<plugin.provider>tech@appsmith.com</plugin.provider>
<plugin.dependencies/>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>5.3.20</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>5.3.20</version>
<exclusions>
<exclusion>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<!--
This doesn't use the maven-shade-plugin because it inherits from spring-boot-starter-parent
for it's dependency on webclient. This is a normal compile.
-->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<archive>
<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>
<Plugin-Dependencies>${plugin.dependencies}</Plugin-Dependencies>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,327 +0,0 @@
package com.external.plugins;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.models.ActionConfiguration;
import com.appsmith.external.models.ActionExecutionResult;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.DatasourceTestResult;
import com.appsmith.external.models.Property;
import com.appsmith.external.plugins.BasePlugin;
import com.appsmith.external.plugins.PluginExecutor;
import com.appsmith.util.WebClientUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import lombok.extern.slf4j.Slf4j;
import org.bson.internal.Base64;
import org.json.JSONObject;
import org.pf4j.Extension;
import org.pf4j.PluginWrapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.util.StringUtils;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Slf4j
public class RapidApiPlugin extends BasePlugin {
private static final int MAX_REDIRECTS = 5;
private static final String JSON_TYPE = "apipayload";
public RapidApiPlugin(PluginWrapper wrapper) {
super(wrapper);
}
@Extension
public static class RapidApiPluginExecutor implements PluginExecutor<Void> {
private static final String RAPID_API_KEY_NAME = "X-RapidAPI-Key";
private static final String RAPID_API_KEY_VALUE = System.getenv("APPSMITH_RAPID_API_KEY_VALUE");
@Override
public Mono<ActionExecutionResult> execute(Void ignored,
DatasourceConfiguration datasourceConfiguration,
ActionConfiguration actionConfiguration) {
if (StringUtils.isEmpty(RAPID_API_KEY_VALUE)) {
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "RapidAPI Key value " +
"not set."));
}
String requestBody = (actionConfiguration.getBody() == null) ? "" : actionConfiguration.getBody();
String path = (actionConfiguration.getPath() == null) ? "" : actionConfiguration.getPath();
String url = datasourceConfiguration.getUrl() + path;
HttpMethod httpMethod = actionConfiguration.getHttpMethod();
if (httpMethod == null) {
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "HTTPMethod must be " +
"set."));
}
WebClient.Builder webClientBuilder = WebClientUtils.builder();
if (datasourceConfiguration.getHeaders() != null) {
addHeadersToRequest(webClientBuilder, datasourceConfiguration.getHeaders());
}
if (actionConfiguration.getHeaders() != null) {
addHeadersToRequest(webClientBuilder, actionConfiguration.getHeaders());
}
// Add the rapid api headers
webClientBuilder.defaultHeader(RAPID_API_KEY_NAME, RAPID_API_KEY_VALUE);
//If route parameters exist, update the URL by replacing the key surrounded by '{' and '}'
if (actionConfiguration.getRouteParameters() != null && !actionConfiguration.getRouteParameters().isEmpty()) {
for (Property property : actionConfiguration.getRouteParameters()) {
// If either the key or the value is empty, skip
if (property.getKey() != null && !property.getKey().isEmpty() &&
property.getValue() != null && !((String) property.getValue()).isEmpty()) {
Pattern pattern = Pattern.compile("\\{" + property.getKey() + "\\}");
Matcher matcher = pattern.matcher(url);
url = matcher.replaceAll(URLEncoder.encode((String) property.getValue()));
}
}
}
URI uri;
try {
String httpUrl = addHttpToUrlWhenPrefixNotPresent(url);
uri = createFinalUriWithQueryParams(httpUrl, actionConfiguration.getQueryParameters());
log.info("Final URL is : {}", uri);
} catch (URISyntaxException e) {
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, e));
}
// Build the body of the request in case of bodyFormData is not null
if (actionConfiguration.getBodyFormData() != null) {
// First set the header to specify the content type
webClientBuilder.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON.toString());
Map<String, String> keyValueMap = new HashMap<>();
List<Property> bodyFormData = actionConfiguration.getBodyFormData();
String jsonString = null;
JSONObject bodyJson;
for (Property property : bodyFormData) {
if (property.getValue() != null) {
if (!property.getType().equals(JSON_TYPE)) {
keyValueMap.put(property.getKey(), (String) property.getValue());
} else {
// This is actually supposed to be the body and should not be in key-value format. No need to
// convert the same.
jsonString = (String) property.getValue();
break;
}
}
}
if (jsonString == null) {
bodyJson = new JSONObject(keyValueMap);
} else {
bodyJson = new JSONObject(jsonString);
}
jsonString = bodyJson.toString();
// Now reset the request body
requestBody = jsonString;
}
WebClient client = webClientBuilder.build();
return httpCall(client, httpMethod, uri, requestBody, 0)
.flatMap(clientResponse -> clientResponse.toEntity(byte[].class))
.map(stringResponseEntity -> {
HttpHeaders headers = stringResponseEntity.getHeaders();
// Find the media type of the response to parse the body as required.
MediaType contentType = headers.getContentType();
byte[] body = stringResponseEntity.getBody();
HttpStatus statusCode = stringResponseEntity.getStatusCode();
ActionExecutionResult result = new ActionExecutionResult();
result.setStatusCode(statusCode.toString());
// If the HTTP response is 200, only then cache the response.
// We shouldn't cache the response even for other 2xx statuses like 201, 204 etc.
if (statusCode.equals(HttpStatus.OK)) {
result.setIsExecutionSuccess(true);
}
if (headers != null) {
// Convert the headers into json tree to store in the results
String headerInJsonString;
try {
headerInJsonString = objectMapper.writeValueAsString(headers);
} catch (JsonProcessingException e) {
e.printStackTrace();
throw Exceptions.propagate(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, e));
}
try {
// Set headers in the result now
result.setHeaders(objectMapper.readTree(headerInJsonString));
} catch (IOException e) {
e.printStackTrace();
throw Exceptions.propagate(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, e));
}
}
if (body != null) {
/**TODO
* Handle XML response. Currently we only handle JSON & Image responses. The other kind of responses
* are kept as is and returned as a string.
*/
if (MediaType.APPLICATION_JSON.equals(contentType) ||
MediaType.APPLICATION_JSON_UTF8.equals(contentType) ||
MediaType.APPLICATION_JSON_UTF8_VALUE.equals(contentType)) {
String jsonBody = new String(body);
try {
result.setBody(objectMapper.readTree(jsonBody));
} catch (IOException e) {
e.printStackTrace();
throw Exceptions.propagate(new AppsmithPluginException(AppsmithPluginError.PLUGIN_JSON_PARSE_ERROR, jsonBody, e));
}
} else if (MediaType.IMAGE_GIF.equals(contentType) ||
MediaType.IMAGE_JPEG.equals(contentType) ||
MediaType.IMAGE_PNG.equals(contentType)) {
String encode = Base64.encode(body);
result.setBody(encode);
} else {
// If the body is not of JSON type, just set it as is.
String bodyString = new String(body);
result.setBody(bodyString.trim());
}
}
return result;
})
.onErrorMap(throwable -> {
final Throwable actualException = Exceptions.unwrap(throwable);
if (actualException instanceof AppsmithPluginException) {
return actualException;
} else {
return new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, actualException);
}
});
}
private Mono<ClientResponse> httpCall(WebClient webClient, HttpMethod httpMethod, URI uri, String requestBody, int iteration) {
if (iteration == MAX_REDIRECTS) {
log.debug("Exceeded the http redirect limits. Returning error");
return Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, "Exceeded the HTTO redirect limits of " + MAX_REDIRECTS));
}
return webClient
.method(httpMethod)
.uri(uri)
.body(BodyInserters.fromObject(requestBody))
.exchange()
.doOnError(e -> Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_ERROR, e)))
.flatMap(res -> {
ClientResponse response = (ClientResponse) res;
if (response.statusCode().is3xxRedirection()) {
String redirectUrl = response.headers().header("Location").get(0);
/**
* TODO
* In case the redirected URL is not absolute (complete), create the new URL using the relative path
* This particular scenario is seen in the URL : https://rickandmortyapi.com/api/character
* It redirects to partial URI : /api/character/
* In this scenario we should convert the partial URI to complete URI
*/
URI redirectUri = null;
try {
redirectUri = new URI(redirectUrl);
} catch (URISyntaxException e) {
e.printStackTrace();
}
return httpCall(webClient, httpMethod, redirectUri, requestBody, iteration + 1);
}
return Mono.just(response);
});
}
@Override
public Mono<Void> datasourceCreate(DatasourceConfiguration datasourceConfiguration) {
return Mono.empty();
}
@Override
public void datasourceDestroy(Void connection) {
}
@Override
public Set<String> validateDatasource(DatasourceConfiguration datasourceConfiguration) {
// Since the datasource is created by rapid api & not by the user and it can't be edited.
// Assume that everything is good. Return as valid.
return Collections.emptySet();
}
@Override
public Mono<DatasourceTestResult> testDatasource(DatasourceConfiguration datasourceConfiguration) {
return StringUtils.isEmpty(RAPID_API_KEY_VALUE)
? Mono.error(new AppsmithPluginException(AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, "RapidAPI Key value " +
"not set."))
: Mono.just(new DatasourceTestResult());
}
private void addHeadersToRequest(WebClient.Builder webClientBuilder, List<Property> headers) {
for (Property header : headers) {
if (header.getKey() != null && !header.getKey().isEmpty()) {
webClientBuilder.defaultHeader(header.getKey(), (String) header.getValue());
}
}
}
private String addHttpToUrlWhenPrefixNotPresent(String url) {
if (url == null || url.toLowerCase().startsWith("http") || url.contains("://")) {
return url;
}
return "http://" + url;
}
private URI createFinalUriWithQueryParams(String url, List<Property> queryParams) throws URISyntaxException {
UriComponentsBuilder uriBuilder = UriComponentsBuilder.newInstance();
uriBuilder.uri(new URI(url));
if (queryParams != null) {
for (Property queryParam : queryParams) {
// If either the key or the value is empty, skip
if (queryParam.getKey() != null && !queryParam.getKey().isEmpty() &&
queryParam.getValue() != null && !((String) queryParam.getValue()).isEmpty()) {
uriBuilder.queryParam(queryParam.getKey(), URLEncoder.encode((String) queryParam.getValue(),
StandardCharsets.UTF_8));
}
}
}
return uriBuilder.build(true).toUri();
}
/**
* TODO :
* Add a function which is called during import of a template to an action. As part of that do the following :
* 1. Get the provider and the template
* 2. Check if the provider is subscribed to, and if not, subscribe.
* 3. Set Property field isRedacted for fields like host, etc. These fields in turn would not be displayed to
* the user during GET Actions.
*/
}
}

View File

@ -1,15 +0,0 @@
{
"form": [
{
"sectionName": "General",
"id": 1,
"children": [
{
"label": "Rapid Api Connection Name",
"configProperty": "connectionName",
"controlType": "INPUT_TEXT"
}
]
}
]
}

View File

@ -1,18 +0,0 @@
package com.external.plugins;
import org.junit.Test;
import static org.junit.Assert.assertTrue;
/**
* Unit test for simple App.
*/
public class RapidApiPluginTest {
/**
* Rigorous Test :-)
*/
@Test
public void shouldAnswerWithTrue() {
assertTrue(true);
}
}

View File

@ -4822,7 +4822,7 @@ public class DatabaseChangelog {
* @param action
* @return true / false
*/
private boolean hasUnpublishedActionConfiguration(NewAction action) {
public boolean hasUnpublishedActionConfiguration(NewAction action) {
ActionDTO unpublishedAction = action.getUnpublishedAction();
if (unpublishedAction == null || unpublishedAction.getActionConfiguration() == null) {
return false;
@ -4837,7 +4837,7 @@ public class DatabaseChangelog {
* @param mongockTemplate
* @return action
*/
private NewAction fetchActionUsingId(String actionId, MongockTemplate mongockTemplate) {
public static NewAction fetchActionUsingId(String actionId, MongockTemplate mongockTemplate) {
final NewAction action =
mongockTemplate.findOne(query(where(fieldName(QNewAction.newAction.id)).is(actionId)), NewAction.class);
return action;
@ -4848,7 +4848,7 @@ public class DatabaseChangelog {
* @param plugin
* @return query
*/
private Query getQueryToFetchAllPluginActionsWhichAreNotDeleted(Plugin plugin) {
public static Query getQueryToFetchAllPluginActionsWhichAreNotDeleted(Plugin plugin) {
Criteria pluginIdMatchesSuppliedPluginId = where("pluginId").is(plugin.getId());
Criteria isNotDeleted = where("deleted").ne(true);
return query((new Criteria()).andOperator(pluginIdMatchesSuppliedPluginId, isNotDeleted));

View File

@ -62,6 +62,7 @@ import com.github.cloudyrock.mongock.ChangeLog;
import com.github.cloudyrock.mongock.ChangeSet;
import com.github.cloudyrock.mongock.driver.mongodb.springdata.v3.decorator.impl.MongockTemplate;
import com.google.gson.Gson;
import com.querydsl.core.types.Path;
import io.changock.migration.api.annotations.NonLockGuarded;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
@ -2593,4 +2594,109 @@ public class DatabaseChangelog2 {
.findOne(query(where("packageName").is("graphql-plugin")), Plugin.class);
installPluginToAllWorkspaces(mongoTemplate, graphQLPlugin.getId());
}
public void softDeletePlugin(MongockTemplate mongockTemplate, Plugin plugin) {
softDeleteAllPluginActions(plugin, mongockTemplate);
softDeleteAllPluginDatasources(plugin, mongockTemplate);
softDeletePluginFromAllWorkspaces(plugin, mongockTemplate);
softDeleteInPluginCollection(plugin, mongockTemplate);
}
@ChangeSet(order = "038", id = "delete-rapid-api-plugin-related-items", author = "")
public void deleteRapidApiPluginRelatedItems(MongockTemplate mongockTemplate) {
Plugin rapidApiPlugin = mongockTemplate.findOne(query(where("packageName").is("rapidapi-plugin")),
Plugin.class);
if (rapidApiPlugin == null) {
return;
}
softDeletePlugin(mongockTemplate, rapidApiPlugin);
}
private void softDeletePluginFromAllWorkspaces(Plugin plugin, MongockTemplate mongockTemplate) {
Query queryToGetNonDeletedWorkspaces = new Query();
queryToGetNonDeletedWorkspaces.fields().include(fieldName(QWorkspace.workspace.id));
List<Workspace> workspaces = mongockTemplate.find(queryToGetNonDeletedWorkspaces, Workspace.class);
workspaces.stream()
.map(Workspace::getId)
.map(id -> fetchDomainObjectUsingId(id, mongockTemplate, QWorkspace.workspace.id, Workspace.class))
.forEachOrdered(workspace -> {
workspace.getPlugins().stream()
.filter(workspacePlugin -> workspacePlugin != null && workspacePlugin.getPluginId() != null)
.filter(workspacePlugin -> workspacePlugin.getPluginId().equals(plugin.getId()))
.forEach(workspacePlugin -> {
workspacePlugin.setDeleted(true);
workspacePlugin.setDeletedAt(Instant.now());
});
mongockTemplate.save(workspace);
});
}
private void softDeleteInPluginCollection(Plugin plugin, MongockTemplate mongockTemplate) {
plugin.setDeleted(true);
plugin.setDeletedAt(Instant.now());
mongockTemplate.save(plugin);
}
private void softDeleteAllPluginDatasources(Plugin plugin, MongockTemplate mongockTemplate) {
/* Query to get all plugin datasources which are not deleted */
Query queryToGetDatasources = getQueryToFetchAllDomainObjectsWhichAreNotDeletedUsingPluginId(plugin);
/* Update the previous query to only include id field */
queryToGetDatasources.fields().include(fieldName(QDatasource.datasource.id));
/* Fetch plugin datasources using the previous query */
List<Datasource> datasources = mongockTemplate.find(queryToGetDatasources, Datasource.class);
/* Mark each selected datasource as deleted */
updateDeleteAndDeletedAtFieldsForEachDomainObject(datasources, mongockTemplate,
QDatasource.datasource.id, Datasource.class);
}
private void softDeleteAllPluginActions(Plugin plugin, MongockTemplate mongockTemplate) {
/* Query to get all plugin actions which are not deleted */
Query queryToGetActions = getQueryToFetchAllDomainObjectsWhichAreNotDeletedUsingPluginId(plugin);
/* Update the previous query to only include id field */
queryToGetActions.fields().include(fieldName(QNewAction.newAction.id));
/* Fetch plugin actions using the previous query */
List<NewAction> actions = mongockTemplate.find(queryToGetActions, NewAction.class);
/* Mark each selected action as deleted */
updateDeleteAndDeletedAtFieldsForEachDomainObject(actions, mongockTemplate, QNewAction.newAction.id,
NewAction.class);
}
private Query getQueryToFetchAllDomainObjectsWhichAreNotDeletedUsingPluginId(Plugin plugin) {
Criteria pluginIdMatchesSuppliedPluginId = where("pluginId").is(plugin.getId());
Criteria isNotDeleted = where("deleted").ne(true);
return query((new Criteria()).andOperator(pluginIdMatchesSuppliedPluginId, isNotDeleted));
}
private <T extends BaseDomain> void updateDeleteAndDeletedAtFieldsForEachDomainObject(List<? extends BaseDomain> domainObjects,
MongockTemplate mongockTemplate, Path path,
Class<T> type) {
domainObjects.stream()
.map(BaseDomain::getId) // iterate over id one by one
.map(id -> fetchDomainObjectUsingId(id, mongockTemplate, path, type)) // find object using id
.forEachOrdered(domainObject -> {
domainObject.setDeleted(true);
domainObject.setDeletedAt(Instant.now());
mongockTemplate.save(domainObject);
});
}
/**
* Here 'id' refers to the ObjectId which is used to uniquely identify each Mongo document. 'path' refers to the
* path in the Query DSL object that indicates which field in a document should be matched against the `id`.
* `type` is a POJO class type that indicates which collection we are interested in. eg. path=QNewAction
* .newAction.id, type=NewAction.class
*/
private <T extends BaseDomain> T fetchDomainObjectUsingId(String id, MongockTemplate mongockTemplate, Path path,
Class<T> type) {
final T domainObject = mongockTemplate.findOne(query(where(fieldName(path)).is(id)), type);
return domainObject;
}
}

View File

@ -133,6 +133,7 @@ public class PluginServiceCEImpl extends BaseService<PluginRepository, Plugin, S
List<String> pluginIds = org.getPlugins()
.stream()
.filter(plugin -> plugin.getDeleted() == false)
.map(WorkspacePlugin::getPluginId)
.collect(Collectors.toList());
Query query = new Query();

View File

@ -51,10 +51,6 @@ sentry.send-default-pii=true
sentry.debug=off
sentry.environment=${APPSMITH_SENTRY_ENVIRONMENT:}
# RapidAPI
rapidapi.key.name = X-RapidAPI-Key
rapidapi.key.value = ${APPSMITH_RAPID_API_KEY_VALUE:}
# Redis Properties
spring.redis.url=${APPSMITH_REDIS_URL}