diff --git a/app/server/appsmith-plugins/pom.xml b/app/server/appsmith-plugins/pom.xml index 26b5c96881ff..6edec0c894ae 100644 --- a/app/server/appsmith-plugins/pom.xml +++ b/app/server/appsmith-plugins/pom.xml @@ -40,6 +40,7 @@ appsmithAiPlugin awsLambdaPlugin databricksPlugin + seaTablePlugin diff --git a/app/server/appsmith-plugins/seaTablePlugin/pom.xml b/app/server/appsmith-plugins/seaTablePlugin/pom.xml new file mode 100644 index 000000000000..36a52e7b2927 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/pom.xml @@ -0,0 +1,71 @@ + + + + appsmith-plugins + com.appsmith + 1.0-SNAPSHOT + + 4.0.0 + + seatable-plugin + 1.0-SNAPSHOT + jar + + + UTF-8 + seatable-plugin + com.external.plugins.SeaTablePlugin + 1.0-SNAPSHOT + tech@appsmith.com + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + false + + + + ${plugin.id} + ${plugin.class} + ${plugin.version} + ${plugin.provider} + + + + + + + package + + shade + + + + + + maven-dependency-plugin + + + copy-dependencies + package + + copy-dependencies + + + runtime + ${project.build.directory}/lib + + + + + + + diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/constants/FieldName.java b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/constants/FieldName.java new file mode 100644 index 000000000000..fa2d3035110c --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/constants/FieldName.java @@ -0,0 +1,19 @@ +package com.external.constants; + +public final class FieldName { + private FieldName() { + // Utility class - prevent instantiation + } + + public static final String COMMAND = "command"; + public static final String TABLE_NAME = "tableName"; + public static final String ROW_ID = "rowId"; + public static final String BODY = "body"; + public static final String WHERE = "where"; + public static final String ORDER_BY = "orderBy"; + public static final String DIRECTION = "direction"; + public static final String LIMIT = "limit"; + public static final String OFFSET = "offset"; + public static final String SQL = "sql"; + public static final String SMART_SUBSTITUTION = "smartSubstitution"; +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java new file mode 100644 index 000000000000..865cc771cc25 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java @@ -0,0 +1,771 @@ +package com.external.plugins; + +import com.appsmith.external.dtos.ExecuteActionDTO; +import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; +import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; +import com.appsmith.external.helpers.DataTypeStringUtils; +import com.appsmith.external.helpers.MustacheHelper; +import com.appsmith.external.helpers.PluginUtils; +import com.appsmith.external.models.ActionConfiguration; +import com.appsmith.external.models.ActionExecutionResult; +import com.appsmith.external.models.DBAuth; +import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.external.models.DatasourceStructure; +import com.appsmith.external.models.DatasourceTestResult; +import com.appsmith.external.models.MustacheBindingToken; +import com.appsmith.external.models.Param; +import com.appsmith.external.plugins.BasePlugin; +import com.appsmith.external.plugins.PluginExecutor; +import com.appsmith.external.plugins.SmartSubstitutionInterface; +import com.appsmith.util.WebClientUtils; +import com.external.plugins.exceptions.SeaTableErrorMessages; +import com.external.plugins.exceptions.SeaTablePluginError; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.pf4j.Extension; +import org.pf4j.PluginWrapper; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.appsmith.external.helpers.PluginUtils.STRING_TYPE; +import static com.appsmith.external.helpers.PluginUtils.getDataValueSafelyFromFormData; +import static com.appsmith.external.helpers.PluginUtils.setDataValueSafelyInFormData; +import static com.external.constants.FieldName.BODY; +import static com.external.constants.FieldName.COMMAND; +import static com.external.constants.FieldName.DIRECTION; +import static com.external.constants.FieldName.LIMIT; +import static com.external.constants.FieldName.OFFSET; +import static com.external.constants.FieldName.ORDER_BY; +import static com.external.constants.FieldName.ROW_ID; +import static com.external.constants.FieldName.SMART_SUBSTITUTION; +import static com.external.constants.FieldName.SQL; +import static com.external.constants.FieldName.TABLE_NAME; +import static java.lang.Boolean.TRUE; + +/** + * SeaTable plugin for Appsmith. + * + *

SeaTable API flow: + *

    + *
  1. Exchange API-Token for a Base-Token (access_token) via GET /api/v2.1/dtable/app-access-token/ + * Response includes: access_token, dtable_uuid, dtable_server
  2. + *
  3. All row/metadata/sql operations use the dtable_server URL: + * {dtable_server}/api/v2/dtables/{dtable_uuid}/... + * with header: Authorization: Token {access_token}
  4. + *
+ * + * @see SeaTable API Reference + */ +public class SeaTablePlugin extends BasePlugin { + + private static final ExchangeStrategies EXCHANGE_STRATEGIES = ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(/* 10MB */ 10 * 1024 * 1024)) + .build(); + + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + + public SeaTablePlugin(PluginWrapper wrapper) { + super(wrapper); + } + + @Slf4j + @Extension + public static class SeaTablePluginExecutor implements PluginExecutor, SmartSubstitutionInterface { + + private final Scheduler scheduler = Schedulers.boundedElastic(); + + /** + * Holds the result of the access token exchange. + * The basePath is pre-computed as {dtableServer}/api/v2/dtables/{dtableUuid} + * so that command methods can simply append their endpoint path. + */ + private record AccessTokenResponse(String accessToken, String basePath) {} + + /** + * @deprecated Use {@link #executeParameterized} instead. + */ + @Override + @Deprecated + public Mono execute( + Void connection, + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.QUERY_EXECUTION_FAILED, "Unsupported Operation")); + } + + @Override + public Object substituteValueInInput( + int index, + String binding, + String value, + Object input, + List> insertedParams, + Object... args) { + String jsonBody = (String) input; + Param param = (Param) args[0]; + return DataTypeStringUtils.jsonSmartReplacementPlaceholderWithValue( + jsonBody, value, null, insertedParams, null, param); + } + + /** + * Main entry point for query execution. Handles smart JSON substitution + * for the body field, then delegates to {@link #executeQuery}. + */ + @Override + public Mono executeParameterized( + Void connection, + ExecuteActionDTO executeActionDTO, + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + + log.debug(Thread.currentThread().getName() + + ": executeParameterized() called for SeaTable plugin."); + + final Map formData = actionConfiguration.getFormData(); + + // Handle smart substitution for the body field + boolean smartJsonSubstitution = TRUE; + Object smartSubObj = formData != null ? formData.getOrDefault(SMART_SUBSTITUTION, TRUE) : TRUE; + if (smartSubObj instanceof Boolean) { + smartJsonSubstitution = (Boolean) smartSubObj; + } else if (smartSubObj instanceof String) { + smartJsonSubstitution = Boolean.parseBoolean((String) smartSubObj); + } + + List> parameters = new ArrayList<>(); + if (TRUE.equals(smartJsonSubstitution)) { + String body = getDataValueSafelyFromFormData(formData, BODY, STRING_TYPE); + if (body != null) { + List mustacheKeysInOrder = + MustacheHelper.extractMustacheKeysInOrder(body); + String updatedBody = + MustacheHelper.replaceMustacheWithPlaceholder(body, mustacheKeysInOrder); + + try { + List params = executeActionDTO.getParams(); + if (params == null) { + params = new ArrayList<>(); + } + updatedBody = (String) smartSubstitutionOfBindings( + updatedBody, + mustacheKeysInOrder, + params, + parameters); + } catch (AppsmithPluginException e) { + ActionExecutionResult errorResult = new ActionExecutionResult(); + errorResult.setIsExecutionSuccess(false); + errorResult.setErrorInfo(e); + return Mono.just(errorResult); + } + + setDataValueSafelyInFormData(formData, BODY, updatedBody); + } + } + + prepareConfigurationsForExecution(executeActionDTO, actionConfiguration, datasourceConfiguration); + + return this.executeQuery(datasourceConfiguration, actionConfiguration); + } + + /** + * Dispatches the query to the appropriate command handler based on the + * selected command in the form data. + */ + private Mono executeQuery( + DatasourceConfiguration datasourceConfiguration, + ActionConfiguration actionConfiguration) { + + final Map formData = actionConfiguration.getFormData(); + final String command = getDataValueSafelyFromFormData(formData, COMMAND, STRING_TYPE); + + if (StringUtils.isBlank(command)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_COMMAND_ERROR_MSG)); + } + + // Fail fast on unsupported commands before making any network calls + Set supportedCommands = Set.of( + "LIST_ROWS", "GET_ROW", "CREATE_ROW", "UPDATE_ROW", + "DELETE_ROW", "LIST_TABLES", "SQL_QUERY"); + if (!supportedCommands.contains(command)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + "Unknown command: " + command)); + } + + // Validate required fields before making any network calls + return validateCommandInputs(command, formData) + .then(fetchAccessToken(datasourceConfiguration) + .flatMap(tokenResponse -> { + String basePath = tokenResponse.basePath(); + String accessToken = tokenResponse.accessToken(); + + return switch (command) { + case "LIST_ROWS" -> executeListRows(basePath, accessToken, formData); + case "GET_ROW" -> executeGetRow(basePath, accessToken, formData); + case "CREATE_ROW" -> executeCreateRow(basePath, accessToken, formData); + case "UPDATE_ROW" -> executeUpdateRow(basePath, accessToken, formData); + case "DELETE_ROW" -> executeDeleteRow(basePath, accessToken, formData); + case "LIST_TABLES" -> executeListTables(basePath, accessToken); + case "SQL_QUERY" -> executeSqlQuery(basePath, accessToken, formData); + default -> Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + "Unknown command: " + command)); + }; + })); + } + + /** + * Exchanges the API-Token for a Base-Token (access token). + * + *

Calls GET {serverUrl}/api/v2.1/dtable/app-access-token/ with the API token. + * The response always includes access_token, dtable_uuid, and dtable_server. + * The dtable_server URL already includes /api-gateway/. + * + * @param datasourceConfiguration the datasource config containing server URL and API token + * @return an {@link AccessTokenResponse} with the access token and pre-computed base path + */ + private Mono fetchAccessToken(DatasourceConfiguration datasourceConfiguration) { + if (datasourceConfiguration.getUrl() == null || datasourceConfiguration.getUrl().isBlank()) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.ACCESS_TOKEN_ERROR, + SeaTableErrorMessages.MISSING_SERVER_URL_ERROR_MSG)); + } + if (datasourceConfiguration.getAuthentication() == null + || !(datasourceConfiguration.getAuthentication() instanceof DBAuth auth) + || StringUtils.isBlank(auth.getPassword())) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.ACCESS_TOKEN_ERROR, + SeaTableErrorMessages.MISSING_API_TOKEN_ERROR_MSG)); + } + + String serverUrl = datasourceConfiguration.getUrl().trim(); + String apiToken = auth.getPassword(); + + if (serverUrl.endsWith("/")) { + serverUrl = serverUrl.substring(0, serverUrl.length() - 1); + } + + WebClient client = WebClientUtils.builder() + .exchangeStrategies(EXCHANGE_STRATEGIES) + .build(); + + final String url = serverUrl + "/api/v2.1/dtable/app-access-token/"; + + return client + .get() + .uri(URI.create(url)) + .header("Authorization", "Token " + apiToken) + .header("Accept", MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .bodyToMono(byte[].class) + .timeout(REQUEST_TIMEOUT) + .map(responseBytes -> { + try { + JsonNode json = objectMapper.readTree(responseBytes); + + JsonNode accessTokenNode = json.get("access_token"); + JsonNode dtableUuidNode = json.get("dtable_uuid"); + JsonNode dtableServerNode = json.get("dtable_server"); + + if (accessTokenNode == null || dtableUuidNode == null || dtableServerNode == null) { + throw Exceptions.propagate(new AppsmithPluginException( + SeaTablePluginError.ACCESS_TOKEN_ERROR, + SeaTableErrorMessages.ACCESS_TOKEN_FETCH_FAILED_ERROR_MSG)); + } + + String accessToken = accessTokenNode.asText(); + String dtableUuid = dtableUuidNode.asText(); + String dtableServer = dtableServerNode.asText(); + + // dtable_server is e.g. "https://cloud.seatable.io/api-gateway/" + // Build the base path for all subsequent API calls + if (!dtableServer.endsWith("/")) { + dtableServer = dtableServer + "/"; + } + String basePath = dtableServer + "api/v2/dtables/" + dtableUuid; + + return new AccessTokenResponse(accessToken, basePath); + } catch (IOException e) { + throw Exceptions.propagate(new AppsmithPluginException( + SeaTablePluginError.ACCESS_TOKEN_ERROR, + SeaTableErrorMessages.ACCESS_TOKEN_FETCH_FAILED_ERROR_MSG)); + } + }) + .onErrorResume(e -> { + if (e instanceof AppsmithPluginException) { + return Mono.error(e); + } + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.ACCESS_TOKEN_ERROR, + SeaTableErrorMessages.ACCESS_TOKEN_FETCH_FAILED_ERROR_MSG)); + }) + .subscribeOn(scheduler); + } + + /** + * Builds an HTTP request against the SeaTable API without a request body. + */ + private WebClient.RequestHeadersSpec buildRequest( + String basePath, String accessToken, HttpMethod method, String path) { + return buildRequest(basePath, accessToken, method, path, null); + } + + /** + * Builds an HTTP request against the SeaTable API with an optional JSON request body. + */ + private WebClient.RequestHeadersSpec buildRequest( + String basePath, String accessToken, HttpMethod method, String path, String body) { + + WebClient client = WebClientUtils.builder() + .exchangeStrategies(EXCHANGE_STRATEGIES) + .build(); + + String url = basePath + path; + + WebClient.RequestBodySpec requestSpec = client + .method(method) + .uri(URI.create(url)) + .header("Authorization", "Token " + accessToken) + .header("Accept", MediaType.APPLICATION_JSON_VALUE); + + if (body != null) { + return requestSpec + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(body)); + } + + return requestSpec; + } + + /** + * Executes an HTTP request and maps the response to an {@link ActionExecutionResult}. + * Applies a timeout and handles errors uniformly. + */ + private Mono executeRequest(WebClient.RequestHeadersSpec requestSpec) { + return requestSpec + .retrieve() + .bodyToMono(byte[].class) + .timeout(REQUEST_TIMEOUT) + .map(responseBytes -> { + ActionExecutionResult result = new ActionExecutionResult(); + result.setIsExecutionSuccess(true); + try { + JsonNode jsonBody = objectMapper.readTree(responseBytes); + result.setBody(jsonBody); + } catch (IOException e) { + result.setBody(new String(responseBytes)); + } + return result; + }) + .onErrorResume(e -> { + ActionExecutionResult errorResult = new ActionExecutionResult(); + errorResult.setIsExecutionSuccess(false); + errorResult.setErrorInfo(new AppsmithPluginException( + SeaTablePluginError.QUERY_EXECUTION_FAILED, + String.format( + SeaTableErrorMessages.QUERY_EXECUTION_FAILED_ERROR_MSG, + e.getMessage()))); + return Mono.just(errorResult); + }) + .subscribeOn(scheduler); + } + + /** + * Validates required form fields for a command before making network calls. + * Returns a Mono.error if validation fails, or Mono.empty() if validation passes. + */ + private Mono validateCommandInputs(String command, Map formData) { + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); + String sql = getDataValueSafelyFromFormData(formData, SQL, STRING_TYPE, ""); + + switch (command) { + case "LIST_ROWS": + case "CREATE_ROW": + if (StringUtils.isBlank(tableName)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_TABLE_NAME_ERROR_MSG)); + } + break; + case "GET_ROW": + case "UPDATE_ROW": + case "DELETE_ROW": + if (StringUtils.isBlank(tableName)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_TABLE_NAME_ERROR_MSG)); + } + if (StringUtils.isBlank(rowId)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_ROW_ID_ERROR_MSG)); + } + break; + case "SQL_QUERY": + if (StringUtils.isBlank(sql)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_SQL_ERROR_MSG)); + } + break; + default: + break; + } + return Mono.empty(); + } + + // --- Command implementations --- + + /** + * Lists rows from a table. + * GET /api/v2/dtables/{base_uuid}/rows/?table_name=X&convert_keys=true&limit=N&start=N&order_by=col&direction=asc + */ + private Mono executeListRows( + String basePath, String accessToken, Map formData) { + + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + + StringBuilder pathBuilder = new StringBuilder("/rows/"); + pathBuilder.append("?table_name=").append(PluginUtils.urlEncode(tableName)); + pathBuilder.append("&convert_keys=true"); + + String limit = getDataValueSafelyFromFormData(formData, LIMIT, STRING_TYPE, ""); + if (StringUtils.isNotBlank(limit)) { + pathBuilder.append("&limit=").append(PluginUtils.urlEncode(limit)); + } + + String offset = getDataValueSafelyFromFormData(formData, OFFSET, STRING_TYPE, ""); + if (StringUtils.isNotBlank(offset)) { + pathBuilder.append("&start=").append(PluginUtils.urlEncode(offset)); + } + + String orderBy = getDataValueSafelyFromFormData(formData, ORDER_BY, STRING_TYPE, ""); + if (StringUtils.isNotBlank(orderBy)) { + pathBuilder.append("&order_by=").append(PluginUtils.urlEncode(orderBy)); + String direction = getDataValueSafelyFromFormData(formData, DIRECTION, STRING_TYPE, "asc"); + pathBuilder.append("&direction=").append(PluginUtils.urlEncode(direction)); + // direction only works when start and limit are set too + if (StringUtils.isBlank(limit)) { + pathBuilder.append("&limit=1000"); + } + if (StringUtils.isBlank(offset)) { + pathBuilder.append("&start=0"); + } + } + + return executeRequest(buildRequest(basePath, accessToken, HttpMethod.GET, pathBuilder.toString())); + } + + /** + * Gets a single row by ID. + * GET /api/v2/dtables/{base_uuid}/rows/{row_id}/?table_name=X&convert_keys=true + */ + private Mono executeGetRow( + String basePath, String accessToken, Map formData) { + + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); + + String path = "/rows/" + PluginUtils.urlEncode(rowId) + + "/?table_name=" + PluginUtils.urlEncode(tableName) + + "&convert_keys=true"; + + return executeRequest(buildRequest(basePath, accessToken, HttpMethod.GET, path)); + } + + /** + * Creates a new row in a table. + * POST /api/v2/dtables/{base_uuid}/rows/ + * Body: { "table_name": "X", "rows": [{ "col": "val", ... }] } + */ + private Mono executeCreateRow( + String basePath, String accessToken, Map formData) { + + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + String body = getDataValueSafelyFromFormData(formData, BODY, STRING_TYPE, ""); + + String requestBody; + try { + JsonNode rowData = objectMapper.readTree(StringUtils.isBlank(body) ? "{}" : body); + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("table_name", tableName); + ArrayNode rowsArray = objectMapper.createArrayNode(); + rowsArray.add(rowData); + wrapper.set("rows", rowsArray); + requestBody = objectMapper.writeValueAsString(wrapper); + } catch (JsonProcessingException e) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.INVALID_BODY_ERROR, + "Invalid JSON in row object: " + e.getMessage())); + } + + return executeRequest( + buildRequest(basePath, accessToken, HttpMethod.POST, "/rows/", requestBody)); + } + + /** + * Updates an existing row. + * PUT /api/v2/dtables/{base_uuid}/rows/ + * Body: { "table_name": "X", "updates": [{ "row_id": "...", "row": { "col": "val" } }] } + */ + private Mono executeUpdateRow( + String basePath, String accessToken, Map formData) { + + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); + String body = getDataValueSafelyFromFormData(formData, BODY, STRING_TYPE, ""); + + String requestBody; + try { + JsonNode rowData = objectMapper.readTree(StringUtils.isBlank(body) ? "{}" : body); + + ObjectNode updateEntry = objectMapper.createObjectNode(); + updateEntry.put("row_id", rowId); + updateEntry.set("row", rowData); + + ArrayNode updatesArray = objectMapper.createArrayNode(); + updatesArray.add(updateEntry); + + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("table_name", tableName); + wrapper.set("updates", updatesArray); + requestBody = objectMapper.writeValueAsString(wrapper); + } catch (JsonProcessingException e) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.INVALID_BODY_ERROR, + "Invalid JSON in row object: " + e.getMessage())); + } + + return executeRequest( + buildRequest(basePath, accessToken, HttpMethod.PUT, "/rows/", requestBody)); + } + + /** + * Deletes a row from a table. + * DELETE /api/v2/dtables/{base_uuid}/rows/ + * Body: { "table_name": "X", "row_ids": ["row_id_1"] } + */ + private Mono executeDeleteRow( + String basePath, String accessToken, Map formData) { + + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); + + String requestBody; + try { + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("table_name", tableName); + ArrayNode rowIdsArray = objectMapper.createArrayNode(); + rowIdsArray.add(rowId); + wrapper.set("row_ids", rowIdsArray); + requestBody = objectMapper.writeValueAsString(wrapper); + } catch (JsonProcessingException e) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.INVALID_BODY_ERROR, + "Failed to build delete request: " + e.getMessage())); + } + + return executeRequest( + buildRequest(basePath, accessToken, HttpMethod.DELETE, "/rows/", requestBody)); + } + + /** + * Lists all tables and their columns (metadata) in the connected base. + * GET /api/v2/dtables/{base_uuid}/metadata/ + */ + private Mono executeListTables(String basePath, String accessToken) { + return executeRequest( + buildRequest(basePath, accessToken, HttpMethod.GET, "/metadata/")); + } + + /** + * Executes a SQL query against the base. + * POST /api/v2/dtables/{base_uuid}/sql/ + * Body: { "sql": "SELECT ...", "convert_keys": true } + */ + private Mono executeSqlQuery( + String basePath, String accessToken, Map formData) { + + String sql = getDataValueSafelyFromFormData(formData, SQL, STRING_TYPE, ""); + + String requestBody; + try { + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("sql", sql); + wrapper.put("convert_keys", true); + requestBody = objectMapper.writeValueAsString(wrapper); + } catch (JsonProcessingException e) { + return Mono.error(new AppsmithPluginException( + SeaTablePluginError.INVALID_BODY_ERROR, + "Failed to build SQL request: " + e.getMessage())); + } + + return executeRequest( + buildRequest(basePath, accessToken, HttpMethod.POST, "/sql/", requestBody)); + } + + // --- Datasource lifecycle --- + + /** + * SeaTable is stateless HTTP - no persistent connection to create. + */ + @Override + public Mono datasourceCreate(DatasourceConfiguration datasourceConfiguration) { + return Mono.empty().then(); + } + + /** + * Nothing to destroy for stateless HTTP connections. + */ + @Override + public void datasourceDestroy(Void connection) { + // Nothing to destroy for stateless HTTP + } + + /** + * Validates the datasource configuration by checking that the server URL + * and API token are present and well-formed. + * + * @param datasourceConfiguration the config to validate + * @return a set of validation error messages (empty if valid) + */ + @Override + public Set validateDatasource(DatasourceConfiguration datasourceConfiguration) { + Set invalids = new HashSet<>(); + + if (StringUtils.isBlank(datasourceConfiguration.getUrl())) { + invalids.add(SeaTableErrorMessages.MISSING_SERVER_URL_ERROR_MSG); + } else { + String url = datasourceConfiguration.getUrl().trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + invalids.add(SeaTableErrorMessages.INVALID_SERVER_URL_ERROR_MSG); + } + } + + if (datasourceConfiguration.getAuthentication() == null + || !(datasourceConfiguration.getAuthentication() instanceof DBAuth) + || StringUtils.isBlank(((DBAuth) datasourceConfiguration.getAuthentication()).getPassword())) { + invalids.add(SeaTableErrorMessages.MISSING_API_TOKEN_ERROR_MSG); + } + + return invalids; + } + + /** + * Tests the datasource by attempting to fetch an access token. + * If the token exchange succeeds, the datasource is valid. + */ + @Override + public Mono testDatasource(DatasourceConfiguration datasourceConfiguration) { + return fetchAccessToken(datasourceConfiguration) + .map(tokenResponse -> new DatasourceTestResult()) + .onErrorResume(error -> { + String errorMessage = error.getMessage() == null + ? SeaTableErrorMessages.ACCESS_TOKEN_FETCH_FAILED_ERROR_MSG + : error.getMessage(); + return Mono.just(new DatasourceTestResult(errorMessage)); + }); + } + + /** + * Fetches the structure (tables and columns) of the connected base + * by calling the metadata endpoint. Used for schema discovery in the Appsmith UI. + */ + @Override + public Mono getStructure( + Void connection, DatasourceConfiguration datasourceConfiguration) { + + return fetchAccessToken(datasourceConfiguration) + .flatMap(tokenResponse -> buildRequest( + tokenResponse.basePath(), + tokenResponse.accessToken(), + HttpMethod.GET, + "/metadata/") + .retrieve() + .bodyToMono(byte[].class) + .timeout(REQUEST_TIMEOUT)) + .map(responseBytes -> { + DatasourceStructure structure = new DatasourceStructure(); + List tables = new ArrayList<>(); + structure.setTables(tables); + + try { + JsonNode json = objectMapper.readTree(responseBytes); + JsonNode metadata = json.get("metadata"); + if (metadata == null) { + return structure; + } + JsonNode tablesNode = metadata.get("tables"); + if (tablesNode == null || !tablesNode.isArray()) { + return structure; + } + + for (JsonNode tableNode : tablesNode) { + if (!tableNode.hasNonNull("name")) { + log.warn("Skipping table entry with missing name"); + continue; + } + String tableName = tableNode.get("name").asText(); + List columns = new ArrayList<>(); + + JsonNode columnsNode = tableNode.get("columns"); + if (columnsNode != null && columnsNode.isArray()) { + for (JsonNode colNode : columnsNode) { + if (!colNode.hasNonNull("name") || !colNode.hasNonNull("type")) { + log.warn("Skipping column entry with missing name or type in table: {}", + tableName); + continue; + } + String colName = colNode.get("name").asText(); + String colType = colNode.get("type").asText(); + columns.add(new DatasourceStructure.Column( + colName, colType, null, false)); + } + } + + tables.add(new DatasourceStructure.Table( + DatasourceStructure.TableType.TABLE, + null, + tableName, + columns, + new ArrayList<>(), + new ArrayList<>())); + } + } catch (IOException e) { + throw Exceptions.propagate(new AppsmithPluginException( + SeaTablePluginError.QUERY_EXECUTION_FAILED, + String.format( + SeaTableErrorMessages.QUERY_EXECUTION_FAILED_ERROR_MSG, + "Failed to parse SeaTable metadata response: " + e.getMessage()))); + } + + return structure; + }) + .subscribeOn(scheduler); + } + } +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTableErrorMessages.java b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTableErrorMessages.java new file mode 100644 index 000000000000..1167baa943c5 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTableErrorMessages.java @@ -0,0 +1,23 @@ +package com.external.plugins.exceptions; + +public class SeaTableErrorMessages { + private SeaTableErrorMessages() { + // Utility class - prevent instantiation + } + + public static final String MISSING_SERVER_URL_ERROR_MSG = "Missing SeaTable server URL."; + public static final String MISSING_API_TOKEN_ERROR_MSG = "Missing SeaTable API token."; + public static final String MISSING_COMMAND_ERROR_MSG = + "Missing command. Please select a command from the dropdown."; + public static final String MISSING_TABLE_NAME_ERROR_MSG = + "Missing table name. Please provide a table name."; + public static final String MISSING_ROW_ID_ERROR_MSG = + "Missing row ID. Please provide a row ID."; + public static final String MISSING_SQL_ERROR_MSG = + "Missing SQL query. Please provide a SQL query."; + public static final String QUERY_EXECUTION_FAILED_ERROR_MSG = "Query execution failed: %s"; + public static final String ACCESS_TOKEN_FETCH_FAILED_ERROR_MSG = + "Failed to fetch access token from SeaTable server. Please check your server URL and API token."; + public static final String INVALID_SERVER_URL_ERROR_MSG = + "Invalid server URL. The URL should start with http:// or https://."; +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTablePluginError.java b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTablePluginError.java new file mode 100644 index 000000000000..a03b4394819b --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTablePluginError.java @@ -0,0 +1,87 @@ +package com.external.plugins.exceptions; + +import com.appsmith.external.exceptions.AppsmithErrorAction; +import com.appsmith.external.exceptions.pluginExceptions.BasePluginError; +import com.appsmith.external.models.ErrorType; +import lombok.Getter; + +import java.text.MessageFormat; + +@Getter +public enum SeaTablePluginError implements BasePluginError { + QUERY_EXECUTION_FAILED( + 500, + "PE-STB-5000", + "{0}", + AppsmithErrorAction.LOG_EXTERNALLY, + "Query execution error", + ErrorType.INTERNAL_ERROR, + "{1}", + "{2}"), + ACCESS_TOKEN_ERROR( + 401, + "PE-STB-4001", + "{0}", + AppsmithErrorAction.LOG_EXTERNALLY, + "Authentication error", + ErrorType.AUTHENTICATION_ERROR, + "{1}", + "{2}"), + INVALID_BODY_ERROR( + 400, + "PE-STB-4000", + "{0}", + AppsmithErrorAction.DEFAULT, + "Invalid request body", + ErrorType.ARGUMENT_ERROR, + "{1}", + "{2}"); + + private final Integer httpErrorCode; + private final String appErrorCode; + private final String message; + private final AppsmithErrorAction errorAction; + private final String title; + private final ErrorType errorType; + private final String downstreamErrorMessage; + private final String downstreamErrorCode; + + SeaTablePluginError( + Integer httpErrorCode, + String appErrorCode, + String message, + AppsmithErrorAction errorAction, + String title, + ErrorType errorType, + String downstreamErrorMessage, + String downstreamErrorCode) { + this.httpErrorCode = httpErrorCode; + this.appErrorCode = appErrorCode; + this.message = message; + this.errorAction = errorAction; + this.title = title; + this.errorType = errorType; + this.downstreamErrorMessage = downstreamErrorMessage; + this.downstreamErrorCode = downstreamErrorCode; + } + + @Override + public String getMessage(Object... args) { + return new MessageFormat(this.message).format(args); + } + + @Override + public String getErrorType() { + return this.errorType.toString(); + } + + @Override + public String getDownstreamErrorMessage(Object... args) { + return replacePlaceholderWithValue(this.downstreamErrorMessage, args); + } + + @Override + public String getDownstreamErrorCode(Object... args) { + return replacePlaceholderWithValue(this.downstreamErrorCode, args); + } +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/createRow.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/createRow.json new file mode 100644 index 000000000000..13afdfd7fa9c --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/createRow.json @@ -0,0 +1,39 @@ +{ + "controlType": "SECTION_V2", + "identifier": "CREATE_ROW", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'CREATE_ROW'}}" + }, + "children": [ + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "CREATE-ROW-Z1", + "children": [ + { + "label": "Table Name", + "configProperty": "actionConfiguration.formData.tableName.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Table1", + "initialValue": "" + } + ] + }, + { + "controlType": "SINGLE_COLUMN_ZONE", + "identifier": "CREATE-ROW-Z2", + "children": [ + { + "label": "Row Object", + "configProperty": "actionConfiguration.formData.body.data", + "controlType": "QUERY_DYNAMIC_TEXT", + "evaluationSubstitutionType": "SMART_SUBSTITUTE", + "isRequired": true, + "placeholderText": "{\n \"Name\": \"New Entry\",\n \"Status\": \"Active\"\n}", + "initialValue": "" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/deleteRow.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/deleteRow.json new file mode 100644 index 000000000000..cbdab1a84714 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/deleteRow.json @@ -0,0 +1,33 @@ +{ + "controlType": "SECTION_V2", + "identifier": "DELETE_ROW", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'DELETE_ROW'}}" + }, + "children": [ + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "DELETE-ROW-Z1", + "children": [ + { + "label": "Table Name", + "configProperty": "actionConfiguration.formData.tableName.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Table1", + "initialValue": "" + }, + { + "label": "Row ID", + "configProperty": "actionConfiguration.formData.rowId.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Enter the row ID to delete", + "initialValue": "" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/getRow.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/getRow.json new file mode 100644 index 000000000000..e7383dca57a0 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/getRow.json @@ -0,0 +1,33 @@ +{ + "controlType": "SECTION_V2", + "identifier": "GET_ROW", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'GET_ROW'}}" + }, + "children": [ + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "GET-ROW-Z1", + "children": [ + { + "label": "Table Name", + "configProperty": "actionConfiguration.formData.tableName.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Table1", + "initialValue": "" + }, + { + "label": "Row ID", + "configProperty": "actionConfiguration.formData.rowId.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Enter the row ID", + "initialValue": "" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.json new file mode 100644 index 000000000000..f75376b3c7dd --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.json @@ -0,0 +1,133 @@ +{ + "controlType": "SECTION_V2", + "identifier": "LIST_ROWS", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'LIST_ROWS'}}" + }, + "children": [ + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "LIST-ROWS-Z1", + "children": [ + { + "label": "Table Name", + "configProperty": "actionConfiguration.formData.tableName.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Table1", + "initialValue": "" + } + ] + }, + { + "controlType": "SINGLE_COLUMN_ZONE", + "identifier": "LIST-ROWS-Z2", + "children": [ + { + "label": "Where", + "configProperty": "actionConfiguration.formData.where.data", + "nestedLevels": 1, + "controlType": "WHERE_CLAUSE", + "logicalTypes": [ + { + "label": "AND", + "value": "AND" + }, + { + "label": "OR", + "value": "OR" + } + ], + "comparisonTypes": [ + { + "label": "==", + "value": "EQ" + }, + { + "label": "!=", + "value": "NOT_EQ" + }, + { + "label": "<", + "value": "LT" + }, + { + "label": "<=", + "value": "LTE" + }, + { + "label": ">", + "value": "GT" + }, + { + "label": ">=", + "value": "GTE" + }, + { + "label": "contains", + "value": "CONTAINS" + } + ] + } + ] + }, + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "LIST-ROWS-Z3", + "children": [ + { + "label": "Order By", + "description": "Column name to sort by", + "configProperty": "actionConfiguration.formData.orderBy.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": false, + "placeholderText": "column_name", + "initialValue": "" + }, + { + "label": "Direction", + "configProperty": "actionConfiguration.formData.direction.data", + "controlType": "DROP_DOWN", + "isRequired": false, + "initialValue": "asc", + "options": [ + { + "label": "Ascending", + "value": "asc" + }, + { + "label": "Descending", + "value": "desc" + } + ] + } + ] + }, + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "LIST-ROWS-Z4", + "children": [ + { + "label": "Limit", + "configProperty": "actionConfiguration.formData.limit.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": false, + "placeholderText": "100", + "initialValue": "100" + }, + { + "label": "Offset", + "configProperty": "actionConfiguration.formData.offset.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": false, + "placeholderText": "0", + "initialValue": "0" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listTables.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listTables.json new file mode 100644 index 000000000000..79ed0a756c4c --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listTables.json @@ -0,0 +1,23 @@ +{ + "controlType": "SECTION_V2", + "identifier": "LIST_TABLES", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'LIST_TABLES'}}" + }, + "children": [ + { + "controlType": "SINGLE_COLUMN_ZONE", + "identifier": "LIST-TABLES-Z1", + "children": [ + { + "label": "", + "description": "This command returns all tables and their columns in the connected base. No additional parameters required.", + "configProperty": "actionConfiguration.formData._info.data", + "controlType": "INPUT_TEXT", + "hidden": true, + "initialValue": "" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/root.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/root.json new file mode 100644 index 000000000000..eda3e831032b --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/root.json @@ -0,0 +1,62 @@ +{ + "editor": [ + { + "controlType": "SECTION_V2", + "identifier": "SELECTOR", + "children": [ + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "SELECTOR-Z1", + "children": [ + { + "label": "Command", + "description": "Select the operation to perform", + "configProperty": "actionConfiguration.formData.command.data", + "controlType": "DROP_DOWN", + "initialValue": "LIST_ROWS", + "options": [ + { + "label": "List Rows", + "value": "LIST_ROWS" + }, + { + "label": "Get Row", + "value": "GET_ROW" + }, + { + "label": "Create Row", + "value": "CREATE_ROW" + }, + { + "label": "Update Row", + "value": "UPDATE_ROW" + }, + { + "label": "Delete Row", + "value": "DELETE_ROW" + }, + { + "label": "List Tables", + "value": "LIST_TABLES" + }, + { + "label": "SQL Query", + "value": "SQL_QUERY" + } + ] + } + ] + } + ] + } + ], + "files": [ + "listRows.json", + "getRow.json", + "createRow.json", + "updateRow.json", + "deleteRow.json", + "listTables.json", + "sqlQuery.json" + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/sqlQuery.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/sqlQuery.json new file mode 100644 index 000000000000..a6d28e76b8f8 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/sqlQuery.json @@ -0,0 +1,25 @@ +{ + "controlType": "SECTION_V2", + "identifier": "SQL_QUERY", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'SQL_QUERY'}}" + }, + "children": [ + { + "controlType": "SINGLE_COLUMN_ZONE", + "identifier": "SQL-QUERY-Z1", + "children": [ + { + "label": "SQL", + "description": "SeaTable supports a subset of SQL. See SeaTable docs for supported syntax.", + "configProperty": "actionConfiguration.formData.sql.data", + "controlType": "QUERY_DYNAMIC_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "SELECT * FROM Table1 WHERE Status = 'Active' LIMIT 100", + "initialValue": "" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/updateRow.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/updateRow.json new file mode 100644 index 000000000000..6b476237f51f --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/updateRow.json @@ -0,0 +1,48 @@ +{ + "controlType": "SECTION_V2", + "identifier": "UPDATE_ROW", + "conditionals": { + "show": "{{actionConfiguration.formData.command.data === 'UPDATE_ROW'}}" + }, + "children": [ + { + "controlType": "DOUBLE_COLUMN_ZONE", + "identifier": "UPDATE-ROW-Z1", + "children": [ + { + "label": "Table Name", + "configProperty": "actionConfiguration.formData.tableName.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Table1", + "initialValue": "" + }, + { + "label": "Row ID", + "configProperty": "actionConfiguration.formData.rowId.data", + "controlType": "QUERY_DYNAMIC_INPUT_TEXT", + "evaluationSubstitutionType": "TEMPLATE", + "isRequired": true, + "placeholderText": "Enter the row ID", + "initialValue": "" + } + ] + }, + { + "controlType": "SINGLE_COLUMN_ZONE", + "identifier": "UPDATE-ROW-Z2", + "children": [ + { + "label": "Row Object", + "configProperty": "actionConfiguration.formData.body.data", + "controlType": "QUERY_DYNAMIC_TEXT", + "evaluationSubstitutionType": "SMART_SUBSTITUTE", + "isRequired": true, + "placeholderText": "{\n \"Status\": \"Completed\"\n}", + "initialValue": "" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/form.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/form.json new file mode 100644 index 000000000000..6a61f6b72a83 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/form.json @@ -0,0 +1,26 @@ +{ + "form": [ + { + "sectionName": "Connection", + "id": 1, + "children": [ + { + "label": "Server URL", + "configProperty": "datasourceConfiguration.url", + "controlType": "INPUT_TEXT", + "isRequired": true, + "placeholderText": "https://cloud.seatable.io" + }, + { + "label": "API Token", + "description": "A base-level API token from SeaTable. Generate one in SeaTable under Base Settings > API Token.", + "configProperty": "datasourceConfiguration.authentication.password", + "controlType": "INPUT_TEXT", + "dataType": "PASSWORD", + "isRequired": true, + "placeholderText": "Enter your SeaTable API Token" + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/plugin.properties b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/plugin.properties new file mode 100644 index 000000000000..fe21d297db0c --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/plugin.properties @@ -0,0 +1,5 @@ +plugin.id=seatable-plugin +plugin.class=com.external.plugins.SeaTablePlugin +plugin.version=1.0-SNAPSHOT +plugin.provider=tech@appsmith.com +plugin.dependencies= diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json new file mode 100644 index 000000000000..84304476aef1 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json @@ -0,0 +1,36 @@ +{ + "setting": [ + { + "sectionName": "", + "id": 1, + "children": [ + { + "label": "Run query on page load", + "configProperty": "executeOnLoad", + "controlType": "SWITCH", + "subtitle": "Will refresh data each time the page is loaded" + }, + { + "label": "Request confirmation before running query", + "configProperty": "confirmBeforeExecute", + "controlType": "SWITCH", + "subtitle": "Ask confirmation from the user each time before refreshing data" + }, + { + "label": "Query timeout (in milliseconds)", + "subtitle": "Maximum time after which the query will return", + "configProperty": "actionConfiguration.timeoutInMillisecond", + "controlType": "INPUT_TEXT", + "dataType": "NUMBER", + "placeholderText": "10000", + "validation": { + "type": "NUMBER", + "params": { + "min": 1 + } + } + } + ] + } + ] +} diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java b/app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java new file mode 100644 index 000000000000..e6cd1b0d062a --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java @@ -0,0 +1,561 @@ +package com.external.plugins; + +import com.appsmith.external.dtos.ExecuteActionDTO; +import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; +import com.appsmith.external.models.ActionConfiguration; +import com.appsmith.external.models.ActionExecutionResult; +import com.appsmith.external.models.DBAuth; +import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.external.models.DatasourceStructure; +import com.appsmith.external.models.DatasourceTestResult; +import com.external.constants.FieldName; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static com.appsmith.external.helpers.PluginUtils.setDataValueSafelyInFormData; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SeaTablePluginTest { + + private MockWebServer mockWebServer; + private String serverUrl; + private final SeaTablePlugin.SeaTablePluginExecutor pluginExecutor = + new SeaTablePlugin.SeaTablePluginExecutor(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String ACCESS_TOKEN_RESPONSE = """ + { + "app_name": "test", + "access_token": "test-access-token-123", + "dtable_uuid": "test-uuid-456", + "dtable_server": "%s/", + "dtable_name": "Test Base", + "workspace_id": 1, + "use_api_gateway": true + } + """; + + private static final String LIST_ROWS_RESPONSE = """ + { + "rows": [ + { "_id": "row1", "Name": "Alice", "Age": 30 }, + { "_id": "row2", "Name": "Bob", "Age": 25 } + ] + } + """; + + private static final String GET_ROW_RESPONSE = """ + { "_id": "row1", "Name": "Alice", "Age": 30 } + """; + + private static final String CREATE_ROW_RESPONSE = """ + { + "inserted_row_count": 1, + "row_ids": [{"_id": "new-row-id"}], + "first_row": { "_id": "new-row-id", "Name": "Charlie", "Age": 35 } + } + """; + + private static final String UPDATE_ROW_RESPONSE = """ + { "success": true } + """; + + private static final String DELETE_ROW_RESPONSE = """ + { "deleted_rows": 1 } + """; + + private static final String METADATA_RESPONSE = """ + { + "metadata": { + "tables": [ + { + "name": "Contacts", + "columns": [ + {"name": "Name", "type": "text", "key": "0000"}, + {"name": "Email", "type": "text", "key": "0001"}, + {"name": "Age", "type": "number", "key": "0002"}, + {"name": "Active", "type": "checkbox", "key": "0003"} + ] + }, + { + "name": "Projects", + "columns": [ + {"name": "Title", "type": "text", "key": "0010"}, + {"name": "Status", "type": "single-select", "key": "0011"} + ] + } + ] + } + } + """; + + private static final String SQL_RESPONSE = """ + { + "results": [ + {"Name": "Alice", "Age": 30}, + {"Name": "Bob", "Age": 25} + ], + "metadata": [ + {"key": "0000", "name": "Name", "type": "text"}, + {"key": "0002", "name": "Age", "type": "number"} + ] + } + """; + + @BeforeEach + void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + serverUrl = "http://localhost:" + mockWebServer.getPort(); + } + + @AfterEach + void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + private DatasourceConfiguration createDatasourceConfig() { + DatasourceConfiguration config = new DatasourceConfiguration(); + config.setUrl(serverUrl); + DBAuth auth = new DBAuth(); + auth.setPassword("test-api-token"); + config.setAuthentication(auth); + return config; + } + + private ActionConfiguration createActionConfig(String command) { + return createActionConfig(command, new HashMap<>()); + } + + private ActionConfiguration createActionConfig(String command, Map extraFormData) { + ActionConfiguration config = new ActionConfiguration(); + Map formData = new HashMap<>(); + setDataValueSafelyInFormData(formData, FieldName.COMMAND, command); + extraFormData.forEach((k, v) -> setDataValueSafelyInFormData(formData, k, v)); + config.setFormData(formData); + return config; + } + + private void enqueueAccessTokenResponse() { + String response = String.format(ACCESS_TOKEN_RESPONSE, serverUrl); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(response) + .build()); + } + + private void enqueueJsonResponse(String body) { + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(body) + .build()); + } + + private RecordedRequest takeRequest() throws InterruptedException { + return mockWebServer.takeRequest(5, TimeUnit.SECONDS); + } + + // --- Validation Tests --- + + @Test + void testValidateDatasource_missingUrl() { + DatasourceConfiguration config = new DatasourceConfiguration(); + DBAuth auth = new DBAuth(); + auth.setPassword("some-token"); + config.setAuthentication(auth); + + Set invalids = pluginExecutor.validateDatasource(config); + assertTrue(invalids.contains("Missing SeaTable server URL.")); + } + + @Test + void testValidateDatasource_invalidUrl() { + DatasourceConfiguration config = new DatasourceConfiguration(); + config.setUrl("not-a-url"); + DBAuth auth = new DBAuth(); + auth.setPassword("some-token"); + config.setAuthentication(auth); + + Set invalids = pluginExecutor.validateDatasource(config); + assertTrue(invalids.contains("Invalid server URL. The URL should start with http:// or https://.")); + } + + @Test + void testValidateDatasource_missingToken() { + DatasourceConfiguration config = new DatasourceConfiguration(); + config.setUrl("https://cloud.seatable.io"); + + Set invalids = pluginExecutor.validateDatasource(config); + assertTrue(invalids.contains("Missing SeaTable API token.")); + } + + @Test + void testValidateDatasource_validConfig() { + DatasourceConfiguration config = createDatasourceConfig(); + Set invalids = pluginExecutor.validateDatasource(config); + assertTrue(invalids.isEmpty()); + } + + // --- Connection Test --- + + @Test + void testTestDatasource_success() throws InterruptedException { + enqueueAccessTokenResponse(); + + Mono resultMono = pluginExecutor.testDatasource(createDatasourceConfig()); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertNotNull(result); + assertTrue(result.getInvalids().isEmpty()); + }) + .verifyComplete(); + + RecordedRequest tokenRequest = takeRequest(); + assertEquals("GET", tokenRequest.getMethod()); + assertTrue(tokenRequest.getPath().contains("/api/v2.1/dtable/app-access-token/")); + assertTrue(tokenRequest.getHeader("Authorization").startsWith("Token ")); + } + + @Test + void testTestDatasource_invalidToken() { + mockWebServer.enqueue(new MockResponse.Builder() + .code(401) + .body("{\"detail\": \"Invalid token\"}") + .build()); + + Mono resultMono = pluginExecutor.testDatasource(createDatasourceConfig()); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertNotNull(result); + assertFalse(result.getInvalids().isEmpty()); + }) + .verifyComplete(); + } + + // --- List Rows --- + + @Test + void testListRows() throws InterruptedException { + enqueueAccessTokenResponse(); + enqueueJsonResponse(LIST_ROWS_RESPONSE); + + Map extra = new HashMap<>(); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.LIMIT, "100"); + + ActionConfiguration actionConfig = createActionConfig("LIST_ROWS", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + assertNotNull(result.getBody()); + }) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest rowsRequest = takeRequest(); + assertEquals("GET", rowsRequest.getMethod()); + assertTrue(rowsRequest.getPath().contains("/rows/")); + assertTrue(rowsRequest.getPath().contains("table_name=Contacts")); + assertTrue(rowsRequest.getPath().contains("convert_keys=true")); + assertTrue(rowsRequest.getPath().contains("limit=100")); + assertTrue(rowsRequest.getHeader("Authorization").startsWith("Token ")); + } + + // --- Get Row --- + + @Test + void testGetRow() throws InterruptedException { + enqueueAccessTokenResponse(); + enqueueJsonResponse(GET_ROW_RESPONSE); + + Map extra = new HashMap<>(); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.ROW_ID, "row1"); + + ActionConfiguration actionConfig = createActionConfig("GET_ROW", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + assertNotNull(result.getBody()); + }) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest rowRequest = takeRequest(); + assertEquals("GET", rowRequest.getMethod()); + assertTrue(rowRequest.getPath().contains("/rows/row1/")); + assertTrue(rowRequest.getPath().contains("table_name=Contacts")); + assertTrue(rowRequest.getPath().contains("convert_keys=true")); + } + + // --- Create Row --- + + @Test + void testCreateRow() throws Exception { + enqueueAccessTokenResponse(); + enqueueJsonResponse(CREATE_ROW_RESPONSE); + + Map extra = new HashMap<>(); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.BODY, "{\"Name\": \"Charlie\", \"Age\": 35}"); + + ActionConfiguration actionConfig = createActionConfig("CREATE_ROW", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + assertNotNull(result.getBody()); + }) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest createRequest = takeRequest(); + assertEquals("POST", createRequest.getMethod()); + assertTrue(createRequest.getPath().contains("/rows/")); + assertEquals("application/json", createRequest.getHeader("Content-Type")); + + String body = createRequest.getBody().readUtf8(); + JsonNode bodyJson = objectMapper.readTree(body); + assertEquals("Contacts", bodyJson.get("table_name").asText()); + assertTrue(bodyJson.get("rows").isArray()); + assertEquals("Charlie", bodyJson.get("rows").get(0).get("Name").asText()); + } + + // --- Update Row --- + + @Test + void testUpdateRow() throws Exception { + enqueueAccessTokenResponse(); + enqueueJsonResponse(UPDATE_ROW_RESPONSE); + + Map extra = new HashMap<>(); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.ROW_ID, "row1"); + extra.put(FieldName.BODY, "{\"Age\": 31}"); + + ActionConfiguration actionConfig = createActionConfig("UPDATE_ROW", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> assertTrue(result.getIsExecutionSuccess())) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest updateRequest = takeRequest(); + assertEquals("PUT", updateRequest.getMethod()); + assertTrue(updateRequest.getPath().contains("/rows/")); + + String body = updateRequest.getBody().readUtf8(); + JsonNode bodyJson = objectMapper.readTree(body); + assertEquals("Contacts", bodyJson.get("table_name").asText()); + assertTrue(bodyJson.get("updates").isArray()); + assertEquals("row1", bodyJson.get("updates").get(0).get("row_id").asText()); + assertEquals(31, bodyJson.get("updates").get(0).get("row").get("Age").asInt()); + } + + // --- Delete Row --- + + @Test + void testDeleteRow() throws Exception { + enqueueAccessTokenResponse(); + enqueueJsonResponse(DELETE_ROW_RESPONSE); + + Map extra = new HashMap<>(); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.ROW_ID, "row1"); + + ActionConfiguration actionConfig = createActionConfig("DELETE_ROW", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> assertTrue(result.getIsExecutionSuccess())) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest deleteRequest = takeRequest(); + assertEquals("DELETE", deleteRequest.getMethod()); + assertTrue(deleteRequest.getPath().contains("/rows/")); + + String body = deleteRequest.getBody().readUtf8(); + JsonNode bodyJson = objectMapper.readTree(body); + assertEquals("Contacts", bodyJson.get("table_name").asText()); + assertTrue(bodyJson.get("row_ids").isArray()); + assertEquals("row1", bodyJson.get("row_ids").get(0).asText()); + } + + // --- List Tables --- + + @Test + void testListTables() throws InterruptedException { + enqueueAccessTokenResponse(); + enqueueJsonResponse(METADATA_RESPONSE); + + ActionConfiguration actionConfig = createActionConfig("LIST_TABLES"); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + assertNotNull(result.getBody()); + }) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest metadataRequest = takeRequest(); + assertEquals("GET", metadataRequest.getMethod()); + assertTrue(metadataRequest.getPath().contains("/metadata/")); + } + + // --- SQL Query --- + + @Test + void testSqlQuery() throws Exception { + enqueueAccessTokenResponse(); + enqueueJsonResponse(SQL_RESPONSE); + + Map extra = new HashMap<>(); + extra.put(FieldName.SQL, "SELECT Name, Age FROM Contacts WHERE Age > 20"); + + ActionConfiguration actionConfig = createActionConfig("SQL_QUERY", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertTrue(result.getIsExecutionSuccess()); + assertNotNull(result.getBody()); + }) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest sqlRequest = takeRequest(); + assertEquals("POST", sqlRequest.getMethod()); + assertTrue(sqlRequest.getPath().contains("/sql/")); + assertEquals("application/json", sqlRequest.getHeader("Content-Type")); + + String body = sqlRequest.getBody().readUtf8(); + JsonNode bodyJson = objectMapper.readTree(body); + assertTrue(bodyJson.get("sql").asText().contains("SELECT Name")); + assertTrue(bodyJson.get("convert_keys").asBoolean()); + } + + // --- Get Structure (Schema Discovery) --- + + @Test + void testGetStructure() throws InterruptedException { + enqueueAccessTokenResponse(); + enqueueJsonResponse(METADATA_RESPONSE); + + Mono structureMono = + pluginExecutor.getStructure(null, createDatasourceConfig()); + + StepVerifier.create(structureMono) + .assertNext(structure -> { + assertNotNull(structure); + assertNotNull(structure.getTables()); + assertEquals(2, structure.getTables().size()); + + DatasourceStructure.Table contactsTable = structure.getTables().get(0); + assertEquals("Contacts", contactsTable.getName()); + assertEquals(4, contactsTable.getColumns().size()); + assertEquals("Name", contactsTable.getColumns().get(0).getName()); + assertEquals("text", contactsTable.getColumns().get(0).getType()); + + DatasourceStructure.Table projectsTable = structure.getTables().get(1); + assertEquals("Projects", projectsTable.getName()); + assertEquals(2, projectsTable.getColumns().size()); + }) + .verifyComplete(); + + takeRequest(); // skip access token request + RecordedRequest metadataRequest = takeRequest(); + assertEquals("GET", metadataRequest.getMethod()); + assertTrue(metadataRequest.getPath().contains("/metadata/")); + } + + // --- Missing Parameters --- + + @Test + void testMissingCommand() { + ActionConfiguration actionConfig = new ActionConfiguration(); + actionConfig.setFormData(new HashMap<>()); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .expectErrorMatches(e -> e instanceof AppsmithPluginException + && e.getMessage().contains("Missing command")) + .verify(); + } + + @Test + void testListRows_missingTableName() { + enqueueAccessTokenResponse(); + + ActionConfiguration actionConfig = createActionConfig("LIST_ROWS"); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .expectErrorMatches(e -> e instanceof AppsmithPluginException + && e.getMessage().contains("Missing table name")) + .verify(); + } + + @Test + void testGetRow_missingRowId() { + enqueueAccessTokenResponse(); + + Map extra = new HashMap<>(); + extra.put(FieldName.TABLE_NAME, "Contacts"); + + ActionConfiguration actionConfig = createActionConfig("GET_ROW", extra); + + Mono resultMono = pluginExecutor.executeParameterized( + null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); + + StepVerifier.create(resultMono) + .expectErrorMatches(e -> e instanceof AppsmithPluginException + && e.getMessage().contains("Missing row ID")) + .verify(); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog2.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog2.java index 552951cd0c5d..0e9cf50b065f 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog2.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog2.java @@ -665,4 +665,29 @@ public void updateOraclePluginName(MongoTemplate mongoTemplate) { oraclePlugin.setIconLocation("https://s3.us-east-2.amazonaws.com/assets.appsmith.com/oracle.svg"); mongoTemplate.save(oraclePlugin); } + + @ChangeSet(order = "044", id = "add-seatable-plugin", author = "") + public void addSeaTablePlugin(MongoTemplate mongoTemplate) { + Plugin plugin = new Plugin(); + plugin.setName("SeaTable"); + plugin.setType(PluginType.SAAS); + plugin.setPackageName("seatable-plugin"); + plugin.setUiComponent("UQIDbEditorForm"); + plugin.setResponseType(Plugin.ResponseType.JSON); + plugin.setIconLocation("https://s3.us-east-2.amazonaws.com/assets.appsmith.com/seatable.svg"); + plugin.setDocumentationLink("https://api.seatable.com/"); + plugin.setDefaultInstall(true); + try { + mongoTemplate.insert(plugin); + } catch (DuplicateKeyException e) { + log.warn(plugin.getPackageName() + " already present in database."); + plugin = mongoTemplate.findOne( + query(where(Plugin.Fields.packageName).is(plugin.getPackageName())), + Plugin.class); + if (plugin == null) { + return; + } + } + installPluginToAllWorkspaces(mongoTemplate, plugin.getId()); + } }