feat: Add SeaTable as native data source plugin#41715
feat: Add SeaTable as native data source plugin#41715christophdb wants to merge 7 commits intoappsmithorg:releasefrom
Conversation
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 appsmithorg#41627
- 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()
- 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
SeaTable should appear under "SaaS Integrations" alongside Airtable and Google Sheets, not under "APIs". Also fix documentationLink URL.
- 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
- 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
- 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
|
Hi team! This is a resubmission of #41629, which was auto-closed due to inactivity before it could be reviewed. The plugin is fully functional and tested. I'd appreciate it if a maintainer could take a look when you get a chance. Happy to address any feedback. Thanks! |
WalkthroughThis PR introduces SeaTable as a native data source plugin for Appsmith. It includes the Maven module configuration, Java plugin executor with stateless HTTP operations, datasource authentication and schema discovery, seven UI editor forms for commands (LIST_ROWS, GET_ROW, CREATE_ROW, UPDATE_ROW, DELETE_ROW, LIST_TABLES, SQL_QUERY), comprehensive test coverage with MockWebServer, and database migration to register the plugin. Changes
Sequence DiagramsequenceDiagram
participant Client as Appsmith Client
participant Executor as SeaTablePlugin Executor
participant SeaTable as SeaTable API
participant DB as SeaTable Metadata
Client->>Executor: testDatasource(url, apiToken)
Executor->>SeaTable: GET /api/v2.1/dtable/app-access-token/<br/>(auth: apiToken)
SeaTable-->>Executor: {access_token, dtable_uuid, dtable_server}
Executor-->>Client: DatasourceTestResult (success/failure)
Client->>Executor: executeParameterized(command=LIST_ROWS, ...)
Executor->>SeaTable: GET /api/v2.1/dtables/{uuid}/rows/<br/>(auth: access_token)
SeaTable-->>Executor: rows[] JSON
Executor-->>Client: ActionExecutionResult
Client->>Executor: getStructure()
Executor->>SeaTable: GET /api/v2.1/dtables/{uuid}/metadata/<br/>(auth: access_token)
SeaTable-->>DB: fetch tables & columns
DB-->>Executor: metadata.tables[]
Executor-->>Client: DatasourceStructure
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java (2)
515-559: Assert that validation fails before the token exchange.These tests currently prove the exception, but not the fail-fast behavior. If a future refactor fetches
/app-access-token/before validatingcommand,tableName, orrowId, they would still pass.✅ Small test hardening
StepVerifier.create(resultMono) .expectErrorMatches(e -> e instanceof AppsmithPluginException && e.getMessage().contains("Missing command")) .verify(); +assertEquals(0, mockWebServer.getRequestCount());Apply the same assertion to the missing-table-name and missing-row-id cases.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java` around lines 515 - 559, Add the same "fail-fast before token exchange" assertion used in testMissingCommand to both testListRows_missingTableName and testGetRow_missingRowId: do not enqueueAccessTokenResponse() (or if you must call it, assert the access-token endpoint on the mock server was not invoked) and after invoking pluginExecutor.executeParameterized(...) and the StepVerifier checks, assert that no token request was made (e.g., mockServer.getRequestCount() == 0 or equivalent). Update the two tests (testListRows_missingTableName and testGetRow_missingRowId) to include this assertion so validation is proven to run before any call to enqueueAccessTokenResponse()/token exchange.
320-386: Add one test for the body-substitution path.
CREATE_ROWandUPDATE_ROWonly exercise literal JSON bodies here. A regression inexecuteParameterized()'s mustache replacement, or in thesmartSubstitution=falsebranch, would slip through.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java` around lines 320 - 386, Add a unit test in SeaTablePluginTest that covers the mustache/body-substitution path: create an ActionConfiguration (via createActionConfig) for CREATE_ROW and another for UPDATE_ROW where FieldName.BODY contains a template (e.g. "{\"Name\":\"{{name}}\"}" or "{\"Age\":{{age}}}") and ensure smartSubstitution is disabled/false on the ActionConfiguration so the code path that performs manual mustache replacement in executeParameterized() is exercised; call pluginExecutor.executeParameterized(...) with a matching ExecuteActionDTO (or provide the template variables in the DTO/context used by substitution), assert execution success, then inspect the RecordedRequest body (like in testCreateRow/testUpdateRow) to verify the substituted values appear in the outgoing JSON. Ensure tests reference createActionConfig, FieldName.BODY, executeParameterized, and the RecordedRequest assertions to locate changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java`:
- Around line 513-519: The code currently wraps any JSON (arrays/scalars)
returned by objectMapper.readTree into the SeaTable "rows"/"updates" payload;
update both executeCreateRow(...) and executeUpdateRow(...) to ensure the parsed
JsonNode (the variable rowData from objectMapper.readTree) is an object node
before building the wrapper: if rowData == null or !rowData.isObject(), return
or throw a clear validation error (rejecting non-object bodies) rather than
proceeding to create the wrapper/rows ArrayNode and requestBody; keep the same
wrapper creation (wrapper.put("table_name", ...), rowsArray.add(rowData),
wrapper.set("rows", rowsArray), requestBody =
objectMapper.writeValueAsString(wrapper)) only after the isObject check passes.
- Around line 718-725: In getStructure(), do not silently return an empty
structure when the response JSON lacks the expected "metadata" or
"metadata.tables" fields; instead treat that as an error and propagate/fail
fast. Locate the checks on JsonNode metadata and JsonNode tablesNode in
SeaTablePlugin.getStructure() and replace the early "return structure" behavior
with raising/returning an appropriate error (e.g., throw a checked/unchecked
exception or return a failed Result/Action with a clear message) that includes
the raw response or an explanatory message so callers see the discovery failure
instead of an empty schema.
In
`@app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.json`:
- Around line 23-74: The UI exposes a WHERE_CLAUSE (configProperty
actionConfiguration.formData.where.data) but executeListRows in
SeaTablePlugin.java never reads it; update executeListRows to parse
actionConfiguration.formData.where.data (the WHERE_CLAUSE structure), map its
comparisonTypes and logicalTypes to SeaTable filter syntax, build the filter
expression and include it in the API request (or query parameters) sent to list
rows; ensure you handle nestedLevels and all comparison values (EQ, NOT_EQ, LT,
LTE, GT, GTE, CONTAINS) and fallback safely for unsupported ops, or if you
prefer not to implement filtering remove the WHERE_CLAUSE control from
listRows.json to avoid a dangling UI.
---
Nitpick comments:
In
`@app/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.java`:
- Around line 515-559: Add the same "fail-fast before token exchange" assertion
used in testMissingCommand to both testListRows_missingTableName and
testGetRow_missingRowId: do not enqueueAccessTokenResponse() (or if you must
call it, assert the access-token endpoint on the mock server was not invoked)
and after invoking pluginExecutor.executeParameterized(...) and the StepVerifier
checks, assert that no token request was made (e.g.,
mockServer.getRequestCount() == 0 or equivalent). Update the two tests
(testListRows_missingTableName and testGetRow_missingRowId) to include this
assertion so validation is proven to run before any call to
enqueueAccessTokenResponse()/token exchange.
- Around line 320-386: Add a unit test in SeaTablePluginTest that covers the
mustache/body-substitution path: create an ActionConfiguration (via
createActionConfig) for CREATE_ROW and another for UPDATE_ROW where
FieldName.BODY contains a template (e.g. "{\"Name\":\"{{name}}\"}" or
"{\"Age\":{{age}}}") and ensure smartSubstitution is disabled/false on the
ActionConfiguration so the code path that performs manual mustache replacement
in executeParameterized() is exercised; call
pluginExecutor.executeParameterized(...) with a matching ExecuteActionDTO (or
provide the template variables in the DTO/context used by substitution), assert
execution success, then inspect the RecordedRequest body (like in
testCreateRow/testUpdateRow) to verify the substituted values appear in the
outgoing JSON. Ensure tests reference createActionConfig, FieldName.BODY,
executeParameterized, and the RecordedRequest assertions to locate changes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: cec430bf-5481-4f3d-8084-6608a0d9652e
📒 Files selected for processing (19)
app/server/appsmith-plugins/pom.xmlapp/server/appsmith-plugins/seaTablePlugin/pom.xmlapp/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/constants/FieldName.javaapp/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.javaapp/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTableErrorMessages.javaapp/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/exceptions/SeaTablePluginError.javaapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/createRow.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/deleteRow.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/getRow.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listTables.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/root.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/sqlQuery.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/updateRow.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/form.jsonapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/plugin.propertiesapp/server/appsmith-plugins/seaTablePlugin/src/main/resources/setting.jsonapp/server/appsmith-plugins/seaTablePlugin/src/test/java/com/external/plugins/SeaTablePluginTest.javaapp/server/appsmith-server/src/main/java/com/appsmith/server/migrations/DatabaseChangelog2.java
| 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); |
There was a problem hiding this comment.
Reject non-object row bodies locally.
readTree() accepts arrays and scalars here, so inputs like [] or "foo" still get wrapped into rows / updates and only fail after the HTTP call. Both commands should require a JSON object before building the SeaTable payload.
🛡️ Tighten the body validation
JsonNode rowData = objectMapper.readTree(StringUtils.isBlank(body) ? "{}" : body);
+if (!rowData.isObject()) {
+ return Mono.error(new AppsmithPluginException(
+ SeaTablePluginError.INVALID_BODY_ERROR,
+ "Row body must be a JSON object."));
+}Apply the same check in both executeCreateRow(...) and executeUpdateRow(...).
Also applies to: 544-556
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java`
around lines 513 - 519, The code currently wraps any JSON (arrays/scalars)
returned by objectMapper.readTree into the SeaTable "rows"/"updates" payload;
update both executeCreateRow(...) and executeUpdateRow(...) to ensure the parsed
JsonNode (the variable rowData from objectMapper.readTree) is an object node
before building the wrapper: if rowData == null or !rowData.isObject(), return
or throw a clear validation error (rejecting non-object bodies) rather than
proceeding to create the wrapper/rows ArrayNode and requestBody; keep the same
wrapper creation (wrapper.put("table_name", ...), rowsArray.add(rowData),
wrapper.set("rows", rowsArray), requestBody =
objectMapper.writeValueAsString(wrapper)) only after the isObject check passes.
| JsonNode metadata = json.get("metadata"); | ||
| if (metadata == null) { | ||
| return structure; | ||
| } | ||
| JsonNode tablesNode = metadata.get("tables"); | ||
| if (tablesNode == null || !tablesNode.isArray()) { | ||
| return structure; | ||
| } |
There was a problem hiding this comment.
Don't turn a malformed metadata response into an empty schema.
If the metadata endpoint returns valid JSON without metadata.tables—for example an API error payload or an unexpected response shape—getStructure() currently reports an empty base instead of surfacing the failure. That makes schema discovery look successful when it actually broke.
🚨 Fail fast on an invalid metadata shape
JsonNode metadata = json.get("metadata");
- if (metadata == null) {
- return structure;
- }
+ if (metadata == null) {
+ throw Exceptions.propagate(new AppsmithPluginException(
+ SeaTablePluginError.QUERY_EXECUTION_FAILED,
+ String.format(
+ SeaTableErrorMessages.QUERY_EXECUTION_FAILED_ERROR_MSG,
+ "Invalid SeaTable metadata response: missing metadata node")));
+ }
JsonNode tablesNode = metadata.get("tables");
- if (tablesNode == null || !tablesNode.isArray()) {
- return structure;
- }
+ if (tablesNode == null || !tablesNode.isArray()) {
+ throw Exceptions.propagate(new AppsmithPluginException(
+ SeaTablePluginError.QUERY_EXECUTION_FAILED,
+ String.format(
+ SeaTableErrorMessages.QUERY_EXECUTION_FAILED_ERROR_MSG,
+ "Invalid SeaTable metadata response: metadata.tables must be an array")));
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java`
around lines 718 - 725, In getStructure(), do not silently return an empty
structure when the response JSON lacks the expected "metadata" or
"metadata.tables" fields; instead treat that as an error and propagate/fail
fast. Locate the checks on JsonNode metadata and JsonNode tablesNode in
SeaTablePlugin.getStructure() and replace the early "return structure" behavior
with raising/returning an appropriate error (e.g., throw a checked/unchecked
exception or return a failed Result/Action with a clear message) that includes
the raw response or an explanatory message so callers see the discovery failure
instead of an empty schema.
| { | ||
| "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" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify that the where clause is not being processed in executeListRows
ast-grep --pattern $'private Mono<ActionExecutionResult> executeListRows($_, $_, $_) {
$$$
}'
# Also search for any usage of "where" field in the plugin
rg -n "where" --type=java app/server/appsmith-plugins/seaTablePlugin/Repository: appsmithorg/appsmith
Length of output: 209
🏁 Script executed:
#!/bin/bash
# Find the executeListRows method implementation
rg -n "executeListRows" --type=java app/server/appsmith-plugins/seaTablePlugin/ -A 30
# Also search for how formData.where is used
rg -n "formData.*where" --type=java app/server/appsmith-plugins/seaTablePlugin/
# Check the SeaTablePlugin main class
fd -e "SeaTablePlugin.java" app/server/appsmith-plugins/seaTablePlugin/Repository: appsmithorg/appsmith
Length of output: 10626
🏁 Script executed:
#!/bin/bash
# Get the complete executeListRows method
sed -n '447,500p' app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java
# Also search for any WHERE_CLAUSE or where references in context of list/filter operations
rg -n "WHERE|where" --type=java app/server/appsmith-plugins/seaTablePlugin/src/main/java/com/external/plugins/SeaTablePlugin.java -B 2 -A 2Repository: appsmithorg/appsmith
Length of output: 2701
WHERE clause UI is defined but not processed by the backend.
The executeListRows method (lines 447-487 in SeaTablePlugin.java) only extracts tableName, limit, offset, orderBy, and direction. The where.data field from this UI element is never read or translated into SeaTable API filter expressions.
Either implement filter logic in the executor or remove the WHERE_CLAUSE control to avoid misleading users.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@app/server/appsmith-plugins/seaTablePlugin/src/main/resources/editor/listRows.json`
around lines 23 - 74, The UI exposes a WHERE_CLAUSE (configProperty
actionConfiguration.formData.where.data) but executeListRows in
SeaTablePlugin.java never reads it; update executeListRows to parse
actionConfiguration.formData.where.data (the WHERE_CLAUSE structure), map its
comparisonTypes and logicalTypes to SeaTable filter syntax, build the filter
expression and include it in the API request (or query parameters) sent to list
rows; ensure you handle nestedLevels and all comparison values (EQ, NOT_EQ, LT,
LTE, GT, GTE, CONTAINS) and fallback safely for unsupported ops, or if you
prefer not to implement filtering remove the WHERE_CLAUSE control from
listRows.json to avoid a dangling UI.
Summary
Features
/api/v2/dtables/{uuid}/rows//api/v2/dtables/{uuid}/rows/{row_id}//api/v2/dtables/{uuid}/rows//api/v2/dtables/{uuid}/rows//api/v2/dtables/{uuid}/rows//api/v2/dtables/{uuid}/metadata//api/v2/dtables/{uuid}/sql/Implementation Details
PluginExecutor<Void>(stateless HTTP, no persistent connection)getStructure()via metadata endpointVerified against live SeaTable instance
All API operations have been tested and verified against a live SeaTable server:
dtable_serveranddtable_uuidreturned correctlyconvert_keys=truereturns column names, pagination vialimit/startworksconvert_keys=truerow_idsandfirst_rowreturned{"success": true}confirmedconvert_keys=truein body works, results and metadata returnedTest plan
dtable_serverURL handling verifiedReferences
Summary by CodeRabbit
Release Notes
New Features