diff --git a/README.md b/README.md index 2195ed6..448858e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The current `golemcore/*` modules in this repository are: | Plugin | Purpose | | --- | --- | +| `golemcore/airtable` | Airtable records tool plugin with configurable read/write operations for bases and tables. | | `golemcore/brave-search` | Brave Search web search tool plugin with API-key backed queries. | | `golemcore/browser` | Playwright-backed browser automation tool with screenshot support. | | `golemcore/browserless` | Browserless smart scrape plugin for rendered HTML, markdown, and link extraction. | diff --git a/golemcore/airtable/plugin.yaml b/golemcore/airtable/plugin.yaml new file mode 100644 index 0000000..4ffc970 --- /dev/null +++ b/golemcore/airtable/plugin.yaml @@ -0,0 +1,12 @@ +id: golemcore/airtable +provider: golemcore +name: airtable +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +entrypoint: me.golemcore.plugins.golemcore.airtable.AirtablePluginBootstrap +description: Airtable records tool plugin backed by the Airtable REST API. +sourceUrl: https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/airtable +license: Apache-2.0 +maintainers: + - alexk-dev diff --git a/golemcore/airtable/pom.xml b/golemcore/airtable/pom.xml new file mode 100644 index 0000000..025329f --- /dev/null +++ b/golemcore/airtable/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + me.golemcore.plugins + golemcore-plugins + 1.0.0 + ../../pom.xml + + + 1.0.0 + golemcore-airtable-plugin + golemcore/airtable + Airtable records tool plugin for GolemCore + + + golemcore + airtable + ../../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/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrap.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrap.java new file mode 100644 index 0000000..d194540 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrap.java @@ -0,0 +1,22 @@ +package me.golemcore.plugins.golemcore.airtable; + +import me.golemcore.plugin.api.extension.spi.PluginBootstrap; +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; + +public class AirtablePluginBootstrap implements PluginBootstrap { + + @Override + public PluginDescriptor descriptor() { + return PluginDescriptor.builder() + .id("golemcore/airtable") + .provider("golemcore") + .name("airtable") + .entrypoint(getClass().getName()) + .build(); + } + + @Override + public Class configurationClass() { + return AirtablePluginConfiguration.class; + } +} diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfig.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfig.java new file mode 100644 index 0000000..fdf7e0f --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfig.java @@ -0,0 +1,86 @@ +package me.golemcore.plugins.golemcore.airtable; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AirtablePluginConfig { + + static final String DEFAULT_API_BASE_URL = "https://api.airtable.com"; + static final int DEFAULT_MAX_RECORDS = 20; + static final int MAX_RECORDS_LIMIT = 100; + + @Builder.Default + private Boolean enabled = false; + + @Builder.Default + private String apiBaseUrl = DEFAULT_API_BASE_URL; + + private String apiToken; + private String baseId; + + @Builder.Default + private String defaultTable = ""; + + @Builder.Default + private String defaultView = ""; + + @Builder.Default + private Integer defaultMaxRecords = DEFAULT_MAX_RECORDS; + + @Builder.Default + private Boolean allowWrite = false; + + @Builder.Default + private Boolean allowDelete = false; + + @Builder.Default + private Boolean typecast = false; + + public void normalize() { + if (enabled == null) { + enabled = false; + } + apiBaseUrl = normalizeUrl(apiBaseUrl, DEFAULT_API_BASE_URL); + apiToken = normalizeText(apiToken, null); + baseId = normalizeText(baseId, null); + defaultTable = normalizeText(defaultTable, ""); + defaultView = normalizeText(defaultView, ""); + if (defaultMaxRecords == null || defaultMaxRecords <= 0) { + defaultMaxRecords = DEFAULT_MAX_RECORDS; + } + if (defaultMaxRecords > MAX_RECORDS_LIMIT) { + defaultMaxRecords = MAX_RECORDS_LIMIT; + } + if (allowWrite == null) { + allowWrite = false; + } + if (allowDelete == null) { + allowDelete = false; + } + if (typecast == null) { + typecast = false; + } + } + + private String normalizeUrl(String value, String defaultValue) { + String trimmed = normalizeText(value, defaultValue); + 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/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigService.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigService.java new file mode 100644 index 0000000..293539f --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigService.java @@ -0,0 +1,36 @@ +package me.golemcore.plugins.golemcore.airtable; + +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 AirtablePluginConfigService { + + static final String PLUGIN_ID = "golemcore/airtable"; + + private final PluginConfigurationService pluginConfigurationService; + private final ObjectMapper objectMapper; + + public AirtablePluginConfigService(PluginConfigurationService pluginConfigurationService) { + this.pluginConfigurationService = pluginConfigurationService; + this.objectMapper = new ObjectMapper(); + } + + public AirtablePluginConfig getConfig() { + Map raw = pluginConfigurationService.getPluginConfig(PLUGIN_ID); + AirtablePluginConfig config = raw.isEmpty() + ? AirtablePluginConfig.builder().build() + : objectMapper.convertValue(raw, AirtablePluginConfig.class); + config.normalize(); + return config; + } + + @SuppressWarnings("unchecked") + public void save(AirtablePluginConfig config) { + config.normalize(); + pluginConfigurationService.savePluginConfig(PLUGIN_ID, objectMapper.convertValue(config, Map.class)); + } +} diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfiguration.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfiguration.java new file mode 100644 index 0000000..1e662d6 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfiguration.java @@ -0,0 +1,12 @@ +package me.golemcore.plugins.golemcore.airtable; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@ComponentScan(basePackageClasses = { + AirtableRecordsToolProvider.class, + AirtablePluginSettingsContributor.class +}) +public class AirtablePluginConfiguration { +} diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributor.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributor.java new file mode 100644 index 0000000..2f98c10 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributor.java @@ -0,0 +1,261 @@ +package me.golemcore.plugins.golemcore.airtable; + +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.airtable.support.AirtableApiClient; +import me.golemcore.plugins.golemcore.airtable.support.AirtableApiException; +import me.golemcore.plugins.golemcore.airtable.support.AirtableTransportException; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class AirtablePluginSettingsContributor implements PluginSettingsContributor { + + private static final String SECTION_KEY = "main"; + private static final String ACTION_TEST_CONNECTION = "test-connection"; + + private final AirtablePluginConfigService configService; + private final AirtableApiClient apiClient; + + public AirtablePluginSettingsContributor( + AirtablePluginConfigService configService, + AirtableApiClient apiClient) { + this.configService = configService; + this.apiClient = apiClient; + } + + @Override + public String getPluginId() { + return AirtablePluginConfigService.PLUGIN_ID; + } + + @Override + public List getCatalogItems() { + return List.of(PluginSettingsCatalogItem.builder() + .pluginId(AirtablePluginConfigService.PLUGIN_ID) + .pluginName("airtable") + .provider("golemcore") + .sectionKey(SECTION_KEY) + .title("Airtable") + .description("Airtable base, default table, and write permissions for records operations.") + .blockKey("tools") + .blockTitle("Tools") + .blockDescription("Tool-specific runtime behavior and integrations") + .order(30) + .build()); + } + + @Override + public PluginSettingsSection getSection(String sectionKey) { + requireSection(sectionKey); + AirtablePluginConfig config = configService.getConfig(); + Map values = new LinkedHashMap<>(); + values.put("enabled", Boolean.TRUE.equals(config.getEnabled())); + values.put("apiBaseUrl", config.getApiBaseUrl()); + values.put("apiToken", ""); + values.put("baseId", orEmpty(config.getBaseId())); + values.put("defaultTable", config.getDefaultTable()); + values.put("defaultView", config.getDefaultView()); + values.put("defaultMaxRecords", config.getDefaultMaxRecords()); + values.put("allowWrite", Boolean.TRUE.equals(config.getAllowWrite())); + values.put("allowDelete", Boolean.TRUE.equals(config.getAllowDelete())); + values.put("typecast", Boolean.TRUE.equals(config.getTypecast())); + return PluginSettingsSection.builder() + .title("Airtable") + .description("Configure Airtable API access and the default table used by the airtable_records tool.") + .fields(List.of( + booleanField("enabled", "Enable Airtable", + "Allow the model to list and mutate Airtable records."), + textField("apiBaseUrl", "API Base URL", + "Airtable REST API base URL.", AirtablePluginConfig.DEFAULT_API_BASE_URL), + secretField("apiToken", "API Token", + "Personal access token. Leave blank to keep the current secret."), + textField("baseId", "Base ID", + "Airtable base identifier used for requests.", "appXXXXXXXXXXXXXX"), + textField("defaultTable", "Default Table", + "Optional default table name or ID used when the tool call omits table.", + "Tasks"), + textField("defaultView", "Default View", + "Optional default Airtable view used for list operations.", "Grid view"), + numberField("defaultMaxRecords", "Default Max Records", + "Maximum records returned when max_records is omitted.", 1.0, 100.0, 1.0), + booleanField("allowWrite", "Allow Write", + "Permit create_record and update_record operations."), + booleanField("allowDelete", "Allow Delete", + "Permit delete_record operations."), + booleanField("typecast", "Enable Typecast", + "Use Airtable typecast for create and update operations by default."))) + .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); + AirtablePluginConfig config = configService.getConfig(); + config.setEnabled(readBoolean(values, "enabled", false)); + config.setApiBaseUrl(readString(values, "apiBaseUrl", config.getApiBaseUrl())); + String apiToken = readString(values, "apiToken", null); + if (apiToken != null && !apiToken.isBlank()) { + config.setApiToken(apiToken); + } + config.setBaseId(readString(values, "baseId", config.getBaseId())); + config.setDefaultTable(readString(values, "defaultTable", config.getDefaultTable())); + config.setDefaultView(readString(values, "defaultView", config.getDefaultView())); + config.setDefaultMaxRecords(readInteger(values, "defaultMaxRecords", config.getDefaultMaxRecords())); + config.setAllowWrite(readBoolean(values, "allowWrite", false)); + config.setAllowDelete(readBoolean(values, "allowDelete", false)); + config.setTypecast(readBoolean(values, "typecast", 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 Airtable action: " + actionId); + } + + private PluginActionResult testConnection() { + AirtablePluginConfig config = configService.getConfig(); + if (!hasText(config.getApiToken())) { + return PluginActionResult.builder() + .status("error") + .message("Airtable API token is not configured.") + .build(); + } + if (!hasText(config.getBaseId())) { + return PluginActionResult.builder() + .status("error") + .message("Airtable base ID is not configured.") + .build(); + } + if (!hasText(config.getDefaultTable())) { + return PluginActionResult.builder() + .status("error") + .message("Default Airtable table is not configured.") + .build(); + } + try { + AirtableApiClient.AirtableListResponse response = apiClient.listRecords( + config.getDefaultTable(), + config.getDefaultView(), + null, + 1, + List.of(), + null, + null); + return PluginActionResult.builder() + .status("ok") + .message("Connected to Airtable. Read access to table " + + config.getDefaultTable() + " is available (" + + response.records().size() + " record(s) checked).") + .build(); + } catch (IllegalArgumentException | IllegalStateException | AirtableApiException + | AirtableTransportException 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 Airtable 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/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsService.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsService.java new file mode 100644 index 0000000..42cdf4e --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsService.java @@ -0,0 +1,304 @@ +package me.golemcore.plugins.golemcore.airtable; + +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.airtable.support.AirtableApiClient; +import me.golemcore.plugins.golemcore.airtable.support.AirtableApiException; +import me.golemcore.plugins.golemcore.airtable.support.AirtableTransportException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +@Service +public class AirtableRecordsService { + + private final AirtableApiClient apiClient; + private final AirtablePluginConfigService configService; + private final ObjectMapper objectMapper; + + public AirtableRecordsService(AirtableApiClient apiClient, AirtablePluginConfigService configService) { + this.apiClient = apiClient; + this.configService = configService; + this.objectMapper = new ObjectMapper(); + } + + public AirtablePluginConfig getConfig() { + return configService.getConfig(); + } + + public ToolResult listRecords( + String table, + String view, + String filterByFormula, + Integer maxRecords, + List fieldNames, + String sortField, + String sortDirection) { + try { + AirtablePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedView = hasText(view) ? view.trim() : config.getDefaultView(); + int resolvedMaxRecords = normalizeMaxRecords(maxRecords, config.getDefaultMaxRecords()); + List normalizedFieldNames = normalizeFieldNames(fieldNames); + String normalizedSortField = normalizeOptional(sortField); + String normalizedSortDirection = normalizeSortDirection(sortDirection); + AirtableApiClient.AirtableListResponse response = apiClient.listRecords( + resolvedTable, + resolvedView, + normalizeOptional(filterByFormula), + resolvedMaxRecords, + normalizedFieldNames, + normalizedSortField, + normalizedSortDirection); + + Map data = new LinkedHashMap<>(); + data.put("table", resolvedTable); + data.put("count", response.records().size()); + if (hasText(response.offset())) { + data.put("next_offset", response.offset()); + } + data.put("records", response.records().stream().map(this::toRecordMap).toList()); + return ToolResult.success(buildListOutput(resolvedTable, resolvedView, response), data); + } catch (IllegalArgumentException | IllegalStateException | AirtableApiException + | AirtableTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult getRecord(String table, String recordId) { + try { + AirtablePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedRecordId = requireRecordId(recordId); + AirtableApiClient.AirtableRecord record = apiClient.getRecord(resolvedTable, resolvedRecordId); + Map data = toRecordMap(record); + data.put("table", resolvedTable); + return ToolResult.success( + "Airtable record " + record.id() + " in " + resolvedTable + ":\n" + + serializeJson(record.fields()), + data); + } catch (IllegalArgumentException | IllegalStateException | AirtableApiException + | AirtableTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult createRecord(String table, Map fields, Optional typecastOverride) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Airtable write is disabled in plugin settings"); + } + try { + AirtablePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + Map normalizedFields = requireFields(fields); + boolean typecast = resolveTypecast(typecastOverride, config); + AirtableApiClient.AirtableRecord record = apiClient.createRecord(resolvedTable, normalizedFields, typecast); + Map data = toRecordMap(record); + data.put("table", resolvedTable); + return ToolResult.success( + "Created Airtable record " + record.id() + " in " + resolvedTable + ":\n" + + serializeJson(record.fields()), + data); + } catch (IllegalArgumentException | IllegalStateException | AirtableApiException + | AirtableTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult updateRecord( + String table, + String recordId, + Map fields, + Optional typecastOverride) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowWrite())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Airtable write is disabled in plugin settings"); + } + try { + AirtablePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedRecordId = requireRecordId(recordId); + Map normalizedFields = requireFields(fields); + boolean typecast = resolveTypecast(typecastOverride, config); + AirtableApiClient.AirtableRecord record = apiClient.updateRecord( + resolvedTable, + resolvedRecordId, + normalizedFields, + typecast); + Map data = toRecordMap(record); + data.put("table", resolvedTable); + return ToolResult.success( + "Updated Airtable record " + record.id() + " in " + resolvedTable + ":\n" + + serializeJson(record.fields()), + data); + } catch (IllegalArgumentException | IllegalStateException | AirtableApiException + | AirtableTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + public ToolResult deleteRecord(String table, String recordId) { + if (!Boolean.TRUE.equals(configService.getConfig().getAllowDelete())) { + return ToolResult.failure(ToolFailureKind.POLICY_DENIED, + "Airtable delete is disabled in plugin settings"); + } + try { + AirtablePluginConfig config = requireConfigured(); + String resolvedTable = resolveTable(table, config); + String resolvedRecordId = requireRecordId(recordId); + AirtableApiClient.AirtableDeleteResponse response = apiClient.deleteRecord(resolvedTable, resolvedRecordId); + Map data = new LinkedHashMap<>(); + data.put("table", resolvedTable); + data.put("record_id", response.id()); + data.put("deleted", response.deleted()); + return ToolResult.success( + "Deleted Airtable record " + response.id() + " from " + resolvedTable + '.', + data); + } catch (IllegalArgumentException | IllegalStateException | AirtableApiException + | AirtableTransportException ex) { + return executionFailure(ex.getMessage()); + } + } + + private AirtablePluginConfig requireConfigured() { + AirtablePluginConfig config = configService.getConfig(); + if (!Boolean.TRUE.equals(config.getEnabled())) { + throw new IllegalStateException("Airtable plugin is disabled in plugin settings"); + } + if (!hasText(config.getApiToken())) { + throw new IllegalStateException("Airtable API token is not configured."); + } + if (!hasText(config.getBaseId())) { + throw new IllegalStateException("Airtable base ID is not configured."); + } + return config; + } + + private String resolveTable(String table, AirtablePluginConfig config) { + String resolvedTable = hasText(table) ? table.trim() : config.getDefaultTable(); + if (!hasText(resolvedTable)) { + throw new IllegalArgumentException( + "Airtable table is required. Configure a default table or pass the table parameter."); + } + return resolvedTable; + } + + private String requireRecordId(String recordId) { + if (!hasText(recordId)) { + throw new IllegalArgumentException("record_id is required"); + } + return recordId.trim(); + } + + private Map requireFields(Map fields) { + if (fields == null || fields.isEmpty()) { + throw new IllegalArgumentException("fields must contain at least one Airtable field"); + } + return fields; + } + + private int normalizeMaxRecords(Integer requestedMaxRecords, int defaultMaxRecords) { + int resolved = requestedMaxRecords != null ? requestedMaxRecords : defaultMaxRecords; + if (resolved <= 0) { + return AirtablePluginConfig.DEFAULT_MAX_RECORDS; + } + return Math.min(resolved, AirtablePluginConfig.MAX_RECORDS_LIMIT); + } + + private List normalizeFieldNames(List fieldNames) { + List normalized = new ArrayList<>(); + if (fieldNames == null) { + return normalized; + } + for (String fieldName : fieldNames) { + if (hasText(fieldName) && !normalized.contains(fieldName.trim())) { + normalized.add(fieldName.trim()); + } + } + return normalized; + } + + private String normalizeOptional(String value) { + return hasText(value) ? value.trim() : null; + } + + private String normalizeSortDirection(String value) { + if (!hasText(value)) { + return null; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if ("asc".equals(normalized) || "desc".equals(normalized)) { + return normalized; + } + throw new IllegalArgumentException("sort_direction must be asc or desc"); + } + + private boolean resolveTypecast(Optional typecastOverride, AirtablePluginConfig config) { + return typecastOverride.orElse(Boolean.TRUE.equals(config.getTypecast())); + } + + private String buildListOutput( + String table, + String view, + AirtableApiClient.AirtableListResponse response) { + if (response.records().isEmpty()) { + return "No Airtable records found in " + table + '.'; + } + StringBuilder output = new StringBuilder(); + output.append("Airtable records from ") + .append(table) + .append(" (") + .append(response.records().size()) + .append(" record(s)"); + if (hasText(view)) { + output.append(", view=").append(view); + } + output.append("):\n\n"); + for (int index = 0; index < response.records().size(); index++) { + AirtableApiClient.AirtableRecord record = response.records().get(index); + output.append(index + 1) + .append(". ") + .append(record.id()) + .append('\n') + .append(serializeJson(record.fields())) + .append("\n\n"); + } + if (hasText(response.offset())) { + output.append("More records are available. Next offset: ") + .append(response.offset()); + } + return output.toString().trim(); + } + + private Map toRecordMap(AirtableApiClient.AirtableRecord record) { + Map data = new LinkedHashMap<>(); + data.put("id", record.id()); + data.put("created_time", record.createdTime()); + data.put("fields", record.fields()); + return data; + } + + 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/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProvider.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProvider.java new file mode 100644 index 0000000..4eb8a01 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProvider.java @@ -0,0 +1,254 @@ +package me.golemcore.plugins.golemcore.airtable; + +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +@Component +public class AirtableRecordsToolProvider 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 TYPE_ARRAY = "array"; + private static final String PROPERTIES = "properties"; + private static final String REQUIRED = "required"; + private static final String ITEMS = "items"; + private static final String PARAM_OPERATION = "operation"; + private static final String PARAM_TABLE = "table"; + private static final String PARAM_VIEW = "view"; + private static final String PARAM_FILTER_BY_FORMULA = "filter_by_formula"; + private static final String PARAM_MAX_RECORDS = "max_records"; + private static final String PARAM_FIELD_NAMES = "field_names"; + private static final String PARAM_SORT_FIELD = "sort_field"; + private static final String PARAM_SORT_DIRECTION = "sort_direction"; + private static final String PARAM_RECORD_ID = "record_id"; + private static final String PARAM_FIELDS = "fields"; + private static final String PARAM_TYPECAST = "typecast"; + + private final AirtableRecordsService service; + private final ObjectMapper objectMapper; + + public AirtableRecordsToolProvider(AirtableRecordsService service) { + this.service = service; + this.objectMapper = new ObjectMapper(); + } + + @Override + public boolean isEnabled() { + AirtablePluginConfig config = service.getConfig(); + return Boolean.TRUE.equals(config.getEnabled()) + && hasText(config.getApiToken()) + && hasText(config.getBaseId()); + } + + @Override + public ToolDefinition getDefinition() { + return ToolDefinition.builder() + .name("airtable_records") + .description("List, read, create, update, and delete Airtable records through the Airtable REST API.") + .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( + "list_records", + "get_record", + "create_record", + "update_record", + "delete_record")))), + Map.entry(PARAM_TABLE, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", "Optional Airtable table name or table ID."))), + Map.entry(PARAM_VIEW, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", "Optional Airtable view used for list_records."))), + Map.entry(PARAM_FILTER_BY_FORMULA, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "Optional Airtable filterByFormula value for list_records."))), + Map.entry(PARAM_MAX_RECORDS, Map.ofEntries( + Map.entry(TYPE, TYPE_INTEGER), + Map.entry("description", + "Maximum number of records to return for list_records (1-100)."))), + Map.entry(PARAM_FIELD_NAMES, Map.ofEntries( + Map.entry(TYPE, TYPE_ARRAY), + Map.entry(ITEMS, Map.of(TYPE, TYPE_STRING)), + Map.entry("description", + "Optional list of Airtable field names to include in list_records."))), + Map.entry(PARAM_SORT_FIELD, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "Optional Airtable field used for sorting list_records."))), + Map.entry(PARAM_SORT_DIRECTION, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("enum", List.of("asc", "desc")), + Map.entry("description", "Optional sort direction for list_records."))), + Map.entry(PARAM_RECORD_ID, Map.ofEntries( + Map.entry(TYPE, TYPE_STRING), + Map.entry("description", + "Airtable record ID for get_record, update_record, or delete_record."))), + Map.entry(PARAM_FIELDS, Map.ofEntries( + Map.entry(TYPE, TYPE_OBJECT), + Map.entry("description", + "Field map for create_record or update_record. A JSON string is also accepted for convenience."))), + Map.entry(PARAM_TYPECAST, Map.ofEntries( + Map.entry(TYPE, TYPE_BOOLEAN), + Map.entry("description", + "Optional override for Airtable typecast on create/update."))))), + Map.entry(REQUIRED, List.of(PARAM_OPERATION)), + Map.entry("allOf", List.of( + requiredWhen("get_record", List.of(PARAM_RECORD_ID)), + requiredWhen("create_record", List.of(PARAM_FIELDS)), + requiredWhen("update_record", List.of(PARAM_RECORD_ID, PARAM_FIELDS)), + requiredWhen("delete_record", List.of(PARAM_RECORD_ID)))))) + .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 "list_records" -> service.listRecords( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_VIEW)), + readString(parameters.get(PARAM_FILTER_BY_FORMULA)), + readInteger(parameters.get(PARAM_MAX_RECORDS)), + readStringList(parameters.get(PARAM_FIELD_NAMES)), + readString(parameters.get(PARAM_SORT_FIELD)), + readString(parameters.get(PARAM_SORT_DIRECTION))); + case "get_record" -> service.getRecord( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_RECORD_ID))); + case "create_record" -> service.createRecord( + readString(parameters.get(PARAM_TABLE)), + readFields(parameters.get(PARAM_FIELDS)), + readOptionalBoolean(parameters, PARAM_TYPECAST)); + case "update_record" -> service.updateRecord( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_RECORD_ID)), + readFields(parameters.get(PARAM_FIELDS)), + readOptionalBoolean(parameters, PARAM_TYPECAST)); + case "delete_record" -> service.deleteRecord( + readString(parameters.get(PARAM_TABLE)), + readString(parameters.get(PARAM_RECORD_ID))); + default -> ToolResult.failure(ToolFailureKind.EXECUTION_FAILED, + "Unsupported airtable_records 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("max_records must be an integer"); + } + } + 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 List readStringList(Object value) { + Set normalized = new LinkedHashSet<>(); + if (value instanceof List list) { + for (Object item : list) { + String text = readString(item); + if (hasText(text)) { + normalized.add(text); + } + } + } else if (value instanceof String text) { + String[] parts = text.split("[,\\n]"); + for (String part : parts) { + String trimmed = part.trim(); + if (!trimmed.isEmpty()) { + normalized.add(trimmed); + } + } + } + return new ArrayList<>(normalized); + } + + private Map readFields(Object value) { + if (value == null) { + throw new IllegalArgumentException("fields must be a JSON object or map"); + } + 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("fields must be a JSON object or map"); + } + } + throw new IllegalArgumentException("fields must be a JSON object or map"); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClient.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClient.java new file mode 100644 index 0000000..75fed71 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClient.java @@ -0,0 +1,244 @@ +package me.golemcore.plugins.golemcore.airtable.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.airtable.AirtablePluginConfig; +import me.golemcore.plugins.golemcore.airtable.AirtablePluginConfigService; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class AirtableApiClient { + + private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + + private final AirtablePluginConfigService configService; + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + + public AirtableApiClient(AirtablePluginConfigService configService) { + this.configService = configService; + this.httpClient = new OkHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + public AirtableListResponse listRecords( + String table, + String view, + String filterByFormula, + Integer maxRecords, + List fieldNames, + String sortField, + String sortDirection) { + HttpUrl.Builder builder = createTableUrlBuilder(table); + if (hasText(view)) { + builder.addQueryParameter("view", view); + } + if (hasText(filterByFormula)) { + builder.addQueryParameter("filterByFormula", filterByFormula); + } + if (maxRecords != null) { + builder.addQueryParameter("maxRecords", Integer.toString(maxRecords)); + } + for (String fieldName : fieldNames) { + builder.addQueryParameter("fields[]", fieldName); + } + if (hasText(sortField)) { + builder.addQueryParameter("sort[0][field]", sortField); + } + if (hasText(sortDirection)) { + builder.addQueryParameter("sort[0][direction]", sortDirection); + } + + Request request = authorizedRequest(builder.build()) + .get() + .build(); + JsonNode root = executeJson(request); + return parseListResponse(root); + } + + public AirtableRecord getRecord(String table, String recordId) { + Request request = authorizedRequest(createRecordUrl(table, recordId)) + .get() + .build(); + return parseRecord(executeJson(request)); + } + + public AirtableRecord createRecord(String table, Map fields, boolean typecast) { + RequestBody body = buildWriteBody(fields, typecast); + Request request = authorizedRequest(createTableUrlBuilder(table).build()) + .post(body) + .build(); + return parseRecord(executeJson(request)); + } + + public AirtableRecord updateRecord(String table, String recordId, Map fields, boolean typecast) { + RequestBody body = buildWriteBody(fields, typecast); + Request request = authorizedRequest(createRecordUrl(table, recordId)) + .patch(body) + .build(); + return parseRecord(executeJson(request)); + } + + public AirtableDeleteResponse deleteRecord(String table, String recordId) { + Request request = authorizedRequest(createRecordUrl(table, recordId)) + .delete() + .build(); + JsonNode root = executeJson(request); + return new AirtableDeleteResponse( + readText(root.get("id")), + root.path("deleted").asBoolean(false)); + } + + protected Response executeRequest(Request request) throws IOException { + return httpClient.newCall(request).execute(); + } + + private Request.Builder authorizedRequest(HttpUrl url) { + AirtablePluginConfig config = configService.getConfig(); + return new Request.Builder() + .url(url) + .header("Accept", "application/json") + .header("Authorization", "Bearer " + config.getApiToken()); + } + + private RequestBody buildWriteBody(Map fields, boolean typecast) { + try { + Map payload = new LinkedHashMap<>(); + payload.put("fields", fields); + payload.put("typecast", typecast); + return RequestBody.create(objectMapper.writeValueAsString(payload), APPLICATION_JSON); + } catch (IOException ex) { + throw new IllegalStateException("Failed to serialize Airtable request body", ex); + } + } + + private JsonNode executeJson(Request request) { + try (Response response = executeRequest(request); + ResponseBody body = response.body()) { + String responseBody = body != null ? body.string() : ""; + if (!response.isSuccessful()) { + throw new AirtableApiException(response.code(), extractErrorMessage(response.code(), responseBody)); + } + if (responseBody.isBlank()) { + return objectMapper.createObjectNode(); + } + return objectMapper.readTree(responseBody); + } catch (AirtableApiException ex) { + throw ex; + } catch (IOException ex) { + throw new AirtableTransportException("Airtable transport failed: " + ex.getMessage(), ex); + } + } + + private AirtableListResponse parseListResponse(JsonNode root) { + List records = new ArrayList<>(); + JsonNode recordsNode = root.get("records"); + if (recordsNode != null && recordsNode.isArray()) { + for (JsonNode recordNode : recordsNode) { + records.add(parseRecord(recordNode)); + } + } + return new AirtableListResponse(records, readText(root.get("offset"))); + } + + private AirtableRecord parseRecord(JsonNode node) { + JsonNode fieldsNode = node.get("fields"); + Map fields = fieldsNode != null && fieldsNode.isObject() + ? objectMapper.convertValue(fieldsNode, MAP_TYPE) + : Map.of(); + return new AirtableRecord( + readText(node.get("id")), + readText(node.get("createdTime")), + fields); + } + + private String extractErrorMessage(int statusCode, String responseBody) { + if (responseBody != null && !responseBody.isBlank()) { + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode errorNode = root.get("error"); + if (errorNode != null) { + if (errorNode.isTextual()) { + return errorNode.asText(); + } + if (errorNode.isObject() && errorNode.hasNonNull("message")) { + return errorNode.get("message").asText(); + } + } + } 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 createRecordUrl(String table, String recordId) { + return createTableUrlBuilder(table) + .addPathSegment(recordId) + .build(); + } + + private HttpUrl.Builder createBaseUrlBuilder() { + AirtablePluginConfig config = configService.getConfig(); + URI uri = URI.create(config.getApiBaseUrl()); + if (uri.getScheme() == null || uri.getHost() == null) { + throw new IllegalStateException("Invalid Airtable API base URL: " + config.getApiBaseUrl()); + } + HttpUrl.Builder builder = new HttpUrl.Builder() + .scheme(uri.getScheme()) + .host(uri.getHost()); + if (uri.getPort() != -1) { + builder.port(uri.getPort()); + } + String path = uri.getPath(); + if (path != null && !path.isBlank() && !"/".equals(path)) { + for (String segment : path.split("/")) { + if (!segment.isBlank()) { + builder.addPathSegment(segment); + } + } + } + builder.addPathSegment("v0"); + builder.addPathSegment(config.getBaseId()); + return builder; + } + + private String readText(JsonNode node) { + return node != null && !node.isNull() ? node.asText() : null; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public record AirtableRecord(String id, String createdTime, Map fields) { + } + + public record AirtableListResponse(List records, String offset) { + } + + public record AirtableDeleteResponse(String id, boolean deleted) { + } +} diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiException.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiException.java new file mode 100644 index 0000000..6263d20 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiException.java @@ -0,0 +1,17 @@ +package me.golemcore.plugins.golemcore.airtable.support; + +public class AirtableApiException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + + public AirtableApiException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } +} diff --git a/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableTransportException.java b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableTransportException.java new file mode 100644 index 0000000..12d6602 --- /dev/null +++ b/golemcore/airtable/src/main/java/me/golemcore/plugins/golemcore/airtable/support/AirtableTransportException.java @@ -0,0 +1,10 @@ +package me.golemcore.plugins.golemcore.airtable.support; + +public class AirtableTransportException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public AirtableTransportException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/golemcore/airtable/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap b/golemcore/airtable/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap new file mode 100644 index 0000000..4bf702c --- /dev/null +++ b/golemcore/airtable/src/main/resources/META-INF/services/me.golemcore.plugin.api.extension.spi.PluginBootstrap @@ -0,0 +1 @@ +me.golemcore.plugins.golemcore.airtable.AirtablePluginBootstrap diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrapTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrapTest.java new file mode 100644 index 0000000..7c47cea --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginBootstrapTest.java @@ -0,0 +1,21 @@ +package me.golemcore.plugins.golemcore.airtable; + +import me.golemcore.plugin.api.extension.spi.PluginDescriptor; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AirtablePluginBootstrapTest { + + @Test + void shouldDescribeAirtablePlugin() { + AirtablePluginBootstrap bootstrap = new AirtablePluginBootstrap(); + + PluginDescriptor descriptor = bootstrap.descriptor(); + + assertEquals("golemcore/airtable", descriptor.getId()); + assertEquals("golemcore", descriptor.getProvider()); + assertEquals("airtable", descriptor.getName()); + assertEquals(AirtablePluginConfiguration.class, bootstrap.configurationClass()); + } +} diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigTest.java new file mode 100644 index 0000000..b55d3a9 --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginConfigTest.java @@ -0,0 +1,54 @@ +package me.golemcore.plugins.golemcore.airtable; + +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 AirtablePluginConfigTest { + + @Test + void shouldNormalizeDefaultsAndTrimStrings() { + AirtablePluginConfig config = AirtablePluginConfig.builder() + .enabled(null) + .apiBaseUrl(" https://api.airtable.com/ ") + .apiToken(" ") + .baseId(" appBase ") + .defaultTable(" Tasks ") + .defaultView(null) + .defaultMaxRecords(0) + .allowWrite(null) + .allowDelete(null) + .typecast(null) + .build(); + + config.normalize(); + + assertFalse(config.getEnabled()); + assertEquals("https://api.airtable.com", config.getApiBaseUrl()); + assertNull(config.getApiToken()); + assertEquals("appBase", config.getBaseId()); + assertEquals("Tasks", config.getDefaultTable()); + assertEquals("", config.getDefaultView()); + assertEquals(AirtablePluginConfig.DEFAULT_MAX_RECORDS, config.getDefaultMaxRecords()); + assertFalse(config.getAllowWrite()); + assertFalse(config.getAllowDelete()); + assertFalse(config.getTypecast()); + } + + @Test + void shouldClampMaxRecordsToAirtableLimit() { + AirtablePluginConfig config = AirtablePluginConfig.builder() + .enabled(true) + .defaultMaxRecords(500) + .typecast(true) + .build(); + + config.normalize(); + + assertEquals(AirtablePluginConfig.MAX_RECORDS_LIMIT, config.getDefaultMaxRecords()); + assertTrue(config.getTypecast()); + } +} diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributorTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributorTest.java new file mode 100644 index 0000000..14ee911 --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtablePluginSettingsContributorTest.java @@ -0,0 +1,139 @@ +package me.golemcore.plugins.golemcore.airtable; + +import me.golemcore.plugin.api.extension.spi.PluginActionResult; +import me.golemcore.plugin.api.extension.spi.PluginSettingsSection; +import me.golemcore.plugins.golemcore.airtable.support.AirtableApiClient; +import me.golemcore.plugins.golemcore.airtable.support.AirtableTransportException; +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 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 AirtablePluginSettingsContributorTest { + + private AirtablePluginConfigService configService; + private AirtableApiClient apiClient; + private AirtablePluginSettingsContributor contributor; + private AirtablePluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(AirtablePluginConfigService.class); + apiClient = mock(AirtableApiClient.class); + contributor = new AirtablePluginSettingsContributor(configService, apiClient); + config = AirtablePluginConfig.builder().build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldExposeSafeDefaults() { + PluginSettingsSection section = contributor.getSection("main"); + + assertEquals(false, section.getValues().get("enabled")); + assertEquals(AirtablePluginConfig.DEFAULT_API_BASE_URL, section.getValues().get("apiBaseUrl")); + assertEquals("", section.getValues().get("apiToken")); + assertEquals("", section.getValues().get("baseId")); + assertEquals("", section.getValues().get("defaultTable")); + assertEquals(AirtablePluginConfig.DEFAULT_MAX_RECORDS, section.getValues().get("defaultMaxRecords")); + assertEquals(1, section.getActions().size()); + assertEquals("test-connection", section.getActions().getFirst().getActionId()); + } + + @Test + void shouldRoundTripSavedValuesWithoutOverwritingBlankSecret() { + AirtablePluginConfig initialConfig = AirtablePluginConfig.builder() + .apiToken("existing-secret") + .build(); + initialConfig.normalize(); + AirtablePluginConfig persistedConfig = AirtablePluginConfig.builder() + .enabled(true) + .apiBaseUrl("https://api.airtable.com") + .apiToken("existing-secret") + .baseId("appBase") + .defaultTable("Tasks") + .defaultView("Grid") + .defaultMaxRecords(15) + .allowWrite(true) + .allowDelete(false) + .typecast(true) + .build(); + persistedConfig.normalize(); + when(configService.getConfig()).thenReturn(initialConfig, persistedConfig); + + Map values = new LinkedHashMap<>(); + values.put("enabled", true); + values.put("apiBaseUrl", "https://api.airtable.com"); + values.put("apiToken", ""); + values.put("baseId", "appBase"); + values.put("defaultTable", "Tasks"); + values.put("defaultView", "Grid"); + values.put("defaultMaxRecords", 15); + values.put("allowWrite", true); + values.put("allowDelete", false); + values.put("typecast", true); + + PluginSettingsSection section = contributor.saveSection("main", values); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AirtablePluginConfig.class); + verify(configService).save(captor.capture()); + AirtablePluginConfig saved = captor.getValue(); + assertEquals("existing-secret", saved.getApiToken()); + assertTrue(saved.getAllowWrite()); + assertFalse(saved.getAllowDelete()); + assertTrue(saved.getTypecast()); + assertEquals("", section.getValues().get("apiToken")); + assertEquals("Tasks", section.getValues().get("defaultTable")); + assertEquals(true, section.getValues().get("allowWrite")); + } + + @Test + void shouldReturnOkWhenConnectionTestSucceeds() { + config.setApiToken("token"); + config.setBaseId("appBase"); + config.setDefaultTable("Tasks"); + when(apiClient.listRecords("Tasks", "", null, 1, List.of(), null, null)) + .thenReturn(new AirtableApiClient.AirtableListResponse(List.of(), null)); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("ok", result.getStatus()); + assertTrue(result.getMessage().contains("Connected to Airtable")); + verify(apiClient).listRecords("Tasks", "", null, 1, List.of(), null, null); + } + + @Test + void shouldReturnErrorWhenTokenIsMissing() { + config.setBaseId("appBase"); + config.setDefaultTable("Tasks"); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Airtable API token is not configured.", result.getMessage()); + } + + @Test + void shouldReturnErrorWhenConnectionFails() { + config.setApiToken("token"); + config.setBaseId("appBase"); + config.setDefaultTable("Tasks"); + when(apiClient.listRecords("Tasks", "", null, 1, List.of(), null, null)) + .thenThrow(new AirtableTransportException("Airtable transport failed: timeout", null)); + + PluginActionResult result = contributor.executeAction("main", "test-connection", Map.of()); + + assertEquals("error", result.getStatus()); + assertEquals("Connection failed: Airtable transport failed: timeout", result.getMessage()); + } +} diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsServiceTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsServiceTest.java new file mode 100644 index 0000000..4c97ee5 --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsServiceTest.java @@ -0,0 +1,100 @@ +package me.golemcore.plugins.golemcore.airtable; + +import me.golemcore.plugin.api.extension.model.ToolFailureKind; +import me.golemcore.plugin.api.extension.model.ToolResult; +import me.golemcore.plugins.golemcore.airtable.support.AirtableApiClient; +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 AirtableRecordsServiceTest { + + private AirtableApiClient apiClient; + private AirtablePluginConfigService configService; + private AirtableRecordsService service; + private AirtablePluginConfig config; + + @BeforeEach + void setUp() { + apiClient = mock(AirtableApiClient.class); + configService = mock(AirtablePluginConfigService.class); + service = new AirtableRecordsService(apiClient, configService); + config = AirtablePluginConfig.builder() + .enabled(true) + .apiToken("token") + .baseId("appBase") + .defaultTable("Tasks") + .defaultView("Grid") + .defaultMaxRecords(20) + .allowWrite(true) + .allowDelete(true) + .typecast(false) + .build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + } + + @Test + void shouldListRecordsUsingConfiguredDefaults() { + when(apiClient.listRecords("Tasks", "Grid", null, 20, List.of(), null, null)) + .thenReturn(new AirtableApiClient.AirtableListResponse( + List.of(new AirtableApiClient.AirtableRecord( + "rec123", + "2026-04-04T00:00:00.000Z", + Map.of("Name", "Alice"))), + null)); + + ToolResult result = service.listRecords(null, null, null, null, List.of(), null, null); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("rec123")); + verify(apiClient).listRecords("Tasks", "Grid", null, 20, List.of(), null, null); + } + + @Test + void shouldCreateRecordWhenWriteIsAllowed() { + when(apiClient.createRecord("Tasks", Map.of("Name", "Alice"), false)) + .thenReturn(new AirtableApiClient.AirtableRecord( + "rec999", + "2026-04-04T00:00:00.000Z", + Map.of("Name", "Alice"))); + + ToolResult result = service.createRecord(null, Map.of("Name", "Alice"), Optional.empty()); + + assertTrue(result.isSuccess()); + assertTrue(result.getOutput().contains("Created Airtable record rec999")); + verify(apiClient).createRecord("Tasks", Map.of("Name", "Alice"), false); + } + + @Test + void shouldRequireTableWhenNoDefaultExists() { + config.setDefaultTable(""); + + ToolResult result = service.getRecord(null, "rec123"); + + assertFalse(result.isSuccess()); + assertEquals("Airtable table is required. Configure a default table or pass the table parameter.", + result.getError()); + } + + @Test + void shouldRejectDeleteWhenDisabled() { + config.setAllowDelete(false); + + ToolResult result = service.deleteRecord(null, "rec123"); + + assertFalse(result.isSuccess()); + assertEquals(ToolFailureKind.POLICY_DENIED, result.getFailureKind()); + assertEquals("Airtable delete is disabled in plugin settings", result.getError()); + } +} diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProviderTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProviderTest.java new file mode 100644 index 0000000..260c7a4 --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableRecordsToolProviderTest.java @@ -0,0 +1,88 @@ +package me.golemcore.plugins.golemcore.airtable; + +import me.golemcore.plugin.api.extension.model.ToolResult; +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 AirtableRecordsToolProviderTest { + + private AirtableRecordsService service; + private AirtableRecordsToolProvider provider; + + @BeforeEach + void setUp() { + service = mock(AirtableRecordsService.class); + provider = new AirtableRecordsToolProvider(service); + AirtablePluginConfig config = AirtablePluginConfig.builder() + .enabled(true) + .apiToken("token") + .baseId("appBase") + .build(); + when(service.getConfig()).thenReturn(config); + } + + @Test + void shouldParseJsonFieldsWhenCreatingRecord() { + when(service.createRecord("Tasks", Map.of("Name", "Alice"), Optional.of(true))) + .thenReturn(ToolResult.success("created")); + + ToolResult result = provider.execute(Map.of( + "operation", "create_record", + "table", "Tasks", + "fields", "{\"Name\":\"Alice\"}", + "typecast", true)).join(); + + assertTrue(result.isSuccess()); + verify(service).createRecord("Tasks", Map.of("Name", "Alice"), Optional.of(true)); + } + + @Test + void shouldPassListParametersToService() { + when(service.listRecords("Tasks", "Grid", "Status='Open'", 5, List.of("Name", "Status"), + "Created", "desc")) + .thenReturn(ToolResult.success("listed")); + + ToolResult result = provider.execute(Map.of( + "operation", "list_records", + "table", "Tasks", + "view", "Grid", + "filter_by_formula", "Status='Open'", + "max_records", 5, + "field_names", "Name,Status", + "sort_field", "Created", + "sort_direction", "desc")).join(); + + assertTrue(result.isSuccess()); + verify(service).listRecords("Tasks", "Grid", "Status='Open'", 5, List.of("Name", "Status"), + "Created", "desc"); + } + + @Test + void shouldRejectInvalidFieldsPayload() { + ToolResult result = provider.execute(Map.of( + "operation", "create_record", + "fields", "not-json")).join(); + + assertFalse(result.isSuccess()); + assertEquals("fields 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 airtable_records operation: unknown", result.getError()); + } +} diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableSpringWiringTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableSpringWiringTest.java new file mode 100644 index 0000000..823c8ef --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/AirtableSpringWiringTest.java @@ -0,0 +1,36 @@ +package me.golemcore.plugins.golemcore.airtable; + +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 AirtableSpringWiringTest { + + @Test + void shouldCreateAirtableBeansWithoutDefaultConstructor() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(AirtablePluginConfiguration.class, TestConfig.class); + context.refresh(); + + assertNotNull(context.getBean(AirtableRecordsToolProvider.class)); + assertNotNull(context.getBean(AirtablePluginSettingsContributor.class)); + } + } + + @Configuration(proxyBeanMethods = false) + @SuppressWarnings("PMD.TestClassWithoutTestCases") + static class TestConfig { + + @Bean + @Primary + PluginConfigurationService pluginConfigurationService() { + return mock(PluginConfigurationService.class); + } + } +} diff --git a/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClientTest.java b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClientTest.java new file mode 100644 index 0000000..aa9493a --- /dev/null +++ b/golemcore/airtable/src/test/java/me/golemcore/plugins/golemcore/airtable/support/AirtableApiClientTest.java @@ -0,0 +1,170 @@ +package me.golemcore.plugins.golemcore.airtable.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import me.golemcore.plugins.golemcore.airtable.AirtablePluginConfig; +import me.golemcore.plugins.golemcore.airtable.AirtablePluginConfigService; +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.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 AirtableApiClientTest { + + private static final MediaType APPLICATION_JSON = MediaType.get("application/json"); + + private AirtablePluginConfigService configService; + private MockAirtableApiClient client; + private AirtablePluginConfig config; + + @BeforeEach + void setUp() { + configService = mock(AirtablePluginConfigService.class); + config = AirtablePluginConfig.builder() + .enabled(true) + .apiToken("token") + .baseId("appBase") + .defaultTable("Tasks") + .build(); + config.normalize(); + when(configService.getConfig()).thenReturn(config); + client = new MockAirtableApiClient(configService); + } + + @Test + void shouldBuildListRequestAndParseRecords() { + client.enqueueResponse(200, """ + { + "records": [ + { + "id": "rec123", + "createdTime": "2026-04-04T00:00:00.000Z", + "fields": { + "Name": "Alice", + "Status": "Open" + } + } + ], + "offset": "itrNext" + } + """); + + AirtableApiClient.AirtableListResponse response = client.listRecords( + "Tasks", + "Grid", + "Status='Open'", + 5, + List.of("Name", "Status"), + "Created", + "desc"); + + Request request = client.getCapturedRequests().getFirst(); + assertEquals("/v0/appBase/Tasks", request.url().encodedPath()); + assertEquals("Grid", request.url().queryParameter("view")); + assertEquals("Status='Open'", request.url().queryParameter("filterByFormula")); + assertEquals("5", request.url().queryParameter("maxRecords")); + assertEquals(List.of("Name", "Status"), request.url().queryParameterValues("fields[]")); + assertEquals("Created", request.url().queryParameter("sort[0][field]")); + assertEquals("desc", request.url().queryParameter("sort[0][direction]")); + assertEquals("Bearer token", request.header("Authorization")); + assertEquals("rec123", response.records().getFirst().id()); + assertEquals("Alice", response.records().getFirst().fields().get("Name")); + assertEquals("itrNext", response.offset()); + } + + @Test + void shouldSendWriteRequestWithTypecast() throws Exception { + client.enqueueResponse(200, """ + { + "id": "rec999", + "fields": { + "Name": "Alice" + } + } + """); + + AirtableApiClient.AirtableRecord record = client.createRecord("Tasks", Map.of("Name", "Alice"), true); + + Request request = client.getCapturedRequests().getFirst(); + ObjectMapper objectMapper = new ObjectMapper(); + Map body = objectMapper.readValue(readRequestBody(request), Map.class); + assertEquals("rec999", record.id()); + assertEquals(Map.of("Name", "Alice"), body.get("fields")); + assertEquals(true, body.get("typecast")); + } + + @Test + void shouldSurfaceApiErrorMessage() { + client.enqueueResponse(422, """ + { + "error": { + "message": "Invalid request" + } + } + """); + + AirtableApiException exception = assertThrows(AirtableApiException.class, + () -> client.getRecord("Tasks", "rec123")); + + assertEquals(422, exception.getStatusCode()); + assertEquals("Invalid request", 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 MockAirtableApiClient extends AirtableApiClient { + + private final Queue plannedResponses = new ArrayDeque<>(); + private final List capturedRequests = new ArrayList<>(); + + private MockAirtableApiClient(AirtablePluginConfigService 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..aab6796 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,7 @@ golemcore/weather golemcore/mail golemcore/lightrag + golemcore/airtable diff --git a/registry/golemcore/airtable/index.yaml b/registry/golemcore/airtable/index.yaml new file mode 100644 index 0000000..56ffc76 --- /dev/null +++ b/registry/golemcore/airtable/index.yaml @@ -0,0 +1,7 @@ +id: golemcore/airtable +owner: golemcore +name: airtable +latest: 1.0.0 +versions: + - 1.0.0 +source: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/airtable" diff --git a/registry/golemcore/airtable/versions/1.0.0.yaml b/registry/golemcore/airtable/versions/1.0.0.yaml new file mode 100644 index 0000000..4274173 --- /dev/null +++ b/registry/golemcore/airtable/versions/1.0.0.yaml @@ -0,0 +1,12 @@ +id: golemcore/airtable +version: 1.0.0 +pluginApiVersion: 1 +engineVersion: ">=0.0.0 <1.0.0" +artifactUrl: "dist/golemcore/airtable/1.0.0/golemcore-airtable-plugin-1.0.0.jar" +publishedAt: "TBD" +sourceCommit: "TBD" +entrypoint: me.golemcore.plugins.golemcore.airtable.AirtablePluginBootstrap +sourceUrl: "https://github.com/alexk-dev/golemcore-plugins/tree/main/golemcore/airtable" +license: "Apache-2.0" +maintainers: + - alexk-dev