This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-12245-7a427d40de48dddba1188a51c6baec7a1ad60c26 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 7d3649ade23ee077d608453d3e9ed54da4479d82 Author: Goutam Adwant <[email protected]> AuthorDate: Sat Sep 12 09:20:52 2026 +0000 [Feature][Connector-V2] Add Salesforce sink (#12245) Signed-off-by: Goutam Adwant <[email protected]> --- .github/workflows/labeler/label-scope-conf.yml | 5 +- .../connectors/changelog/connector-salesforce.md | 5 + docs/en/connectors/sink/Salesforce.md | 157 +++++++ .../connectors/changelog/connector-salesforce.md | 5 + docs/zh/connectors/sink/Salesforce.md | 138 ++++++ plugin-mapping.properties | 1 + .../salesforce/client/SalesforceClient.java | 223 ++++++++- .../salesforce/config/SalesforceSinkConfig.java | 107 +++++ .../salesforce/config/SalesforceSinkOptions.java | 53 +++ .../exception/SalesforceConnectorErrorCode.java | 3 +- .../salesforce/sink/SalesforceRowSerializer.java | 206 +++++++++ .../seatunnel/salesforce/sink/SalesforceSink.java | 57 +++ .../salesforce/sink/SalesforceSinkFactory.java | 91 ++++ .../salesforce/sink/SalesforceSinkWriter.java | 174 +++++++ .../sink/SalesforceRowSerializerTest.java | 251 ++++++++++ .../salesforce/sink/SalesforceSinkTest.java | 505 +++++++++++++++++++++ .../core/starter/utils/ConfigShadeUtils.java | 2 +- .../core/starter/utils/ConfigBuilderTest.java | 24 + .../connector-http-e2e/pom.xml | 6 + .../e2e/connector/http/SalesforceSinkIT.java | 132 ++++++ .../src/test/resources/fake_to_salesforce.conf | 47 ++ 21 files changed, 2186 insertions(+), 6 deletions(-) diff --git a/.github/workflows/labeler/label-scope-conf.yml b/.github/workflows/labeler/label-scope-conf.yml index fcc5cbdc9e..7dea290bc8 100644 --- a/.github/workflows/labeler/label-scope-conf.yml +++ b/.github/workflows/labeler/label-scope-conf.yml @@ -102,7 +102,10 @@ cassandra: salesforce: - all: - changed-files: - - any-glob-to-any-file: seatunnel-connectors-v2/connector-salesforce/** + - any-glob-to-any-file: + - seatunnel-connectors-v2/connector-salesforce/** + - seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/java/org/apache/seatunnel/e2e/connector/http/SalesforceSinkIT.java + - seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/resources/fake_to_salesforce.conf - all-globs-to-all-files: '!seatunnel-connectors-v2/connector-!(salesforce)/**' deeplake: - all: diff --git a/docs/en/connectors/changelog/connector-salesforce.md b/docs/en/connectors/changelog/connector-salesforce.md new file mode 100644 index 0000000000..04fce7f381 --- /dev/null +++ b/docs/en/connectors/changelog/connector-salesforce.md @@ -0,0 +1,5 @@ +# Changelog + +| Change | Commit | Version | +| --- | --- | --- | +| Add external-ID REST collection upsert sink | - | Next | diff --git a/docs/en/connectors/sink/Salesforce.md b/docs/en/connectors/sink/Salesforce.md new file mode 100644 index 0000000000..deb2d9e588 --- /dev/null +++ b/docs/en/connectors/sink/Salesforce.md @@ -0,0 +1,157 @@ +import ChangeLog from '../changelog/connector-salesforce.md'; + +# Salesforce + +> Salesforce REST sObject Collections upsert sink + +## Description + +Writes one input table to one existing Salesforce object using its external ID field. +The connector extends the existing Salesforce module without changing source options or data-reading behavior. +It does not create objects, fields, external IDs, or connected apps. + +## Support Those Engines + +> SeaTunnel Zeta<br/> +> Flink<br/> +> Spark<br/> + +## Key Features + +- [x] batch +- [x] stream +- [ ] exactly-once +- [ ] cdc +- [ ] support multiple table write + +Streaming support is limited to `INSERT` and `UPDATE_AFTER` rows. This is not a CDC sink: +`UPDATE_BEFORE` and `DELETE` fail the job. See [Delivery Semantics](#delivery-semantics) +before connecting a changelog source. + +## Options + +| name | type | required | default value | +| --- | --- | --- | --- | +| client_id | string | yes | - | +| client_secret | string | yes | - | +| username | string | yes | - | +| password | string | yes | - | +| security_token | string | no | empty string | +| instance_url | string | yes | - | +| api_version | string | no | v59.0 | +| object_name | string | yes | - | +| external_id_field | string | yes | - | +| batch_size | int | no | 200 | +| batch_max_bytes | int | no | 1048576 | +| request_timeout_ms | int | no | 60000 | +| max_retries | int | no | 3 | +| retry_interval_ms | long | no | 1000 | +| common-options | | no | - | + +### Authentication and Permissions + +Uses the existing source connector's OAuth username-password flow. Your org must permit that flow; +the integration user needs API access, object create/update access, and write access to all mapped fields. +The security token is appended to the password, as in the source connector. + +Use an HTTPS login or org origin for `instance_url`, for example +`https://login.salesforce.com` or your sandbox login origin. Do not include a trailing slash, +path, embedded credentials, query, or fragment. HTTP is accepted for explicitly configured +trusted test endpoints but transmits credentials in plaintext; do not use it for production. +The authenticated instance URL is used for data requests. Redirects and HTTPS-to-HTTP +authentication downgrades are rejected in the sink path. + +`client_secret` and `security_token` are automatically masked in parsed-configuration logs. +This is log masking, not configuration encryption. Keep credentials out of checked-in job files. + +### Object and Field Mapping + +`object_name` is the Salesforce API object name, such as `Account`. +Input field names must match writable Salesforce API fields. Relationships, nested objects, +arrays, and automatic schema creation are not supported. + +`external_id_field` must exist in both Salesforce and the input schema. Configure it as an +external ID and preferably Unique in Salesforce. Each row must provide a non-null, non-blank +STRING, integer, or DECIMAL key. Salesforce `Id` is not accepted as the external ID or an input field. +The connector does not verify remote field metadata before sending; Salesforce validation or +permission errors fail the job. Null values in other columns explicitly clear those fields. + +BOOLEAN and numeric values remain native JSON values. DECIMAL is serialized without conversion +to double; the target field's configured precision still applies. Non-finite floats are rejected. +DATE uses ISO dates, TIME uses UTC time with milliseconds, TIMESTAMP without a zone is interpreted +as UTC, and TIMESTAMP_TZ preserves its explicit offset. Sub-millisecond temporal values are +rejected instead of truncated. BYTES uses Base64. Salesforce determines whether each value +is compatible with the configured target field. + +### Batching and Failures + +`batch_size` is 1-200 records per request. `batch_max_bytes` is the UTF-8 JSON request bound, +including the envelope, from 128 to 8388608 bytes. This is a client-side bound, not a promise +about Salesforce's request limits. A single oversized record fails before sending. + +Batches use `allOrNone = true`. Every record result must report success; malformed responses, +missing results, or any record error fail the task and prevent checkpoint completion. +Error diagnostics include result indices and Salesforce status codes, not record values or +raw response bodies. Per-record failures are not retried locally, including lock/validation errors. + +`max_retries` is 0-10 additional attempts after an I/O failure or HTTP 429, 500, 502, 503, or 504. +One HTTP 401 may refresh authentication within the same retry budget. Other HTTP errors fail +immediately. Authentication failures themselves are not retried automatically. This avoids +repeated login attempts with rejected credentials. Quota errors returned as HTTP 403 also fail +immediately. Retry-After is honored up to 60 seconds; +larger or invalid values fail the task rather than retrying earlier than the service permits. +`retry_interval_ms` is 0-60000. `request_timeout_ms` is a positive connection/pool/socket timeout, +not a whole-job deadline. Choose parallelism and retry settings within your org's API quota. +Flushing is synchronous, so size the checkpoint timeout to allow the complete flush, including +request timeouts, retry delays, and any authentication refresh. A single request timeout is not +the total flush budget, especially during sustained throttling or service failures. + +The writer flushes on count/byte bounds, checkpoint preparation, and normal close. It also +registers the engine flush callback. Zeta can enable periodic flushing through +`env.sink.flush.interval` (milliseconds; 0 disables it by default). Engines without that callback +rely on count/checkpoint/end of input. Streaming jobs should enable periodic checkpoints to +bound low-volume delivery latency. + +## Delivery Semantics + +Only `INSERT` and `UPDATE_AFTER` are accepted; `DELETE` and `UPDATE_BEFORE` fail rather than +being ignored. The sink does not reconcile deletes or external-ID changes. Silently discarding +before-images could hide an unsupported changelog pipeline and leave old Salesforce records +behind when a key changes. + +Delivery is at-least-once with a replay-capable source and checkpointing. An uncertain HTTP +outcome or task recovery can repeat an upsert and re-run Salesforce triggers/flows. This is not +an exactly-once or distributed-transaction sink; earlier successful requests cannot be rolled back. + +Repeated external IDs within one writer are flushed in input order in separate requests. +For ordered updates to the same key, use parallelism 1 or ensure all updates for that key are +routed in order to one writer. There is no global ordering across writers or recovery attempts. +Delete, insert-only, Bulk API ingestion, and multi-object routing are outside this connector slice. + +## Task Example + +Create `External_Id__c` on Account as a unique external ID and supply the following input columns: + +```hocon +sink { + Salesforce { + instance_url = "https://login.salesforce.com" + client_id = "${SALESFORCE_CLIENT_ID}" + client_secret = "${SALESFORCE_CLIENT_SECRET}" + username = "${SALESFORCE_USERNAME}" + password = "${SALESFORCE_PASSWORD}" + security_token = "${SALESFORCE_SECURITY_TOKEN}" + object_name = "Account" + external_id_field = "External_Id__c" + batch_size = 200 + } +} +``` + +Use your deployment's substitution mechanism to supply these placeholders. +The input schema should include `External_Id__c = string` and `Name = string`. +See [Sink Common Options](../common-options/sink-common-options.md) for shared parameters. + +## Changelog + +<ChangeLog /> diff --git a/docs/zh/connectors/changelog/connector-salesforce.md b/docs/zh/connectors/changelog/connector-salesforce.md new file mode 100644 index 0000000000..d7789cd617 --- /dev/null +++ b/docs/zh/connectors/changelog/connector-salesforce.md @@ -0,0 +1,5 @@ +# 更新日志 + +| 变更 | 提交 | 版本 | +| --- | --- | --- | +| 添加基于外部 ID 的 REST collection upsert Sink | - | Next | diff --git a/docs/zh/connectors/sink/Salesforce.md b/docs/zh/connectors/sink/Salesforce.md new file mode 100644 index 0000000000..915fcdcd04 --- /dev/null +++ b/docs/zh/connectors/sink/Salesforce.md @@ -0,0 +1,138 @@ +import ChangeLog from '../changelog/connector-salesforce.md'; + +# Salesforce + +> 基于 Salesforce REST sObject Collections 的 upsert Sink + +## 描述 + +将一个输入表写入一个已存在的 Salesforce 对象,根据外部 ID 更新或插入记录。 +复用现有 Salesforce 模块,不改变 Source 配置和读取行为;不会创建对象、字段、外部 ID 或应用。 + +## 支持的引擎 + +> SeaTunnel Zeta<br/> +> Flink<br/> +> Spark<br/> + +## 功能 + +- [x] 批处理 +- [x] 流处理 +- [ ] exactly-once +- [ ] 完整 CDC +- [ ] 多表写入 + +流处理仅支持 `INSERT` 和 `UPDATE_AFTER`。本连接器不是 CDC Sink: +`UPDATE_BEFORE` 和 `DELETE` 会导致作业失败。连接变更日志 Source 前请阅读下方的投递语义。 + +## 配置 + +| 名称 | 类型 | 必填 | 默认值 | +| --- | --- | --- | --- | +| client_id | string | 是 | - | +| client_secret | string | 是 | - | +| username | string | 是 | - | +| password | string | 是 | - | +| security_token | string | 否 | 空字符串 | +| instance_url | string | 是 | - | +| api_version | string | 否 | v59.0 | +| object_name | string | 是 | - | +| external_id_field | string | 是 | - | +| batch_size | int | 否 | 200 | +| batch_max_bytes | int | 否 | 1048576 | +| request_timeout_ms | int | 否 | 60000 | +| max_retries | int | 否 | 3 | +| retry_interval_ms | long | 否 | 1000 | +| common-options | | 否 | - | + +### 认证 + +使用与 Source 相同的 OAuth 用户名密码流程,组织必须允许该认证方式。 +集成用户需要 API、对象创建/更新以及所有目标字段的写权限。security_token 追加在密码后。 +生产环境的 instance_url 应为 HTTPS 登录或组织地址,例如 [Salesforce 登录地址](https://login.salesforce.com), +不包含末尾斜杠、路径、内嵌凭证、查询参数或片段。HTTP 仅适用于显式配置的可信测试端点, +会明文传输凭证,不应在生产环境使用。数据请求使用认证响应中的实例地址。 +Sink 不跟随重定向,也不接受认证地址从 HTTPS 降级到 HTTP。 + +client_secret 和 security_token 自动在解析后的配置日志中掩码;这不是配置加密。 +不要将真实凭证提交到配置文件。 + +### 字段映射 + +object_name 为 Salesforce API 对象名,例如 Account。输入列名应与可写 API 字段匹配。 +不支持嵌套对象、数组、关系对象或自动创建表结构。 +external_id_field 必须存在于输入结构和 Salesforce 中,并在 Salesforce 中配置为外部 ID, +建议同时启用 Unique。每行键必须为非空、非空白的 STRING、整数或 DECIMAL。 +不接受 Salesforce Id 作为外部 ID 或输入列。 + +不预先查询远端字段元数据,权限、校验或字段不兼容由 Salesforce 返回并导致作业失败。 +其他字段的 null 会明确清空目标值。布尔和数值使用 JSON 原生类型,DECIMAL 不经过 double 转换; +远端字段的精度限制仍适用。拒绝非有限浮点数。 +DATE 使用 ISO 日期,TIME 使用 UTC 毫秒格式,无时区 TIMESTAMP 按 UTC 解释, +TIMESTAMP_TZ 保留显式偏移量。拒绝亚毫秒时间值而不是静默截断;BYTES 使用 Base64。 + +### 批次和错误 + +batch_size 范围为 1-200。batch_max_bytes 限制包含封装在内的 UTF-8 JSON 请求大小, +范围为 128-8388608 字节,这是客户端限制而不是 Salesforce 限制的声明。 +单条记录超限时在发送前失败。 + +固定使用 allOrNone=true,逐条检查响应。任意失败、缺失结果或非法响应均导致任务失败, +阻止检查点完成。错误只包含结果索引和状态码,不包含原始响应或字段值。 +单条记录错误(包括锁冲突、校验错误)不会在客户端局部重试。 + +max_retries 为额外尝试次数(0-10),仅重试 I/O 错误或 HTTP 429、500、502、503、504。 +在同一预算内允许一次 HTTP 401 重新认证。包括配额限制 HTTP 403 在内的其他错误立即失败。 +认证请求自身失败时不自动重试,避免使用被拒绝的凭证反复登录。 +遵守不超过 60 秒的 Retry-After;无效或更大的值直接失败,不提前重试。 +retry_interval_ms 范围 0-60000。request_timeout_ms 必须为正,是连接、连接池和 socket 超时, +不是整个任务的截止时间。并行度和重试次数需要符合组织 API 配额。 +刷新是同步执行的,检查点超时需要覆盖完整刷新过程,包括请求超时、重试等待和可能发生的重新认证。 +单次请求超时并不等于总刷新预算,尤其是在持续限流或服务故障期间。 + +达到批次数量/字节限制、检查点准备或正常关闭时刷新。 +同时注册引擎刷新回调。Zeta 可通过 env.sink.flush.interval 启用周期刷新(毫秒,默认 0 表示禁用)。 +未实现此回调的引擎依赖数量限制、检查点和输入结束刷新。 +流作业应启用周期性检查点以限制低流量下的延迟。 + +## 投递语义 + +仅接受 `INSERT` 和 `UPDATE_AFTER`;`DELETE` 和 `UPDATE_BEFORE` 会失败而不是被忽略。 +本连接器不处理删除或外部 ID 变更的旧记录清理。静默丢弃更新前镜像可能掩盖不受支持的变更日志输入, +并在键变更后将旧记录留在 Salesforce 中。 + +配合可回放 Source 和检查点提供至少一次语义。不确定的 HTTP 结果和恢复可能再次 upsert, +重复执行触发器或 Flow,因此不保证 exactly-once,也不能回滚之前成功的请求。 + +单个 writer 内重复外部 ID 按输入顺序分批发送。同键有序更新应使用并行度 1, +或确保同键更新有序路由到同一 writer;不同 writer 和恢复尝试之间不保证全局顺序。 +删除、仅插入模式、Bulk API 和多对象路由不属于本次范围。 + +## 示例 + +先在 Account 中创建启用 Unique 的外部 ID 字段 External_Id__c, +输入包含 External_Id__c=string 和 Name=string: + +```hocon +sink { + Salesforce { + instance_url = "https://login.salesforce.com" + client_id = "${SALESFORCE_CLIENT_ID}" + client_secret = "${SALESFORCE_CLIENT_SECRET}" + username = "${SALESFORCE_USERNAME}" + password = "${SALESFORCE_PASSWORD}" + security_token = "${SALESFORCE_SECURITY_TOKEN}" + object_name = "Account" + external_id_field = "External_Id__c" + batch_size = 200 + } +} +``` + +请使用部署环境的变量替换机制提供示例占位符。 +通用配置请参阅 [Sink Common Options](../common-options/sink-common-options.md)。 + +## 更新日志 + +<ChangeLog /> diff --git a/plugin-mapping.properties b/plugin-mapping.properties index 48e5d11c3d..08fd3b0494 100644 --- a/plugin-mapping.properties +++ b/plugin-mapping.properties @@ -93,6 +93,7 @@ seatunnel.source.AzureCosmosDB = connector-azurecosmosdb seatunnel.source.Cassandra = connector-cassandra seatunnel.sink.Cassandra = connector-cassandra seatunnel.source.Salesforce = connector-salesforce +seatunnel.sink.Salesforce = connector-salesforce seatunnel.sink.StarRocks = connector-starrocks seatunnel.source.MyHours = connector-http-myhours seatunnel.sink.InfluxDB = connector-influxdb diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/client/SalesforceClient.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/client/SalesforceClient.java index 03aa7c5509..f7d8183829 100644 --- a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/client/SalesforceClient.java +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/client/SalesforceClient.java @@ -30,6 +30,7 @@ import org.apache.seatunnel.api.table.type.LocalTimeType; import org.apache.seatunnel.api.table.type.PrimitiveByteArrayType; import org.apache.seatunnel.api.table.type.SeaTunnelDataType; import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceParameters; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; import org.apache.seatunnel.connectors.seatunnel.salesforce.exception.SalesforceConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.salesforce.exception.SalesforceConnectorException; @@ -43,22 +44,28 @@ import org.apache.http.client.config.RequestConfig; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.utils.DateUtils; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClients; import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; import lombok.extern.slf4j.Slf4j; +import java.io.ByteArrayOutputStream; import java.io.Closeable; import java.io.IOException; +import java.io.InputStream; import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.function.Consumer; @@ -75,17 +82,33 @@ public class SalesforceClient implements Closeable { private final SalesforceParameters params; private final CloseableHttpClient httpClient; private final ObjectMapper objectMapper = new ObjectMapper(); + private final boolean sinkMode; private String accessToken; private String authorizedInstanceUrl; public SalesforceClient(SalesforceParameters params) { + this(params, false); + } + + /** Create a write client with explicit retries and no credential-forwarding redirects. */ + public static SalesforceClient forSink(SalesforceParameters params) { + return new SalesforceClient(params, true); + } + + private SalesforceClient(SalesforceParameters params, boolean sinkMode) { this.params = params; + this.sinkMode = sinkMode; RequestConfig requestConfig = RequestConfig.custom() .setConnectTimeout(params.getRequestTimeoutMs()) .setSocketTimeout(params.getRequestTimeoutMs()) + .setConnectionRequestTimeout(sinkMode ? params.getRequestTimeoutMs() : -1) .build(); - this.httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build(); + HttpClientBuilder builder = HttpClients.custom().setDefaultRequestConfig(requestConfig); + if (sinkMode) { + builder.disableAutomaticRetries().disableRedirectHandling(); + } + this.httpClient = builder.build(); } public void authenticate() { @@ -105,13 +128,32 @@ public class SalesforceClient implements Closeable { post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); try (CloseableHttpResponse response = httpClient.execute(post)) { int status = response.getStatusLine().getStatusCode(); - String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + String body = + sinkMode + ? readSinkBody(response) + : EntityUtils.toString( + response.getEntity(), StandardCharsets.UTF_8); if (status != 200) { throw new SalesforceConnectorException( SalesforceConnectorErrorCode.AUTH_FAILED, - "HTTP " + status + ": " + body); + "HTTP " + status + (sinkMode ? "" : ": " + body)); } JsonNode json = objectMapper.readTree(body); + if (sinkMode) { + if (json == null + || !json.path("access_token").isTextual() + || json.path("access_token").asText().isEmpty() + || !json.path("instance_url").isTextual()) { + throw new IOException("Invalid authentication response"); + } + SalesforceSinkConfig.validateInstanceUrl(json.path("instance_url").asText()); + if (params.getInstanceUrl().regionMatches(true, 0, "https:", 0, 6) + && !json.path("instance_url") + .asText() + .regionMatches(true, 0, "https:", 0, 6)) { + throw new IOException("Authentication returned an insecure instance"); + } + } this.accessToken = json.get("access_token").asText(); this.authorizedInstanceUrl = json.get("instance_url").asText(); log.info("Authenticated with Salesforce instance {}", authorizedInstanceUrl); @@ -119,10 +161,185 @@ public class SalesforceClient implements Closeable { } catch (SalesforceConnectorException e) { throw e; } catch (Exception e) { + if (sinkMode) { + throw new SalesforceConnectorException( + SalesforceConnectorErrorCode.AUTH_FAILED, + "Authentication failed (" + e.getClass().getSimpleName() + ")"); + } throw new SalesforceConnectorException(SalesforceConnectorErrorCode.AUTH_FAILED, e); } } + /** + * Upsert one bounded collection with allOrNone=true. Request retries can repeat Salesforce-side + * effects; this is not a checkpoint transaction or exactly-once protocol. + */ + public void upsert( + String objectName, + String externalIdField, + List<ObjectNode> records, + int maxRetries, + long retryIntervalMs) { + if (!sinkMode || accessToken == null) { + throw writeFailure("An authenticated sink client is required"); + } + SalesforceSinkConfig.requireIdentifier(objectName, "object_name"); + SalesforceSinkConfig.requireIdentifier(externalIdField, "external_id_field"); + if (records.isEmpty() + || records.size() > 200 + || maxRetries < 0 + || maxRetries > 10 + || retryIntervalMs < 0 + || retryIntervalMs > 60000) { + throw writeFailure("Invalid upsert batch or retry bounds"); + } + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("allOrNone", true); + records.forEach(payload.putArray("records")::add); + String requestBody = payload.toString(); + boolean refreshed = false; + for (int attempt = 0; attempt <= maxRetries; attempt++) { + if (Thread.currentThread().isInterrupted()) { + throw writeFailure("Interrupted before collection upsert"); + } + long delay = retryIntervalMs; + HttpPatch patch = + new HttpPatch( + authorizedInstanceUrl + + "/services/data/" + + params.getApiVersion() + + "/composite/sobjects/" + + objectName + + "/" + + externalIdField); + patch.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken); + patch.setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON)); + boolean refresh = false; + try (CloseableHttpResponse response = httpClient.execute(patch)) { + int status = response.getStatusLine().getStatusCode(); + if (status == 200) { + validateUpsertResults(readSinkBody(response), records.size()); + return; + } + if (status == 401 && !refreshed && attempt < maxRetries) { + refresh = true; + } else if ((status == 429 + || status == 500 + || status == 502 + || status == 503 + || status == 504) + && attempt < maxRetries) { + delay = retryDelay(response.getFirstHeader("Retry-After"), retryIntervalMs); + } else { + throw writeFailure( + "Collection upsert returned HTTP " + + status + + " after " + + (attempt + 1) + + " attempt(s)"); + } + } catch (IOException e) { + if (Thread.currentThread().isInterrupted() || attempt == maxRetries) { + throw writeFailure( + "Collection upsert I/O failed (" + + e.getClass().getSimpleName() + + ") after " + + (attempt + 1) + + " attempt(s)"); + } + } + if (refresh) { + authenticate(); + refreshed = true; + } else { + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw writeFailure("Interrupted while waiting to retry collection upsert"); + } + } + } + throw writeFailure("Collection upsert exhausted retries"); + } + + private void validateUpsertResults(String body, int count) { + JsonNode results; + try { + results = objectMapper.readTree(body); + } catch (IOException e) { + throw writeFailure("Invalid collection upsert JSON response"); + } + if (results == null || !results.isArray() || results.size() != count) { + throw writeFailure("Collection upsert response count does not match the request"); + } + List<String> failures = new ArrayList<>(); + for (int i = 0; i < count; i++) { + JsonNode result = results.get(i); + if (!result.path("success").isBoolean() || !result.path("errors").isArray()) { + throw writeFailure("Malformed collection result at index " + i); + } + if (!result.path("success").asBoolean() || result.path("errors").size() != 0) { + List<String> codes = new ArrayList<>(); + for (JsonNode error : result.path("errors")) { + String code = error.path("statusCode").asText(); + codes.add(code.matches("[A-Z_]{1,80}") ? code : "UNKNOWN"); + } + failures.add("index " + i + ": " + codes); + } else if (!result.path("id").isTextual() || result.path("id").asText().isEmpty()) { + throw writeFailure("Missing record ID in collection result at index " + i); + } + } + if (!failures.isEmpty()) { + // API messages can contain field values; report indices and codes, not response bodies. + throw writeFailure("Collection upsert failed: " + failures); + } + } + + private long retryDelay(Header retryAfter, long configuredDelay) { + if (retryAfter == null) { + return configuredDelay; + } + long delay; + try { + delay = Math.multiplyExact(Long.parseLong(retryAfter.getValue().trim()), 1000L); + } catch (NumberFormatException e) { + Date date = DateUtils.parseDate(retryAfter.getValue()); + if (date == null) { + throw writeFailure("Invalid Retry-After response header"); + } + delay = Math.max(0, date.getTime() - System.currentTimeMillis()); + } catch (ArithmeticException e) { + throw writeFailure("Retry-After exceeds the bounded retry budget"); + } + if (delay < 0 || delay > 60000) { + throw writeFailure("Retry-After exceeds the bounded retry budget"); + } + return Math.max(delay, configuredDelay); + } + + private String readSinkBody(CloseableHttpResponse response) throws IOException { + if (response.getEntity() == null) { + throw writeFailure("Missing Salesforce response body"); + } + try (InputStream input = response.getEntity().getContent(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int read; + while ((read = input.read(buffer)) != -1) { + if (output.size() + read > 1024 * 1024) { + throw writeFailure("Salesforce response exceeds the 1 MiB client limit"); + } + output.write(buffer, 0, read); + } + return new String(output.toByteArray(), StandardCharsets.UTF_8); + } + } + + private SalesforceConnectorException writeFailure(String message) { + return new SalesforceConnectorException(SalesforceConnectorErrorCode.WRITE_FAILED, message); + } + public CatalogTable describeObject(String database, String objectName) { String url = authorizedInstanceUrl diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/config/SalesforceSinkConfig.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/config/SalesforceSinkConfig.java new file mode 100644 index 0000000000..372ae44b7d --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/config/SalesforceSinkConfig.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.config; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; + +import lombok.Getter; + +import java.io.Serializable; +import java.net.URI; + +@Getter +public final class SalesforceSinkConfig implements Serializable { + private static final long serialVersionUID = 1L; + public static final String IDENTIFIER_PATTERN = "[A-Za-z][A-Za-z0-9_]*"; + + private final SalesforceParameters parameters; + private final String objectName; + private final String externalIdField; + private final int batchSize; + private final int batchMaxBytes; + private final int maxRetries; + private final long retryIntervalMs; + + public SalesforceSinkConfig(ReadonlyConfig config) { + parameters = new SalesforceParameters(); + parameters.buildWithConfig(config); + requireText(parameters.getClientId(), "client_id"); + requireText(parameters.getClientSecret(), "client_secret"); + requireText(parameters.getUsername(), "username"); + requireText(parameters.getPassword(), "password"); + validateInstanceUrl(parameters.getInstanceUrl()); + if (!parameters.getApiVersion().matches("v[0-9]+\\.[0-9]+")) { + throw new IllegalArgumentException("api_version must have the form v59.0"); + } + if (parameters.getRequestTimeoutMs() <= 0) { + throw new IllegalArgumentException("request_timeout_ms must be positive"); + } + objectName = config.get(SalesforceSourceOptions.OBJECT_NAME); + externalIdField = config.get(SalesforceSinkOptions.EXTERNAL_ID_FIELD); + requireIdentifier(objectName, "object_name"); + requireIdentifier(externalIdField, "external_id_field"); + if ("Id".equalsIgnoreCase(externalIdField)) { + throw new IllegalArgumentException( + "external_id_field must be an external ID, not the Salesforce Id"); + } + batchSize = config.get(SalesforceSinkOptions.BATCH_SIZE); + batchMaxBytes = config.get(SalesforceSinkOptions.BATCH_MAX_BYTES); + maxRetries = config.get(SalesforceSinkOptions.MAX_RETRIES); + retryIntervalMs = config.get(SalesforceSinkOptions.RETRY_INTERVAL_MS); + requireRange(batchSize, 1, 200, "batch_size"); + requireRange(batchMaxBytes, 128, 8 * 1024 * 1024, "batch_max_bytes"); + requireRange(maxRetries, 0, 10, "max_retries"); + requireRange(retryIntervalMs, 0, 60000, "retry_interval_ms"); + } + + public static void requireIdentifier(String value, String option) { + if (value == null || !value.matches(IDENTIFIER_PATTERN)) { + throw new IllegalArgumentException(option + " must be a Salesforce API identifier"); + } + } + + public static void validateInstanceUrl(String value) { + try { + URI uri = URI.create(value); + if ((!"http".equalsIgnoreCase(uri.getScheme()) + && !"https".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null + || uri.getUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null + || (uri.getPath() != null && !uri.getPath().isEmpty())) { + throw new IllegalArgumentException(); + } + } catch (RuntimeException invalid) { + throw new IllegalArgumentException( + "instance_url must be an HTTP(S) origin without credentials, path, query or fragment"); + } + } + + private static void requireText(String value, String option) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(option + " must not be blank"); + } + } + + private static void requireRange(long value, long min, long max, String option) { + if (value < min || value > max) { + throw new IllegalArgumentException(option + " must be between " + min + " and " + max); + } + } +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/config/SalesforceSinkOptions.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/config/SalesforceSinkOptions.java new file mode 100644 index 0000000000..7e9fb8708d --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/config/SalesforceSinkOptions.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.config; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.Options; + +public final class SalesforceSinkOptions { + private SalesforceSinkOptions() {} + + public static final Option<String> EXTERNAL_ID_FIELD = + Options.key("external_id_field") + .stringType() + .noDefaultValue() + .withDescription("Salesforce external ID field present in every input row."); + public static final Option<Integer> BATCH_SIZE = + Options.key("batch_size") + .intType() + .defaultValue(200) + .withDescription("Maximum records per REST collection upsert, from 1 to 200."); + public static final Option<Integer> BATCH_MAX_BYTES = + Options.key("batch_max_bytes") + .intType() + .defaultValue(1024 * 1024) + .withDescription( + "Maximum serialized request size in bytes, including the JSON envelope. This is a client memory bound, not a Salesforce API limit."); + public static final Option<Integer> MAX_RETRIES = + Options.key("max_retries") + .intType() + .defaultValue(3) + .withDescription( + "Maximum retries per upsert request, from 0 to 10. Record-level failures are not retried."); + public static final Option<Long> RETRY_INTERVAL_MS = + Options.key("retry_interval_ms") + .longType() + .defaultValue(1000L) + .withDescription("Delay between retries, from 0 to 60000 milliseconds."); +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/exception/SalesforceConnectorErrorCode.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/exception/SalesforceConnectorErrorCode.java index 0ee618b4c0..1717d5f077 100644 --- a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/exception/SalesforceConnectorErrorCode.java +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/exception/SalesforceConnectorErrorCode.java @@ -26,7 +26,8 @@ public enum SalesforceConnectorErrorCode implements SeaTunnelErrorCode { BULK_JOB_FAILED("SALESFORCE-04", "Salesforce Bulk API query job failed or was aborted"), BULK_RESULTS_FAILED("SALESFORCE-05", "Failed to retrieve Salesforce Bulk API job results"), INVALID_TABLE_PATH("SALESFORCE-06", "Invalid table_path; expected format: database.ObjectName"), - DUPLICATE_OBJECT("SALESFORCE-07", "Duplicate object found in tables_configs"); + DUPLICATE_OBJECT("SALESFORCE-07", "Duplicate object found in tables_configs"), + WRITE_FAILED("SALESFORCE-08", "Failed to upsert Salesforce records"); private final String code; private final String description; diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceRowSerializer.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceRowSerializer.java new file mode 100644 index 0000000000..1101afbe89 --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceRowSerializer.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.sink; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.api.table.type.SqlType; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +final class SalesforceRowSerializer { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final DateTimeFormatter TIME_FORMAT = + DateTimeFormatter.ofPattern("HH:mm:ss.SSS'Z'"); + private final SeaTunnelRowType rowType; + private final SalesforceSinkConfig config; + + SalesforceRowSerializer(SeaTunnelRowType rowType, SalesforceSinkConfig config) { + validateSchema(rowType, config); + this.rowType = rowType; + this.config = config; + } + + static void validateSchema(SeaTunnelRowType rowType, SalesforceSinkConfig config) { + boolean externalIdFound = false; + Set<String> names = new HashSet<>(); + for (int i = 0; i < rowType.getTotalFields(); i++) { + String name = rowType.getFieldName(i); + SalesforceSinkConfig.requireIdentifier(name, "Input field"); + if (!names.add(name.toLowerCase(Locale.ROOT)) + || "attributes".equalsIgnoreCase(name) + || "Id".equalsIgnoreCase(name)) { + throw new IllegalArgumentException( + "Input schema contains a duplicate or reserved Salesforce field: " + name); + } + SqlType type = rowType.getFieldType(i).getSqlType(); + switch (type) { + case STRING: + case BOOLEAN: + case TINYINT: + case SMALLINT: + case INT: + case BIGINT: + case FLOAT: + case DOUBLE: + case DECIMAL: + case DATE: + case TIME: + case TIMESTAMP: + case TIMESTAMP_TZ: + case BYTES: + break; + default: + throw new IllegalArgumentException( + "Unsupported Salesforce field type: " + name + " (" + type + ")"); + } + if (name.equals(config.getExternalIdField())) { + externalIdFound = true; + if (type != SqlType.STRING + && type != SqlType.TINYINT + && type != SqlType.SMALLINT + && type != SqlType.INT + && type != SqlType.BIGINT + && type != SqlType.DECIMAL) { + throw new IllegalArgumentException( + "external_id_field must be STRING, an integer type or DECIMAL"); + } + } + } + if (!externalIdFound) { + throw new IllegalArgumentException( + "external_id_field must be present in the input schema"); + } + } + + ObjectNode serialize(SeaTunnelRow row) { + if (row.getFields().length != rowType.getTotalFields()) { + throw new IllegalArgumentException( + "Input row does not match the Salesforce sink schema"); + } + ObjectNode record = MAPPER.createObjectNode(); + record.putObject("attributes").put("type", config.getObjectName()); + for (int i = 0; i < rowType.getTotalFields(); i++) { + String name = rowType.getFieldName(i); + Object value = row.getField(i); + if (value == null) { + record.putNull(name); + continue; + } + switch (rowType.getFieldType(i).getSqlType()) { + case STRING: + record.put(name, (String) value); + break; + case BOOLEAN: + record.put(name, (Boolean) value); + break; + case TINYINT: + case SMALLINT: + case INT: + case BIGINT: + record.put(name, ((Number) value).longValue()); + break; + case FLOAT: + float floatValue = ((Number) value).floatValue(); + if (!Float.isFinite(floatValue)) { + throw invalidNumber(name); + } + record.put(name, floatValue); + break; + case DOUBLE: + double doubleValue = ((Number) value).doubleValue(); + if (!Double.isFinite(doubleValue)) { + throw invalidNumber(name); + } + record.put(name, doubleValue); + break; + case DECIMAL: + record.put(name, (BigDecimal) value); + break; + case DATE: + record.put(name, ((LocalDate) value).toString()); + break; + case TIME: + LocalTime time = (LocalTime) value; + requireMilliseconds(time.getNano(), name); + record.put(name, time.format(TIME_FORMAT)); + break; + case TIMESTAMP: + requireMilliseconds(((LocalDateTime) value).getNano(), name); + record.put( + name, + ((LocalDateTime) value) + .atOffset(ZoneOffset.UTC) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + break; + case TIMESTAMP_TZ: + requireMilliseconds(((OffsetDateTime) value).getNano(), name); + record.put( + name, + ((OffsetDateTime) value) + .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)); + break; + case BYTES: + record.put(name, ((byte[]) value).clone()); + break; + default: + throw new IllegalArgumentException("Unsupported Salesforce field: " + name); + } + } + if (record.path(config.getExternalIdField()).isNull() + || record.path(config.getExternalIdField()).asText().trim().isEmpty()) { + throw new IllegalArgumentException( + "external_id_field must be non-null and non-blank in every row"); + } + return record; + } + + String externalIdKey(ObjectNode record) { + if (record.path(config.getExternalIdField()).isNumber()) { + return record.path(config.getExternalIdField()) + .decimalValue() + .stripTrailingZeros() + .toPlainString(); + } + return record.path(config.getExternalIdField()).asText().toLowerCase(Locale.ROOT); + } + + private IllegalArgumentException invalidNumber(String name) { + return new IllegalArgumentException("Non-finite number in Salesforce field " + name); + } + + private static void requireMilliseconds(int nanos, String name) { + if (nanos % 1_000_000 != 0) { + throw new IllegalArgumentException( + "Salesforce temporal fields support millisecond precision: " + name); + } + } +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSink.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSink.java new file mode 100644 index 0000000000..836ad0d3ca --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSink.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.sink; + +import org.apache.seatunnel.api.sink.SinkWriter; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.common.sink.AbstractSimpleSink; +import org.apache.seatunnel.connectors.seatunnel.common.sink.AbstractSinkWriter; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; + +import java.io.IOException; +import java.util.Optional; + +public class SalesforceSink extends AbstractSimpleSink<SeaTunnelRow, Void> { + private final SalesforceSinkConfig config; + private final CatalogTable catalogTable; + + public SalesforceSink(SalesforceSinkConfig config, CatalogTable catalogTable) { + this.config = config; + this.catalogTable = catalogTable; + } + + @Override + public String getPluginName() { + return "Salesforce"; + } + + @Override + public AbstractSinkWriter<SeaTunnelRow, Void> createWriter(SinkWriter.Context context) + throws IOException { + SalesforceSinkWriter writer = + new SalesforceSinkWriter(catalogTable.getSeaTunnelRowType(), config); + context.registerFlushAction(writer::flush); + return writer; + } + + @Override + public Optional<CatalogTable> getWriteCatalogTable() { + return Optional.of(catalogTable); + } +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkFactory.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkFactory.java new file mode 100644 index 0000000000..31b9d7338c --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkFactory.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.sink; + +import org.apache.seatunnel.api.configuration.util.Conditions; +import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.table.connector.TableSink; +import org.apache.seatunnel.api.table.factory.Factory; +import org.apache.seatunnel.api.table.factory.TableSinkFactory; +import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkOptions; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSourceOptions; + +import com.google.auto.service.AutoService; + +@AutoService(Factory.class) +public class SalesforceSinkFactory implements TableSinkFactory { + @Override + public String factoryIdentifier() { + return "Salesforce"; + } + + @Override + public OptionRule optionRule() { + return OptionRule.builder() + .required( + SalesforceSourceOptions.CLIENT_ID, + SalesforceSourceOptions.CLIENT_SECRET, + SalesforceSourceOptions.USERNAME, + SalesforceSourceOptions.PASSWORD, + SalesforceSourceOptions.INSTANCE_URL) + .required( + SalesforceSourceOptions.OBJECT_NAME, + Conditions.matches( + SalesforceSourceOptions.OBJECT_NAME, + SalesforceSinkConfig.IDENTIFIER_PATTERN)) + .required( + SalesforceSinkOptions.EXTERNAL_ID_FIELD, + Conditions.matches( + SalesforceSinkOptions.EXTERNAL_ID_FIELD, + SalesforceSinkConfig.IDENTIFIER_PATTERN)) + .optional( + SalesforceSourceOptions.SECURITY_TOKEN, SalesforceSourceOptions.API_VERSION) + .optional( + SalesforceSourceOptions.REQUEST_TIMEOUT_MS, + Conditions.greaterThan(SalesforceSourceOptions.REQUEST_TIMEOUT_MS, 0)) + .optional( + SalesforceSinkOptions.BATCH_SIZE, + Conditions.greaterThan(SalesforceSinkOptions.BATCH_SIZE, 0), + Conditions.lessThan(SalesforceSinkOptions.BATCH_SIZE, 201)) + .optional( + SalesforceSinkOptions.BATCH_MAX_BYTES, + Conditions.greaterThan(SalesforceSinkOptions.BATCH_MAX_BYTES, 127), + Conditions.lessThan( + SalesforceSinkOptions.BATCH_MAX_BYTES, 8 * 1024 * 1024 + 1)) + .optional( + SalesforceSinkOptions.MAX_RETRIES, + Conditions.greaterThan(SalesforceSinkOptions.MAX_RETRIES, -1), + Conditions.lessThan(SalesforceSinkOptions.MAX_RETRIES, 11)) + .optional( + SalesforceSinkOptions.RETRY_INTERVAL_MS, + Conditions.greaterThan(SalesforceSinkOptions.RETRY_INTERVAL_MS, -1L), + Conditions.lessThan(SalesforceSinkOptions.RETRY_INTERVAL_MS, 60001L)) + .build(); + } + + @Override + public TableSink createSink(TableSinkFactoryContext context) { + SalesforceSinkConfig config = new SalesforceSinkConfig(context.getOptions()); + // Fail during planning; the serializer also validates direct writer construction. + SalesforceRowSerializer.validateSchema( + context.getCatalogTable().getSeaTunnelRowType(), config); + return () -> new SalesforceSink(config, context.getCatalogTable()); + } +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkWriter.java b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkWriter.java new file mode 100644 index 0000000000..c81e239bd0 --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/main/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkWriter.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.sink; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.seatunnel.api.table.type.RowKind; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.common.sink.AbstractSinkWriter; +import org.apache.seatunnel.connectors.seatunnel.salesforce.client.SalesforceClient; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.salesforce.exception.SalesforceConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.salesforce.exception.SalesforceConnectorException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Buffers detached rows and verifies every upsert result before allowing a checkpoint to advance. + */ +public final class SalesforceSinkWriter extends AbstractSinkWriter<SeaTunnelRow, Void> { + private static final ObjectMapper MAPPER = new ObjectMapper(); + // Exact UTF-8 size of {"allOrNone":true,"records":[]} before adding records and commas. + private static final int ENVELOPE_BYTES = 31; + private final SalesforceSinkConfig config; + private final SalesforceRowSerializer serializer; + private final SalesforceClient client; + private final List<ObjectNode> batch = new ArrayList<>(); + private final Set<String> externalIds = new HashSet<>(); + private int batchBytes = ENVELOPE_BYTES; + private RuntimeException failure; + private boolean closed; + + public SalesforceSinkWriter(SeaTunnelRowType rowType, SalesforceSinkConfig config) + throws IOException { + this.config = config; + this.serializer = new SalesforceRowSerializer(rowType, config); + this.client = SalesforceClient.forSink(config.getParameters()); + try { + client.authenticate(); + } catch (RuntimeException authenticationFailure) { + try { + client.close(); + } catch (IOException closeFailure) { + authenticationFailure.addSuppressed(closeFailure); + } + throw authenticationFailure; + } + } + + @Override + public synchronized void write(SeaTunnelRow row) throws IOException { + checkOpen(); + try { + if (row.getRowKind() != RowKind.INSERT && row.getRowKind() != RowKind.UPDATE_AFTER) { + throw new IllegalArgumentException( + "Salesforce sink supports INSERT and UPDATE_AFTER only"); + } + ObjectNode record = serializer.serialize(row); + int bytes = MAPPER.writeValueAsBytes(record).length; + if ((long) ENVELOPE_BYTES + bytes > config.getBatchMaxBytes()) { + throw new IllegalArgumentException("A Salesforce record exceeds batch_max_bytes"); + } + String key = serializer.externalIdKey(record); + if (!batch.isEmpty() + && (externalIds.contains(key) + || (long) batchBytes + 1 + bytes > config.getBatchMaxBytes())) { + flush(); + } + batchBytes += bytes + (batch.isEmpty() ? 0 : 1); + batch.add(record); + externalIds.add(key); + if (batch.size() >= config.getBatchSize()) { + flush(); + } + } catch (RuntimeException e) { + failure = e; + throw e; + } catch (IOException e) { + failure = + new SalesforceConnectorException( + SalesforceConnectorErrorCode.WRITE_FAILED, + "Could not serialize a Salesforce record"); + throw failure; + } + } + + /** A checkpoint can advance only after all buffered records have succeeded remotely. */ + @Override + public synchronized Optional<Void> prepareCommit() { + flush(); + return Optional.empty(); + } + + /** Flush on the writer/engine thread; no connector-owned timer or executor is created. */ + public synchronized void flush() { + checkOpen(); + if (batch.isEmpty()) { + return; + } + try { + client.upsert( + config.getObjectName(), + config.getExternalIdField(), + batch, + config.getMaxRetries(), + config.getRetryIntervalMs()); + batch.clear(); + externalIds.clear(); + batchBytes = ENVELOPE_BYTES; + } catch (RuntimeException e) { + failure = e; + throw e; + } + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + RuntimeException flushFailure = null; + try { + // A failed batch must not be silently retried during abort/cleanup. + if (failure == null) { + flush(); + } + } catch (RuntimeException e) { + flushFailure = e; + throw e; + } finally { + closed = true; + try { + client.close(); + } catch (IOException closeFailure) { + if (flushFailure != null) { + flushFailure.addSuppressed(closeFailure); + } else { + throw closeFailure; + } + } + } + } + + private void checkOpen() { + if (failure != null) { + throw failure; + } + if (closed) { + throw new IllegalStateException("Salesforce sink writer is closed"); + } + } +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/test/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceRowSerializerTest.java b/seatunnel-connectors-v2/connector-salesforce/src/test/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceRowSerializerTest.java new file mode 100644 index 0000000000..7c0e0075e4 --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/test/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceRowSerializerTest.java @@ -0,0 +1,251 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.sink; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.DecimalType; +import org.apache.seatunnel.api.table.type.LocalTimeType; +import org.apache.seatunnel.api.table.type.PrimitiveByteArrayType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SalesforceRowSerializerTest { + private Map<String, Object> options() { + Map<String, Object> values = new HashMap<>(); + values.put("client_id", "client"); + values.put("client_secret", "secret"); + values.put("username", "user"); + values.put("password", "password"); + values.put("instance_url", "https://example.my.salesforce.com"); + values.put("object_name", "Account"); + values.put("external_id_field", "External_Id__c"); + return values; + } + + private SalesforceSinkConfig config() { + return new SalesforceSinkConfig(ReadonlyConfig.fromMap(options())); + } + + @Test + void serializesNativeValuesAndNullsWithoutStringifyingNumbers() { + String[] names = { + "External_Id__c", + "Flag", + "Count", + "Amount", + "Day", + "Time", + "Timestamp", + "ZonedTimestamp", + "Body", + "Empty" + }; + SeaTunnelDataType<?>[] types = { + BasicType.STRING_TYPE, + BasicType.BOOLEAN_TYPE, + BasicType.LONG_TYPE, + new DecimalType(38, 10), + LocalTimeType.LOCAL_DATE_TYPE, + LocalTimeType.LOCAL_TIME_TYPE, + LocalTimeType.LOCAL_DATE_TIME_TYPE, + LocalTimeType.OFFSET_DATE_TIME_TYPE, + PrimitiveByteArrayType.INSTANCE, + BasicType.STRING_TYPE + }; + SalesforceRowSerializer serializer = + new SalesforceRowSerializer(new SeaTunnelRowType(names, types), config()); + BigDecimal amount = new BigDecimal("12345678901234567890.1234567890"); + ObjectNode result = + serializer.serialize( + new SeaTunnelRow( + new Object[] { + "key", + true, + 9007199254740993L, + amount, + LocalDate.of(2026, 9, 8), + LocalTime.of(12, 3, 4, 123000000), + LocalDateTime.of(2026, 9, 8, 12, 3, 4), + OffsetDateTime.parse("2026-09-08T12:03:04+05:30"), + new byte[] {0, 1, 2}, + null + })); + assertTrue(result.path("Flag").isBoolean()); + assertEquals(9007199254740993L, result.path("Count").longValue()); + assertEquals(amount, result.path("Amount").decimalValue()); + assertEquals("2026-09-08", result.path("Day").asText()); + assertEquals("12:03:04.123Z", result.path("Time").asText()); + assertEquals("2026-09-08T12:03:04Z", result.path("Timestamp").asText()); + assertEquals("2026-09-08T12:03:04+05:30", result.path("ZonedTimestamp").asText()); + assertEquals("AAEC", result.path("Body").asText()); + assertTrue(result.path("Empty").isNull()); + } + + @Test + void rejectsPrecisionLossAndNonFiniteNumbers() { + SalesforceRowSerializer time = serializer(LocalTimeType.LOCAL_TIME_TYPE); + assertThrows( + IllegalArgumentException.class, + () -> + time.serialize( + new SeaTunnelRow(new Object[] {"key", LocalTime.of(12, 0, 0, 1)}))); + SalesforceRowSerializer timestamp = serializer(LocalTimeType.LOCAL_DATE_TIME_TYPE); + assertThrows( + IllegalArgumentException.class, + () -> + timestamp.serialize( + new SeaTunnelRow( + new Object[] { + "key", LocalDateTime.of(2026, 9, 8, 12, 0, 0, 1) + }))); + SalesforceRowSerializer number = serializer(BasicType.DOUBLE_TYPE); + for (double value : + new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + assertThrows( + IllegalArgumentException.class, + () -> number.serialize(new SeaTunnelRow(new Object[] {"key", value}))); + } + } + + @Test + void detachesMutableBinaryFieldsFromReusedInputRows() { + SalesforceRowSerializer serializer = serializer(PrimitiveByteArrayType.INSTANCE); + byte[] bytes = {0, 1, 2}; + ObjectNode result = serializer.serialize(new SeaTunnelRow(new Object[] {"key", bytes})); + bytes[0] = 42; + assertEquals("AAEC", result.path("Value").asText()); + } + + private SalesforceRowSerializer serializer(SeaTunnelDataType<?> type) { + return new SalesforceRowSerializer( + new SeaTunnelRowType( + new String[] {"External_Id__c", "Value"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, type}), + config()); + } + + @ParameterizedTest + @ValueSource(strings = {"Id", "attributes", "external_id__c", "bad/field"}) + void rejectsReservedDuplicateAndInvalidSchemaFields(String field) { + assertThrows( + IllegalArgumentException.class, + () -> + new SalesforceRowSerializer( + new SeaTunnelRowType( + new String[] {"External_Id__c", field}, + new SeaTunnelDataType[] { + BasicType.STRING_TYPE, BasicType.STRING_TYPE + }), + config())); + } + + @Test + void requiresExternalIdAndRejectsNestedTypes() { + assertThrows( + IllegalArgumentException.class, + () -> + new SalesforceRowSerializer( + new SeaTunnelRowType( + new String[] {"Name"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE}), + config())); + assertThrows( + IllegalArgumentException.class, + () -> + serializer( + new SeaTunnelRowType( + new String[] {"nested"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE}))); + assertThrows( + IllegalArgumentException.class, + () -> + new SalesforceRowSerializer( + new SeaTunnelRowType( + new String[] {"External_Id__c"}, + new SeaTunnelDataType[] {BasicType.BOOLEAN_TYPE}), + config())); + } + + @ParameterizedTest + @ValueSource( + strings = { + "ftp://host", + "https://user:secret@host", + "https://host/path", + "https://host?token=secret", + "https://host#secret", + "invalid" + }) + void rejectsUnsafeOrNonOriginUrls(String url) { + Map<String, Object> values = options(); + values.put("instance_url", url); + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new SalesforceSinkConfig(ReadonlyConfig.fromMap(values))); + assertTrue(!error.getMessage().contains("secret")); + } + + @Test + void validatesBoundsBeforeOpeningConnections() { + Object[][] invalid = { + {"batch_size", 0}, + {"batch_size", 201}, + {"batch_max_bytes", 127}, + {"batch_max_bytes", 8388609}, + {"max_retries", -1}, + {"max_retries", 11}, + {"retry_interval_ms", -1L}, + {"retry_interval_ms", 60001L}, + {"request_timeout_ms", 0}, + {"object_name", "../Account"}, + {"external_id_field", "Id"}, + {"api_version", "v59/0"}, + {"client_secret", " "} + }; + for (Object[] setting : invalid) { + Map<String, Object> values = options(); + values.put((String) setting[0], setting[1]); + assertThrows( + IllegalArgumentException.class, + () -> new SalesforceSinkConfig(ReadonlyConfig.fromMap(values)), + setting[0].toString()); + } + } +} diff --git a/seatunnel-connectors-v2/connector-salesforce/src/test/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkTest.java b/seatunnel-connectors-v2/connector-salesforce/src/test/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkTest.java new file mode 100644 index 0000000000..f957711801 --- /dev/null +++ b/seatunnel-connectors-v2/connector-salesforce/src/test/java/org/apache/seatunnel/connectors/seatunnel/salesforce/sink/SalesforceSinkTest.java @@ -0,0 +1,505 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.connectors.seatunnel.salesforce.sink; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ArrayNode; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.ConfigValidator; +import org.apache.seatunnel.api.table.factory.FactoryUtil; +import org.apache.seatunnel.api.table.factory.TableSinkFactory; +import org.apache.seatunnel.api.table.factory.TableSourceFactory; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.RowKind; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.salesforce.config.SalesforceSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.salesforce.exception.SalesforceConnectorException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SalesforceSinkTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final SeaTunnelRowType ROW_TYPE = + new SeaTunnelRowType( + new String[] {"External_Id__c", "Name"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.STRING_TYPE}); + private HttpServer server; + private String url; + private Map<String, Object> options; + private final List<SalesforceSinkWriter> writers = new ArrayList<>(); + private final List<JsonNode> requests = Collections.synchronizedList(new ArrayList<>()); + private final List<Integer> sizes = Collections.synchronizedList(new ArrayList<>()); + private final List<String> authorization = Collections.synchronizedList(new ArrayList<>()); + private final AtomicInteger tokens = new AtomicInteger(); + private volatile BiFunction<Integer, JsonNode, Reply> reply; + private volatile String retryAfter; + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + url = "http://127.0.0.1:" + server.getAddress().getPort(); + options = new HashMap<>(); + options.put("client_id", "client"); + options.put("client_secret", "private-secret"); + options.put("username", "[email protected]"); + options.put("password", "private-password"); + options.put("security_token", "private-token"); + options.put("instance_url", url); + options.put("object_name", "Account"); + options.put("external_id_field", "External_Id__c"); + options.put("max_retries", 0); + options.put("retry_interval_ms", 0L); + options.put("request_timeout_ms", 1000); + server.createContext( + "/services/oauth2/token", + exchange -> { + int token = tokens.incrementAndGet(); + respond( + exchange, + 200, + "{\"access_token\":\"token-" + + token + + "\",\"instance_url\":\"" + + url + + "\"}"); + }); + reply = (index, body) -> new Reply(200, success(body.path("records").size())); + server.createContext( + "/services/data/v59.0/composite/sobjects/Account/External_Id__c", + exchange -> { + if (!"PATCH".equals(exchange.getRequestMethod())) { + respond(exchange, 405, "[]"); + return; + } + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] bytes = new byte[4096]; + int read; + while ((read = exchange.getRequestBody().read(bytes)) != -1) { + buffer.write(bytes, 0, read); + } + JsonNode request = MAPPER.readTree(buffer.toByteArray()); + requests.add(request); + sizes.add(buffer.size()); + authorization.add(exchange.getRequestHeaders().getFirst("Authorization")); + Reply result = reply.apply(requests.size(), request); + if (retryAfter != null) { + exchange.getResponseHeaders().add("Retry-After", retryAfter); + } + if (result.status == 307) { + exchange.getResponseHeaders().add("Location", url + "/trap"); + } + respond(exchange, result.status, result.body); + }); + server.start(); + } + + @AfterEach + void stop() throws IOException { + try { + for (SalesforceSinkWriter writer : writers) { + writer.close(); + } + } finally { + server.stop(0); + } + } + + @Test + void sourceAndSinkFactoriesRemainDiscoverable() { + assertNotNull( + FactoryUtil.discoverFactory( + getClass().getClassLoader(), TableSourceFactory.class, "Salesforce")); + assertNotNull( + FactoryUtil.discoverFactory( + getClass().getClassLoader(), TableSinkFactory.class, "Salesforce")); + ConfigValidator.of(ReadonlyConfig.fromMap(options)) + .validate(new SalesforceSinkFactory().optionRule()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "{}", + "{\"access_token\":\"secret\"}", + "invalid-json", + "{\"access_token\":\"secret\",\"instance_url\":\"https://user:secret@host\"}" + }) + void rejectsMalformedAuthenticationWithoutLeakingCredentials(String body) { + server.removeContext("/services/oauth2/token"); + server.createContext("/services/oauth2/token", exchange -> respond(exchange, 200, body)); + SalesforceConnectorException failure = + assertThrows(SalesforceConnectorException.class, this::writer); + assertFalse(failure.toString().contains("private-secret")); + assertFalse(failure.toString().contains("user:secret")); + assertTrue(requests.isEmpty()); + } + + @Test + void socketTimeoutHasBoundedAttemptsAndDoesNotCompleteCheckpoint() throws Exception { + options.put("request_timeout_ms", 1000); + options.put("max_retries", 1); + CountDownLatch release = new CountDownLatch(1); + reply = + (index, body) -> { + try { + release.await(60, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return new Reply(200, success(body.path("records").size())); + }; + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "first")); + try { + SalesforceConnectorException failure = + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertTrue(failure.getMessage().contains("after 2 attempt(s)")); + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + } finally { + release.countDown(); + } + } + + @Test + void flushesByCountAndCheckpointWithDetachedRows() throws IOException { + options.put("batch_size", 2); + SalesforceSinkWriter writer = writer(); + SeaTunnelRow first = row("1", "first"); + writer.write(first); + first.setField(1, "mutated-after-write"); + writer.write(row("2", "second")); + assertEquals(1, requests.size()); + assertTrue(requests.get(0).path("allOrNone").asBoolean()); + assertEquals("first", requests.get(0).path("records").get(0).path("Name").asText()); + assertEquals( + "Account", + requests.get(0).path("records").get(0).path("attributes").path("type").asText()); + writer.write(row("3", null)); + writer.prepareCommit(11); + assertEquals(2, requests.size()); + assertTrue(requests.get(1).path("records").get(0).path("Name").isNull()); + writer.prepareCommit(12); + assertEquals(2, requests.size()); + } + + @Test + void flushesRepeatedKeysInOrder() throws IOException { + SalesforceSinkWriter writer = writer(); + writer.write(row("customer", "before")); + SeaTunnelRow update = row("CUSTOMER", "after"); + update.setRowKind(RowKind.UPDATE_AFTER); + writer.write(update); + writer.prepareCommit(); + assertEquals(2, requests.size()); + assertEquals("before", requests.get(0).path("records").get(0).path("Name").asText()); + assertEquals("after", requests.get(1).path("records").get(0).path("Name").asText()); + } + + @Test + void boundsActualUtf8RequestBytes() throws IOException { + options.put("batch_max_bytes", 180); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "雪雪雪雪雪")); + writer.write(row("2", "雪雪雪雪雪")); + writer.prepareCommit(); + assertEquals(2, requests.size()); + assertTrue(sizes.stream().allMatch(size -> size <= 180)); + } + + @Test + void rejectsOversizedRecordWithoutSendingOrReplayingOnClose() throws IOException { + options.put("batch_max_bytes", 128); + SalesforceSinkWriter writer = writer(); + assertThrows( + IllegalArgumentException.class, + () -> writer.write(row("1", String.join("", Collections.nCopies(200, "x"))))); + assertThrows(IllegalArgumentException.class, writer::prepareCommit); + writer.close(); + writer.close(); + assertTrue(requests.isEmpty()); + } + + @ParameterizedTest + @EnumSource( + value = RowKind.class, + names = {"DELETE", "UPDATE_BEFORE"}) + void rejectsUnsupportedChangelogKinds(RowKind kind) throws IOException { + SalesforceSinkWriter writer = writer(); + SeaTunnelRow row = row("1", "name"); + row.setRowKind(kind); + assertThrows(IllegalArgumentException.class, () -> writer.write(row)); + assertTrue(requests.isEmpty()); + } + + @Test + void rejectsMissingExternalIdBeforeSending() throws IOException { + SalesforceSinkWriter writer = writer(); + assertThrows(IllegalArgumentException.class, () -> writer.write(row(null, "name"))); + assertTrue(requests.isEmpty()); + } + + @Test + void partialResultFailsCheckpointWithoutRetryAndWithoutEchoingData() throws IOException { + options.put("max_retries", 3); + reply = + (index, body) -> + new Reply( + 200, + "[{\"id\":\"001\",\"success\":true,\"errors\":[]}," + + "{\"id\":null,\"success\":false,\"errors\":[{\"statusCode\":\"INVALID_FIELD\",\"message\":\"private-secret row value\"}]}]"); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "one")); + writer.write(row("2", "two")); + SalesforceConnectorException failure = + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertTrue(failure.getMessage().contains("index 1")); + assertTrue(failure.getMessage().contains("INVALID_FIELD")); + assertFalse(failure.toString().contains("private-secret")); + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + writer.close(); + assertEquals(1, requests.size()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "not-json", + "[]", + "{}", + "[{\"success\":true,\"errors\":[]}]", + "[{\"id\":\"001\",\"success\":true}]", + "[{\"success\":\"true\",\"errors\":[]}]" + }) + void malformedSuccessResponseDoesNotAcknowledgeOrRetry(String body) throws IOException { + reply = (index, request) -> new Reply(200, body); + options.put("max_retries", 3); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertEquals(1, requests.size()); + } + + @ParameterizedTest + @ValueSource(ints = {429, 500, 502, 503, 504}) + void retriesTransientHttpStatusWithIdenticalPayload(int status) throws IOException { + options.put("max_retries", 1); + retryAfter = "0"; + reply = + (index, body) -> + index == 1 + ? new Reply(status, "[]") + : new Reply(200, success(body.path("records").size())); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + writer.prepareCommit(); + assertEquals(2, requests.size()); + assertEquals(requests.get(0), requests.get(1)); + } + + @Test + void retryExhaustionIsBounded() throws IOException { + options.put("max_retries", 2); + reply = (index, body) -> new Reply(503, "[]"); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertEquals(3, requests.size()); + } + + @Test + void excessiveRetryAfterFailsWithoutHammeringEndpoint() throws IOException { + options.put("max_retries", 3); + retryAfter = "3600"; + reply = (index, body) -> new Reply(429, "[]"); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertEquals(1, requests.size()); + } + + @Test + void expiredSessionRefreshesOnce() throws IOException { + options.put("max_retries", 3); + reply = (index, body) -> index == 1 ? new Reply(401, "[]") : new Reply(200, success(1)); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + writer.prepareCommit(); + assertEquals(2, tokens.get()); + assertEquals("Bearer token-1", authorization.get(0)); + assertEquals("Bearer token-2", authorization.get(1)); + } + + @Test + void repeatedUnauthorizedResponseDoesNotRefreshForever() throws IOException { + options.put("max_retries", 3); + reply = (index, body) -> new Reply(401, "[]"); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertEquals(2, tokens.get()); + assertEquals(2, requests.size()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 403, 404, 307}) + void permanentErrorsAndRedirectsAreNotRetried(int status) throws IOException { + AtomicInteger trap = new AtomicInteger(); + server.createContext( + "/trap", + exchange -> { + trap.incrementAndGet(); + respond(exchange, 200, success(1)); + }); + options.put("max_retries", 3); + reply = (index, body) -> new Reply(status, "private-secret"); + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + SalesforceConnectorException failure = + assertThrows(SalesforceConnectorException.class, writer::prepareCommit); + assertFalse(failure.toString().contains("private-secret")); + assertEquals(1, requests.size()); + assertEquals(0, trap.get()); + } + + @Test + void interruptionStopsBackoffAndPreservesInterrupt() throws Exception { + options.put("max_retries", 3); + options.put("retry_interval_ms", 60000L); + CountDownLatch requested = new CountDownLatch(1); + reply = + (index, body) -> { + requested.countDown(); + return new Reply(503, "[]"); + }; + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + AtomicReference<Throwable> error = new AtomicReference<>(); + AtomicReference<Boolean> interrupted = new AtomicReference<>(false); + Thread thread = + new Thread( + () -> { + try { + writer.prepareCommit(); + } catch (Throwable e) { + error.set(e); + interrupted.set(Thread.currentThread().isInterrupted()); + } + }); + thread.start(); + try { + assertTrue(requested.await(5, TimeUnit.SECONDS)); + thread.interrupt(); + thread.join(5000); + assertFalse(thread.isAlive()); + assertInstanceOf(SalesforceConnectorException.class, error.get()); + assertEquals(true, interrupted.get()); + assertEquals(1, requests.size()); + } finally { + thread.interrupt(); + thread.join(5000); + } + } + + @Test + void closesWithFinalFlushAndIsIdempotent() throws IOException { + SalesforceSinkWriter writer = writer(); + writer.write(row("1", "name")); + writer.close(); + writer.close(); + assertEquals(1, requests.size()); + assertThrows(IllegalStateException.class, () -> writer.write(row("2", "name"))); + } + + private SalesforceSinkWriter writer() throws IOException { + SalesforceSinkWriter writer = + new SalesforceSinkWriter( + ROW_TYPE, new SalesforceSinkConfig(ReadonlyConfig.fromMap(options))); + writers.add(writer); + return writer; + } + + private static SeaTunnelRow row(String key, String name) { + return new SeaTunnelRow(new Object[] {key, name}); + } + + private static String success(int count) { + ArrayNode results = MAPPER.createArrayNode(); + for (int i = 0; i < count; i++) { + results.addObject() + .put("id", "001" + i) + .put("success", true) + .put("created", false) + .putArray("errors"); + } + return results.toString(); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private static final class Reply { + final int status; + final String body; + + Reply(int status, String body) { + this.status = status; + this.body = body; + } + } +} diff --git a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java index 706ffc8237..ae73de60f2 100644 --- a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java +++ b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/ConfigShadeUtils.java @@ -55,7 +55,7 @@ public final class ConfigShadeUtils { new String[] {"password", "username", "auth", "token", "access_key", "secret_key"}; private static final String[] DEFAULT_LOG_MASK_ONLY_KEYWORDS = - new String[] {"sasl.jaas.config", "community"}; + new String[] {"sasl.jaas.config", "community", "client_secret", "security_token"}; private static final Map<String, ConfigShade> CONFIG_SHADES = new HashMap<>(); diff --git a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java index fe1b71f7a9..415ff58562 100644 --- a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java +++ b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/utils/ConfigBuilderTest.java @@ -120,6 +120,30 @@ public class ConfigBuilderTest { Assertions.assertEquals("127.0.0.1", desensitizedSink.get("host")); } + @Test + public void testConfigDesensitizationMasksSalesforceSecretsWithoutAddingEncryptionKeys() { + Map<String, Object> connector = new LinkedHashMap<>(); + connector.put("client_secret", "private-secret"); + connector.put("security_token", "private-token"); + connector.put("object_name", "Account"); + Map<String, Object> config = new LinkedHashMap<>(); + config.put("source", Arrays.asList(connector)); + config.put("sink", Arrays.asList(connector)); + Map<String, Object> masked = + ConfigBuilder.configDesensitization( + config, ConfigShadeUtils.getLogDesensitizationOptions(null)); + for (String role : Arrays.asList("source", "sink")) { + Map<?, ?> entry = (Map<?, ?>) ((List<?>) masked.get(role)).get(0); + Assertions.assertEquals("******", entry.get("client_secret")); + Assertions.assertEquals("******", entry.get("security_token")); + Assertions.assertEquals("Account", entry.get("object_name")); + } + Assertions.assertFalse( + ConfigShadeUtils.getSensitiveOptions(null).contains("client_secret")); + Assertions.assertFalse( + ConfigShadeUtils.getSensitiveOptions(null).contains("security_token")); + } + @Test public void testConfigDesensitizationMasksS3CredentialOptions() { Map<String, Object> accessKeyConfig = new LinkedHashMap<>(); diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/pom.xml b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/pom.xml index 4c5f122512..702f6de9db 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/pom.xml +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/pom.xml @@ -26,6 +26,12 @@ <name>SeaTunnel : E2E : Connector V2 : Http</name> <dependencies> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-salesforce</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> <dependency> <groupId>org.apache.seatunnel</groupId> <artifactId>connector-assert</artifactId> diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/java/org/apache/seatunnel/e2e/connector/http/SalesforceSinkIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/java/org/apache/seatunnel/e2e/connector/http/SalesforceSinkIT.java new file mode 100644 index 0000000000..24349090a2 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/java/org/apache/seatunnel/e2e/connector/http/SalesforceSinkIT.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.seatunnel.e2e.connector.http; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.seatunnel.e2e.common.TestResource; +import org.apache.seatunnel.e2e.common.TestSuiteBase; +import org.apache.seatunnel.e2e.common.container.TestContainer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.mockserver.client.MockServerClient; +import org.mockserver.model.HttpRequest; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +class SalesforceSinkIT extends TestSuiteBase implements TestResource { + private static final String PATH = + "/services/data/v59.0/composite/sobjects/Account/External_Id__c"; + private GenericContainer<?> server; + private MockServerClient mock; + + @BeforeAll + @Override + public void startUp() { + server = + new GenericContainer<>(DockerImageName.parse("mockserver/mockserver:5.14.0")) + .withNetwork(NETWORK) + .withNetworkAliases("salesforce-mock") + .withExposedPorts(1080) + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + server.start(); + mock = new MockServerClient(server.getHost(), server.getMappedPort(1080)); + } + + @AfterAll + @Override + public void tearDown() { + if (mock != null) { + mock.close(); + } + if (server != null) { + server.close(); + } + } + + private void responses(String result) { + mock.reset(); + mock.when(request().withMethod("POST").withPath("/services/oauth2/token")) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody( + "{\"access_token\":\"test-token\",\"instance_url\":\"http://salesforce-mock:1080\"}")); + mock.when( + request() + .withMethod("PATCH") + .withPath(PATH) + .withHeader("Authorization", "Bearer test-token")) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(result)); + } + + @TestTemplate + void upsertsOneObjectAndFlushesAtEndOfInput(TestContainer container) throws Exception { + responses( + "[{\"id\":\"001A\",\"success\":true,\"created\":true,\"errors\":[]}," + + "{\"id\":\"001B\",\"success\":true,\"created\":true,\"errors\":[]}]"); + Container.ExecResult result = container.executeJob("/fake_to_salesforce.conf"); + Assertions.assertEquals(0, result.getExitCode(), result.getStderr()); + HttpRequest[] requests = + mock.retrieveRecordedRequests(request().withMethod("PATCH").withPath(PATH)); + Assertions.assertTrue(requests.length > 0); + ObjectMapper mapper = new ObjectMapper(); + for (HttpRequest sent : requests) { + JsonNode body = mapper.readTree(sent.getBodyAsString()); + Assertions.assertTrue(body.path("allOrNone").asBoolean()); + Assertions.assertEquals(2, body.path("records").size()); + Assertions.assertEquals( + "first", body.path("records").get(0).path("External_Id__c").asText()); + Assertions.assertEquals( + "second", body.path("records").get(1).path("External_Id__c").asText()); + Assertions.assertEquals( + "Account", + body.path("records").get(0).path("attributes").path("type").asText()); + } + } + + @TestTemplate + void rejectsRecordErrorsInsteadOfCompletingTheJob(TestContainer container) throws Exception { + responses( + "[{\"id\":null,\"success\":false,\"errors\":[{\"statusCode\":\"REQUIRED_FIELD_MISSING\",\"message\":\"mock validation failure\"}]}," + + "{\"id\":null,\"success\":false,\"errors\":[{\"statusCode\":\"ALL_OR_NONE_OPERATION_ROLLED_BACK\",\"message\":\"rollback\"}]}]"); + Container.ExecResult result = container.executeJob("/fake_to_salesforce.conf"); + Assertions.assertNotEquals(0, result.getExitCode(), "A rejected upsert must fail the job"); + String logs = result.getStdout() + result.getStderr() + container.getServerLogs(); + Assertions.assertTrue( + logs.contains("REQUIRED_FIELD_MISSING"), + "The failure must expose the Salesforce record status code"); + Assertions.assertTrue( + mock.retrieveRecordedRequests(request().withMethod("PATCH").withPath(PATH)).length + > 0); + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/resources/fake_to_salesforce.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/resources/fake_to_salesforce.conf new file mode 100644 index 0000000000..7c895c0778 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-http-e2e/src/test/resources/fake_to_salesforce.conf @@ -0,0 +1,47 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} +source { + FakeSource { + schema { + fields { + External_Id__c = string + Name = string + } + } + rows = [ + { kind = INSERT, fields = ["first", "Alice"] }, + { kind = INSERT, fields = ["second", "Bob"] } + ] + } +} +sink { + Salesforce { + instance_url = "http://salesforce-mock:1080" + client_id = "test-client" + client_secret = "test-secret" + username = "[email protected]" + password = "test-password" + object_name = "Account" + external_id_field = "External_Id__c" + batch_size = 200 + max_retries = 0 + } +}
