From 4d72ac59b1b62c2811a06e098234fffbcdf3d48d Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Tue, 17 Mar 2026 18:25:46 +0100 Subject: [PATCH 1/7] feat: Add SeaTable as native data source plugin Add a native Appsmith data source plugin for SeaTable, an open-source database platform (spreadsheet-database hybrid). Supported operations: - List Rows (with filtering, sorting, pagination) - Get Row (by ID) - Create Row - Update Row - Delete Row - List Tables (metadata/schema discovery) - SQL Query Authentication uses SeaTable's API Token, which is automatically exchanged for a base access token. The plugin is stateless (HTTP via WebClient) and follows the UQI editor pattern (like Firestore). All API endpoints verified against the SeaTable OpenAPI specification and tested against a live SeaTable instance. Closes #41627 --- app/server/appsmith-plugins/pom.xml | 1 + .../appsmith-plugins/seaTablePlugin/pom.xml | 71 ++ .../com/external/constants/FieldName.java | 15 + .../com/external/plugins/SeaTablePlugin.java | 679 ++++++++++++++++++ .../exceptions/SeaTableErrorMessages.java | 19 + .../exceptions/SeaTablePluginError.java | 74 ++ .../src/main/resources/editor/createRow.json | 39 + .../src/main/resources/editor/deleteRow.json | 33 + .../src/main/resources/editor/getRow.json | 33 + .../src/main/resources/editor/listRows.json | 133 ++++ .../src/main/resources/editor/listTables.json | 23 + .../src/main/resources/editor/root.json | 62 ++ .../src/main/resources/editor/sqlQuery.json | 25 + .../src/main/resources/editor/updateRow.json | 48 ++ .../src/main/resources/form.json | 26 + .../src/main/resources/plugin.properties | 5 + .../src/main/resources/setting.json | 29 + .../external/plugins/SeaTablePluginTest.java | 522 ++++++++++++++ .../server/migrations/DatabaseChangelog2.java | 19 + 19 files changed, 1856 insertions(+) create mode 100644 app/server/appsmith-plugins/seaTablePlugin/pom.xml create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/constants/FieldName.java create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTableErrorMessages.java create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTablePluginError.java create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/createRow.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/deleteRow.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/getRow.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listTables.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/root.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/sqlQuery.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/updateRow.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/form.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/plugin.properties create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json create mode 100644 app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java 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..78ea569cd659 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/constants/FieldName.java @@ -0,0 +1,15 @@ +package com.external.constants; + +public class FieldName { + 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..419912bf1625 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java @@ -0,0 +1,679 @@ +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.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. All row/metadata/sql operations use the dtable_server URL: + * {dtable_server}/api/v2/dtables/{dtable_uuid}/... + * with header: Authorization: Token {access_token} + * + * API reference: https://api.seatable.com/ + */ +public class SeaTablePlugin extends BasePlugin { + + private static final ExchangeStrategies EXCHANGE_STRATEGIES = ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(/* 10MB */ 10 * 1024 * 1024)) + .build(); + + 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) {} + + @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); + } + + @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 { + updatedBody = (String) smartSubstitutionOfBindings( + updatedBody, + mustacheKeysInOrder, + executeActionDTO.getParams(), + 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); + } + + 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)); + } + + return 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)); + }; + }); + } + + /** + * Exchange the API-Token for a Base-Token (access token). + * GET {serverUrl}/api/v2.1/dtable/app-access-token/ + * Header: Authorization: Token {apiToken} + * + * Response: + * { + * "app_name": "...", + * "access_token": "eyJ...", + * "dtable_uuid": "650d8a0d-...", + * "dtable_server": "https://cloud.seatable.io/api-gateway/", + * ... + * } + * + * The dtable_server URL already includes /api-gateway/. + * All subsequent calls go to: {dtable_server}api/v2/dtables/{dtable_uuid}/... + */ + private Mono fetchAccessToken(DatasourceConfiguration datasourceConfiguration) { + String serverUrl = datasourceConfiguration.getUrl().trim(); + DBAuth auth = (DBAuth) datasourceConfiguration.getAuthentication(); + 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) + .map(responseBytes -> { + try { + JsonNode json = objectMapper.readTree(responseBytes); + String accessToken = json.get("access_token").asText(); + String dtableUuid = json.get("dtable_uuid").asText(); + String dtableServer = json.get("dtable_server").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); + } + + private WebClient.RequestHeadersSpec buildRequest( + String basePath, String accessToken, HttpMethod method, String path) { + return buildRequest(basePath, accessToken, method, path, null); + } + + 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; + } + + private Mono executeRequest(WebClient.RequestHeadersSpec requestSpec) { + return requestSpec + .retrieve() + .bodyToMono(byte[].class) + .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); + } + + // --- Command implementations --- + + /** + * GET /api/v2/dtables/{base_uuid}/rows/?table_name=X&start=0&limit=100&order_by=col&direction=asc&convert_keys=true + */ + private Mono executeListRows( + String basePath, String accessToken, Map formData) { + + String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); + if (StringUtils.isBlank(tableName)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_TABLE_NAME_ERROR_MSG)); + } + + 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())); + } + + /** + * 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, ""); + + 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)); + } + + String path = "/rows/" + PluginUtils.urlEncode(rowId) + + "/?table_name=" + PluginUtils.urlEncode(tableName) + + "&convert_keys=true"; + + return executeRequest(buildRequest(basePath, accessToken, HttpMethod.GET, path)); + } + + /** + * 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, ""); + + if (StringUtils.isBlank(tableName)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_TABLE_NAME_ERROR_MSG)); + } + + 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)); + } + + /** + * 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, ""); + + 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)); + } + + 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)); + } + + /** + * 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, ""); + + 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)); + } + + 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)); + } + + /** + * GET /api/v2/dtables/{base_uuid}/metadata/ + */ + private Mono executeListTables(String basePath, String accessToken) { + return executeRequest( + buildRequest(basePath, accessToken, HttpMethod.GET, "/metadata/")); + } + + /** + * 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, ""); + if (StringUtils.isBlank(sql)) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, + SeaTableErrorMessages.MISSING_SQL_ERROR_MSG)); + } + + 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 --- + + @Override + public Mono datasourceCreate(DatasourceConfiguration datasourceConfiguration) { + return Mono.empty().then(); + } + + @Override + public void datasourceDestroy(Void connection) { + // Nothing to destroy for stateless HTTP + } + + @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; + } + + @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)); + }); + } + + @Override + public Mono getStructure( + Void connection, DatasourceConfiguration datasourceConfiguration) { + + return fetchAccessToken(datasourceConfiguration) + .flatMap(tokenResponse -> { + WebClient client = WebClientUtils.builder() + .exchangeStrategies(EXCHANGE_STRATEGIES) + .build(); + + return client + .get() + .uri(URI.create(tokenResponse.basePath() + "/metadata/")) + .header("Authorization", "Token " + tokenResponse.accessToken()) + .header("Accept", MediaType.APPLICATION_JSON_VALUE) + .retrieve() + .bodyToMono(byte[].class); + }) + .map(responseBytes -> { + DatasourceStructure structure = new DatasourceStructure(); + List tables = new ArrayList<>(); + + 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) { + String tableName = tableNode.get("name").asText(); + List columns = new ArrayList<>(); + + JsonNode columnsNode = tableNode.get("columns"); + if (columnsNode != null && columnsNode.isArray()) { + for (JsonNode colNode : columnsNode) { + 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) { + log.error("Failed to parse SeaTable metadata", e); + } + + structure.setTables(tables); + 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..266e155b58ac --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTableErrorMessages.java @@ -0,0 +1,19 @@ +package com.external.plugins.exceptions; + +public class SeaTableErrorMessages { + 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..b69308072a18 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTablePluginError.java @@ -0,0 +1,74 @@ +package com.external.plugins.exceptions; + +import com.appsmith.external.exceptions.pluginExceptions.BasePluginError; +import com.appsmith.external.models.ErrorType; +import lombok.Getter; + +@Getter +public enum SeaTablePluginError implements BasePluginError { + QUERY_EXECUTION_FAILED( + 500, + "PE-STB-5000", + "{0}", + ErrorType.INTERNAL_ERROR, + "{1}", + "{2}"), + ACCESS_TOKEN_ERROR( + 401, + "PE-STB-4001", + "{0}", + ErrorType.AUTHENTICATION_ERROR, + "{1}", + "{2}"), + INVALID_BODY_ERROR( + 400, + "PE-STB-4000", + "{0}", + ErrorType.ARGUMENT_ERROR, + "{1}", + "{2}"); + + private final Integer httpErrorCode; + private final String appErrorCode; + private final String message; + private final String title; + private final ErrorType errorType; + private final String downstreamErrorMessage; + private final String downstreamErrorCode; + + SeaTablePluginError( + Integer httpErrorCode, + String appErrorCode, + String message, + ErrorType errorType, + String downstreamErrorMessage, + String downstreamErrorCode) { + this.httpErrorCode = httpErrorCode; + this.appErrorCode = appErrorCode; + this.message = message; + this.title = "SeaTable plugin error"; + this.errorType = errorType; + this.downstreamErrorMessage = downstreamErrorMessage; + this.downstreamErrorCode = downstreamErrorCode; + } + + @Override + public String getMessage(Object... args) { + return BasePluginError.super.getMessage(args); + } + + @Override + public String getErrorType() { + return this.errorType.toString(); + } + + @Override + public String getDownstreamErrorMessage(Object... args) { + return BasePluginError.super.getDownstreamErrorMessage(args); + } + + @Override + public String getDownstreamErrorCode(Object... args) { + return BasePluginError.super.getDownstreamErrorCode(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..51b3edcc6b79 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json @@ -0,0 +1,29 @@ +{ + "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" + } + ] + } + ] +} 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..258e3848be10 --- /dev/null +++ b/app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java @@ -0,0 +1,522 @@ +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.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import okhttp3.Headers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +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 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 static MockWebServer mockWebServer; + private static String serverUrl; + private static final SeaTablePlugin.SeaTablePluginExecutor pluginExecutor = + new SeaTablePlugin.SeaTablePluginExecutor(); + private static 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 = """ + { + "success": true + } + """; + + 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"} + ] + } + """; + + @BeforeAll + static void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + serverUrl = "http://localhost:" + mockWebServer.getPort(); + } + + @AfterAll + static 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, "command", command); + extraFormData.forEach((k, v) -> setDataValueSafelyInFormData(formData, k, v)); + config.setFormData(formData); + return config; + } + + private void enqueueAccessTokenResponse() { + // dtable_server in real SeaTable points to the api-gateway, e.g. https://cloud.seatable.io/api-gateway/ + // In tests we point it back to our mock server to intercept all subsequent calls. + String response = String.format(ACCESS_TOKEN_RESPONSE, serverUrl); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(response) + .build()); + } + + // --- 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() { + enqueueAccessTokenResponse(); + + Mono resultMono = pluginExecutor.testDatasource(createDatasourceConfig()); + + StepVerifier.create(resultMono) + .assertNext(result -> { + assertNotNull(result); + assertTrue(result.getInvalids().isEmpty()); + }) + .verifyComplete(); + } + + @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() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(LIST_ROWS_RESPONSE) + .build()); + + Map extra = new HashMap<>(); + extra.put("tableName", "Contacts"); + extra.put("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(); + } + + // --- Get Row --- + + @Test + void testGetRow() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(GET_ROW_RESPONSE) + .build()); + + Map extra = new HashMap<>(); + extra.put("tableName", "Contacts"); + extra.put("rowId", "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(); + } + + // --- Create Row --- + + @Test + void testCreateRow() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(CREATE_ROW_RESPONSE) + .build()); + + Map extra = new HashMap<>(); + extra.put("tableName", "Contacts"); + extra.put("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(); + } + + // --- Update Row --- + + @Test + void testUpdateRow() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(UPDATE_ROW_RESPONSE) + .build()); + + Map extra = new HashMap<>(); + extra.put("tableName", "Contacts"); + extra.put("rowId", "row1"); + extra.put("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(); + } + + // --- Delete Row --- + + @Test + void testDeleteRow() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(DELETE_ROW_RESPONSE) + .build()); + + Map extra = new HashMap<>(); + extra.put("tableName", "Contacts"); + extra.put("rowId", "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(); + } + + // --- List Tables --- + + @Test + void testListTables() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(METADATA_RESPONSE) + .build()); + + 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(); + } + + // --- SQL Query --- + + @Test + void testSqlQuery() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(SQL_RESPONSE) + .build()); + + Map extra = new HashMap<>(); + extra.put("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(); + } + + // --- Get Structure (Schema Discovery) --- + + @Test + void testGetStructure() { + enqueueAccessTokenResponse(); + mockWebServer.enqueue(new MockResponse.Builder() + .addHeader("Content-Type", "application/json") + .body(METADATA_RESPONSE) + .build()); + + 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(); + } + + // --- 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("tableName", "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..2c76399bee9c 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,23 @@ 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.API); + plugin.setPackageName("seatable-plugin"); + plugin.setUiComponent("UQIDbEditorForm"); + plugin.setResponseType(Plugin.ResponseType.JSON); + plugin.setIconLocation("https://seatable.com/favicon.svg"); + plugin.setDocumentationLink("https://developer.seatable.io/"); + plugin.setDefaultInstall(true); + try { + mongoTemplate.insert(plugin); + } catch (DuplicateKeyException e) { + log.warn(plugin.getPackageName() + " already present in database."); + } + installPluginToAllWorkspaces(mongoTemplate, plugin.getId()); + } } From f79cd7eaf6ad867daf9a97d6d6baa65504bb5bed Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Tue, 17 Mar 2026 18:50:16 +0100 Subject: [PATCH 2/7] Address CodeRabbit review feedback - Add private constructors to FieldName and SeaTableErrorMessages utility classes - Use MessageFormat.format() in SeaTablePluginError instead of super delegation - Add 30s timeout to all HTTP requests (fetchAccessToken, executeRequest, getStructure) - Add null-checks for access token response fields - Add defensive null-checks for metadata parsing (table name, column name/type) - Add Javadoc to all public and significant private methods - Add timeout validation (min: 1) and placeholder to setting.json - Use FieldName constants instead of string literals in tests - Add HTTP request assertions (method, path, headers, body) to all tests via MockWebServer.takeRequest() --- .../com/external/constants/FieldName.java | 4 + .../com/external/plugins/SeaTablePlugin.java | 122 +++++++--- .../exceptions/SeaTableErrorMessages.java | 4 + .../exceptions/SeaTablePluginError.java | 8 +- .../src/main/resources/setting.json | 9 +- .../external/plugins/SeaTablePluginTest.java | 219 +++++++++++------- 6 files changed, 246 insertions(+), 120 deletions(-) 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 index 78ea569cd659..374be5a0f0e3 100644 --- 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 @@ -1,6 +1,10 @@ package com.external.constants; public 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"; 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 index 419912bf1625..fbc1bfe09514 100644 --- 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 @@ -40,6 +40,7 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -64,14 +65,16 @@ /** * 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. All row/metadata/sql operations use the dtable_server URL: - * {dtable_server}/api/v2/dtables/{dtable_uuid}/... - * with header: Authorization: Token {access_token} + *

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. + *
* - * API reference: https://api.seatable.com/ + * @see SeaTable API Reference */ public class SeaTablePlugin extends BasePlugin { @@ -79,6 +82,8 @@ public class SeaTablePlugin extends BasePlugin { .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); } @@ -96,6 +101,9 @@ public static class SeaTablePluginExecutor implements PluginExecutor, Smar */ private record AccessTokenResponse(String accessToken, String basePath) {} + /** + * @deprecated Use {@link #executeParameterized} instead. + */ @Override @Deprecated public Mono execute( @@ -120,6 +128,10 @@ public Object substituteValueInInput( 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, @@ -172,6 +184,10 @@ public Mono executeParameterized( 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) { @@ -206,21 +222,14 @@ private Mono executeQuery( } /** - * Exchange the API-Token for a Base-Token (access token). - * GET {serverUrl}/api/v2.1/dtable/app-access-token/ - * Header: Authorization: Token {apiToken} - * - * Response: - * { - * "app_name": "...", - * "access_token": "eyJ...", - * "dtable_uuid": "650d8a0d-...", - * "dtable_server": "https://cloud.seatable.io/api-gateway/", - * ... - * } + * 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/. - * All subsequent calls go to: {dtable_server}api/v2/dtables/{dtable_uuid}/... + * + * @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) { String serverUrl = datasourceConfiguration.getUrl().trim(); @@ -244,12 +253,24 @@ private Mono fetchAccessToken(DatasourceConfiguration datas .header("Accept", MediaType.APPLICATION_JSON_VALUE) .retrieve() .bodyToMono(byte[].class) + .timeout(REQUEST_TIMEOUT) .map(responseBytes -> { try { JsonNode json = objectMapper.readTree(responseBytes); - String accessToken = json.get("access_token").asText(); - String dtableUuid = json.get("dtable_uuid").asText(); - String dtableServer = json.get("dtable_server").asText(); + + 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 @@ -276,11 +297,17 @@ private Mono fetchAccessToken(DatasourceConfiguration datas .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) { @@ -305,10 +332,15 @@ private WebClient.RequestHeadersSpec buildRequest( 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); @@ -336,7 +368,8 @@ private Mono executeRequest(WebClient.RequestHeadersSpec< // --- Command implementations --- /** - * GET /api/v2/dtables/{base_uuid}/rows/?table_name=X&start=0&limit=100&order_by=col&direction=asc&convert_keys=true + * 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) { @@ -380,7 +413,8 @@ private Mono executeListRows( } /** - * GET /api/v2/dtables/{base_uuid}/rows/{row_id}/?table_name=X&convert_keys=true + * 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) { @@ -407,6 +441,7 @@ private Mono executeGetRow( } /** + * Creates a new row in a table. * POST /api/v2/dtables/{base_uuid}/rows/ * Body: { "table_name": "X", "rows": [{ "col": "val", ... }] } */ @@ -442,6 +477,7 @@ private Mono executeCreateRow( } /** + * Updates an existing row. * PUT /api/v2/dtables/{base_uuid}/rows/ * Body: { "table_name": "X", "updates": [{ "row_id": "...", "row": { "col": "val" } }] } */ @@ -489,6 +525,7 @@ private Mono executeUpdateRow( } /** + * Deletes a row from a table. * DELETE /api/v2/dtables/{base_uuid}/rows/ * Body: { "table_name": "X", "row_ids": ["row_id_1"] } */ @@ -528,6 +565,7 @@ private Mono executeDeleteRow( } /** + * 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) { @@ -536,6 +574,7 @@ private Mono executeListTables(String basePath, String ac } /** + * Executes a SQL query against the base. * POST /api/v2/dtables/{base_uuid}/sql/ * Body: { "sql": "SELECT ...", "convert_keys": true } */ @@ -567,16 +606,29 @@ private Mono executeSqlQuery( // --- 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<>(); @@ -599,6 +651,10 @@ public Set validateDatasource(DatasourceConfiguration datasourceConfigur 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) @@ -611,6 +667,10 @@ public Mono testDatasource(DatasourceConfiguration datasou }); } + /** + * 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) { @@ -627,7 +687,8 @@ public Mono getStructure( .header("Authorization", "Token " + tokenResponse.accessToken()) .header("Accept", MediaType.APPLICATION_JSON_VALUE) .retrieve() - .bodyToMono(byte[].class); + .bodyToMono(byte[].class) + .timeout(REQUEST_TIMEOUT); }) .map(responseBytes -> { DatasourceStructure structure = new DatasourceStructure(); @@ -645,12 +706,21 @@ public Mono getStructure( } 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( 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 index 266e155b58ac..1167baa943c5 100644 --- 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 @@ -1,6 +1,10 @@ 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 = 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 index b69308072a18..d3277b517c11 100644 --- 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 @@ -4,6 +4,8 @@ import com.appsmith.external.models.ErrorType; import lombok.Getter; +import java.text.MessageFormat; + @Getter public enum SeaTablePluginError implements BasePluginError { QUERY_EXECUTION_FAILED( @@ -54,7 +56,7 @@ public enum SeaTablePluginError implements BasePluginError { @Override public String getMessage(Object... args) { - return BasePluginError.super.getMessage(args); + return new MessageFormat(this.message).format(args); } @Override @@ -64,11 +66,11 @@ public String getErrorType() { @Override public String getDownstreamErrorMessage(Object... args) { - return BasePluginError.super.getDownstreamErrorMessage(args); + return replacePlaceholderWithValue(this.downstreamErrorMessage, args); } @Override public String getDownstreamErrorCode(Object... args) { - return BasePluginError.super.getDownstreamErrorCode(args); + return replacePlaceholderWithValue(this.downstreamErrorCode, args); } } diff --git a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json index 51b3edcc6b79..84304476aef1 100644 --- a/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json +++ b/app/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.json @@ -21,7 +21,14 @@ "subtitle": "Maximum time after which the query will return", "configProperty": "actionConfiguration.timeoutInMillisecond", "controlType": "INPUT_TEXT", - "dataType": "NUMBER" + "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 index 258e3848be10..d17ae347296e 100644 --- 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 @@ -8,12 +8,12 @@ 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 okhttp3.Headers; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -24,6 +24,7 @@ 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; @@ -54,50 +55,30 @@ class SeaTablePluginTest { private static final String LIST_ROWS_RESPONSE = """ { "rows": [ - { - "_id": "row1", - "Name": "Alice", - "Age": 30 - }, - { - "_id": "row2", - "Name": "Bob", - "Age": 25 - } + { "_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 - } + { "_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 - } + "first_row": { "_id": "new-row-id", "Name": "Charlie", "Age": 35 } } """; private static final String UPDATE_ROW_RESPONSE = """ - { - "success": true - } + { "success": true } """; private static final String DELETE_ROW_RESPONSE = """ - { - "success": true - } + { "deleted_rows": 1 } """; private static final String METADATA_RESPONSE = """ @@ -166,15 +147,13 @@ private ActionConfiguration createActionConfig(String command) { private ActionConfiguration createActionConfig(String command, Map extraFormData) { ActionConfiguration config = new ActionConfiguration(); Map formData = new HashMap<>(); - setDataValueSafelyInFormData(formData, "command", command); + setDataValueSafelyInFormData(formData, FieldName.COMMAND, command); extraFormData.forEach((k, v) -> setDataValueSafelyInFormData(formData, k, v)); config.setFormData(formData); return config; } private void enqueueAccessTokenResponse() { - // dtable_server in real SeaTable points to the api-gateway, e.g. https://cloud.seatable.io/api-gateway/ - // In tests we point it back to our mock server to intercept all subsequent calls. String response = String.format(ACCESS_TOKEN_RESPONSE, serverUrl); mockWebServer.enqueue(new MockResponse.Builder() .addHeader("Content-Type", "application/json") @@ -182,6 +161,17 @@ private void enqueueAccessTokenResponse() { .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 @@ -226,7 +216,7 @@ void testValidateDatasource_validConfig() { // --- Connection Test --- @Test - void testTestDatasource_success() { + void testTestDatasource_success() throws InterruptedException { enqueueAccessTokenResponse(); Mono resultMono = pluginExecutor.testDatasource(createDatasourceConfig()); @@ -237,6 +227,11 @@ void testTestDatasource_success() { 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 @@ -259,16 +254,13 @@ void testTestDatasource_invalidToken() { // --- List Rows --- @Test - void testListRows() { + void testListRows() throws InterruptedException { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(LIST_ROWS_RESPONSE) - .build()); + enqueueJsonResponse(LIST_ROWS_RESPONSE); Map extra = new HashMap<>(); - extra.put("tableName", "Contacts"); - extra.put("limit", "100"); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.LIMIT, "100"); ActionConfiguration actionConfig = createActionConfig("LIST_ROWS", extra); @@ -281,21 +273,27 @@ void testListRows() { 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() { + void testGetRow() throws InterruptedException { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(GET_ROW_RESPONSE) - .build()); + enqueueJsonResponse(GET_ROW_RESPONSE); Map extra = new HashMap<>(); - extra.put("tableName", "Contacts"); - extra.put("rowId", "row1"); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.ROW_ID, "row1"); ActionConfiguration actionConfig = createActionConfig("GET_ROW", extra); @@ -308,21 +306,25 @@ void testGetRow() { 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() { + void testCreateRow() throws Exception { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(CREATE_ROW_RESPONSE) - .build()); + enqueueJsonResponse(CREATE_ROW_RESPONSE); Map extra = new HashMap<>(); - extra.put("tableName", "Contacts"); - extra.put("body", "{\"Name\": \"Charlie\", \"Age\": 35}"); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.BODY, "{\"Name\": \"Charlie\", \"Age\": 35}"); ActionConfiguration actionConfig = createActionConfig("CREATE_ROW", extra); @@ -335,22 +337,31 @@ void testCreateRow() { 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() { + void testUpdateRow() throws Exception { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(UPDATE_ROW_RESPONSE) - .build()); + enqueueJsonResponse(UPDATE_ROW_RESPONSE); Map extra = new HashMap<>(); - extra.put("tableName", "Contacts"); - extra.put("rowId", "row1"); - extra.put("body", "{\"Age\": 31}"); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.ROW_ID, "row1"); + extra.put(FieldName.BODY, "{\"Age\": 31}"); ActionConfiguration actionConfig = createActionConfig("UPDATE_ROW", extra); @@ -358,25 +369,32 @@ void testUpdateRow() { null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); StepVerifier.create(resultMono) - .assertNext(result -> { - assertTrue(result.getIsExecutionSuccess()); - }) + .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() { + void testDeleteRow() throws Exception { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(DELETE_ROW_RESPONSE) - .build()); + enqueueJsonResponse(DELETE_ROW_RESPONSE); Map extra = new HashMap<>(); - extra.put("tableName", "Contacts"); - extra.put("rowId", "row1"); + extra.put(FieldName.TABLE_NAME, "Contacts"); + extra.put(FieldName.ROW_ID, "row1"); ActionConfiguration actionConfig = createActionConfig("DELETE_ROW", extra); @@ -384,21 +402,27 @@ void testDeleteRow() { null, new ExecuteActionDTO(), createDatasourceConfig(), actionConfig); StepVerifier.create(resultMono) - .assertNext(result -> { - assertTrue(result.getIsExecutionSuccess()); - }) + .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() { + void testListTables() throws InterruptedException { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(METADATA_RESPONSE) - .build()); + enqueueJsonResponse(METADATA_RESPONSE); ActionConfiguration actionConfig = createActionConfig("LIST_TABLES"); @@ -411,20 +435,22 @@ void testListTables() { 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() { + void testSqlQuery() throws Exception { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(SQL_RESPONSE) - .build()); + enqueueJsonResponse(SQL_RESPONSE); Map extra = new HashMap<>(); - extra.put("sql", "SELECT Name, Age FROM Contacts WHERE Age > 20"); + extra.put(FieldName.SQL, "SELECT Name, Age FROM Contacts WHERE Age > 20"); ActionConfiguration actionConfig = createActionConfig("SQL_QUERY", extra); @@ -437,17 +463,25 @@ void testSqlQuery() { 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() { + void testGetStructure() throws InterruptedException { enqueueAccessTokenResponse(); - mockWebServer.enqueue(new MockResponse.Builder() - .addHeader("Content-Type", "application/json") - .body(METADATA_RESPONSE) - .build()); + enqueueJsonResponse(METADATA_RESPONSE); Mono structureMono = pluginExecutor.getStructure(null, createDatasourceConfig()); @@ -469,6 +503,11 @@ void testGetStructure() { 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 --- @@ -507,7 +546,7 @@ void testGetRow_missingRowId() { enqueueAccessTokenResponse(); Map extra = new HashMap<>(); - extra.put("tableName", "Contacts"); + extra.put(FieldName.TABLE_NAME, "Contacts"); ActionConfiguration actionConfig = createActionConfig("GET_ROW", extra); From f69a7059b389be1b6fee4075b7dac4a0f8e6e07a Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Tue, 17 Mar 2026 19:13:12 +0100 Subject: [PATCH 3/7] Address CodeRabbit review round 2 - Add getErrorAction() and AppsmithErrorAction to SeaTablePluginError (match BasePluginError interface fully, follow FirestorePluginError pattern) - Validate required form fields before fetching access token (avoid unnecessary network calls) - Guard against null executeActionDTO.getParams() in smart substitution - Initialize structure.setTables() before early returns in getStructure() - Switch tests from @BeforeAll/@AfterAll to @BeforeEach/@AfterEach for per-test MockWebServer isolation --- .../com/external/plugins/SeaTablePlugin.java | 60 ++++++++++++++++++- .../exceptions/SeaTablePluginError.java | 17 ++++-- .../external/plugins/SeaTablePluginTest.java | 20 +++---- 3 files changed, 81 insertions(+), 16 deletions(-) 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 index fbc1bfe09514..1be9afa73a7e 100644 --- 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 @@ -163,10 +163,14 @@ public Mono executeParameterized( MustacheHelper.replaceMustacheWithPlaceholder(body, mustacheKeysInOrder); try { + List params = executeActionDTO.getParams(); + if (params == null) { + params = new ArrayList<>(); + } updatedBody = (String) smartSubstitutionOfBindings( updatedBody, mustacheKeysInOrder, - executeActionDTO.getParams(), + params, parameters); } catch (AppsmithPluginException e) { ActionExecutionResult errorResult = new ActionExecutionResult(); @@ -201,6 +205,12 @@ private Mono executeQuery( SeaTableErrorMessages.MISSING_COMMAND_ERROR_MSG)); } + // Validate required fields before making any network calls + Mono validation = validateCommandInputs(command, formData); + if (validation != null) { + return validation.then(Mono.empty()); + } + return fetchAccessToken(datasourceConfiguration) .flatMap(tokenResponse -> { String basePath = tokenResponse.basePath(); @@ -365,6 +375,52 @@ private Mono executeRequest(WebClient.RequestHeadersSpec< .subscribeOn(scheduler); } + /** + * Validates required form fields for a command before making network calls. + * Returns a Mono.error if validation fails, or null 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: + // LIST_TABLES and unknown commands need no pre-validation + break; + } + return null; + } + // --- Command implementations --- /** @@ -693,6 +749,7 @@ public Mono getStructure( .map(responseBytes -> { DatasourceStructure structure = new DatasourceStructure(); List tables = new ArrayList<>(); + structure.setTables(tables); try { JsonNode json = objectMapper.readTree(responseBytes); @@ -740,7 +797,6 @@ public Mono getStructure( log.error("Failed to parse SeaTable metadata", e); } - structure.setTables(tables); return structure; }) .subscribeOn(scheduler); 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 index d3277b517c11..51ad5a850cda 100644 --- 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 @@ -1,17 +1,18 @@ 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}"), @@ -19,6 +20,8 @@ public enum SeaTablePluginError implements BasePluginError { 401, "PE-STB-4001", "{0}", + AppsmithErrorAction.LOG_EXTERNALLY, + "Authentication error", ErrorType.AUTHENTICATION_ERROR, "{1}", "{2}"), @@ -26,6 +29,8 @@ public enum SeaTablePluginError implements BasePluginError { 400, "PE-STB-4000", "{0}", + AppsmithErrorAction.DEFAULT, + "Invalid request body", ErrorType.ARGUMENT_ERROR, "{1}", "{2}"); @@ -33,6 +38,7 @@ public enum SeaTablePluginError implements BasePluginError { 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; @@ -42,13 +48,16 @@ public enum SeaTablePluginError implements BasePluginError { 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.title = "SeaTable plugin error"; + this.errorAction = errorAction; + this.title = title; this.errorType = errorType; this.downstreamErrorMessage = downstreamErrorMessage; this.downstreamErrorCode = downstreamErrorCode; @@ -56,7 +65,7 @@ public enum SeaTablePluginError implements BasePluginError { @Override public String getMessage(Object... args) { - return new MessageFormat(this.message).format(args); + return replacePlaceholderWithValue(this.message, args); } @Override 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 index d17ae347296e..e6cd1b0d062a 100644 --- 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 @@ -14,8 +14,8 @@ import mockwebserver3.MockResponse; import mockwebserver3.MockWebServer; import mockwebserver3.RecordedRequest; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; +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; @@ -34,11 +34,11 @@ class SeaTablePluginTest { - private static MockWebServer mockWebServer; - private static String serverUrl; - private static final SeaTablePlugin.SeaTablePluginExecutor pluginExecutor = + private MockWebServer mockWebServer; + private String serverUrl; + private final SeaTablePlugin.SeaTablePluginExecutor pluginExecutor = new SeaTablePlugin.SeaTablePluginExecutor(); - private static final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = new ObjectMapper(); private static final String ACCESS_TOKEN_RESPONSE = """ { @@ -119,15 +119,15 @@ class SeaTablePluginTest { } """; - @BeforeAll - static void setUp() throws IOException { + @BeforeEach + void setUp() throws IOException { mockWebServer = new MockWebServer(); mockWebServer.start(); serverUrl = "http://localhost:" + mockWebServer.getPort(); } - @AfterAll - static void tearDown() throws IOException { + @AfterEach + void tearDown() throws IOException { mockWebServer.shutdown(); } From 95f3593519e84dd04605403d8085fc551e91679b Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Tue, 17 Mar 2026 19:22:38 +0100 Subject: [PATCH 4/7] Change PluginType from API to SAAS SeaTable should appear under "SaaS Integrations" alongside Airtable and Google Sheets, not under "APIs". Also fix documentationLink URL. --- .../com/appsmith/server/migrations/DatabaseChangelog2.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2c76399bee9c..dd4d9a2d2d42 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 @@ -670,12 +670,12 @@ public void updateOraclePluginName(MongoTemplate mongoTemplate) { public void addSeaTablePlugin(MongoTemplate mongoTemplate) { Plugin plugin = new Plugin(); plugin.setName("SeaTable"); - plugin.setType(PluginType.API); + plugin.setType(PluginType.SAAS); plugin.setPackageName("seatable-plugin"); plugin.setUiComponent("UQIDbEditorForm"); plugin.setResponseType(Plugin.ResponseType.JSON); plugin.setIconLocation("https://seatable.com/favicon.svg"); - plugin.setDocumentationLink("https://developer.seatable.io/"); + plugin.setDocumentationLink("https://api.seatable.com/"); plugin.setDefaultInstall(true); try { mongoTemplate.insert(plugin); From 6450703dee9a9f274edbc620d387a737643352e8 Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Wed, 18 Mar 2026 00:42:44 +0100 Subject: [PATCH 5/7] Address remaining CodeRabbit review feedback - Use MessageFormat in SeaTablePluginError.getMessage() for consistency with PostgresPluginError pattern - Add defensive null checks in fetchAccessToken() to prevent NPE when URL or authentication is missing - Use Appsmith-hosted icon URL instead of external seatable.com favicon - Make migration changeset idempotent by fetching persisted plugin on duplicate key --- .../java/com/external/plugins/SeaTablePlugin.java | 14 +++++++++++++- .../plugins/exceptions/SeaTablePluginError.java | 4 +++- .../server/migrations/DatabaseChangelog2.java | 8 +++++++- 3 files changed, 23 insertions(+), 3 deletions(-) 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 index 1be9afa73a7e..0347023db4d9 100644 --- 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 @@ -242,8 +242,20 @@ private Mono executeQuery( * @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(); - DBAuth auth = (DBAuth) datasourceConfiguration.getAuthentication(); String apiToken = auth.getPassword(); if (serverUrl.endsWith("/")) { 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 index 51ad5a850cda..a03b4394819b 100644 --- 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 @@ -5,6 +5,8 @@ import com.appsmith.external.models.ErrorType; import lombok.Getter; +import java.text.MessageFormat; + @Getter public enum SeaTablePluginError implements BasePluginError { QUERY_EXECUTION_FAILED( @@ -65,7 +67,7 @@ public enum SeaTablePluginError implements BasePluginError { @Override public String getMessage(Object... args) { - return replacePlaceholderWithValue(this.message, args); + return new MessageFormat(this.message).format(args); } @Override 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 dd4d9a2d2d42..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 @@ -674,13 +674,19 @@ public void addSeaTablePlugin(MongoTemplate mongoTemplate) { plugin.setPackageName("seatable-plugin"); plugin.setUiComponent("UQIDbEditorForm"); plugin.setResponseType(Plugin.ResponseType.JSON); - plugin.setIconLocation("https://seatable.com/favicon.svg"); + 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()); } From f9a53277f9667cb484eea0f16fa72974dff8a33b Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Wed, 18 Mar 2026 20:23:43 +0100 Subject: [PATCH 6/7] Address CodeRabbit review round 3 - Mark FieldName as final (utility class pattern) - Remove duplicate validation in command methods (already handled by validateCommandInputs) - Reuse buildRequest helper in getStructure instead of inline WebClient --- .../com/external/constants/FieldName.java | 2 +- .../com/external/plugins/SeaTablePlugin.java | 71 +++---------------- 2 files changed, 9 insertions(+), 64 deletions(-) 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 index 374be5a0f0e3..fa2d3035110c 100644 --- 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 @@ -1,6 +1,6 @@ package com.external.constants; -public class FieldName { +public final class FieldName { private FieldName() { // Utility class - prevent instantiation } 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 index 0347023db4d9..ab94c2d90cd9 100644 --- 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 @@ -443,11 +443,6 @@ private Mono executeListRows( String basePath, String accessToken, Map formData) { String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); - if (StringUtils.isBlank(tableName)) { - return Mono.error(new AppsmithPluginException( - AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, - SeaTableErrorMessages.MISSING_TABLE_NAME_ERROR_MSG)); - } StringBuilder pathBuilder = new StringBuilder("/rows/"); pathBuilder.append("?table_name=").append(PluginUtils.urlEncode(tableName)); @@ -490,17 +485,6 @@ private Mono executeGetRow( String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); - 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)); - } - String path = "/rows/" + PluginUtils.urlEncode(rowId) + "/?table_name=" + PluginUtils.urlEncode(tableName) + "&convert_keys=true"; @@ -519,12 +503,6 @@ private Mono executeCreateRow( String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); String body = getDataValueSafelyFromFormData(formData, BODY, STRING_TYPE, ""); - if (StringUtils.isBlank(tableName)) { - return Mono.error(new AppsmithPluginException( - AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, - SeaTableErrorMessages.MISSING_TABLE_NAME_ERROR_MSG)); - } - String requestBody; try { JsonNode rowData = objectMapper.readTree(StringUtils.isBlank(body) ? "{}" : body); @@ -556,17 +534,6 @@ private Mono executeUpdateRow( String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); String body = getDataValueSafelyFromFormData(formData, BODY, STRING_TYPE, ""); - 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)); - } - String requestBody; try { JsonNode rowData = objectMapper.readTree(StringUtils.isBlank(body) ? "{}" : body); @@ -603,17 +570,6 @@ private Mono executeDeleteRow( String tableName = getDataValueSafelyFromFormData(formData, TABLE_NAME, STRING_TYPE, ""); String rowId = getDataValueSafelyFromFormData(formData, ROW_ID, STRING_TYPE, ""); - 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)); - } - String requestBody; try { ObjectNode wrapper = objectMapper.createObjectNode(); @@ -650,11 +606,6 @@ private Mono executeSqlQuery( String basePath, String accessToken, Map formData) { String sql = getDataValueSafelyFromFormData(formData, SQL, STRING_TYPE, ""); - if (StringUtils.isBlank(sql)) { - return Mono.error(new AppsmithPluginException( - AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, - SeaTableErrorMessages.MISSING_SQL_ERROR_MSG)); - } String requestBody; try { @@ -744,20 +695,14 @@ public Mono getStructure( Void connection, DatasourceConfiguration datasourceConfiguration) { return fetchAccessToken(datasourceConfiguration) - .flatMap(tokenResponse -> { - WebClient client = WebClientUtils.builder() - .exchangeStrategies(EXCHANGE_STRATEGIES) - .build(); - - return client - .get() - .uri(URI.create(tokenResponse.basePath() + "/metadata/")) - .header("Authorization", "Token " + tokenResponse.accessToken()) - .header("Accept", MediaType.APPLICATION_JSON_VALUE) - .retrieve() - .bodyToMono(byte[].class) - .timeout(REQUEST_TIMEOUT); - }) + .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<>(); From e6efe9650a371f85bef791d04a93b6f4aebba027 Mon Sep 17 00:00:00 2001 From: Christoph Dyllick-Brenzinger Date: Thu, 19 Mar 2026 01:53:59 +0100 Subject: [PATCH 7/7] Address final CodeRabbit review feedback - Fail fast on unsupported commands before token exchange - Use Mono.empty() instead of null in validateCommandInputs - Propagate metadata parsing errors instead of silently returning empty schema --- .../com/external/plugins/SeaTablePlugin.java | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 index ab94c2d90cd9..865cc771cc25 100644 --- 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 @@ -205,13 +205,19 @@ private Mono executeQuery( SeaTableErrorMessages.MISSING_COMMAND_ERROR_MSG)); } - // Validate required fields before making any network calls - Mono validation = validateCommandInputs(command, formData); - if (validation != null) { - return validation.then(Mono.empty()); + // 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)); } - return fetchAccessToken(datasourceConfiguration) + // 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(); @@ -228,7 +234,7 @@ private Mono executeQuery( AppsmithPluginError.PLUGIN_EXECUTE_ARGUMENT_ERROR, "Unknown command: " + command)); }; - }); + })); } /** @@ -389,7 +395,7 @@ private Mono executeRequest(WebClient.RequestHeadersSpec< /** * Validates required form fields for a command before making network calls. - * Returns a Mono.error if validation fails, or null if validation passes. + * 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, ""); @@ -427,10 +433,9 @@ private Mono validateCommandInputs(String command, Map for } break; default: - // LIST_TABLES and unknown commands need no pre-validation break; } - return null; + return Mono.empty(); } // --- Command implementations --- @@ -751,7 +756,11 @@ public Mono getStructure( new ArrayList<>())); } } catch (IOException e) { - log.error("Failed to parse SeaTable metadata", 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;