diff --git a/README.md b/README.md index fa2bebf..ec8f3e8 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The current `golemcore/*` modules in this repository are: | `golemcore/tavily-search` | Tavily-backed web search tool plugin with configurable search depth and answer generation. | | `golemcore/telegram` | Telegram channel, invite onboarding, confirmations, and plan approval integration. | | `golemcore/slack` | Slack Socket Mode channel plugin with thread follow-ups, confirmations, and plan approval UI. | +| `golemcore/supabase` | Supabase PostgREST rows plugin with configurable read/write operations for database tables. | | `golemcore/weather` | Open-Meteo weather tool plugin with no external credentials required. | | `golemcore/whisper` | Whisper-compatible speech-to-text provider plugin. | diff --git a/golemcore/supabase/plugin.yaml b/golemcore/supabase/plugin.yaml new file mode 100644 index 0000000..c58b407 --- /dev/null +++ b/golemcore/supabase/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/supabase +provider: golemcore +name: supabase +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.supabase.SupabasePluginBootstrap +description: Supabase PostgREST rows plugin backed by the Supabase REST API. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/supabase +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/supabase/pom.xml b/golemcore/supabase/pom.xml new file mode 100644 index 0000000..b38f00c --- /dev/null +++ b/golemcore/supabase/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-supabase-plugin + golemcore/supabase + Supabase PostgREST rows plugin for GolemCore + + + golemcore + supabase + ../../misc/formatter_eclipse.xml + + + + + me.golemcore.plugins + golemcore-plugin-extension-api + provided + + + me.golemcore.plugins + golemcore-plugin-runtime-api + provided + + + org.projectlombok + lombok + provided + + + org.springframework + spring-context + + + com.fasterxml.jackson.core + jackson-databind + + + com.squareup.okhttp3 + okhttp-jvm + ${okhttp.version} + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + src/main/resources + + + ${project.basedir} + + plugin.yaml + + META-INF/golemcore + + + + + net.revelc.code.formatter + formatter-maven-plugin + + + maven-shade-plugin + + + maven-antrun-plugin + + + copy-plugin-artifact + package + + + + + + + + run + + + + + + + diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginBootstrap.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginBootstrap.java new file mode 100644 index 0000000..f17eb9d --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class SupabasePluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/supabase") + .provider("golemcore") + .name("supabase") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return SupabasePluginConfiguration.class; + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfig.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfig.java new file mode 100644 index 0000000..19b7309 --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfig.java @@ -0,0 +1,84 @@ +package me.golemcore.plugins.golemcore.supabase; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SupabasePluginConfig { + + static final String DEFAULT_SCHEMA = "public"; + static final String DEFAULT_SELECT = "*"; + static final int DEFAULT_LIMIT = 20; + static final int MAX_LIMIT = 1000; + + @Builder.Default + private Boolean enabled = false; + + private String projectUrl; + private String apiKey; + + @Builder.Default + private String defaultSchema = DEFAULT_SCHEMA; + + @Builder.Default + private String defaultTable = ""; + + @Builder.Default + private String defaultSelect = DEFAULT_SELECT; + + @Builder.Default + private Integer defaultLimit = DEFAULT_LIMIT; + + @Builder.Default + private Boolean allowWrite = false; + + @Builder.Default + private Boolean allowDelete = false; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + projectUrl = normalizeUrl(projectUrl, null); + apiKey = normalizeText(apiKey, null); + defaultSchema = normalizeText(defaultSchema, DEFAULT_SCHEMA); + defaultTable = normalizeText(defaultTable, ""); + defaultSelect = normalizeText(defaultSelect, DEFAULT_SELECT); + if (defaultLimit == null || defaultLimit <= 0) { + defaultLimit = DEFAULT_LIMIT; + } + if (defaultLimit > MAX_LIMIT) { + defaultLimit = MAX_LIMIT; + } + if (allowWrite == null) { + allowWrite = false; + } + if (allowDelete == null) { + allowDelete = false; + } + } + + private String normalizeUrl(String value, String defaultValue) { + String trimmed = normalizeText(value, defaultValue); + if (trimmed == null) { + return null; + } + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed.isBlank() ? defaultValue : trimmed; + } + + private String normalizeText(String value, String defaultValue) { + if (value == null) { + return defaultValue; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? defaultValue : trimmed; + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfigService.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfigService.java new file mode 100644 index 0000000..c84ae5f --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfigService.java @@ -0,0 +1,36 @@ +package me.golemcore.plugins.golemcore.supabase; + +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class SupabasePluginConfigService { + + static final String PLUGIN_ID = "golemcore/supabase"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper; + + public SupabasePluginConfigService(PluginConfigurationService pluginConfigurationService) { + this.pluginConfigurationService = pluginConfigurationService; + this.objectMapper = new ObjectMapper(); + } + + public SupabasePluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + SupabasePluginConfig config = raw.isEmpty() + ? SupabasePluginConfig.builder().build() + : objectMapper.convertValue(raw, SupabasePluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(SupabasePluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfiguration.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfiguration.java new file mode 100644 index 0000000..f5b834a --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfiguration.java @@ -0,0 +1,12 @@ +package me.golemcore.plugins.golemcore.supabase; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = { + SupabaseRowsToolProvider.class, + SupabasePluginSettingsContributor.class +}) +public class SupabasePluginConfiguration { +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginSettingsContributor.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginSettingsContributor.java new file mode 100644 index 0000000..4c04d95 --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginSettingsContributor.java @@ -0,0 +1,261 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsAction; +import me.golemcore.plugin.api.extension.spi.PluginSettingsCatalogItem; +import me.golemcore.plugin.api.extension.spi.PluginSettingsContributor; +import me.golemcore.plugin.api.extension.spi.PluginSettingsField; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseApiClient; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseApiException; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseTransportException; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +public class SupabasePluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; + + private final SupabasePluginConfigService configService; + private final SupabaseApiClient apiClient; + + public SupabasePluginSettingsContributor( + SupabasePluginConfigService configService, + SupabaseApiClient apiClient) { + this.configService = configService; + this.apiClient = apiClient; + } + + @Override + public String getPluginId() { + return SupabasePluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(SupabasePluginConfigService.PLUGIN_ID) + .pluginName("supabase") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("Supabase") + .description("Supabase project, schema, and write permissions for table row operations.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(34) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + SupabasePluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("projectUrl", orEmpty(config.getProjectUrl())); + values.put("apiKey", ""); + values.put("defaultSchema", config.getDefaultSchema()); + values.put("defaultTable", config.getDefaultTable()); + values.put("defaultSelect", config.getDefaultSelect()); + values.put("defaultLimit", config.getDefaultLimit()); + values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite())); + values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete())); + return PluginSettingsSection.builder() + .title("Supabase") + .description("Configure Supabase PostgREST access for listing and mutating table rows.") + .fields(List.of( + booleanField("enabled", "Enable Supabase", + "Allow the model to query and mutate Supabase rows."), + textField("projectUrl", "Project URL", + "Supabase project URL, for example https://your-project.supabase.co.", + "https://your-project.supabase.co"), + secretField("apiKey", "API Key", + "Supabase service role or anon key. Leave blank to keep the current secret."), + textField("defaultSchema", "Default Schema", + "Default Postgres schema used for requests.", SupabasePluginConfig.DEFAULT_SCHEMA), + textField("defaultTable", "Default Table", + "Optional default table name used when the tool call omits table.", + "tasks"), + textField("defaultSelect", "Default Select", + "Default PostgREST select expression for row queries.", + SupabasePluginConfig.DEFAULT_SELECT), + numberField("defaultLimit", "Default Limit", + "Maximum rows returned when limit is omitted.", 1.0, 1000.0, 1.0), + booleanField("allowWrite", "Allow Write", + "Permit insert_row and update_rows operations."), + booleanField("allowDelete", "Allow Delete", + "Permit delete_rows operations."))) + .values(values) + .actions(List.of(PluginSettingsAction.builder() + .actionId(ACTION_TEST_CONNECTION) + .label("Test Connection") + .variant("secondary") + .build())) + .build(); + } + + @Override + public PluginSettingsSection saveSection(String sectionKey, Map values) { + requireSection(sectionKey); + SupabasePluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setProjectUrl(readString(values, "projectUrl", config.getProjectUrl())); + String apiKey = readString(values, "apiKey", null); + if (apiKey != null && !apiKey.isBlank()) { + config.setApiKey(apiKey); + } + config.setDefaultSchema(readString(values, "defaultSchema", config.getDefaultSchema())); + config.setDefaultTable(readString(values, "defaultTable", config.getDefaultTable())); + config.setDefaultSelect(readString(values, "defaultSelect", config.getDefaultSelect())); + config.setDefaultLimit(readInteger(values, "defaultLimit", config.getDefaultLimit())); + config.setAllowWrite(readBoolean(values, "allowWrite", false)); + config.setAllowDelete(readBoolean(values, "allowDelete", false)); + configService.save(config); + return getSection(sectionKey); + } + + @Override + public PluginActionResult executeAction(String sectionKey, String actionId, Map payload) { + requireSection(sectionKey); + if (ACTION_TEST_CONNECTION.equals(actionId)) { + return testConnection(); + } + throw new IllegalArgumentException("Unknown Supabase action: " + actionId); + } + + private PluginActionResult testConnection() { + SupabasePluginConfig config = configService.getConfig(); + if (!hasText(config.getProjectUrl())) { + return PluginActionResult.builder() + .status("error") + .message("Supabase project URL is not configured.") + .build(); + } + if (!hasText(config.getApiKey())) { + return PluginActionResult.builder() + .status("error") + .message("Supabase API key is not configured.") + .build(); + } + if (!hasText(config.getDefaultTable())) { + return PluginActionResult.builder() + .status("error") + .message("Default Supabase table is not configured.") + .build(); + } + try { + List> rows = apiClient.selectRows( + config.getDefaultTable(), + config.getDefaultSchema(), + config.getDefaultSelect(), + 1, + null, + null, + Optional.empty(), + Map.of()); + return PluginActionResult.builder() + .status("ok") + .message("Connected to Supabase. Read access to " + + config.getDefaultSchema() + "." + config.getDefaultTable() + + " is available (" + rows.size() + " row(s) checked).") + .build(); + } catch (IllegalArgumentException | IllegalStateException | SupabaseApiException + | SupabaseTransportException ex) { + return PluginActionResult.builder() + .status("error") + .message("Connection failed: " + ex.getMessage()) + .build(); + } + } + + private PluginSettingsField booleanField(String key, String label, String description) { + return PluginSettingsField.builder() + .key(key) + .type("boolean") + .label(label) + .description(description) + .build(); + } + + private PluginSettingsField textField(String key, String label, String description, String placeholder) { + return PluginSettingsField.builder() + .key(key) + .type("text") + .label(label) + .description(description) + .placeholder(placeholder) + .build(); + } + + private PluginSettingsField secretField(String key, String label, String description) { + return PluginSettingsField.builder() + .key(key) + .type("secret") + .label(label) + .description(description) + .build(); + } + + private PluginSettingsField numberField(String key, String label, String description, + Double min, Double max, Double step) { + return PluginSettingsField.builder() + .key(key) + .type("number") + .label(label) + .description(description) + .min(min) + .max(max) + .step(step) + .build(); + } + + private void requireSection(String sectionKey) { + if (!SECTION_KEY.equals(sectionKey)) { + throw new IllegalArgumentException("Unknown Supabase settings section: " + sectionKey); + } + } + + private boolean readBoolean(Map values, String key, boolean defaultValue) { + Object value = values.get(key); + return value instanceof Boolean bool ? bool : defaultValue; + } + + private int readInteger(Map values, String key, int defaultValue) { + Object value = values.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + return defaultValue; + } + + private String readString(Map values, String key, String defaultValue) { + Object value = values.get(key); + if (value instanceof String text) { + String trimmed = text.trim(); + return trimmed.isEmpty() ? defaultValue : trimmed; + } + return defaultValue; + } + + private String orEmpty(String value) { + return value != null ? value : ""; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsService.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsService.java new file mode 100644 index 0000000..5c4db8f --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsService.java @@ -0,0 +1,243 @@ +package me.golemcore.plugins.golemcore.supabase; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseApiClient; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseApiException; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseTransportException; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +public class SupabaseRowsService { + + private final SupabaseApiClient apiClient; + private final SupabasePluginConfigService configService; + private final ObjectMapper objectMapper; + + public SupabaseRowsService(SupabaseApiClient apiClient, SupabasePluginConfigService configService) { + this.apiClient = apiClient; + this.configService = configService; + this.objectMapper = new ObjectMapper(); + } + + public SupabasePluginConfig getConfig() { + return configService.getConfig(); + } + + public ToolResult selectRows( + String table, + String schema, + String select, + Integer limit, + Integer offset, + String orderBy, + Optional ascending, + Map filters) { + try { + SupabasePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedSchema = resolveSchema(schema, config); + String resolvedSelect = hasText(select) ? select.trim() : config.getDefaultSelect(); + int resolvedLimit = normalizeLimit(limit, config.getDefaultLimit()); + List> rows = apiClient.selectRows( + resolvedTable, + resolvedSchema, + resolvedSelect, + resolvedLimit, + offset, + normalizeOptional(orderBy), + ascending, + filters); + Map data = new LinkedHashMap<>(); + data.put("table", resolvedTable); + data.put("schema", resolvedSchema); + data.put("count", rows.size()); + data.put("rows", rows); + return ToolResult.success(buildRowsOutput("Supabase rows", resolvedSchema, resolvedTable, rows), data); + } catch (IllegalArgumentException | IllegalStateException | SupabaseApiException + | SupabaseTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult insertRow(String table, String schema, Map values) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Supabase write is disabled in plugin settings"); + } + try { + SupabasePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedSchema = resolveSchema(schema, config); + Map normalizedValues = requireValues(values); + List> rows = apiClient.insertRow(resolvedTable, resolvedSchema, normalizedValues); + Map data = new LinkedHashMap<>(); + data.put("table", resolvedTable); + data.put("schema", resolvedSchema); + data.put("count", rows.size()); + data.put("rows", rows); + return ToolResult.success( + buildRowsOutput("Inserted Supabase rows into", resolvedSchema, resolvedTable, rows), + data); + } catch (IllegalArgumentException | IllegalStateException | SupabaseApiException + | SupabaseTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult updateRows( + String table, + String schema, + Map filters, + Map values) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Supabase write is disabled in plugin settings"); + } + try { + SupabasePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedSchema = resolveSchema(schema, config); + Map normalizedFilters = requireFilters(filters); + Map normalizedValues = requireValues(values); + List> rows = apiClient.updateRows( + resolvedTable, + resolvedSchema, + normalizedFilters, + normalizedValues); + Map data = new LinkedHashMap<>(); + data.put("table", resolvedTable); + data.put("schema", resolvedSchema); + data.put("count", rows.size()); + data.put("rows", rows); + return ToolResult.success(buildRowsOutput("Updated Supabase rows in", resolvedSchema, resolvedTable, rows), + data); + } catch (IllegalArgumentException | IllegalStateException | SupabaseApiException + | SupabaseTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult deleteRows(String table, String schema, Map filters) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Supabase delete is disabled in plugin settings"); + } + try { + SupabasePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedSchema = resolveSchema(schema, config); + Map normalizedFilters = requireFilters(filters); + List> rows = apiClient.deleteRows(resolvedTable, resolvedSchema, normalizedFilters); + Map data = new LinkedHashMap<>(); + data.put("table", resolvedTable); + data.put("schema", resolvedSchema); + data.put("count", rows.size()); + data.put("rows", rows); + return ToolResult.success( + buildRowsOutput("Deleted Supabase rows from", resolvedSchema, resolvedTable, rows), + data); + } catch (IllegalArgumentException | IllegalStateException | SupabaseApiException + | SupabaseTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private SupabasePluginConfig requireConfigured() { + SupabasePluginConfig config = configService.getConfig(); + if (!Boolean.TRUE.equals(config.getEnabled())) { + throw new IllegalStateException("Supabase plugin is disabled in plugin settings"); + } + if (!hasText(config.getProjectUrl())) { + throw new IllegalStateException("Supabase project URL is not configured."); + } + if (!hasText(config.getApiKey())) { + throw new IllegalStateException("Supabase API key is not configured."); + } + return config; + } + + private String resolveTable(String table, SupabasePluginConfig config) { + String resolvedTable = hasText(table) ? table.trim() : config.getDefaultTable(); + if (!hasText(resolvedTable)) { + throw new IllegalArgumentException( + "Supabase table is required. Configure a default table or pass the table parameter."); + } + return resolvedTable; + } + + private String resolveSchema(String schema, SupabasePluginConfig config) { + return hasText(schema) ? schema.trim() : config.getDefaultSchema(); + } + + private int normalizeLimit(Integer requestedLimit, int defaultLimit) { + int resolved = requestedLimit != null ? requestedLimit : defaultLimit; + if (resolved <= 0) { + return SupabasePluginConfig.DEFAULT_LIMIT; + } + return Math.min(resolved, SupabasePluginConfig.MAX_LIMIT); + } + + private String normalizeOptional(String value) { + return hasText(value) ? value.trim() : null; + } + + private Map requireFilters(Map filters) { + if (filters == null || filters.isEmpty()) { + throw new IllegalArgumentException( + "filters must contain at least one PostgREST filter expression for update_rows or delete_rows"); + } + return filters; + } + + private Map requireValues(Map values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must contain at least one column value"); + } + return values; + } + + private String buildRowsOutput(String action, String schema, String table, List> rows) { + String qualifiedTable = schema + "." + table; + if (rows.isEmpty()) { + return action + " " + qualifiedTable + ": 0 row(s) matched."; + } + StringBuilder output = new StringBuilder(); + output.append(action) + .append(' ') + .append(qualifiedTable) + .append(" (") + .append(rows.size()) + .append(" row(s)):\n\n"); + for (int index = 0; index < rows.size(); index++) { + output.append(index + 1) + .append(". ") + .append(serializeJson(rows.get(index))) + .append("\n\n"); + } + return output.toString().trim(); + } + + private String serializeJson(Object value) { + try { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value); + } catch (JsonProcessingException ex) { + return String.valueOf(value); + } + } + + private ToolResult executionFailure(String message) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, message); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsToolProvider.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsToolProvider.java new file mode 100644 index 0000000..6a7480f --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsToolProvider.java @@ -0,0 +1,237 @@ +package me.golemcore.plugins.golemcore.supabase; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugin.api.extension.model.ToolDefinition; +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugin.api.extension.spi.ToolProvider; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Component +public class SupabaseRowsToolProvider implements ToolProvider { + + private static final String TYPE = "type"; + private static final String TYPE_OBJECT = "object"; + private static final String TYPE_STRING = "string"; + private static final String TYPE_INTEGER = "integer"; + private static final String TYPE_BOOLEAN = "boolean"; + private static final String PROPERTIES = "properties"; + private static final String REQUIRED = "required"; + private static final String PARAM_OPERATION = "operation"; + private static final String PARAM_TABLE = "table"; + private static final String PARAM_SCHEMA = "schema"; + private static final String PARAM_SELECT = "select"; + private static final String PARAM_LIMIT = "limit"; + private static final String PARAM_OFFSET = "offset"; + private static final String PARAM_ORDER_BY = "order_by"; + private static final String PARAM_ASCENDING = "ascending"; + private static final String PARAM_FILTERS = "filters"; + private static final String PARAM_VALUES = "values"; + + private final SupabaseRowsService service; + private final ObjectMapper objectMapper; + + public SupabaseRowsToolProvider(SupabaseRowsService service) { + this.service = service; + this.objectMapper = new ObjectMapper(); + } + + @Override + public boolean isEnabled() { + SupabasePluginConfig config = service.getConfig(); + return Boolean.TRUE.equals(config.getEnabled()) + && hasText(config.getProjectUrl()) + && hasText(config.getApiKey()); + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("supabase_rows") + .description( + "Query and mutate Supabase PostgREST table rows using filters, select expressions, and JSON values.") + .inputSchema(Map.ofEntries( + Map.entry(TYPE, TYPE_OBJECT), + Map.entry(PROPERTIES, Map.ofEntries( + Map.entry(PARAM_OPERATION, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("enum", List.of( + "select_rows", + "insert_row", + "update_rows", + "delete_rows")))), + Map.entry(PARAM_TABLE, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "Optional table name. Uses the configured default table when omitted."))), + Map.entry(PARAM_SCHEMA, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "Optional Postgres schema. Defaults to the configured schema."))), + Map.entry(PARAM_SELECT, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "PostgREST select expression, for example id,name,status."))), + Map.entry(PARAM_LIMIT, Map.ofEntries( + Map.entry(TYPE, TYPE_INTEGER), + Map.entry("description", + "Maximum number of rows to return for select_rows."))), + Map.entry(PARAM_OFFSET, Map.ofEntries( + Map.entry(TYPE, TYPE_INTEGER), + Map.entry("description", "Optional row offset for select_rows."))), + Map.entry(PARAM_ORDER_BY, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "Optional column used for ordering select_rows."))), + Map.entry(PARAM_ASCENDING, Map.ofEntries( + Map.entry(TYPE, TYPE_BOOLEAN), + Map.entry("description", + "Optional sort direction flag for select_rows."))), + Map.entry(PARAM_FILTERS, Map.ofEntries( + Map.entry(TYPE, TYPE_OBJECT), + Map.entry("description", + "Map of column to PostgREST filter expression, for example {status: \"eq.active\"}. A JSON string is also accepted."))), + Map.entry(PARAM_VALUES, Map.ofEntries( + Map.entry(TYPE, TYPE_OBJECT), + Map.entry("description", + "Map of column values for insert_row or update_rows. A JSON string is also accepted."))))), + Map.entry(REQUIRED, List.of(PARAM_OPERATION)), + Map.entry("allOf", List.of( + requiredWhen("insert_row", List.of(PARAM_VALUES)), + requiredWhen("update_rows", List.of(PARAM_FILTERS, PARAM_VALUES)), + requiredWhen("delete_rows", List.of(PARAM_FILTERS)))))) + .build(); + } + + @Override + public CompletableFuture execute(Map parameters) { + return CompletableFuture.supplyAsync(() -> executeOperation(parameters)); + } + + private ToolResult executeOperation(Map parameters) { + try { + String operation = readString(parameters.get(PARAM_OPERATION)); + if (!hasText(operation)) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, "operation is required"); + } + return switch (operation) { + case "select_rows" -> service.selectRows( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_SCHEMA)), + readString(parameters.get(PARAM_SELECT)), + readInteger(parameters.get(PARAM_LIMIT)), + readInteger(parameters.get(PARAM_OFFSET)), + readString(parameters.get(PARAM_ORDER_BY)), + readOptionalBoolean(parameters, PARAM_ASCENDING), + readFilterMap(parameters.get(PARAM_FILTERS))); + case "insert_row" -> service.insertRow( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_SCHEMA)), + readValueMap(parameters.get(PARAM_VALUES))); + case "update_rows" -> service.updateRows( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_SCHEMA)), + readFilterMap(parameters.get(PARAM_FILTERS)), + readValueMap(parameters.get(PARAM_VALUES))); + case "delete_rows" -> service.deleteRows( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_SCHEMA)), + readFilterMap(parameters.get(PARAM_FILTERS))); + default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Unsupported supabase_rows operation: " + operation); + }; + } catch (IllegalArgumentException ex) { + return ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, ex.getMessage()); + } + } + + private Map requiredWhen(String operation, List requiredFields) { + return Map.of( + "if", Map.of( + PROPERTIES, Map.of( + PARAM_OPERATION, Map.of("const", operation))), + "then", Map.of( + REQUIRED, requiredFields)); + } + + private String readString(Object value) { + return value instanceof String text ? text.trim() : null; + } + + private Integer readInteger(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text && !text.isBlank()) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("limit and offset must be integers"); + } + } + return null; + } + + private Optional readOptionalBoolean(Map parameters, String key) { + if (!parameters.containsKey(key)) { + return Optional.empty(); + } + Object value = parameters.get(key); + if (value instanceof Boolean bool) { + return Optional.of(bool); + } + throw new IllegalArgumentException(key + " must be a boolean"); + } + + private Map readFilterMap(Object value) { + Map raw = readObjectMap(value, "filters"); + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + Object filterValue = entry.getValue(); + if (filterValue != null) { + normalized.put(entry.getKey(), filterValue.toString()); + } + } + return normalized; + } + + private Map readValueMap(Object value) { + return readObjectMap(value, "values"); + } + + private Map readObjectMap(Object value, String fieldName) { + if (value == null) { + return Map.of(); + } + if (value instanceof Map map) { + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() == null) { + continue; + } + normalized.put(entry.getKey().toString(), entry.getValue()); + } + return normalized; + } + if (value instanceof String text && !text.isBlank()) { + try { + return objectMapper.readValue(text, new TypeReference<>() { + }); + } catch (Exception ex) { + throw new IllegalArgumentException(fieldName + " must be a JSON object or map"); + } + } + throw new IllegalArgumentException(fieldName + " must be a JSON object or map"); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiClient.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiClient.java new file mode 100644 index 0000000..d309a80 --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiClient.java @@ -0,0 +1,253 @@ +package me.golemcore.plugins.golemcore.supabase.support; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugins.golemcore.supabase.SupabasePluginConfig; +import me.golemcore.plugins.golemcore.supabase.SupabasePluginConfigService; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +public class SupabaseApiClient { + + private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final SupabasePluginConfigService configService; + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + + public SupabaseApiClient(SupabasePluginConfigService configService) { + this.configService = configService; + this.httpClient = new OkHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + public List> selectRows( + String table, + String schema, + String select, + Integer limit, + Integer offset, + String orderBy, + Optional ascending, + Map filters) { + HttpUrl.Builder builder = createTableUrlBuilder(table); + builder.addQueryParameter("select", select); + if (limit != null) { + builder.addQueryParameter("limit", Integer.toString(limit)); + } + if (offset != null) { + builder.addQueryParameter("offset", Integer.toString(offset)); + } + if (hasText(orderBy)) { + String direction = ascending.orElse(Boolean.TRUE) ? "asc" : "desc"; + builder.addQueryParameter("order", orderBy + "." + direction); + } + for (Map.Entry filter : filters.entrySet()) { + builder.addQueryParameter(filter.getKey(), filter.getValue()); + } + + Request request = authorizedRequest(builder.build()) + .header("Accept-Profile", schema) + .get() + .build(); + return executeRows(request); + } + + public List> insertRow(String table, String schema, Map values) { + Request request = authorizedRequest(createTableUrlBuilder(table).build()) + .header("Content-Profile", schema) + .header("Prefer", "return=representation") + .post(buildBody(values)) + .build(); + return executeRows(request); + } + + public List> updateRows( + String table, + String schema, + Map filters, + Map values) { + HttpUrl.Builder builder = createTableUrlBuilder(table); + for (Map.Entry filter : filters.entrySet()) { + builder.addQueryParameter(filter.getKey(), filter.getValue()); + } + Request request = authorizedRequest(builder.build()) + .header("Content-Profile", schema) + .header("Prefer", "return=representation") + .patch(buildBody(values)) + .build(); + return executeRows(request); + } + + public List> deleteRows(String table, String schema, Map filters) { + HttpUrl.Builder builder = createTableUrlBuilder(table); + for (Map.Entry filter : filters.entrySet()) { + builder.addQueryParameter(filter.getKey(), filter.getValue()); + } + Request request = authorizedRequest(builder.build()) + .header("Accept-Profile", schema) + .header("Prefer", "return=representation") + .delete() + .build(); + return executeRows(request); + } + + protected Response executeRequest(Request request) throws IOException { + return httpClient.newCall(request).execute(); + } + + private Request.Builder authorizedRequest(HttpUrl url) { + SupabasePluginConfig config = configService.getConfig(); + return new Request.Builder() + .url(url) + .header("Accept", "application/json") + .header("apikey", config.getApiKey()) + .header("Authorization", "Bearer " + config.getApiKey()); + } + + private RequestBody buildBody(Map values) { + try { + return RequestBody.create(objectMapper.writeValueAsString(values), APPLICATION_JSON); + } catch (IOException ex) { + throw new IllegalStateException("Failed to serialize Supabase request body", ex); + } + } + + private List> executeRows(Request request) { + try (Response response = executeRequest(request); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + if (!response.isSuccessful()) { + throw new SupabaseApiException(response.code(), extractErrorMessage(response.code(), responseBody)); + } + if (responseBody.isBlank()) { + return List.of(); + } + return parseRows(objectMapper.readTree(responseBody)); + } catch (SupabaseApiException ex) { + throw ex; + } catch (IOException ex) { + throw new SupabaseTransportException("Supabase transport failed: " + ex.getMessage(), ex); + } + } + + private List> parseRows(JsonNode root) { + List> rows = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode rowNode : root) { + rows.add(objectMapper.convertValue(rowNode, MAP_TYPE)); + } + return rows; + } + if (root.isObject()) { + rows.add(objectMapper.convertValue(root, MAP_TYPE)); + } + return rows; + } + + private String extractErrorMessage(int statusCode, String responseBody) { + if (responseBody != null && !responseBody.isBlank()) { + try { + JsonNode root = objectMapper.readTree(responseBody); + String message = readText(root.get("message")); + String details = readText(root.get("details")); + String hint = readText(root.get("hint")); + StringBuilder error = new StringBuilder(); + if (hasText(message)) { + error.append(message); + } + if (hasText(details)) { + if (error.length() > 0) { + error.append(" | "); + } + error.append(details); + } + if (hasText(hint)) { + if (error.length() > 0) { + error.append(" | "); + } + error.append("hint: ").append(hint); + } + if (error.length() > 0) { + return error.toString(); + } + } catch (IOException ignored) { + return "HTTP " + statusCode + ": " + responseBody; + } + return "HTTP " + statusCode + ": " + responseBody; + } + return "HTTP " + statusCode; + } + + private HttpUrl.Builder createTableUrlBuilder(String table) { + return createBaseUrlBuilder().addPathSegment(table); + } + + private HttpUrl.Builder createBaseUrlBuilder() { + SupabasePluginConfig config = configService.getConfig(); + URI uri = URI.create(config.getProjectUrl()); + if (uri.getScheme() == null || uri.getHost() == null) { + throw new IllegalStateException("Invalid Supabase project URL: " + config.getProjectUrl()); + } + HttpUrl.Builder builder = new HttpUrl.Builder() + .scheme(uri.getScheme()) + .host(uri.getHost()); + if (uri.getPort() != -1) { + builder.port(uri.getPort()); + } + String normalizedPath = normalizePath(uri.getPath()); + addPathSegments(builder, normalizedPath); + if (!normalizedPath.endsWith("/rest/v1") && !"rest/v1".equals(normalizedPath)) { + builder.addPathSegment("rest"); + builder.addPathSegment("v1"); + } + return builder; + } + + private void addPathSegments(HttpUrl.Builder builder, String path) { + if (!hasText(path)) { + return; + } + for (String segment : path.split("/")) { + if (!segment.isBlank()) { + builder.addPathSegment(segment); + } + } + } + + private String normalizePath(String path) { + if (path == null) { + return ""; + } + String trimmed = path.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private String readText(JsonNode node) { + return node != null && !node.isNull() ? node.asText() : null; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiException.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiException.java new file mode 100644 index 0000000..d00554b --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiException.java @@ -0,0 +1,17 @@ +package me.golemcore.plugins.golemcore.supabase.support; + +public class SupabaseApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public SupabaseApiException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseTransportException.java b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseTransportException.java new file mode 100644 index 0000000..b58978e --- /dev/null +++ b/golemcore/supabase/src/main/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseTransportException.java @@ -0,0 +1,10 @@ +package me.golemcore.plugins.golemcore.supabase.support; + +public class SupabaseTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public SupabaseTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/golemcore/supabase/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap b/golemcore/supabase/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap new file mode 100644 index 0000000..70b1ab9 --- /dev/null +++ b/golemcore/supabase/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap @@ -0,0 +1 @@ +me.golemcore.plugins.golemcore.supabase.SupabasePluginBootstrap diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginBootstrapTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginBootstrapTest.java new file mode 100644 index 0000000..beb0ca3 --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SupabasePluginBootstrapTest { + + @Test + void shouldDescribeSupabasePlugin() { + SupabasePluginBootstrap bootstrap = new SupabasePluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/supabase", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("supabase", descriptor.getName()); + assertEquals(SupabasePluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfigTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfigTest.java new file mode 100644 index 0000000..d010808 --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginConfigTest.java @@ -0,0 +1,52 @@ +package me.golemcore.plugins.golemcore.supabase; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SupabasePluginConfigTest { + + @Test + void shouldNormalizeDefaultsAndTrimStrings() { + SupabasePluginConfig config = SupabasePluginConfig.builder() + .enabled(null) + .projectUrl(" https://project.supabase.co/ ") + .apiKey(" ") + .defaultSchema(" ") + .defaultTable(" tasks ") + .defaultSelect(" ") + .defaultLimit(0) + .allowWrite(null) + .allowDelete(null) + .build(); + + config.normalize(); + + assertFalse(config.getEnabled()); + assertEquals("https://project.supabase.co", config.getProjectUrl()); + assertNull(config.getApiKey()); + assertEquals(SupabasePluginConfig.DEFAULT_SCHEMA, config.getDefaultSchema()); + assertEquals("tasks", config.getDefaultTable()); + assertEquals(SupabasePluginConfig.DEFAULT_SELECT, config.getDefaultSelect()); + assertEquals(SupabasePluginConfig.DEFAULT_LIMIT, config.getDefaultLimit()); + assertFalse(config.getAllowWrite()); + assertFalse(config.getAllowDelete()); + } + + @Test + void shouldClampLimitToMaximum() { + SupabasePluginConfig config = SupabasePluginConfig.builder() + .enabled(true) + .defaultLimit(10_000) + .allowWrite(true) + .build(); + + config.normalize(); + + assertEquals(SupabasePluginConfig.MAX_LIMIT, config.getDefaultLimit()); + assertTrue(config.getAllowWrite()); + } +} diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginSettingsContributorTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginSettingsContributorTest.java new file mode 100644 index 0000000..c4419b0 --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabasePluginSettingsContributorTest.java @@ -0,0 +1,136 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseApiClient; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseTransportException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SupabasePluginSettingsContributorTest { + + private SupabasePluginConfigService configService; + private SupabaseApiClient apiClient; + private SupabasePluginSettingsContributor contributor; + private SupabasePluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(SupabasePluginConfigService.class); + apiClient = mock(SupabaseApiClient.class); + contributor = new SupabasePluginSettingsContributor(configService, apiClient); + config = SupabasePluginConfig.builder().build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldExposeSafeDefaults() { + PluginSettingsSection section = contributor.getSection("main"); + + assertEquals(false, section.getValues().get("enabled")); + assertEquals("", section.getValues().get("projectUrl")); + assertEquals("", section.getValues().get("apiKey")); + assertEquals(SupabasePluginConfig.DEFAULT_SCHEMA, section.getValues().get("defaultSchema")); + assertEquals(SupabasePluginConfig.DEFAULT_SELECT, section.getValues().get("defaultSelect")); + assertEquals(SupabasePluginConfig.DEFAULT_LIMIT, section.getValues().get("defaultLimit")); + assertEquals(1, section.getActions().size()); + assertEquals("test-connection", section.getActions().getFirst().getActionId()); + } + + @Test + void shouldRoundTripSavedValuesWithoutOverwritingBlankSecret() { + SupabasePluginConfig initialConfig = SupabasePluginConfig.builder() + .apiKey("existing-secret") + .build(); + initialConfig.normalize(); + SupabasePluginConfig persistedConfig = SupabasePluginConfig.builder() + .enabled(true) + .projectUrl("https://project.supabase.co") + .apiKey("existing-secret") + .defaultSchema("public") + .defaultTable("tasks") + .defaultSelect("id,name") + .defaultLimit(15) + .allowWrite(true) + .allowDelete(false) + .build(); + persistedConfig.normalize(); + when(configService.getConfig()).thenReturn(initialConfig, persistedConfig); + + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("projectUrl", "https://project.supabase.co"); + values.put("apiKey", ""); + values.put("defaultSchema", "public"); + values.put("defaultTable", "tasks"); + values.put("defaultSelect", "id,name"); + values.put("defaultLimit", 15); + values.put("allowWrite", true); + values.put("allowDelete", false); + + PluginSettingsSection section = contributor.saveSection("main", values); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SupabasePluginConfig.class); + verify(configService).save(captor.capture()); + SupabasePluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getApiKey()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertEquals("", section.getValues().get("apiKey")); + assertEquals("tasks", section.getValues().get("defaultTable")); + } + + @Test + void shouldReturnOkWhenConnectionTestSucceeds() { + config.setProjectUrl("https://project.supabase.co"); + config.setApiKey("token"); + config.setDefaultTable("tasks"); + when(apiClient.selectRows("tasks", "public", "*", 1, null, null, Optional.empty(), Map.of())) + .thenReturn(List.of()); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertTrue(result.getMessage().contains("Connected to Supabase")); + verify(apiClient).selectRows("tasks", "public", "*", 1, null, null, Optional.empty(), Map.of()); + } + + @Test + void shouldReturnErrorWhenProjectUrlIsMissing() { + config.setApiKey("token"); + config.setDefaultTable("tasks"); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Supabase project URL is not configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionFails() { + config.setProjectUrl("https://project.supabase.co"); + config.setApiKey("token"); + config.setDefaultTable("tasks"); + when(apiClient.selectRows("tasks", "public", "*", 1, null, null, Optional.empty(), Map.of())) + .thenThrow(new SupabaseTransportException("Supabase transport failed: timeout", null)); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: Supabase transport failed: timeout", result.getMessage()); + } +} diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsServiceTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsServiceTest.java new file mode 100644 index 0000000..380d036 --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsServiceTest.java @@ -0,0 +1,91 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.supabase.support.SupabaseApiClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SupabaseRowsServiceTest { + + private SupabaseApiClient apiClient; + private SupabasePluginConfigService configService; + private SupabaseRowsService service; + private SupabasePluginConfig config; + + @BeforeEach + void setUp() { + apiClient = mock(SupabaseApiClient.class); + configService = mock(SupabasePluginConfigService.class); + service = new SupabaseRowsService(apiClient, configService); + config = SupabasePluginConfig.builder() + .enabled(true) + .projectUrl("https://project.supabase.co") + .apiKey("token") + .defaultSchema("public") + .defaultTable("tasks") + .defaultSelect("*") + .defaultLimit(20) + .allowWrite(true) + .allowDelete(true) + .build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldSelectRowsUsingConfiguredDefaults() { + when(apiClient.selectRows("tasks", "public", "*", 20, null, null, Optional.empty(), Map.of())) + .thenReturn(List.of(Map.of("id", 1, "name", "Alice"))); + + ToolResult result = service.selectRows(null, null, null, null, null, null, Optional.empty(), Map.of()); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Alice")); + verify(apiClient).selectRows("tasks", "public", "*", 20, null, null, Optional.empty(), Map.of()); + } + + @Test + void shouldInsertRowWhenWriteIsAllowed() { + when(apiClient.insertRow("tasks", "public", Map.of("name", "Alice"))) + .thenReturn(List.of(Map.of("id", 7, "name", "Alice"))); + + ToolResult result = service.insertRow(null, null, Map.of("name", "Alice")); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Inserted Supabase rows into public.tasks")); + verify(apiClient).insertRow("tasks", "public", Map.of("name", "Alice")); + } + + @Test + void shouldRequireFiltersForDelete() { + ToolResult result = service.deleteRows(null, null, Map.of()); + + assertFalse(result.isSuccess()); + assertEquals( + "filters must contain at least one PostgREST filter expression for update_rows or delete_rows", + result.getError()); + } + + @Test + void shouldRejectDeleteWhenDisabled() { + config.setAllowDelete(false); + + ToolResult result = service.deleteRows(null, null, Map.of("id", "eq.1")); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertEquals("Supabase delete is disabled in plugin settings", result.getError()); + } +} diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsToolProviderTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsToolProviderTest.java new file mode 100644 index 0000000..433b19b --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseRowsToolProviderTest.java @@ -0,0 +1,87 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.extension.model.ToolResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SupabaseRowsToolProviderTest { + + private SupabaseRowsService service; + private SupabaseRowsToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(SupabaseRowsService.class); + provider = new SupabaseRowsToolProvider(service); + SupabasePluginConfig config = SupabasePluginConfig.builder() + .enabled(true) + .projectUrl("https://project.supabase.co") + .apiKey("token") + .build(); + when(service.getConfig()).thenReturn(config); + } + + @Test + void shouldParseJsonMapsForSelectRows() { + when(service.selectRows("tasks", "public", "id,name", 5, null, "id", Optional.of(false), + Map.of("status", "eq.active"))) + .thenReturn(ToolResult.success("selected")); + + ToolResult result = provider.execute(Map.of( + "operation", "select_rows", + "table", "tasks", + "schema", "public", + "select", "id,name", + "limit", 5, + "order_by", "id", + "ascending", false, + "filters", "{\"status\":\"eq.active\"}")).join(); + + assertTrue(result.isSuccess()); + verify(service).selectRows("tasks", "public", "id,name", 5, null, "id", Optional.of(false), + Map.of("status", "eq.active")); + } + + @Test + void shouldParseJsonValuesWhenInserting() { + when(service.insertRow("tasks", "public", Map.of("name", "Alice"))) + .thenReturn(ToolResult.success("inserted")); + + ToolResult result = provider.execute(Map.of( + "operation", "insert_row", + "table", "tasks", + "schema", "public", + "values", "{\"name\":\"Alice\"}")).join(); + + assertTrue(result.isSuccess()); + verify(service).insertRow("tasks", "public", Map.of("name", "Alice")); + } + + @Test + void shouldRejectInvalidFilterPayload() { + ToolResult result = provider.execute(Map.of( + "operation", "delete_rows", + "filters", "not-json")).join(); + + assertFalse(result.isSuccess()); + assertEquals("filters must be a JSON object or map", result.getError()); + } + + @Test + void shouldRejectUnsupportedOperation() { + ToolResult result = provider.execute(Map.of("operation", "unknown")).join(); + + assertFalse(result.isSuccess()); + assertEquals("Unsupported supabase_rows operation: unknown", result.getError()); + } +} diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseSpringWiringTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseSpringWiringTest.java new file mode 100644 index 0000000..e439db7 --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/SupabaseSpringWiringTest.java @@ -0,0 +1,36 @@ +package me.golemcore.plugins.golemcore.supabase; + +import me.golemcore.plugin.api.runtime.PluginConfigurationService; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +class SupabaseSpringWiringTest { + + @Test + void shouldCreateSupabaseBeansWithoutDefaultConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(SupabasePluginConfiguration.class, TestConfig.class); + context.refresh(); + + assertNotNull(context.getBean(SupabaseRowsToolProvider.class)); + assertNotNull(context.getBean(SupabasePluginSettingsContributor.class)); + } + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("PMD.TestClassWithoutTestCases") + static class TestConfig { + + @Bean + @Primary + PluginConfigurationService pluginConfigurationService() { + return mock(PluginConfigurationService.class); + } + } +} diff --git a/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiClientTest.java b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiClientTest.java new file mode 100644 index 0000000..d00ca94 --- /dev/null +++ b/golemcore/supabase/src/test/java/me/golemcore/plugins/golemcore/supabase/support/SupabaseApiClientTest.java @@ -0,0 +1,165 @@ +package me.golemcore.plugins.golemcore.supabase.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugins.golemcore.supabase.SupabasePluginConfig; +import me.golemcore.plugins.golemcore.supabase.SupabasePluginConfigService; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SupabaseApiClientTest { + + private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); + + private SupabasePluginConfigService configService; + private MockSupabaseApiClient client; + private SupabasePluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(SupabasePluginConfigService.class); + config = SupabasePluginConfig.builder() + .enabled(true) + .projectUrl("https://project.supabase.co") + .apiKey("token") + .defaultSchema("public") + .build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + client = new MockSupabaseApiClient(configService); + } + + @Test + void shouldBuildSelectRequestAndParseRows() { + client.enqueueResponse(200, """ + [ + { + "id": 1, + "name": "Alice" + } + ] + """); + + List> rows = client.selectRows( + "tasks", + "public", + "id,name", + 5, + 10, + "id", + Optional.of(false), + Map.of("status", "eq.active")); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("/rest/v1/tasks", request.url().encodedPath()); + assertEquals("id,name", request.url().queryParameter("select")); + assertEquals("5", request.url().queryParameter("limit")); + assertEquals("10", request.url().queryParameter("offset")); + assertEquals("id.desc", request.url().queryParameter("order")); + assertEquals("eq.active", request.url().queryParameter("status")); + assertEquals("token", request.header("apikey")); + assertEquals("Bearer token", request.header("Authorization")); + assertEquals("public", request.header("Accept-Profile")); + assertEquals(1, rows.size()); + assertEquals("Alice", rows.getFirst().get("name")); + } + + @Test + void shouldSendInsertRequestWithRepresentationPreference() throws Exception { + client.enqueueResponse(201, """ + [ + { + "id": 7, + "name": "Alice" + } + ] + """); + + List> rows = client.insertRow("tasks", "public", Map.of("name", "Alice")); + + Request request = client.getCapturedRequests().getFirst(); + ObjectMapper objectMapper = new ObjectMapper(); + Map body = objectMapper.readValue(readRequestBody(request), Map.class); + assertEquals(1, rows.size()); + assertEquals(Map.of("name", "Alice"), body); + assertEquals("public", request.header("Content-Profile")); + assertEquals("return=representation", request.header("Prefer")); + } + + @Test + void shouldSurfaceApiErrorMessage() { + client.enqueueResponse(400, """ + { + "message": "Bad request", + "details": "column missing" + } + """); + + SupabaseApiException exception = assertThrows(SupabaseApiException.class, + () -> client.deleteRows("tasks", "public", Map.of("id", "eq.1"))); + + assertEquals(400, exception.getStatusCode()); + assertEquals("Bad request | column missing", exception.getMessage()); + } + + private String readRequestBody(Request request) throws IOException { + assertNotNull(request.body()); + try (Buffer buffer = new Buffer()) { + request.body().writeTo(buffer); + return buffer.readUtf8(); + } + } + + private static final class MockSupabaseApiClient extends SupabaseApiClient { + + private final Queue plannedResponses = new ArrayDeque<>(); + private final List capturedRequests = new ArrayList<>(); + + private MockSupabaseApiClient(SupabasePluginConfigService configService) { + super(configService); + } + + @Override + protected Response executeRequest(Request request) { + capturedRequests.add(request); + PlannedResponse plannedResponse = plannedResponses.remove(); + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(plannedResponse.code()) + .message("mock") + .body(ResponseBody.create(plannedResponse.body(), APPLICATION_JSON)) + .build(); + } + + private void enqueueResponse(int code, String body) { + plannedResponses.add(new PlannedResponse(code, body)); + } + + private List getCapturedRequests() { + return capturedRequests; + } + } + + private record PlannedResponse(int code, String body) { + } +} diff --git a/pom.xml b/pom.xml index 272ad83..ceb3197 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,7 @@ golemcore/weather golemcore/mail golemcore/lightrag + golemcore/supabase diff --git a/registry/golemcore/supabase/index.yaml b/registry/golemcore/supabase/index.yaml new file mode 100644 index 0000000..629969c --- /dev/null +++ b/registry/golemcore/supabase/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/supabase +owner: golemcore +name: supabase +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/supabase" diff --git a/registry/golemcore/supabase/versions/1.0.0.yaml b/registry/golemcore/supabase/versions/1.0.0.yaml new file mode 100644 index 0000000..01dab30 --- /dev/null +++ b/registry/golemcore/supabase/versions/1.0.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/supabase +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/supabase/1.0.0/golemcore-supabase-plugin-1.0.0.jar" +publishedAt: "TBD" +sourceCommit: "TBD" +entrypoint: me.golemcore.plugins.golemcore.supabase.SupabasePluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/supabase" +license: "Apache-2.0" +maintainers: + - alexk-dev