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