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-12282-a51712781b17b9982b8f288304508a31e3e27feb in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit e86c03e53757b63869cf5a60de1e21cf36806ace Author: Goutam Adwant <[email protected]> AuthorDate: Tue Sep 15 12:55:15 2026 +0000 [Feature][Connector-V2] Add PayPal Transaction Search source (#12282) Signed-off-by: Goutam Adwant <[email protected]> --- .github/workflows/labeler/label-scope-conf.yml | 4 +- config/plugin_config | 1 + .../connectors/changelog/connector-http-paypal.md | 5 + docs/en/connectors/source/PayPal.md | 107 +++++ .../connectors/changelog/connector-http-paypal.md | 5 + docs/zh/connectors/source/PayPal.md | 107 +++++ plugin-mapping.properties | 1 + .../{ => connector-http-paypal}/pom.xml | 36 +- .../seatunnel/paypal/source/PayPalClient.java | 296 +++++++++++++ .../seatunnel/paypal/source/PayPalConfig.java | 155 +++++++ .../seatunnel/paypal/source/PayPalResponse.java | 168 +++++++ .../seatunnel/paypal/source/PayPalSource.java | 100 +++++ .../paypal/source/PayPalSourceFactory.java | 70 +++ .../paypal/source/PayPalSourceOptions.java | 85 ++++ .../paypal/source/PayPalSourceReader.java | 95 ++++ .../seatunnel/paypal/source/PayPalClientTest.java | 487 +++++++++++++++++++++ .../paypal/source/PayPalResponseTest.java | 402 +++++++++++++++++ .../paypal/source/TestPayPalPackagingIT.java | 141 ++++++ seatunnel-connectors-v2/connector-http/pom.xml | 1 + seatunnel-dist/pom.xml | 6 + .../connector-paypal-e2e/pom.xml | 54 +++ .../seatunnel/e2e/connector/paypal/PayPalIT.java | 155 +++++++ .../e2e/connector/paypal/PayPalMaskingTest.java | 63 +++ .../src/test/resources/paypal_to_assert.conf | 52 +++ seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml | 1 + 25 files changed, 2571 insertions(+), 26 deletions(-) diff --git a/.github/workflows/labeler/label-scope-conf.yml b/.github/workflows/labeler/label-scope-conf.yml index f4fe6c9946..8ccad991d9 100644 --- a/.github/workflows/labeler/label-scope-conf.yml +++ b/.github/workflows/labeler/label-scope-conf.yml @@ -239,7 +239,9 @@ hive: http: - all: - changed-files: - - any-glob-to-any-file: seatunnel-connectors-v2/connector-http/** + - any-glob-to-any-file: + - seatunnel-connectors-v2/connector-http/** + - seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/** - all-globs-to-all-files: '!seatunnel-connectors-v2/connector-!(http)/**' prometheus: - all: diff --git a/config/plugin_config b/config/plugin_config index d4efe4cdee..a278e647f0 100644 --- a/config/plugin_config +++ b/config/plugin_config @@ -74,6 +74,7 @@ connector-http-github connector-http-jira connector-http-klaviyo connector-http-stripe +connector-http-paypal connector-http-lemlist connector-http-linear connector-http-myhours diff --git a/docs/en/connectors/changelog/connector-http-paypal.md b/docs/en/connectors/changelog/connector-http-paypal.md new file mode 100644 index 0000000000..6dd26bec05 --- /dev/null +++ b/docs/en/connectors/changelog/connector-http-paypal.md @@ -0,0 +1,5 @@ +# Changelog + +## Next version + +- Add bounded PayPal Transaction Search source with OAuth authentication. diff --git a/docs/en/connectors/source/PayPal.md b/docs/en/connectors/source/PayPal.md new file mode 100644 index 0000000000..71655b07a2 --- /dev/null +++ b/docs/en/connectors/source/PayPal.md @@ -0,0 +1,107 @@ +import ChangeLog from '../changelog/connector-http-paypal.md'; + +# PayPal + +> PayPal Transaction Search source connector. + +## Description + +Reads one account's transactions through `GET /v1/reporting/transactions` using first-party OAuth client credentials. This is a bounded BATCH report, not CDC, a payment API, or a balance reconciliation service. + +## Key Features + +- [x] Batch +- [ ] Streaming +- [ ] Exactly-once + +Parallelism must be 1. Recovery replays the entire configured window; no page offset is checkpointed. A failed job may already have emitted rows to a nontransactional sink. + +## Prerequisites + +Use a PayPal REST application authorized for Transaction Search for its own account. Enable Transaction Search permissions for the app and verify the issued credentials have reporting access; successful token issuance alone does not prove this. Permission changes may require a fresh token. Third-party account access and partner authorization are out of scope. + +Production origin is `https://api-m.paypal.com`; sandbox origin is `https://api-m.sandbox.paypal.com`. Sandbox credentials and reporting availability are separate from production. A mock test does not prove sandbox or production permissions, data availability, or completeness. This connector has no live-account verification claim. + +See [the official Transaction Search schema](https://github.com/paypal/paypal-rest-api-specifications/blob/main/openapi/reporting_transactions_v1.json), [List transactions](https://developer.paypal.com/api/transaction-search/v1/search-get), and [REST authentication](https://developer.paypal.com/api/rest/authentication/). + +## Reporting Contract + +Supply explicit absolute RFC3339 `start_date` and `end_date` with seconds and an offset, with start before end and at most 31 days between them. Dates are normalized to UTC, never the host time zone. PayPal provides up to three years of history and transactions can take up to three hours to appear. + +The connector always sends `fields=all` and `balance_affecting_records_only=N`, retaining both balance-affecting and non-affecting reporting records. Transaction IDs are NOT unique; no primary key or ID deduplication is applied. Preserve this distinction in downstream storage. + +Every response must include account, dates, page, totals, and a transaction array. Returned dates must cover exactly the requested interval after UTC normalization. In particular, PayPal's returned `end_date` may instead be the last date available; a shortened response fails. Wait for reporting availability or explicitly narrow the request, then rerun. The connector does not silently move the requested end. + +Malformed rows, API error envelopes (even with HTTP 200), inconsistent page sizes, changed account/totals, and detected truncation fail the job. `RESULTSET_TOO_LARGE` fails with advice to narrow the date window. The connector safety cap is 10,000 records; exactly 10,000 is accepted when every page and count validates. Larger results fail. The connector never truncates or automatically splits the interval. Reconcile boundaries and replay effects explicitly when changing windows. + +Stable totals and matching coverage detect only some problems. PayPal does not provide a remote immutable snapshot here; same-count changes, delayed updates, or omissions may remain undetectable. No snapshot-consistency, lossless interval-splitting, or complete-history guarantee is made. + +## Output Schema + +The schema is fixed; no user-defined `schema` option is supported. + +| Field | Type | Nullable | Meaning | +| --- | --- | --- | --- | +| account_number | STRING | no | Reporting account from the response | +| transaction_id | STRING | yes | Non-unique reporting transaction ID | +| transaction_event_code | STRING | yes | PayPal event code | +| transaction_status | STRING | yes | PayPal status | +| transaction_initiation_date | STRING | yes | RFC3339 timestamp normalized to UTC | +| transaction_updated_date | STRING | yes | RFC3339 timestamp normalized to UTC | +| transaction_amount | DECIMAL(38,9) | yes | Gross amount in native currency units | +| transaction_currency | STRING | yes | Gross amount currency | +| fee_amount | DECIMAL(38,9) | yes | Fee in native currency units | +| fee_currency | STRING | yes | Fee currency, independent of gross currency | +| content | STRING | no | Entire transaction detail object as JSON, including references and additional fields | + +Optional fields may be absent or null. Present money objects must contain a string amount and three-letter currency. Zero- and three-decimal currencies are supported. Amounts must fit DECIMAL(38,9) exactly; excess scale or precision fails rather than rounding. Additional monetary fields remain in `content` in their original JSON representation. No floating-point conversion is used. Raw records can contain personal or financial data: use access-controlled sinks and do not log them. + +## Options + +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| client_id | STRING | yes | - | First-party REST app client ID | +| client_secret | STRING | yes | - | Client secret; native parsed-config log masking applies | +| start_date | STRING | yes | - | Absolute start with seconds and offset | +| end_date | STRING | yes | - | Absolute end, no more than 31 days after start | +| api_base_url | STRING | no | https://api-m.paypal.com | Exact production or sandbox origin, no trailing slash | +| page_size | INT | no | 100 | 1 to 500 | +| max_retries | INT | no | 3 | Additional transient retries, 0 to 5 | +| retry_delay_ms | INT | no | 1000 | 1 to 60000 | +| request_timeout_ms | INT | no | 30000 | Connect/read timeout and request abort deadline, 1 to 120000 | +| max_response_bytes | INT | no | 8388608 | Decompressed bytes per response, 1024 to 16777216 | +| mock_mode | BOOLEAN | no | false | Custom origin for tests; requires exactly mock-client / mock-secret | + +OAuth tokens are acquired via the real token endpoint and refreshed on expiry or once after HTTP 401 per page. HTTP 429/500/502/503/504 and transport failures use bounded retries. Other errors fail. Numeric Retry-After up to 60 seconds is honored; longer or unsupported values fail so the job can be retried later. Redirects are not followed. Close aborts active HTTP requests and wakes retry waits. + +Request deadlines cannot interrupt JVM DNS resolution or a blocked downstream collector; they are not an unconditional wall-clock job deadline. The page count, response size, attempt count and wait duration are bounded. Keep HTTP wire/header logging disabled. Do not place secrets in endpoint URLs, dotted option names, or diagnostics. Use environment substitution or an approved secret provider. `mock_mode` is not an official PayPal emulator and must never use real credentials. + +## Example + +```hocon +env { + parallelism = 1 + job.mode = "BATCH" +} +source { + PayPal { + plugin_output = "transactions" + client_id = ${PAYPAL_CLIENT_ID} + client_secret = ${PAYPAL_CLIENT_SECRET} + start_date = "2026-01-01T00:00:00Z" + end_date = "2026-01-02T00:00:00Z" + page_size = 100 + } +} +sink { + LocalFile { + plugin_input = "transactions" + path = "/data/paypal" + file_format_type = "json" + } +} +``` + +## Changelog + +<ChangeLog /> diff --git a/docs/zh/connectors/changelog/connector-http-paypal.md b/docs/zh/connectors/changelog/connector-http-paypal.md new file mode 100644 index 0000000000..6dd26bec05 --- /dev/null +++ b/docs/zh/connectors/changelog/connector-http-paypal.md @@ -0,0 +1,5 @@ +# Changelog + +## Next version + +- Add bounded PayPal Transaction Search source with OAuth authentication. diff --git a/docs/zh/connectors/source/PayPal.md b/docs/zh/connectors/source/PayPal.md new file mode 100644 index 0000000000..c54ab83e37 --- /dev/null +++ b/docs/zh/connectors/source/PayPal.md @@ -0,0 +1,107 @@ +import ChangeLog from '../changelog/connector-http-paypal.md'; + +# PayPal + +> PayPal Transaction Search 数据源连接器。 + +## 描述 + +使用第一方 OAuth 客户端凭据,通过 `GET /v1/reporting/transactions` 读取一个账户的有界交易报表。仅支持 BATCH,不是 CDC、支付接口或余额对账服务。 + +## 主要功能 + +- [x] 批处理 +- [ ] 流处理 +- [ ] Exactly-once + +并行度必须为 1。恢复时重新读取整个配置窗口,不保存分页偏移量。任务失败前可能已向非事务型下游写入部分记录。 + +## 前提条件 + +REST 应用必须获得其所属账户的 Transaction Search 权限。请启用应用的交易查询权限并确认报表访问;获取 OAuth token 成功不代表具有报表权限。权限变更可能需要重新获取 token。不支持第三方账户或合作伙伴授权流程。 + +生产地址为 `https://api-m.paypal.com`,沙箱地址为 `https://api-m.sandbox.paypal.com`。沙箱凭据和报表可用性独立于生产环境。模拟测试不证明沙箱或生产权限、数据可用性或完整性。本连接器不声称已通过真实账户验证。 + +参考 [官方 OpenAPI](https://github.com/paypal/paypal-rest-api-specifications/blob/main/openapi/reporting_transactions_v1.json)、[交易查询](https://developer.paypal.com/api/transaction-search/v1/search-get) 和 [REST 认证](https://developer.paypal.com/api/rest/authentication/)。 + +## 报表约定 + +必须指定带秒和时区偏移的绝对 RFC3339 `start_date`、`end_date`。开始早于结束,跨度不超过 31 天。时间统一转换为 UTC,不使用主机时区。PayPal 提供最近三年的历史数据,交易最多可能延迟三小时才出现在报表中。 + +固定请求 `fields=all` 和 `balance_affecting_records_only=N`,保留影响余额及不影响余额的所有返回记录。交易 ID 在报表系统中不唯一,不建立主键、不按 ID 去重,下游也应保留此区别。 + +每页必须包含账户、时间范围、页码、总数及交易数组。返回时间范围经 UTC 归一化后必须与请求完全一致。PayPal 的 `end_date` 也可能表示当前可提供数据的最后时间:若返回范围缩短,任务失败,不会静默修改请求。请等待报表可用,或显式缩小窗口后重跑。 + +遇到格式错误、HTTP 200 中的业务错误、分页数量不一致、账户或总数变化及可检测的截断时失败。`RESULTSET_TOO_LARGE` 提示缩小日期窗口。连接器限制为最多 10,000 条;恰好 10,000 条且所有分页及总数校验通过时可读取,超过限制则失败。不截断、不自动拆分窗口。调整窗口时须明确核对边界和重放影响。 + +总数稳定、时间范围匹配只能检测部分问题,不代表远端不可变快照。同数量的记录变化、延迟更新或遗漏可能无法检测。不保证快照一致性、无损时间切分或完整历史。 + +## 输出结构 + +固定结构,不支持自定义 `schema`。 + +| 字段 | 类型 | 可空 | 含义 | +| --- | --- | --- | --- | +| account_number | STRING | 否 | 响应中的报表账户 | +| transaction_id | STRING | 是 | 非唯一交易 ID | +| transaction_event_code | STRING | 是 | 事件代码 | +| transaction_status | STRING | 是 | 状态 | +| transaction_initiation_date | STRING | 是 | UTC RFC3339 创建时间 | +| transaction_updated_date | STRING | 是 | UTC RFC3339 更新时间 | +| transaction_amount | DECIMAL(38,9) | 是 | 原币单位总金额 | +| transaction_currency | STRING | 是 | 总金额币种 | +| fee_amount | DECIMAL(38,9) | 是 | 原币单位手续费 | +| fee_currency | STRING | 是 | 手续费币种,与总金额币种独立 | +| content | STRING | 否 | 完整交易对象 JSON,包含引用及额外字段 | + +可选字段缺失或为 null 均保留为空。存在的金额对象必须含字符串金额和三字母币种;支持零位及三位小数币种。金额须能精确表示为 DECIMAL(38,9),超出精度或小数位时失败而非舍入。其他金额字段保留在原始 JSON 中,不进行浮点转换。原始记录可能包含个人或财务数据,请使用受控下游并避免记录到日志。 + +## 配置项 + +| 名称 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| client_id | STRING | 是 | - | 第一方 REST 应用 ID | +| client_secret | STRING | 是 | - | 客户端密钥,使用原生配置日志脱敏 | +| start_date | STRING | 是 | - | 带秒和偏移的绝对开始时间 | +| end_date | STRING | 是 | - | 绝对结束时间,窗口不超过 31 天 | +| api_base_url | STRING | 否 | https://api-m.paypal.com | 精确生产或沙箱源地址,不带末尾斜线 | +| page_size | INT | 否 | 100 | 1 到 500 | +| max_retries | INT | 否 | 3 | 额外瞬时故障重试,0 到 5 | +| retry_delay_ms | INT | 否 | 1000 | 1 到 60000 毫秒 | +| request_timeout_ms | INT | 否 | 30000 | 连接、读取及请求中止期限,1 到 120000 毫秒 | +| max_response_bytes | INT | 否 | 8388608 | 每次响应解压后字节上限,1024 到 16777216 | +| mock_mode | BOOLEAN | 否 | false | 测试自定义源地址,凭据必须严格为 mock-client / mock-secret | + +通过真实 token 接口获取 OAuth token,到期或每页首次 HTTP 401 后刷新。仅对 HTTP 429/500/502/503/504 及传输错误进行有界重试。遵守最多 60 秒的数字 Retry-After;更长或不支持的值直接失败,请稍后重跑。不跟随重定向。关闭时中止活动请求并唤醒重试等待。 + +请求期限不能中断 JVM DNS 解析或阻塞的下游 collect,因此不是无条件的任务总耗时保证。分页数、响应大小、请求次数和等待时间均受限。请关闭 HTTP wire/header 日志;密钥不得写入 URL、点分配置名或诊断信息。使用环境变量或认可的密钥提供方式。模拟模式不是官方 PayPal 模拟器,不得使用真实凭据。 + +## 示例 + +```hocon +env { + parallelism = 1 + job.mode = "BATCH" +} +source { + PayPal { + plugin_output = "transactions" + client_id = ${PAYPAL_CLIENT_ID} + client_secret = ${PAYPAL_CLIENT_SECRET} + start_date = "2026-01-01T00:00:00Z" + end_date = "2026-01-02T00:00:00Z" + page_size = 100 + } +} +sink { + LocalFile { + plugin_input = "transactions" + path = "/data/paypal" + file_format_type = "json" + } +} +``` + +## 变更日志 + +<ChangeLog /> diff --git a/plugin-mapping.properties b/plugin-mapping.properties index f3bf789fbe..265a2c5398 100644 --- a/plugin-mapping.properties +++ b/plugin-mapping.properties @@ -118,6 +118,7 @@ seatunnel.source.Shopify = connector-http-shopify seatunnel.source.Zendesk = connector-http-zendesk seatunnel.sink.Zendesk = connector-http-zendesk seatunnel.source.Stripe = connector-http-stripe +seatunnel.source.PayPal = connector-http-paypal seatunnel.sink.Slack = connector-slack seatunnel.source.OneSignal = connector-http-onesignal seatunnel.source.Jira = connector-http-jira diff --git a/seatunnel-connectors-v2/connector-http/pom.xml b/seatunnel-connectors-v2/connector-http/connector-http-paypal/pom.xml similarity index 52% copy from seatunnel-connectors-v2/connector-http/pom.xml copy to seatunnel-connectors-v2/connector-http/connector-http-paypal/pom.xml index 05f73a000c..13877b6323 100644 --- a/seatunnel-connectors-v2/connector-http/pom.xml +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/pom.xml @@ -22,33 +22,19 @@ <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.apache.seatunnel</groupId> - <artifactId>seatunnel-connectors-v2</artifactId> + <artifactId>connector-http</artifactId> <version>${revision}</version> </parent> - <artifactId>connector-http</artifactId> - <packaging>pom</packaging> - <name>SeaTunnel : Connectors V2 : Http :</name> - <modules> - <module>connector-http-base</module> - <module>connector-http-feishu</module> - <module>connector-http-wechat</module> - <module>connector-http-myhours</module> - <module>connector-http-lemlist</module> - <module>connector-http-klaviyo</module> - <module>connector-http-onesignal</module> - <module>connector-http-jira</module> - <module>connector-http-gitlab</module> - <module>connector-http-github</module> - <module>connector-http-notion</module> - <module>connector-http-persistiq</module> - <module>connector-http-airtable</module> - <module>connector-http-posthog</module> - <module>connector-http-shopify</module> - <module>connector-http-zendesk</module> - <module>connector-http-stripe</module> - <module>connector-http-linear</module> - <module>connector-mem0</module> - </modules> + <artifactId>connector-http-paypal</artifactId> + <name>SeaTunnel : Connectors V2 : Http : PayPal</name> + + <dependencies> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-base</artifactId> + <version>${project.version}</version> + </dependency> + </dependencies> </project> diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalClient.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalClient.java new file mode 100644 index 0000000000..8553a29b92 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalClient.java @@ -0,0 +1,296 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode; + +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.utils.URIBuilder; +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.HttpClients; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Owns OAuth renewal, streaming response limits and cancellation for Transaction Search. These + * request-lifecycle requirements are not provided by the shared HTTP source framework; response + * decoding remains separate so transaction amounts are validated without rounding. + */ +final class PayPalClient implements AutoCloseable { + private final PayPalConfig config; + private final CloseableHttpClient client; + private final ScheduledThreadPoolExecutor timer; + private volatile HttpRequestBase active; + private volatile boolean closed; + private String token; + private long expiresAt; + + PayPalClient(PayPalConfig config) { + this.config = config; + client = + HttpClients.custom() + .disableAutomaticRetries() + .disableRedirectHandling() + .disableCookieManagement() + .setDefaultRequestConfig( + RequestConfig.custom() + .setConnectTimeout(config.timeout) + .setSocketTimeout(config.timeout) + .setConnectionRequestTimeout(config.timeout) + .build()) + .build(); + timer = + new ScheduledThreadPoolExecutor( + 1, + r -> { + Thread thread = new Thread(r, "paypal-request-deadline"); + thread.setDaemon(true); + return thread; + }); + timer.setRemoveOnCancelPolicy(true); + } + + JsonNode page(int page) throws Exception { + checkOpen(); + boolean refreshed = false; + for (int attempt = 0; ; attempt++) { + if (token == null || System.nanoTime() >= expiresAt) { + authenticate(); + } + HttpGet request = + new HttpGet( + new URIBuilder(config.origin + "/v1/reporting/transactions") + .addParameter("start_date", config.start.toString()) + .addParameter("end_date", config.end.toString()) + .addParameter("fields", "all") + .addParameter("balance_affecting_records_only", "N") + .addParameter("page_size", Integer.toString(config.pageSize)) + .addParameter("page", Integer.toString(page)) + .build()); + request.setHeader("Authorization", "Bearer " + token); + Reply reply; + try { + reply = execute(request); + } catch (IOException e) { + retry(attempt, config.retryDelay); + continue; + } + if (reply.status == 401 && !refreshed) { + token = null; + refreshed = true; + continue; + } + if (transientStatus(reply.status)) { + retry(attempt, reply.delay); + continue; + } + return success(reply); + } + } + + private void authenticate() throws Exception { + for (int attempt = 0; ; attempt++) { + HttpPost request = new HttpPost(config.origin + "/v1/oauth2/token"); + String basic = + Base64.getEncoder() + .encodeToString( + (config.clientId + ":" + config.clientSecret) + .getBytes(StandardCharsets.UTF_8)); + request.setHeader("Authorization", "Basic " + basic); + request.setEntity( + new StringEntity( + "grant_type=client_credentials", + ContentType.APPLICATION_FORM_URLENCODED)); + long issuedAt = System.nanoTime(); + Reply reply; + try { + reply = execute(request); + } catch (IOException e) { + retry(attempt, config.retryDelay); + continue; + } + if (transientStatus(reply.status)) { + retry(attempt, reply.delay); + continue; + } + JsonNode root = success(reply); + String accessToken = PayPalResponse.text(root, "access_token", true); + int lifetime = PayPalResponse.integer(root, "expires_in"); + if (!"Bearer".equalsIgnoreCase(PayPalResponse.text(root, "token_type", true)) + || lifetime < 1 + || lifetime > 86400 + || !accessToken.matches("[A-Za-z0-9._~+/-]+=*") + || accessToken.length() > 8192) { + throw PayPalResponse.failure("Invalid OAuth token response"); + } + // Count lifetime from before token exchange, never from after a slow response. + expiresAt = issuedAt + TimeUnit.SECONDS.toNanos(lifetime); + token = accessToken; + return; + } + } + + private Reply execute(HttpRequestBase request) throws IOException { + ScheduledFuture<?> deadline; + synchronized (this) { + checkOpen(); + active = request; + deadline = timer.schedule(request::abort, config.timeout, TimeUnit.MILLISECONDS); + } + request.setHeader("Accept", "application/json"); + request.setHeader("PayPal-Enforce-ISO8601-Format", "true"); + CloseableHttpResponse response = null; + try { + response = client.execute(request); + int status = response.getStatusLine().getStatusCode(); + int delay = config.retryDelay; + if (transientStatus(status) && response.getFirstHeader("Retry-After") != null) { + try { + long seconds = + Long.parseLong(response.getFirstHeader("Retry-After").getValue()); + if (seconds < 0 || seconds > 60) { + throw new NumberFormatException(); + } + delay = Math.max(delay, (int) seconds * 1000); + } catch (NumberFormatException e) { + request.abort(); + throw PayPalResponse.failure( + "Retry-After exceeds bounded retry policy or is unsupported; retry the job later"); + } + } + if (transientStatus(status) || status == 401 || status == 403) { + return new Reply(status, new byte[0], delay); + } + if (response.getEntity() == null) { + throw PayPalResponse.failure("Empty HTTP response"); + } + if (response.getEntity().getContentLength() > config.maxBytes) { + request.abort(); + throw PayPalResponse.failure("HTTP response exceeds max_response_bytes"); + } + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + { + InputStream input = response.getEntity().getContent(); + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) { + checkOpen(); + if (bytes.size() > config.maxBytes - count) { + request.abort(); + throw PayPalResponse.failure("HTTP response exceeds max_response_bytes"); + } + bytes.write(buffer, 0, count); + } + } + return new Reply(status, bytes.toByteArray(), delay); + } finally { + // Abort before closing prevents draining oversized or malformed responses. + request.abort(); + deadline.cancel(false); + if (response != null) { + try { + response.close(); + } catch (IOException ignored) { + // The request is already aborted; never attach transport details to errors. + } + } + synchronized (this) { + active = null; + } + } + } + + private JsonNode success(Reply reply) { + if (reply.status == 401 || reply.status == 403) { + throw PayPalResponse.failure( + "HTTP " + + reply.status + + "; check app credentials and Transaction Search permission (body withheld)"); + } + JsonNode root = PayPalResponse.parse(reply.body); + PayPalResponse.rejectError(root); + if (reply.status != 200) { + throw PayPalResponse.failure( + "HTTP " + + reply.status + + "; check app credentials and Transaction Search permission (body withheld)"); + } + return root; + } + + private static boolean transientStatus(int status) { + return status == 429 || status == 500 || status == 502 || status == 503 || status == 504; + } + + private synchronized void retry(int attempt, int delay) throws InterruptedException { + checkOpen(); + if (attempt >= config.retries) { + throw PayPalResponse.failure( + "HTTP retry budget exhausted (transport details withheld)"); + } + long end = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delay); + long remaining; + while (!closed && (remaining = end - System.nanoTime()) > 0) { + TimeUnit.NANOSECONDS.timedWait(this, remaining); + } + checkOpen(); + } + + private void checkOpen() { + if (closed || Thread.currentThread().isInterrupted()) { + throw PayPalResponse.failure("Request cancelled"); + } + } + + @Override + public synchronized void close() throws IOException { + closed = true; + token = null; + if (active != null) { + active.abort(); + } + notifyAll(); + timer.shutdownNow(); + client.close(); + } + + private static final class Reply { + private final int status; + private final byte[] body; + private final int delay; + + private Reply(int status, byte[] body, int delay) { + this.status = status; + this.body = body; + this.delay = delay; + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalConfig.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalConfig.java new file mode 100644 index 0000000000..d4dba9525f --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalConfig.java @@ -0,0 +1,155 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.ConfigValidator; +import org.apache.seatunnel.api.options.EnvCommonOptions; + +import java.io.Serializable; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +final class PayPalConfig implements Serializable { + final String clientId; + final String clientSecret; + final String origin; + final Instant start; + final Instant end; + final int pageSize; + final int retries; + final int retryDelay; + final int timeout; + final int maxBytes; + + PayPalConfig(ReadonlyConfig config) { + ConfigValidator.validateUnknownKeys( + config, new PayPalSourceFactory().optionRule(), "PayPal"); + if (config.getSourceMap().containsKey("schema")) { + throw new IllegalArgumentException( + "PayPal uses a fixed schema and does not support a schema option"); + } + if (get(config, EnvCommonOptions.PARALLELISM) != 1) { + throw new IllegalArgumentException("PayPal source requires parallelism=1"); + } + clientId = credential(get(config, PayPalSourceOptions.CLIENT_ID), "client_id"); + clientSecret = credential(get(config, PayPalSourceOptions.CLIENT_SECRET), "client_secret"); + start = date(get(config, PayPalSourceOptions.START_DATE)); + end = date(get(config, PayPalSourceOptions.END_DATE)); + if (!start.isBefore(end) + || Duration.between(start, end).compareTo(Duration.ofDays(31)) > 0) { + throw new IllegalArgumentException( + "PayPal requires start_date < end_date and a window <=31 days"); + } + if (get(config, PayPalSourceOptions.MOCK_MODE) + && !(clientId.equals("mock-client") && clientSecret.equals("mock-secret"))) { + throw new IllegalArgumentException( + "PayPal mock_mode requires client_id=mock-client and client_secret=mock-secret"); + } + origin = + origin( + get(config, PayPalSourceOptions.API_BASE_URL), + get(config, PayPalSourceOptions.MOCK_MODE)); + pageSize = range(get(config, PayPalSourceOptions.PAGE_SIZE), 1, 500, "page_size"); + retries = range(get(config, PayPalSourceOptions.MAX_RETRIES), 0, 5, "max_retries"); + retryDelay = + range(get(config, PayPalSourceOptions.RETRY_DELAY_MS), 1, 60000, "retry_delay_ms"); + timeout = + range( + get(config, PayPalSourceOptions.REQUEST_TIMEOUT_MS), + 1, + 120000, + "request_timeout_ms"); + maxBytes = + range( + get(config, PayPalSourceOptions.MAX_RESPONSE_BYTES), + 1024, + 16777216, + "max_response_bytes"); + } + + private static <T> T get(ReadonlyConfig config, Option<T> option) { + try { + return config.get(option); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "Invalid PayPal option " + option.key() + " (value withheld)"); + } + } + + static Instant date(String value) { + try { + if (value == null + || !value.matches( + "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})")) { + throw new IllegalArgumentException(); + } + return OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant(); + } catch (RuntimeException e) { + // Parser causes can contain configuration values or response data. + throw new IllegalArgumentException( + "PayPal requires valid absolute RFC3339 dates with seconds and offset"); + } + } + + private static String credential(String value, String key) { + if (value == null + || value.trim().isEmpty() + || value.length() > 4096 + || value.indexOf(':') >= 0 + || value.chars().anyMatch(c -> c < 33 || c > 126)) { + throw new IllegalArgumentException("Invalid PayPal " + key); + } + return value; + } + + private static String origin(String value, boolean mock) { + try { + URI uri = URI.create(value); + boolean official = + value.equals("https://api-m.paypal.com") + || value.equals("https://api-m.sandbox.paypal.com"); + if ((!official && !mock) + || uri.getHost() == null + || uri.getRawUserInfo() != null + || uri.getRawQuery() != null + || uri.getRawFragment() != null + || !uri.getRawPath().isEmpty() + || !(uri.getScheme().equals("https") + || (mock && uri.getScheme().equals("http")))) { + throw new IllegalArgumentException(); + } + return value; + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "PayPal api_base_url must be an official origin; custom origins require mock_mode and dummy credentials"); + } + } + + private static int range(int value, int min, int max, String key) { + if (value < min || value > max) { + throw new IllegalArgumentException( + "PayPal " + key + " must be between " + min + " and " + max); + } + return value; + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalResponse.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalResponse.java new file mode 100644 index 0000000000..b6c4817c07 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalResponse.java @@ -0,0 +1,168 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.core.JsonParser; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.DeserializationFeature; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.seatunnel.api.table.type.SeaTunnelRow; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +final class PayPalResponse { + private static final ObjectMapper MAPPER = + new ObjectMapper() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + final int totalItems; + final int totalPages; + final String account; + final List<SeaTunnelRow> rows; + + PayPalResponse(JsonNode root, PayPalConfig config, int page) { + rejectError(root); + totalItems = integer(root, "total_items"); + totalPages = integer(root, "total_pages"); + if (totalItems > 10000) { + throw failure( + "Result exceeds the 10000-record safety cap; narrow start_date/end_date and reconcile boundaries explicitly"); + } + int expectedPages = (totalItems + config.pageSize - 1) / config.pageSize; + if (integer(root, "page") != page + || (totalPages != expectedPages && !(totalItems == 0 && totalPages == 1))) { + throw failure("Inconsistent page metadata"); + } + account = text(root, "account_number", true); + if (!PayPalConfig.date(text(root, "start_date", true)).equals(config.start) + || !PayPalConfig.date(text(root, "end_date", true)).equals(config.end)) { + throw failure( + "Response coverage differs from requested dates; wait for reporting availability or explicitly narrow the window"); + } + JsonNode details = root.path("transaction_details"); + int expectedRows = Math.min(config.pageSize, totalItems - (page - 1) * config.pageSize); + if (!details.isArray() || details.size() != expectedRows) { + throw failure("Missing, truncated or inconsistent transaction_details"); + } + rows = new ArrayList<>(details.size()); + for (JsonNode detail : details) { + if (!detail.isObject() || !detail.path("transaction_info").isObject()) { + throw failure("Invalid transaction_info object"); + } + JsonNode info = detail.get("transaction_info"); + Object[] amount = money(info.get("transaction_amount")); + Object[] fee = money(info.get("fee_amount")); + rows.add( + new SeaTunnelRow( + new Object[] { + account, + text(info, "transaction_id", false), + text(info, "transaction_event_code", false), + text(info, "transaction_status", false), + timestamp(info, "transaction_initiation_date"), + timestamp(info, "transaction_updated_date"), + amount[0], + amount[1], + fee[0], + fee[1], + detail.toString() + })); + } + } + + static JsonNode parse(byte[] bytes) { + try { + JsonNode root = MAPPER.readTree(bytes); + if (root == null || !root.isObject()) { + throw new IllegalArgumentException(); + } + return root; + } catch (Exception e) { + throw failure("Invalid JSON response (body and parser cause withheld)"); + } + } + + static void rejectError(JsonNode node) { + boolean tooLarge = "RESULTSET_TOO_LARGE".equals(node.path("name").asText()); + for (JsonNode detail : node.path("details")) { + tooLarge |= "RESULTSET_TOO_LARGE".equals(detail.path("issue").asText()); + } + if (tooLarge) { + throw failure( + "RESULTSET_TOO_LARGE: narrow start_date/end_date; automatic lossless splitting is not supported"); + } + if (node.has("name") || node.has("error") || node.has("details")) { + throw failure("API returned an error envelope (body withheld)"); + } + } + + static int integer(JsonNode node, String field) { + JsonNode value = node.path(field); + if (!value.isIntegralNumber() || !value.canConvertToInt() || value.intValue() < 0) { + throw failure("Missing or invalid " + field); + } + return value.intValue(); + } + + static String text(JsonNode node, String field, boolean required) { + JsonNode value = node.get(field); + if (!required && (value == null || value.isNull())) { + return null; + } + if (value == null || !value.isTextual() || (required && value.textValue().isEmpty())) { + throw failure("Missing or invalid " + field); + } + return value.textValue(); + } + + private static String timestamp(JsonNode node, String field) { + String value = text(node, field, false); + return value == null ? null : PayPalConfig.date(value).toString(); + } + + private static Object[] money(JsonNode node) { + if (node == null || node.isNull()) { + return new Object[] {null, null}; + } + String value = text(node, "value", true); + String currency = text(node, "currency_code", true); + if (!node.isObject() + || value.length() > 32 + || !value.matches("-?([0-9]+|[0-9]*\\.[0-9]+)") + || !currency.matches("[A-Z]{3}")) { + throw failure("Malformed currency amount"); + } + try { + BigDecimal decimal = new BigDecimal(value).setScale(9, RoundingMode.UNNECESSARY); + if (decimal.precision() > 38) { + throw new ArithmeticException(); + } + return new Object[] {decimal, currency}; + } catch (ArithmeticException e) { + throw failure("Currency amount cannot be represented exactly as DECIMAL(38,9)"); + } + } + + static IllegalStateException failure(String message) { + return new IllegalStateException("PayPal: " + message); + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSource.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSource.java new file mode 100644 index 0000000000..07d9ede187 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSource.java @@ -0,0 +1,100 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.common.JobContext; +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.catalog.PhysicalColumn; +import org.apache.seatunnel.api.table.catalog.TableIdentifier; +import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.catalog.TableSchema; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.DecimalType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.common.constants.JobMode; +import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader; +import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitSource; +import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; + +import java.util.Collections; +import java.util.List; + +/** A single-account bounded report, preserving reporting records without snapshot guarantees. */ +public class PayPalSource extends AbstractSingleSplitSource<SeaTunnelRow> { + public static final String PLUGIN_NAME = "PayPal"; + private final PayPalConfig config; + + public PayPalSource(ReadonlyConfig options) { + config = new PayPalConfig(options); + } + + @Override + public String getPluginName() { + return PLUGIN_NAME; + } + + @Override + public void setJobContext(JobContext context) { + if (context.getJobMode() != JobMode.BATCH) { + throw new IllegalArgumentException("PayPal source supports BATCH only"); + } + } + + @Override + public Boundedness getBoundedness() { + return Boundedness.BOUNDED; + } + + @Override + public List<CatalogTable> getProducedCatalogTables() { + TableSchema.Builder schema = TableSchema.builder(); + String[] names = { + "account_number", + "transaction_id", + "transaction_event_code", + "transaction_status", + "transaction_initiation_date", + "transaction_updated_date", + "transaction_amount", + "transaction_currency", + "fee_amount", + "fee_currency", + "content" + }; + for (int i = 0; i < names.length; i++) { + SeaTunnelDataType<?> type = + i == 6 || i == 8 ? new DecimalType(38, 9) : BasicType.STRING_TYPE; + schema.column(PhysicalColumn.of(names[i], type, 0, i != 0 && i != 10, null, null)); + } + return Collections.singletonList( + CatalogTable.of( + TableIdentifier.of(PLUGIN_NAME, TablePath.DEFAULT), + schema.build(), + Collections.emptyMap(), + Collections.emptyList(), + null)); + } + + @Override + public AbstractSingleSplitReader<SeaTunnelRow> createReader(SingleSplitReaderContext context) { + return new PayPalSourceReader(config, context); + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceFactory.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceFactory.java new file mode 100644 index 0000000000..dc3db51323 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceFactory.java @@ -0,0 +1,70 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.options.MultiTableCommonOptions; +import org.apache.seatunnel.api.source.SeaTunnelSource; +import org.apache.seatunnel.api.source.SourceSplit; +import org.apache.seatunnel.api.table.connector.TableSource; +import org.apache.seatunnel.api.table.factory.Factory; +import org.apache.seatunnel.api.table.factory.TableSourceFactory; +import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext; + +import com.google.auto.service.AutoService; + +import java.io.Serializable; + +@AutoService(Factory.class) +public class PayPalSourceFactory implements TableSourceFactory { + @Override + public String factoryIdentifier() { + return PayPalSource.PLUGIN_NAME; + } + + @Override + public OptionRule optionRule() { + return OptionRule.builder() + .required( + PayPalSourceOptions.CLIENT_ID, + PayPalSourceOptions.CLIENT_SECRET, + PayPalSourceOptions.START_DATE, + PayPalSourceOptions.END_DATE) + .optional( + MultiTableCommonOptions.MULTI_TABLE_FAILURE_POLICY, + PayPalSourceOptions.API_BASE_URL, + PayPalSourceOptions.MOCK_MODE, + PayPalSourceOptions.PAGE_SIZE, + PayPalSourceOptions.MAX_RETRIES, + PayPalSourceOptions.RETRY_DELAY_MS, + PayPalSourceOptions.REQUEST_TIMEOUT_MS, + PayPalSourceOptions.MAX_RESPONSE_BYTES) + .build(); + } + + @Override + public <T, SplitT extends SourceSplit, StateT extends Serializable> + TableSource<T, SplitT, StateT> createSource(TableSourceFactoryContext context) { + return () -> (SeaTunnelSource<T, SplitT, StateT>) new PayPalSource(context.getOptions()); + } + + @Override + public Class<? extends SeaTunnelSource> getSourceClass() { + return PayPalSource.class; + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceOptions.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceOptions.java new file mode 100644 index 0000000000..fd4beb38ca --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceOptions.java @@ -0,0 +1,85 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.Options; + +public final class PayPalSourceOptions { + public static final Option<String> CLIENT_ID = + Options.key("client_id") + .stringType() + .noDefaultValue() + .withDescription("First-party PayPal REST app client ID."); + public static final Option<String> CLIENT_SECRET = + Options.key("client_secret") + .stringType() + .noDefaultValue() + .withDescription("PayPal client secret; masked in parsed job configuration."); + public static final Option<String> START_DATE = + Options.key("start_date") + .stringType() + .noDefaultValue() + .withDescription("Absolute RFC3339 start with seconds and offset."); + public static final Option<String> END_DATE = + Options.key("end_date") + .stringType() + .noDefaultValue() + .withDescription( + "Absolute RFC3339 end, at most 31 days after start; shortened response coverage fails."); + public static final Option<String> API_BASE_URL = + Options.key("api_base_url") + .stringType() + .defaultValue("https://api-m.paypal.com") + .withDescription("PayPal production or sandbox origin."); + public static final Option<Boolean> MOCK_MODE = + Options.key("mock_mode") + .booleanType() + .defaultValue(false) + .withDescription( + "Allow an HTTP mock origin with dummy credentials only; never enable for PayPal."); + public static final Option<Integer> PAGE_SIZE = + Options.key("page_size") + .intType() + .defaultValue(100) + .withDescription("Records per page, 1 to 500."); + public static final Option<Integer> MAX_RETRIES = + Options.key("max_retries") + .intType() + .defaultValue(3) + .withDescription( + "Additional attempts for HTTP 429/5xx and transport failures, 0 to 5."); + public static final Option<Integer> RETRY_DELAY_MS = + Options.key("retry_delay_ms") + .intType() + .defaultValue(1000) + .withDescription( + "Retry delay, 1 to 60000 ms. Longer Retry-After fails instead of retrying early."); + public static final Option<Integer> REQUEST_TIMEOUT_MS = + Options.key("request_timeout_ms") + .intType() + .defaultValue(30000) + .withDescription("Connect, socket and request abort deadline, 1 to 120000 ms."); + public static final Option<Integer> MAX_RESPONSE_BYTES = + Options.key("max_response_bytes") + .intType() + .defaultValue(8388608) + .withDescription("Maximum uncompressed response bytes, 1024 to 16777216."); + + private PayPalSourceOptions() {} +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceReader.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceReader.java new file mode 100644 index 0000000000..6b52e22254 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/main/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalSourceReader.java @@ -0,0 +1,95 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader; +import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; + +import java.io.IOException; + +final class PayPalSourceReader extends AbstractSingleSplitReader<SeaTunnelRow> { + private final PayPalConfig config; + private final SingleSplitReaderContext context; + private volatile boolean closed; + private PayPalClient client; + + PayPalSourceReader(PayPalConfig config, SingleSplitReaderContext context) { + this.config = config; + this.context = context; + } + + /** Create worker-local HTTP resources; credentials and clients never enter split state. */ + @Override + public synchronized void open() { + if (client != null) { + throw PayPalResponse.failure("Reader already opened"); + } + if (closed) { + throw PayPalResponse.failure("Reader closed"); + } + client = new PayPalClient(config); + } + + /** Replay the bounded report on recovery; page offsets are not durable snapshots. */ + @Override + public void internalPollNext(Collector<SeaTunnelRow> output) throws Exception { + int total = -1; + int pages = -1; + String account = null; + for (int page = 1; ; page++) { + if (closed) { + throw PayPalResponse.failure("Reader closed"); + } + PayPalResponse response = new PayPalResponse(client.page(page), config, page); + if (total < 0) { + total = response.totalItems; + pages = response.totalPages; + account = response.account; + } else if (total != response.totalItems + || pages != response.totalPages + || !account.equals(response.account)) { + throw PayPalResponse.failure( + "Report totals or account changed between pages; no stable remote snapshot is guaranteed"); + } + for (SeaTunnelRow row : response.rows) { + if (closed) { + throw PayPalResponse.failure("Reader closed"); + } + output.collect(row); + } + if (page >= pages) { + break; + } + } + if (closed) { + throw PayPalResponse.failure("Reader closed"); + } + context.signalNoMoreElement(); + } + + /** Abort in-flight HTTP and wake retry waits before releasing worker-local resources. */ + @Override + public synchronized void close() throws IOException { + closed = true; + if (client != null) { + client.close(); + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalClientTest.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalClientTest.java new file mode 100644 index 0000000000..b72899d907 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalClientTest.java @@ -0,0 +1,487 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; + +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.ValueSource; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PayPalClientTest { + @Test + void statusPolicyWorksWithBodyless503429And401() throws Exception { + Reply unavailable = new Reply(503, ""); + unavailable.bodyless = true; + replies.add(unavailable); + token(3600, "first-token"); + Reply unauthorized = new Reply(401, ""); + unauthorized.bodyless = true; + replies.add(unauthorized); + token(3600, "next-token"); + page(1, 0, ""); + assertNotNull(client().page(1)); + assertEquals(5, requests.size()); + } + + @Test + void bodylessRateLimitUsesBoundedRetry() throws Exception { + token(3600, "token"); + Reply limit = new Reply(429, ""); + limit.bodyless = true; + replies.add(limit); + page(1, 0, ""); + assertNotNull(client().page(1)); + assertEquals(3, requests.size()); + } + + @Test + void repeatedOpenIsRejectedAndCloseIsIdempotent() throws Exception { + PayPalSourceReader reader = + new PayPalSourceReader( + new PayPalConfig(ReadonlyConfig.fromMap(options)), + new SingleSplitReaderContext(mock(SourceReader.Context.class))); + reader.open(); + try { + assertThrows(IllegalStateException.class, reader::open); + } finally { + reader.close(); + reader.close(); + } + assertThrows(IllegalStateException.class, reader::open); + } + + private HttpServer server; + private ExecutorService executor; + private final Queue<Reply> replies = new ConcurrentLinkedQueue<>(); + private final List<String> requests = Collections.synchronizedList(new ArrayList<>()); + private final CountDownLatch arrived = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private Map<String, Object> options; + private PayPalClient client; + + @BeforeEach + void start() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + executor = Executors.newCachedThreadPool(); + server.setExecutor(executor); + server.createContext("/", this::serve); + server.start(); + options = PayPalResponseTest.options(); + options.put("mock_mode", true); + options.put("api_base_url", "http://127.0.0.1:" + server.getAddress().getPort()); + options.put("retry_delay_ms", 10); + options.put("max_retries", 1); + } + + private void serve(HttpExchange exchange) throws IOException { + Reply reply = replies.poll(); + String body = new String(read(exchange.getRequestBody()), StandardCharsets.UTF_8); + requests.add( + exchange.getRequestMethod() + + " " + + exchange.getRequestURI() + + " " + + exchange.getRequestHeaders().getFirst("Authorization") + + " " + + body + + " " + + exchange.getRequestHeaders().getFirst("PayPal-Enforce-ISO8601-Format")); + if (reply == null) { + exchange.sendResponseHeaders(500, -1); + exchange.close(); + return; + } + try { + if (reply.delay > 0) { + Thread.sleep(reply.delay); + } + if (reply.gzip) { + exchange.getResponseHeaders().set("Content-Encoding", "gzip"); + } + if (reply.retryAfter != null) { + exchange.getResponseHeaders().set("Retry-After", reply.retryAfter); + } + if (reply.status == 302) { + exchange.getResponseHeaders() + .set("Location", options.get("api_base_url") + "/credential-theft"); + } + exchange.sendResponseHeaders( + reply.status, reply.bodyless ? -1 : reply.chunked ? 0 : reply.body.length); + if (reply.bodyless) { + arrived.countDown(); + return; + } + if (reply.block) { + exchange.getResponseBody().write(' '); + exchange.getResponseBody().flush(); + arrived.countDown(); + release.await(5, TimeUnit.SECONDS); + } else { + exchange.getResponseBody().write(reply.body); + arrived.countDown(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + /* Cancellation intentionally closes the peer socket. */ + } finally { + exchange.close(); + } + } + + private static byte[] read(InputStream input) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = input.read(buffer)) != -1) { + bytes.write(buffer, 0, count); + } + return bytes.toByteArray(); + } + + private PayPalClient client() { + client = new PayPalClient(new PayPalConfig(ReadonlyConfig.fromMap(options))); + return client; + } + + private void token(int lifetime, String value) { + replies.add( + new Reply( + 200, + "{\"access_token\":\"" + + value + + "\",\"token_type\":\"Bearer\",\"expires_in\":" + + lifetime + + "}")); + } + + private Reply page(int number, int total, String records) { + Reply reply = new Reply(200, PayPalResponseTest.page(number, total, records)); + replies.add(reply); + return reply; + } + + @AfterEach + void stop() throws Exception { + release.countDown(); + if (client != null) { + client.close(); + } + server.stop(0); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + + @Test + void exchangesOAuthAndRefreshesExpiredTokenAcrossPages() throws Exception { + token(1, "first-token"); + page( + 1, + 3, + PayPalResponseTest.record("123", "JPY") + + "," + + PayPalResponseTest.record("0.123", "TND")) + .delay = + 1200; + token(3600, "next-token"); + page(2, 3, PayPalResponseTest.record("-1.23", "USD")); + PayPalClient transport = client(); + assertEquals(1, transport.page(1).path("page").intValue()); + assertEquals(2, transport.page(2).path("page").intValue()); + assertEquals(4, requests.size()); + String basic = + Base64.getEncoder() + .encodeToString("mock-client:mock-secret".getBytes(StandardCharsets.UTF_8)); + assertTrue( + requests.get(0) + .startsWith( + "POST /v1/oauth2/token Basic " + + basic + + " grant_type=client_credentials")); + assertTrue(requests.get(1).contains("balance_affecting_records_only=N")); + assertTrue(requests.get(1).contains("fields=all")); + assertTrue(requests.get(1).contains("page_size=2")); + assertTrue(requests.get(1).contains("Bearer first-token")); + assertTrue(requests.get(3).contains("Bearer next-token")); + assertTrue(requests.get(3).endsWith("true")); + } + + @Test + void unauthorizedRefreshIsBoundedAndSanitized() throws Exception { + token(3600, "first-token"); + replies.add(new Reply(401, "secret-error-body")); + token(3600, "second-token"); + replies.add(new Reply(401, "secret-error-body")); + Exception error = assertThrows(Exception.class, () -> client().page(1)); + assertTrue(error.getMessage().contains("401")); + assertFalse(error.toString().contains("secret-error-body")); + assertNull(error.getCause()); + assertEquals(4, requests.size()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 401, 403}) + void invalidOAuthCredentialsFailWithoutRetries(int status) { + replies.add( + new Reply( + status, + "{\"error\":\"invalid_client\",\"error_description\":\"secret-body\"}")); + Exception error = assertThrows(Exception.class, () -> client().page(1)); + assertFalse(error.toString().contains("secret-body")); + assertNull(error.getCause()); + assertEquals(1, requests.size()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "{\"access_token\":\"secret-value\",\"expires_in\":-1,\"token_type\":\"Bearer\"}", + "{\"access_token\":\"secret-value\",\"expires_in\":1.5,\"token_type\":\"Bearer\"}", + "{\"access_token\":\"secret-value\",\"expires_in\":3600,\"token_type\":\"Basic\"}", + "{\"access_token\":\"secret-value\",\"expires_in\":3600}" + }) + void rejectsMalformedTokensWithoutCauses(String body) { + replies.add(new Reply(200, body)); + Exception error = assertThrows(Exception.class, () -> client().page(1)); + assertFalse(error.toString().contains("secret-value")); + assertNull(error.getCause()); + } + + @Test + void retriesOAuthAndReportTransientResponses() throws Exception { + replies.add(new Reply(503, "unavailable")); + token(3600, "token"); + replies.add(new Reply(429, "rate-limited")); + page(1, 0, ""); + assertEquals(0, client().page(1).path("total_items").intValue()); + assertEquals(4, requests.size()); + } + + @Test + void rejectsLongRetryAfterAndRedirect() { + token(3600, "token"); + Reply busy = new Reply(429, "secret-body"); + busy.retryAfter = "61"; + replies.add(busy); + assertThrows(Exception.class, () -> client().page(1)); + assertEquals(2, requests.size()); + } + + @Test + void neverFollowsRedirectWithCredentials() { + replies.add(new Reply(302, "{}")); + assertThrows(Exception.class, () -> client().page(1)); + assertEquals(1, requests.size()); + assertFalse(requests.get(0).contains("credential-theft")); + } + + @Test + void failsOnResultsetTooLargeAndPermissionErrors() throws Exception { + token(3600, "token"); + replies.add( + new Reply(400, "{\"name\":\"RESULTSET_TOO_LARGE\",\"message\":\"secret-body\"}")); + Exception error = assertThrows(Exception.class, () -> client().page(1)); + assertTrue(error.getMessage().contains("narrow")); + assertFalse(error.toString().contains("secret-body")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void boundsPlainAndGzipResponses(boolean gzip) throws Exception { + options.put("max_response_bytes", 1024); + Reply huge = new Reply(200, String.join("", Collections.nCopies(2048, "x"))); + huge.chunked = true; + if (gzip) { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream output = new GZIPOutputStream(compressed)) { + output.write(huge.body); + } + huge.body = compressed.toByteArray(); + huge.gzip = true; + } + replies.add(huge); + assertThrows(Exception.class, () -> client().page(1)); + assertEquals(1, requests.size()); + } + + @Test + void deadlineAbortsStalledBodyAndRetryBudgetTerminates() { + options.put("request_timeout_ms", 150); + options.put("max_retries", 0); + Reply stalled = new Reply(200, "{}"); + stalled.block = true; + stalled.chunked = true; + replies.add(stalled); + long before = System.nanoTime(); + assertThrows(Exception.class, () -> client().page(1)); + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - before) < 3000); + } + + @Test + void closeAbortsActiveBody() throws Exception { + Reply stalled = new Reply(200, "{}"); + stalled.block = true; + stalled.chunked = true; + replies.add(stalled); + PayPalClient transport = client(); + Future<?> result = + executor.submit(() -> assertThrows(Exception.class, () -> transport.page(1))); + assertTrue(arrived.await(3, TimeUnit.SECONDS)); + transport.close(); + result.get(3, TimeUnit.SECONDS); + } + + @Test + void closeWakesRetryWait() throws Exception { + options.put("retry_delay_ms", 60000); + replies.add(new Reply(503, "{}")); + PayPalClient transport = client(); + CountDownLatch complete = new CountDownLatch(1); + Thread worker = + new Thread( + () -> { + try { + assertThrows(Exception.class, () -> transport.page(1)); + } finally { + complete.countDown(); + } + }); + worker.start(); + assertTrue(arrived.await(3, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while (worker.getState() != Thread.State.TIMED_WAITING && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertEquals(Thread.State.TIMED_WAITING, worker.getState()); + transport.close(); + assertTrue(complete.await(3, TimeUnit.SECONDS)); + worker.join(); + assertEquals(1, requests.size()); + } + + @Test + void readerPreservesRecordsAndSignalsCompletionOnlyAfterValidation() throws Exception { + token(3600, "token"); + page( + 1, + 3, + PayPalResponseTest.record("1", "USD") + + "," + + PayPalResponseTest.record("2", "USD")); + page(2, 3, "{\"transaction_info\":{}}"); + SourceReader.Context context = mock(SourceReader.Context.class); + Collector<SeaTunnelRow> collector = mock(Collector.class); + when(collector.getCheckpointLock()).thenReturn(new Object()); + try (PayPalSourceReader reader = + new PayPalSourceReader( + new PayPalConfig(ReadonlyConfig.fromMap(options)), + new SingleSplitReaderContext(context))) { + reader.open(); + reader.pollNext(collector); + reader.pollNext(collector); + verify(collector, times(3)).collect(any(SeaTunnelRow.class)); + verify(context).signalNoMoreElement(); + } + } + + @Test + void changedTotalsFailWithoutCompletionSignal() throws Exception { + token(3600, "token"); + page( + 1, + 3, + PayPalResponseTest.record("1", "USD") + + "," + + PayPalResponseTest.record("2", "USD")); + page( + 2, + 4, + PayPalResponseTest.record("3", "USD") + + "," + + PayPalResponseTest.record("4", "USD")); + SourceReader.Context context = mock(SourceReader.Context.class); + Collector<SeaTunnelRow> collector = mock(Collector.class); + try (PayPalSourceReader reader = + new PayPalSourceReader( + new PayPalConfig(ReadonlyConfig.fromMap(options)), + new SingleSplitReaderContext(context))) { + reader.open(); + assertThrows(Exception.class, () -> reader.internalPollNext(collector)); + verify(context, never()).signalNoMoreElement(); + verify(collector, times(2)).collect(any(SeaTunnelRow.class)); + } + } + + private static class Reply { + private final int status; + private byte[] body; + private int delay; + private boolean gzip; + private boolean chunked; + private boolean block; + private boolean bodyless; + private String retryAfter; + + private Reply(int status, String body) { + this.status = status; + this.body = body.getBytes(StandardCharsets.UTF_8); + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalResponseTest.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalResponseTest.java new file mode 100644 index 0000000000..c58faf1aa7 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/PayPalResponseTest.java @@ -0,0 +1,402 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.JsonNode; +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode; + +import org.apache.seatunnel.api.common.JobContext; +import org.apache.seatunnel.api.common.multitable.MultiTableFailureHelper; +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.table.factory.Factory; +import org.apache.seatunnel.common.constants.JobMode; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PayPalResponseTest { + @Test + void permitsExactlyTenThousandWithConsistentFinalPage() { + PayPalResponse response = + new PayPalResponse( + json(page(5000, 10000, record("1", "USD") + "," + record("2", "USD"))), + config(), + 5000); + assertEquals(10000, response.totalItems); + assertEquals(2, response.rows.size()); + } + + @Test + void acceptsNativeMetadataAndNestedDagOptions() { + Map<String, Object> values = options(); + values.put("metadata_datasource_id", "data-source"); + values.put("dag-parsing.mode", "SINGLENESS"); + assertNotNull(new PayPalConfig(ReadonlyConfig.fromMap(values))); + values.remove("dag-parsing.mode"); + values.put("dag-parsing", java.util.Collections.singletonMap("mode", "SINGLENESS")); + assertNotNull(new PayPalConfig(ReadonlyConfig.fromMap(values))); + } + + @Test + void acceptsEngineInjectedFailurePolicyWithoutMultiTableCapability() { + ReadonlyConfig injected = + MultiTableFailureHelper.withMultiTableFailurePolicy( + ReadonlyConfig.fromMap(options()), + ReadonlyConfig.fromMap(java.util.Collections.emptyMap())); + assertNotNull(new PayPalSource(injected)); + } + + @Test + void invalidOptionTypesNeverExposeValuesOrCauses() { + for (String key : + new String[] { + "page_size", + "max_retries", + "request_timeout_ms", + "retry_delay_ms", + "max_response_bytes", + "parallelism", + "mock_mode" + }) { + Map<String, Object> values = options(); + values.put(key, "DO-NOT-LOG-VALUE"); + RuntimeException error = + assertThrows( + RuntimeException.class, + () -> new PayPalSource(ReadonlyConfig.fromMap(values))); + assertFalse(error.toString().contains("DO-NOT-LOG-VALUE")); + assertNull(error.getCause()); + } + } + + @Test + void rejectsUnsupportedOptionsAndExecutionModes() { + for (String key : + new String[] { + "schema", + "fields", + "access_token", + "balance_affecting_records_only", + "unknown_option" + }) { + Map<String, Object> options = options(); + options.put(key, "value-not-to-log"); + assertThrows( + RuntimeException.class, + () -> new PayPalSource(ReadonlyConfig.fromMap(options))); + } + Map<String, Object> options = options(); + options.put("parallelism", 2); + assertThrows( + RuntimeException.class, () -> new PayPalSource(ReadonlyConfig.fromMap(options))); + PayPalSource source = new PayPalSource(ReadonlyConfig.fromMap(options())); + assertThrows( + IllegalArgumentException.class, + () -> source.setJobContext(new JobContext().setJobMode(JobMode.STREAMING))); + source.setJobContext(new JobContext().setJobMode(JobMode.BATCH)); + } + + static Map<String, Object> options() { + Map<String, Object> options = new HashMap<>(); + options.put("client_id", "mock-client"); + options.put("client_secret", "mock-secret"); + options.put("start_date", "2026-01-01T00:00:00Z"); + options.put("end_date", "2026-01-02T00:00:00Z"); + options.put("page_size", 2); + return options; + } + + static PayPalConfig config() { + return new PayPalConfig(ReadonlyConfig.fromMap(options())); + } + + static String record(String amount, String currency) { + return "{\"transaction_info\":{\"transaction_id\":\"SAME-ID\",\"transaction_amount\":{\"value\":\"" + + amount + + "\",\"currency_code\":\"" + + currency + + "\"}}}"; + } + + static String page(int number, int total, String records) { + return "{\"account_number\":\"ACCOUNT\",\"start_date\":\"2026-01-01T00:00:00Z\",\"end_date\":\"2026-01-02T00:00:00Z\",\"page\":" + + number + + ",\"total_items\":" + + total + + ",\"total_pages\":" + + ((total + 1) / 2) + + ",\"transaction_details\":[" + + records + + "]}"; + } + + static JsonNode json(String value) { + return PayPalResponse.parse(value.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void preservesDuplicateIdsAndExactCurrencyAmounts() { + PayPalResponse response = + new PayPalResponse( + json(page(1, 2, record("123", "JPY") + "," + record("-0.123", "TND"))), + config(), + 1); + assertEquals(2, response.rows.size()); + assertEquals(response.rows.get(0).getField(1), response.rows.get(1).getField(1)); + assertEquals(new BigDecimal("123.000000000"), response.rows.get(0).getField(6)); + assertEquals(new BigDecimal("-0.123000000"), response.rows.get(1).getField(6)); + assertNull(response.rows.get(0).getField(8)); + } + + @Test + void optionalFieldsRemainNullAndRawRecordIsPreserved() { + String record = + "{\"transaction_info\":{\"transaction_id\":null,\"fee_amount\":null,\"paypal_reference_id\":null},\"payer_info\":{\"unknown\":\"kept\"}}"; + PayPalResponse response = new PayPalResponse(json(page(1, 1, record)), config(), 1); + for (int index = 1; index < 10; index++) { + assertNull(response.rows.get(0).getField(index)); + } + assertEquals(json(record), json((String) response.rows.get(0).getField(10))); + } + + @Test + void acceptsEmptyResultsAndEquivalentOffsets() { + String content = + page(1, 0, "").replace("2026-01-01T00:00:00Z", "2026-01-01T01:00:00+01:00"); + assertTrue(new PayPalResponse(json(content), config(), 1).rows.isEmpty()); + assertTrue( + new PayPalResponse( + json(content.replace("\"total_pages\":0", "\"total_pages\":1")), + config(), + 1) + .rows.isEmpty()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "page", + "total_items", + "total_pages", + "start_date", + "end_date", + "account_number", + "transaction_details" + }) + void rejectsMissingEnvelopeFields(String field) { + ObjectNode root = (ObjectNode) json(page(1, 1, record("1", "USD"))); + root.remove(field); + assertThrows(RuntimeException.class, () -> new PayPalResponse(root, config(), 1)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "\"transaction_info\":null", + "\"transaction_info\":[]", + "\"transaction_info\":{\"transaction_amount\":{}}", + "\"transaction_info\":{\"transaction_status\":123}", + "\"transaction_info\":{\"fee_amount\":{\"value\":1,\"currency_code\":\"USD\"}}", + "\"transaction_info\":{\"transaction_initiation_date\":\"secret-malformed-date\"}" + }) + void rejectsMalformedRecordsWithoutLeakingValues(String content) { + RuntimeException error = + assertThrows( + RuntimeException.class, + () -> + new PayPalResponse( + json(page(1, 1, "{" + content + "}")), config(), 1)); + assertFalse(error.toString().contains("secret-malformed-date")); + assertNull(error.getCause()); + } + + @ParameterizedTest + @ValueSource( + strings = {"1e3", "NaN", "0.0000000001", "123456789012345678901234567890", "1.0secret"}) + void refusesLossyOrInvalidMoney(String amount) { + assertThrows( + RuntimeException.class, + () -> new PayPalResponse(json(page(1, 1, record(amount, "USD"))), config(), 1)); + } + + @Test + void rejectsShortCoverageAndTruncationAndCap() { + assertThrows( + RuntimeException.class, + () -> + new PayPalResponse( + json( + page(1, 0, "") + .replace( + "2026-01-02T00:00:00Z", + "2026-01-01T23:59:59Z")), + config(), + 1)); + assertThrows( + RuntimeException.class, + () -> new PayPalResponse(json(page(1, 2, record("1", "USD"))), config(), 1)); + assertThrows( + RuntimeException.class, + () -> new PayPalResponse(json(page(1, 10000, "")), config(), 1)); + assertThrows( + RuntimeException.class, + () -> + new PayPalResponse( + json( + page(1, 0, "") + .replace( + "\"total_items\":0", + "\"total_items\":0.0")), + config(), + 1)); + } + + @Test + void detectsApiErrorsAndMalformedJson() { + for (String error : + new String[] { + "{\"name\":\"RESULTSET_TOO_LARGE\",\"message\":\"secret\"}", + "{\"details\":[{\"issue\":\"RESULTSET_TOO_LARGE\"}]}" + }) { + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> PayPalResponse.rejectError(json(error))); + assertTrue(exception.getMessage().contains("narrow")); + assertFalse(exception.toString().contains("secret")); + } + for (String malformed : + new String[] {"{\"secret\":", "{}", "{} {}", "{\"a\":1,\"a\":2}", "[]", "null"}) { + assertThrows( + RuntimeException.class, () -> new PayPalResponse(json(malformed), config(), 1)); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "2026-01-01", + "today", + "2026-02-30T00:00:00Z", + "2026-01-01T00:00:00", + "2026-01-01T00:00Z" + }) + void requiresAbsoluteDatesWithSeconds(String value) { + assertThrows(IllegalArgumentException.class, () -> PayPalConfig.date(value)); + } + + @Test + void validatesConfigAndMockCredentialsBeforeHttp() { + for (String key : + new String[] { + "page_size", + "max_retries", + "retry_delay_ms", + "request_timeout_ms", + "max_response_bytes" + }) { + Map<String, Object> values = options(); + values.put(key, -1); + assertThrows( + IllegalArgumentException.class, + () -> new PayPalConfig(ReadonlyConfig.fromMap(values))); + } + for (String endpoint : + new String[] { + "http://localhost:8080", + "https://api-m.paypal.com/", + "https://[email protected]", + "https://evil.example", + "https://api-m.paypal.com?secret=yes" + }) { + Map<String, Object> values = options(); + values.put("api_base_url", endpoint); + assertThrows( + IllegalArgumentException.class, + () -> new PayPalConfig(ReadonlyConfig.fromMap(values))); + } + Map<String, Object> values = options(); + values.put("mock_mode", true); + values.put("api_base_url", "http://localhost:8080"); + assertNotNull(new PayPalConfig(ReadonlyConfig.fromMap(values))); + values.put("client_secret", "real-secret"); + assertThrows( + IllegalArgumentException.class, + () -> new PayPalConfig(ReadonlyConfig.fromMap(values))); + values.put("client_secret", "mock-secret"); + values.put("client_id", "real-id"); + assertThrows( + IllegalArgumentException.class, + () -> new PayPalConfig(ReadonlyConfig.fromMap(values))); + } + + @Test + void validatesIntervalAndHasNoClockDependentAgeRestriction() { + Map<String, Object> values = options(); + values.put("end_date", "2026-02-02T00:00:00Z"); + assertThrows( + IllegalArgumentException.class, + () -> new PayPalConfig(ReadonlyConfig.fromMap(values))); + values.put("end_date", values.get("start_date")); + assertThrows( + IllegalArgumentException.class, + () -> new PayPalConfig(ReadonlyConfig.fromMap(values))); + values.put("start_date", "2099-01-01T00:00:00Z"); + values.put("end_date", "2099-02-01T00:00:00Z"); + assertNotNull(new PayPalConfig(ReadonlyConfig.fromMap(values))); + } + + @Test + void factorySpiAndSourceSerialization() throws Exception { + boolean found = false; + for (Factory factory : ServiceLoader.load(Factory.class)) { + if (factory.factoryIdentifier().equals("PayPal")) { + found = true; + assertTrue(factory instanceof PayPalSourceFactory); + } + } + assertTrue(found); + PayPalSource source = new PayPalSource(ReadonlyConfig.fromMap(options())); + assertEquals(Boundedness.BOUNDED, source.getBoundedness()); + assertEquals( + 11, + source.getProducedCatalogTables().get(0).getSeaTunnelRowType().getTotalFields()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { + output.writeObject(source); + } + assertTrue(bytes.size() > 0); + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/TestPayPalPackagingIT.java b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/TestPayPalPackagingIT.java new file mode 100644 index 0000000000..e26a1379f1 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-paypal/src/test/java/org/apache/seatunnel/connectors/seatunnel/paypal/source/TestPayPalPackagingIT.java @@ -0,0 +1,141 @@ +/* + * 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.paypal.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.factory.Factory; + +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpServer; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.jar.JarFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TestPayPalPackagingIT { + @Test + void packagedFactoryAndOAuthTransportLoadWithoutConnectorParentFallback() throws Exception { + File[] files = + new File("target") + .listFiles( + (dir, name) -> + name.startsWith("connector-http-paypal-") + && name.endsWith(".jar") + && !name.contains("sources") + && !name.contains("tests")); + assertNotNull(files); + assertEquals(1, files.length); + try (JarFile jar = new JarFile(files[0])) { + assertNotNull( + jar.getJarEntry( + "META-INF/services/org.apache.seatunnel.api.table.factory.Factory")); + assertNotNull(jar.getJarEntry("org/apache/http/impl/client/HttpClients.class")); + } + AtomicInteger requests = new AtomicInteger(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", + exchange -> { + requests.incrementAndGet(); + String response; + if (exchange.getRequestURI().getPath().equals("/v1/oauth2/token")) { + assertTrue( + exchange.getRequestHeaders() + .getFirst("Authorization") + .startsWith("Basic ")); + response = + "{\"access_token\":\"packaged-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}"; + } else { + assertEquals( + "Bearer packaged-token", + exchange.getRequestHeaders().getFirst("Authorization")); + response = PayPalResponseTest.page(1, 0, ""); + } + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + try (URLClassLoader loader = + new URLClassLoader( + new URL[] {files[0].toURI().toURL()}, getClass().getClassLoader()) { + @Override + protected synchronized Class<?> loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (name.startsWith("org.apache.seatunnel.connectors.seatunnel.paypal.") + || name.startsWith("org.apache.http.")) { + Class<?> result = findLoadedClass(name); + if (result == null) { + result = findClass(name); + } + if (resolve) { + resolveClass(result); + } + return result; + } + return super.loadClass(name, resolve); + } + }) { + String prefix = "org.apache.seatunnel.connectors.seatunnel.paypal.source."; + Factory factory = + (Factory) + loader.loadClass(prefix + "PayPalSourceFactory") + .getConstructor() + .newInstance(); + assertEquals("PayPal", factory.factoryIdentifier()); + assertEquals(loader, factory.getClass().getClassLoader()); + Map<String, Object> options = PayPalResponseTest.options(); + options.put("mock_mode", true); + options.put("api_base_url", "http://127.0.0.1:" + server.getAddress().getPort()); + ReadonlyConfig readonly = ReadonlyConfig.fromMap(options); + assertNotNull( + loader.loadClass(prefix + "PayPalSource") + .getConstructor(ReadonlyConfig.class) + .newInstance(readonly)); + Class<?> configClass = loader.loadClass(prefix + "PayPalConfig"); + Constructor<?> configConstructor = + configClass.getDeclaredConstructor(ReadonlyConfig.class); + configConstructor.setAccessible(true); + Object config = configConstructor.newInstance(readonly); + Class<?> clientClass = loader.loadClass(prefix + "PayPalClient"); + Constructor<?> clientConstructor = clientClass.getDeclaredConstructor(configClass); + clientConstructor.setAccessible(true); + try (AutoCloseable client = (AutoCloseable) clientConstructor.newInstance(config)) { + Method page = clientClass.getDeclaredMethod("page", int.class); + page.setAccessible(true); + assertNotNull(page.invoke(client, 1)); + assertEquals(2, requests.get()); + } + } finally { + server.stop(0); + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/pom.xml b/seatunnel-connectors-v2/connector-http/pom.xml index 05f73a000c..4d33447da9 100644 --- a/seatunnel-connectors-v2/connector-http/pom.xml +++ b/seatunnel-connectors-v2/connector-http/pom.xml @@ -47,6 +47,7 @@ <module>connector-http-shopify</module> <module>connector-http-zendesk</module> <module>connector-http-stripe</module> + <module>connector-http-paypal</module> <module>connector-http-linear</module> <module>connector-mem0</module> </modules> diff --git a/seatunnel-dist/pom.xml b/seatunnel-dist/pom.xml index 13787f09cc..089cf82cc9 100644 --- a/seatunnel-dist/pom.xml +++ b/seatunnel-dist/pom.xml @@ -326,6 +326,12 @@ <version>${project.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-paypal</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>org.apache.seatunnel</groupId> <artifactId>connector-http-onesignal</artifactId> diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/pom.xml b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/pom.xml new file mode 100644 index 0000000000..99d1b7fc4b --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/pom.xml @@ -0,0 +1,54 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.apache.seatunnel</groupId> + <artifactId>seatunnel-connector-v2-e2e</artifactId> + <version>${revision}</version> + </parent> + <artifactId>connector-paypal-e2e</artifactId> + <name>SeaTunnel : E2E : Connector V2 : PayPal</name> + <dependencies> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>seatunnel-core-starter</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-paypal</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-assert</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mock-server</groupId> + <artifactId>mockserver-netty-no-dependencies</artifactId> + <version>5.14.0</version> + <scope>test</scope> + </dependency> + </dependencies> +</project> diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java new file mode 100644 index 0000000000..95b27bb4ce --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalIT.java @@ -0,0 +1,155 @@ +/* + * 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.paypal; + +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.matchers.Times; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; +import org.mockserver.verify.VerificationTimes; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +public class PayPalIT extends TestSuiteBase implements TestResource { + private GenericContainer<?> fixture; + private MockServerClient client; + + @BeforeAll + @Override + public void startUp() { + fixture = + new GenericContainer<>(DockerImageName.parse("mockserver/mockserver:5.14.0")) + .withNetwork(NETWORK) + .withNetworkAliases("paypal-fixture") + .withExposedPorts(1080) + .withEnv("MOCKSERVER_LOG_LEVEL", "WARN") + .waitingFor(Wait.forHttp("/").forStatusCode(404)); + fixture.start(); + client = new MockServerClient(fixture.getHost(), fixture.getMappedPort(1080)); + } + + @AfterAll + @Override + public void tearDown() { + try { + if (client != null) { + client.close(); + } + } finally { + try { + if (fixture != null) { + fixture.stop(); + } + } finally { + NETWORK.close(); + } + } + } + + @TestTemplate + public void readsAllRecordsThroughOAuthExpiryRefresh(TestContainer container) throws Exception { + client.reset(); + HttpRequest oauth = + HttpRequest.request() + .withMethod("POST") + .withPath("/v1/oauth2/token") + .withHeader( + "Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString( + "mock-client:mock-secret" + .getBytes(StandardCharsets.UTF_8))) + .withBody("grant_type=client_credentials"); + client.when(oauth, Times.exactly(1)) + .respond( + HttpResponse.response() + .withHeader("Content-Type", "application/json") + .withBody( + "{\"access_token\":\"first-token\",\"token_type\":\"Bearer\",\"expires_in\":1}")); + client.when(oauth) + .respond( + HttpResponse.response() + .withHeader("Content-Type", "application/json") + .withBody( + "{\"access_token\":\"next-token\",\"token_type\":\"Bearer\",\"expires_in\":3600}")); + HttpRequest first = request(1, "first-token"); + HttpRequest last = request(2, "next-token"); + client.when(first) + .respond( + HttpResponse.response() + .withHeader("Content-Type", "application/json") + .withDelay(TimeUnit.MILLISECONDS, 1500) + .withBody(report(1, row("T0006") + "," + row("T1107")))); + client.when(last) + .respond( + HttpResponse.response() + .withHeader("Content-Type", "application/json") + .withBody(report(2, row("T0006")))); + Container.ExecResult result = container.executeJob("/paypal_to_assert.conf"); + Assertions.assertEquals(0, result.getExitCode(), result.getStderr()); + client.verify(oauth, VerificationTimes.exactly(2)); + client.verify(first, VerificationTimes.exactly(1)); + client.verify(last, VerificationTimes.exactly(1)); + client.verify( + HttpRequest.request().withPath("/v1/reporting/transactions"), + VerificationTimes.exactly(2)); + } + + private HttpRequest request(int page, String token) { + return HttpRequest.request() + .withMethod("GET") + .withPath("/v1/reporting/transactions") + .withHeader("Authorization", "Bearer " + token) + .withHeader("PayPal-Enforce-ISO8601-Format", "true") + .withQueryStringParameter("start_date", "2026-01-01T00:00:00Z") + .withQueryStringParameter("end_date", "2026-01-02T00:00:00Z") + .withQueryStringParameter("fields", "all") + .withQueryStringParameter("balance_affecting_records_only", "N") + .withQueryStringParameter("page_size", "2") + .withQueryStringParameter("page", Integer.toString(page)); + } + + private String report(int page, String records) { + return "{\"account_number\":\"ACCOUNT\",\"start_date\":\"2026-01-01T00:00:00Z\",\"end_date\":\"2026-01-02T00:00:00Z\",\"page\":" + + page + + ",\"total_pages\":2,\"total_items\":3,\"transaction_details\":[" + + records + + "]}"; + } + + private String row(String event) { + return "{\"transaction_info\":{\"transaction_id\":\"SAME-ID\",\"transaction_event_code\":\"" + + event + + "\",\"transaction_amount\":{\"value\":\"0.123\",\"currency_code\":\"TND\"},\"fee_amount\":null}}"; + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalMaskingTest.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalMaskingTest.java new file mode 100644 index 0000000000..bd750b2b52 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/java/org/apache/seatunnel/e2e/connector/paypal/PayPalMaskingTest.java @@ -0,0 +1,63 @@ +/* + * 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.paypal; + +import org.apache.seatunnel.shade.com.typesafe.config.Config; +import org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory; + +import org.apache.seatunnel.core.starter.utils.ConfigBuilder; +import org.apache.seatunnel.core.starter.utils.ConfigShadeUtils; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PayPalMaskingTest { + @Test + void nativeParsedConfigMasksClientSecretWithoutChangingSourceConfig() { + Config config = + ConfigFactory.parseString( + "source { PayPal { client_id = \"example-id\", client_secret = \"DO-NOT-LOG-PAYPAL\" } }"); + Object sources = config.root().unwrapped().get("source"); + assertTrue(sources instanceof List); + assertEquals(1, ((List<?>) sources).size()); + assertTrue(((List<?>) sources).get(0) instanceof Map); + assertTrue( + ConfigShadeUtils.getLogDesensitizationOptions(config).contains("client_secret"), + "Native mask rules missing from " + + ConfigShadeUtils.class + .getProtectionDomain() + .getCodeSource() + .getLocation()); + Map<String, Object> masked = + ConfigBuilder.configDesensitization( + config.root().unwrapped(), + ConfigShadeUtils.getLogDesensitizationOptions(config)); + assertFalse(masked.toString().contains("DO-NOT-LOG-PAYPAL")); + assertTrue( + config.getConfigList("source") + .get(0) + .getString("client_secret") + .equals("DO-NOT-LOG-PAYPAL")); + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/resources/paypal_to_assert.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/resources/paypal_to_assert.conf new file mode 100644 index 0000000000..fe040f0e2e --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-paypal-e2e/src/test/resources/paypal_to_assert.conf @@ -0,0 +1,52 @@ +# +# 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 { + PayPal { + plugin_output = "transactions" + client_id = "mock-client" + client_secret = "mock-secret" + mock_mode = true + api_base_url = "http://paypal-fixture:1080" + start_date = "2026-01-01T00:00:00Z" + end_date = "2026-01-02T00:00:00Z" + page_size = 2 + } +} +sink { + Assert { + plugin_input = "transactions" + rules { + row_rules = [ + {rule_type = MIN_ROW, rule_value = 3}, + {rule_type = MAX_ROW, rule_value = 3} + ] + field_rules = [ + {field_name = account_number, field_type = string, field_value = [{rule_type = NOT_NULL, equals_to = "ACCOUNT"}]}, + {field_name = transaction_id, field_type = string, field_value = [{rule_type = NOT_NULL, equals_to = "SAME-ID"}]}, + {field_name = transaction_amount, field_type = "decimal(38,9)", field_value = [{rule_type = NOT_NULL, equals_to = "0.123000000"}]}, + {field_name = transaction_currency, field_type = string, field_value = [{rule_type = NOT_NULL, equals_to = "TND"}]}, + {field_name = fee_amount, field_type = "decimal(38,9)", field_value = [{rule_type = NULL}]}, + {field_name = content, field_type = string, field_value = [{rule_type = NOT_NULL}]} + ] + } + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml b/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml index 2341cbe852..4dc825191b 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml @@ -51,6 +51,7 @@ <module>connector-cassandra-e2e</module> <module>connector-neo4j-e2e</module> <module>connector-http-e2e</module> + <module>connector-paypal-e2e</module> <module>connector-tiktok-ads-e2e</module> <module>connector-rabbitmq-e2e</module> <module>connector-kafka-e2e</module>
