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-12844-449225292833a7f8c5e0b0a9169a28b855245c86 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 75f9636c99a7957bf1819e8d0855a5fe92d3d3ee Author: Rishavbhattarai <[email protected]> AuthorDate: Sun Oct 18 13:27:45 2026 +0000 [Feature][Connector-V2] Add Typeform source connector (#12844) Signed-off-by: Rishavbhattarai <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> --- config/plugin_config | 1 + .../changelog/connector-http-typeform.md | 3 + docs/en/connectors/source/Typeform.md | 108 +++++++++ .../changelog/connector-http-typeform.md | 3 + docs/zh/connectors/source/Typeform.md | 80 +++++++ plugin-mapping.properties | 1 + .../connector-http/connector-http-typeform/pom.xml | 51 +++++ .../seatunnel/typeform/TypeformSource.java | 120 ++++++++++ .../seatunnel/typeform/TypeformSourceFactory.java | 70 ++++++ .../seatunnel/typeform/TypeformSourceReader.java | 126 ++++++++++ .../typeform/config/TypeformSourceOptions.java | 77 +++++++ .../typeform/config/TypeformSourceParameter.java | 115 ++++++++++ .../typeform/TypeformSourceFactoryTest.java | 157 +++++++++++++ .../seatunnel/typeform/TypeformSourceReadTest.java | 253 +++++++++++++++++++++ .../typeform/TypeformSourceReaderTest.java | 208 +++++++++++++++++ seatunnel-connectors-v2/connector-http/pom.xml | 1 + seatunnel-dist/pom.xml | 6 + .../connector-typeform-e2e/pom.xml | 53 +++++ .../e2e/connector/typeform/TypeformIT.java | 170 ++++++++++++++ .../test/resources/typeform_source_to_assert.conf | 78 +++++++ seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml | 1 + 21 files changed, 1682 insertions(+) diff --git a/config/plugin_config b/config/plugin_config index b4dfc44e62..07fd5a7a43 100644 --- a/config/plugin_config +++ b/config/plugin_config @@ -73,6 +73,7 @@ connector-http-base connector-mem0 connector-http-asana connector-http-intercom +connector-http-typeform connector-http-feishu connector-http-gitlab connector-http-github diff --git a/docs/en/connectors/changelog/connector-http-typeform.md b/docs/en/connectors/changelog/connector-http-typeform.md new file mode 100644 index 0000000000..fafa5140f5 --- /dev/null +++ b/docs/en/connectors/changelog/connector-http-typeform.md @@ -0,0 +1,3 @@ +| Change | Commit | Version | +| --- | --- | --- | +| [Feature][connector-http-typeform] Add Typeform source connector | https://github.com/apache/seatunnel | dev | \ No newline at end of file diff --git a/docs/en/connectors/source/Typeform.md b/docs/en/connectors/source/Typeform.md new file mode 100644 index 0000000000..8b596ab210 --- /dev/null +++ b/docs/en/connectors/source/Typeform.md @@ -0,0 +1,108 @@ +import ChangeLog from '../changelog/connector-http-typeform.md'; + +# Http-Typeform + +> Typeform source connector + +## Description + +Reads the responses of one form from the Typeform Responses API +(`GET /forms/{form_id}/responses`). Built on `connector-http-base`. Batch mode only. Version 1 +reads responses only; forms, workspaces, webhooks, OAuth and flattening `answers` into columns are +not supported. + +## Key Features + +- [x] Batch +- [ ] Stream +- [ ] Exactly-once +- [ ] Column projection +- [ ] Parallelism + +## Options + +| Name | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| api_key | String | Yes | - | Typeform personal access token with the `responses:read` scope (raw token, no `Bearer ` prefix). | +| form_id | String | Yes | - | ID of the form to read. Letters, digits, `_` and `-` only. | +| base_url | String | No | `https://api.typeform.com` | Accounts on Typeform's EU data center use `https://api.eu.typeform.com` or `https://api.typeform.eu`. | +| page_size | Int | No | `100` | Responses per page, 1 to 1000 (Typeform's maximum). | +| since | String | No | - | Only responses since this time, inclusive. Unix seconds or ISO 8601 UTC, for example `2026-01-01T00:00:00`. | +| until | String | No | - | Only responses until this time, inclusive. Same format as `since`. | +| response_type | String | No | - | Comma-separated, for example `partial,completed`. Typeform returns completed responses when this is not set. | +| retry | Int | No | `3` | Max retries on HTTP 429 and 5xx. | +| retry_backoff_multiplier_ms | Int | No | `100` | Backoff multiplier in milliseconds. | +| retry_backoff_max_ms | Int | No | `10000` | Maximum backoff time in milliseconds. | + +### api_key +Sent only in the `Authorization: Bearer <token>` header, never in query parameters. The connector +never logs headers or the config, and persists no state, so the token does not reach checkpoints. +Whether the engine masks `api_key` when it logs the job config is outside this connector. + +### since / until / response_type +Passed to Typeform as is. For completed responses Typeform filters `since` and `until` on +`submitted_at`; for other response types it uses a different timestamp (see Typeform's API +reference). + +### retry +Requests are spaced at least 500 ms apart, because Typeform allows 2 requests per second per +account. HTTP 429 and 5xx are retried with a backoff that doubles from +`retry_backoff_multiplier_ms` up to `retry_backoff_max_ms`. `Retry-After` is not honored. + +## Output columns + +| Column | Type | Notes | +| --- | --- | --- | +| response_id | STRING | Typeform response id | +| landing_id | STRING | | +| token | STRING | Used as the paging cursor | +| landed_at | STRING | ISO 8601 timestamp | +| submitted_at | STRING | ISO 8601 timestamp; null for partial responses | +| answers | STRING | The `answers` array as a JSON string, unchanged. NULL when the response has no answers (empty array or missing). | +| hidden | STRING | The `hidden` fields object as a JSON string, unchanged. `{}` when the form has no hidden fields; NULL when missing. | + +Answer shapes differ by question type, so `answers` is kept as raw JSON. Use a JSON transform or +the sink's JSON functions to extract the fields you need. + +## Example + +```hocon +env { + parallelism = 1 + job.mode = BATCH +} + +source { + Typeform { + api_key = "<PERSONAL_ACCESS_TOKEN>" + form_id = "<FORM_ID>" + since = "2026-01-01T00:00:00" + plugin_output = "typeform_responses" + } +} + +sink { + Console { + plugin_input = "typeform_responses" + } +} +``` + +## Notes + +Each run is a bounded snapshot of the form's responses, optionally narrowed by `since` and +`until`. Pages are read newest first: the `token` of the last response on a page is sent as the +`before` cursor, and an empty page ends the read. Responses submitted during the read arrive at the +newest end and do not shift the pages being read. Sort order is not configurable, because the +`before` cursor depends on Typeform's default order. + +No incremental state is stored. After a failure, a restarted job re-reads from the first page and +may emit rows that were already sent. Every row includes `response_id`, which a downstream sink can +use to deduplicate or upsert, but the connector provides no delivery guarantee. + +Typeform notes that responses from roughly the last 30 minutes may not be returned yet. + +The whole form is read in one uninterrupted pass, with a short pause between requests, so on a very +large form checkpoint triggering waits until the read finishes. + +<ChangeLog /> diff --git a/docs/zh/connectors/changelog/connector-http-typeform.md b/docs/zh/connectors/changelog/connector-http-typeform.md new file mode 100644 index 0000000000..fafa5140f5 --- /dev/null +++ b/docs/zh/connectors/changelog/connector-http-typeform.md @@ -0,0 +1,3 @@ +| Change | Commit | Version | +| --- | --- | --- | +| [Feature][connector-http-typeform] Add Typeform source connector | https://github.com/apache/seatunnel | dev | \ No newline at end of file diff --git a/docs/zh/connectors/source/Typeform.md b/docs/zh/connectors/source/Typeform.md new file mode 100644 index 0000000000..0d060b5d65 --- /dev/null +++ b/docs/zh/connectors/source/Typeform.md @@ -0,0 +1,80 @@ +import ChangeLog from '../changelog/connector-http-typeform.md'; + +# Http-Typeform + +> Typeform 源连接器 + +## 描述 + +通过 Typeform Responses API(`GET /forms/{form_id}/responses`)读取一个表单的回复。基于 +`connector-http-base` 构建,仅支持批处理模式。第一版只读取回复,不支持表单、工作区、Webhook、OAuth, +也不会把 `answers` 展开成多列。 + +## 主要特性 + +- [x] 批处理 +- [ ] 流处理 +- [ ] 精确一次 +- [ ] 列投影 +- [ ] 并行度 + +## 选项 + +| 名称 | 类型 | 必填 | 默认值 | 描述 | +| --- | --- | --- | --- | --- | +| api_key | String | 是 | - | 具有 `responses:read` 权限的 Typeform 个人访问令牌(原始令牌,不带 `Bearer ` 前缀)。 | +| form_id | String | 是 | - | 要读取的表单 ID,只能包含字母、数字、`_` 和 `-`。 | +| base_url | String | 否 | `https://api.typeform.com` | 使用 Typeform 欧盟数据中心的账号请使用 `https://api.eu.typeform.com` 或 `https://api.typeform.eu`。 | +| page_size | Int | 否 | `100` | 每页回复数量,范围 1 到 1000。 | +| since | String | 否 | - | 只读取该时间(含)之后的回复。Unix 秒或 ISO 8601 UTC,例如 `2026-01-01T00:00:00`。 | +| until | String | 否 | - | 只读取该时间(含)之前的回复,格式同 `since`。 | +| response_type | String | 否 | - | 逗号分隔,例如 `partial,completed`。未设置时 Typeform 只返回已完成的回复。 | +| retry | Int | 否 | `3` | HTTP 429 和 5xx 的最大重试次数。 | +| retry_backoff_multiplier_ms | Int | 否 | `100` | 退避倍数(毫秒)。 | +| retry_backoff_max_ms | Int | 否 | `10000` | 最大退避时间(毫秒)。 | + +令牌只放在 `Authorization: Bearer <token>` 请求头中,不会出现在查询参数、日志或作业状态里。 +Typeform 每个账号每秒允许 2 个请求,因此连接器会让请求间隔至少 500 毫秒。 + +## 输出列 + +`response_id`、`landing_id`、`token`、`landed_at`、`submitted_at`(均为 STRING),以及 +`answers`、`hidden`(原样输出的 JSON 字符串)。回复没有答案时(空数组或缺失),`answers` 为 NULL; +表单没有隐藏字段时 `hidden` 为 `{}`。 + +## 示例 + +```hocon +env { + parallelism = 1 + job.mode = BATCH +} + +source { + Typeform { + api_key = "<PERSONAL_ACCESS_TOKEN>" + form_id = "<FORM_ID>" + since = "2026-01-01T00:00:00" + plugin_output = "typeform_responses" + } +} + +sink { + Console { + plugin_input = "typeform_responses" + } +} +``` + +## 注意事项 + +每次运行都是表单回复的有界快照,可用 `since` 和 `until` 缩小范围。分页从最新的回复开始:把每页最后一条回复的 +`token` 作为 `before` 游标发送,遇到空页即结束。读取期间新提交的回复出现在最新的一端,不会影响正在读取的分页。 +排序方式不可配置,因为 `before` 游标依赖 Typeform 的默认排序。 + +连接器不保存增量状态。作业失败后重启会从第一页重新读取,可能重复发送已发送的行。每行都包含 `response_id`, +下游可据此去重或 upsert,但连接器不提供投递保证。Typeform 提示最近约 30 分钟内的回复可能尚未返回。 + +整个表单在一次不中断的读取中完成,请求之间有短暂间隔,因此表单很大时,触发 checkpoint 需要等到读取结束。 + +<ChangeLog /> diff --git a/plugin-mapping.properties b/plugin-mapping.properties index f47a5e41a0..5df09428b5 100644 --- a/plugin-mapping.properties +++ b/plugin-mapping.properties @@ -209,6 +209,7 @@ seatunnel.source.Splunk = connector-http-splunk seatunnel.source.WebSocket = connector-websocket seatunnel.source.Asana = connector-http-asana seatunnel.source.Intercom = connector-http-intercom +seatunnel.source.Typeform = connector-http-typeform # For custom transforms, make sure to use the seatunnel.transform.[PluginIdentifier]=[JarPerfix] naming convention. For example: # seatunnel.transform.Sql = seatunnel-transforms-v2 diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/pom.xml b/seatunnel-connectors-v2/connector-http/connector-http-typeform/pom.xml new file mode 100644 index 0000000000..2b36ba5339 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/pom.xml @@ -0,0 +1,51 @@ +<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http</artifactId> + <version>${revision}</version> + </parent> + + <artifactId>connector-http-typeform</artifactId> + <name>SeaTunnel : Connectors V2 : Http : Typeform</name> + + <dependencies> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-base</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>seatunnel-common</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>com.squareup.okhttp3</groupId> + <artifactId>mockwebserver</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + +</project> diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSource.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSource.java new file mode 100644 index 0000000000..bbc899efe9 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSource.java @@ -0,0 +1,120 @@ +/* + * 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.typeform; + +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.SeaTunnelRow; +import org.apache.seatunnel.common.constants.JobMode; +import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated; +import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader; +import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpPaginationType; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpSourceOptions; +import org.apache.seatunnel.connectors.seatunnel.http.config.JsonField; +import org.apache.seatunnel.connectors.seatunnel.http.config.PageInfo; +import org.apache.seatunnel.connectors.seatunnel.http.exception.HttpConnectorException; +import org.apache.seatunnel.connectors.seatunnel.http.source.HttpSource; +import org.apache.seatunnel.connectors.seatunnel.typeform.config.TypeformSourceParameter; +import org.apache.seatunnel.format.json.JsonDeserializationSchema; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Batch source that reads one form's responses from the Typeform Responses API. Pages are read + * newest first by passing the token of the last response on a page as the {@code before} cursor. + * {@code answers} and {@code hidden} are emitted as raw JSON strings. + */ +@Slf4j +public class TypeformSource extends HttpSource { + static final String[] COLUMNS = { + "response_id", "landing_id", "token", "landed_at", "submitted_at", "answers", "hidden" + }; + + private final TypeformSourceParameter typeformSourceParameter = new TypeformSourceParameter(); + + public TypeformSource(ReadonlyConfig pluginConfig) { + super(pluginConfig); + typeformSourceParameter.buildWithConfig(pluginConfig); + + TableSchema.Builder schema = TableSchema.builder(); + Map<String, String> fields = new LinkedHashMap<>(); + for (String column : COLUMNS) { + fields.put(column, "$.items[*]." + column); + schema.column(PhysicalColumn.of(column, BasicType.STRING_TYPE, 0, true, null, null)); + } + this.catalogTable = + CatalogTable.of( + TableIdentifier.of("Typeform", TablePath.DEFAULT), + schema.build(), + Collections.emptyMap(), + Collections.emptyList(), + null); + this.deserializationSchema = new JsonDeserializationSchema(catalogTable, false, false); + this.jsonField = JsonField.builder().fields(fields).build(); + + PageInfo info = new PageInfo(); + info.setPageType(HttpPaginationType.CURSOR.getCode()); + info.setPageCursorFieldName("before"); + // The token of the last response on the page; an empty page yields no value and ends the + // read. + info.setPageCursorResponseField("$.items[-1:].token"); + info.setPageIndex(HttpSourceOptions.START_PAGE_NUMBER.defaultValue()); + info.setBatchSize(HttpSourceOptions.BATCH_SIZE.defaultValue()); + info.setTotalPageSize(HttpSourceOptions.TOTAL_PAGE_SIZE.defaultValue()); + info.setUsePlaceholderReplacement(false); + this.pageInfo = info; + } + + @Override + public AbstractSingleSplitReader<SeaTunnelRow> createReader( + SingleSplitReaderContext readerContext) throws Exception { + return new TypeformSourceReader( + this.typeformSourceParameter, + readerContext, + this.deserializationSchema, + jsonField, + contentField, + pageInfo); + } + + @Override + public String getPluginName() { + return "Typeform"; + } + + @Override + public Boundedness getBoundedness() { + if (JobMode.STREAMING.equals(jobContext.getJobMode())) { + throw new HttpConnectorException( + CommonErrorCodeDeprecated.ILLEGAL_ARGUMENT, + "Typeform source only supports batch mode."); + } + return Boundedness.BOUNDED; + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceFactory.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceFactory.java new file mode 100644 index 0000000000..647471965a --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceFactory.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.typeform; + +import org.apache.seatunnel.api.configuration.util.OptionRule; +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.TableSourceFactoryContext; +import org.apache.seatunnel.connectors.seatunnel.http.source.HttpSourceFactory; +import org.apache.seatunnel.connectors.seatunnel.typeform.config.TypeformSourceOptions; + +import com.google.auto.service.AutoService; + +import java.io.Serializable; + +/** + * Factory that registers the Typeform source under the identifier "Typeform" and declares its + * options. + */ +@AutoService(Factory.class) +public class TypeformSourceFactory extends HttpSourceFactory { + @Override + public String factoryIdentifier() { + return "Typeform"; + } + + @Override + public <T, SplitT extends SourceSplit, StateT extends Serializable> + TableSource<T, SplitT, StateT> createSource(TableSourceFactoryContext context) { + return () -> (SeaTunnelSource<T, SplitT, StateT>) new TypeformSource(context.getOptions()); + } + + @Override + public OptionRule optionRule() { + return OptionRule.builder() + .required(TypeformSourceOptions.API_KEY, TypeformSourceOptions.FORM_ID) + .optional( + TypeformSourceOptions.BASE_URL, + TypeformSourceOptions.PAGE_SIZE, + TypeformSourceOptions.SINCE, + TypeformSourceOptions.UNTIL, + TypeformSourceOptions.RESPONSE_TYPE, + TypeformSourceOptions.RETRY, + TypeformSourceOptions.RETRY_BACKOFF_MULTIPLIER_MS, + TypeformSourceOptions.RETRY_BACKOFF_MAX_MS) + .build(); + } + + @Override + public Class<? extends SeaTunnelSource> getSourceClass() { + return TypeformSource.class; + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReader.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReader.java new file mode 100644 index 0000000000..80336c0eb6 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReader.java @@ -0,0 +1,126 @@ +/* + * 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.typeform; + +import org.apache.seatunnel.api.serialization.DeserializationSchema; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; +import org.apache.seatunnel.connectors.seatunnel.http.client.HttpResponse; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpParameter; +import org.apache.seatunnel.connectors.seatunnel.http.config.JsonField; +import org.apache.seatunnel.connectors.seatunnel.http.config.PageInfo; +import org.apache.seatunnel.connectors.seatunnel.http.exception.HttpConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.http.exception.HttpConnectorException; +import org.apache.seatunnel.connectors.seatunnel.http.source.HttpSourceReader; + +import lombok.extern.slf4j.Slf4j; + +/** + * Reader that re-implements executeRequest to keep requests at least 500 ms apart (Typeform allows + * two requests per second per account) and to retry HTTP 429 and 5xx with exponential backoff + * before handing the response to the base reader. + */ +@Slf4j +public class TypeformSourceReader extends HttpSourceReader { + + static final long DEFAULT_MIN_REQUEST_INTERVAL_MILLIS = 500; + + private long minRequestIntervalMillis = DEFAULT_MIN_REQUEST_INTERVAL_MILLIS; + private long lastRequestNanos = -1; + + public TypeformSourceReader( + HttpParameter httpParameter, + SingleSplitReaderContext readerContext, + DeserializationSchema<SeaTunnelRow> deserializationSchema, + JsonField jsonField, + String contentField, + PageInfo pageInfo) { + super( + httpParameter, + readerContext, + deserializationSchema, + jsonField, + contentField, + pageInfo); + } + + /** Lets tests shorten the pacing interval. */ + void setMinRequestIntervalMillis(long minRequestIntervalMillis) { + this.minRequestIntervalMillis = minRequestIntervalMillis; + } + + @Override + protected HttpResponse executeRequest() throws Exception { + int maxRetries = httpParameter.getRetry(); + long waitMillis = httpParameter.getRetryBackoffMultiplierMillis(); + long maxWaitMillis = httpParameter.getRetryBackoffMaxMillis(); + int attempt = 1; + while (true) { + waitForRateLimit(); + HttpResponse response = super.executeRequest(); + int code = response.getCode(); + boolean retryable = code == 429 || (code >= 500 && code < 600); + if (!retryable) { + return response; + } + if (attempt > maxRetries) { + throw new HttpConnectorException( + HttpConnectorErrorCode.REQUEST_FAILED, + String.format( + "Typeform request failed with HTTP %d after %d attempt(s) (retry=%d)", + code, attempt, maxRetries)); + } + log.warn( + "Typeform returned HTTP {}; retry {}/{} in {} ms", + code, + attempt, + maxRetries, + waitMillis); + + try { + Thread.sleep(waitMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + String.format( + "Interrupted while backing off after Typeform HTTP %d (attempt %d)", + code, attempt), + e); + } + waitMillis = Math.min(waitMillis * 2, maxWaitMillis); + attempt++; + } + } + + private void waitForRateLimit() { + if (lastRequestNanos >= 0) { + long elapsedMillis = (System.nanoTime() - lastRequestNanos) / 1_000_000L; + long remaining = minRequestIntervalMillis - elapsedMillis; + if (remaining > 0) { + try { + Thread.sleep(remaining); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException( + "Interrupted while pacing requests to the Typeform API", e); + } + } + } + lastRequestNanos = System.nanoTime(); + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/config/TypeformSourceOptions.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/config/TypeformSourceOptions.java new file mode 100644 index 0000000000..c170be85c0 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/config/TypeformSourceOptions.java @@ -0,0 +1,77 @@ +/* + * 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.typeform.config; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.Options; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpCommonOptions; + +/** Config options specific to the Typeform source, on top of the shared HTTP options. */ +public class TypeformSourceOptions extends HttpCommonOptions { + public static final Option<String> API_KEY = + Options.key("api_key") + .stringType() + .noDefaultValue() + .withDescription( + "Typeform personal access token with the responses:read scope (raw" + + " token, no Bearer prefix)"); + + public static final Option<String> FORM_ID = + Options.key("form_id") + .stringType() + .noDefaultValue() + .withDescription("ID of the form whose responses are read"); + + public static final Option<String> BASE_URL = + Options.key("base_url") + .stringType() + .defaultValue("https://api.typeform.com") + .withDescription( + "Typeform API base URL. Accounts on Typeform's EU data center use" + + " https://api.eu.typeform.com or https://api.typeform.eu."); + + public static final Option<Integer> PAGE_SIZE = + Options.key("page_size") + .intType() + .defaultValue(100) + .withDescription("Responses requested per page (1 to 1000)"); + + public static final Option<String> SINCE = + Options.key("since") + .stringType() + .noDefaultValue() + .withDescription( + "Only read responses since this time, inclusive. Unix seconds or ISO" + + " 8601 UTC, passed to Typeform as is."); + + public static final Option<String> UNTIL = + Options.key("until") + .stringType() + .noDefaultValue() + .withDescription( + "Only read responses until this time, inclusive. Unix seconds or ISO" + + " 8601 UTC, passed to Typeform as is."); + + public static final Option<String> RESPONSE_TYPE = + Options.key("response_type") + .stringType() + .noDefaultValue() + .withDescription( + "Comma-separated response types, for example partial,completed." + + " Typeform returns completed responses when this is not set."); +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/config/TypeformSourceParameter.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/config/TypeformSourceParameter.java new file mode 100644 index 0000000000..b9f3531bc1 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/main/java/org/apache/seatunnel/connectors/seatunnel/typeform/config/TypeformSourceParameter.java @@ -0,0 +1,115 @@ +/* + * 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.typeform.config; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpParameter; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpRequestMethod; + +import java.util.HashMap; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Builds the HTTP request parameters for the Typeform responses endpoint: the responses URL for one + * form, the Bearer Authorization header, the page size, the optional filters and the retry + * defaults. + */ +public class TypeformSourceParameter extends HttpParameter { + + private static final int MAX_PAGE_SIZE = 1000; + private static final Pattern FORM_ID_PATTERN = Pattern.compile("[A-Za-z0-9_-]+"); + + private static String requireNonBlank(String value, String optionKey) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException( + String.format("Typeform option '%s' must not be blank.", optionKey)); + } + return value.trim(); + } + + /** + * Builds the request for {@code GET {base_url}/forms/{form_id}/responses}. Validates that + * api_key, form_id and base_url are not blank, that form_id contains only letters, digits, '_' + * and '-', that page_size is within 1..1000 and that the retry backoff values are not negative. + * Passes since, until and response_type to Typeform only when they are set. Retry defaults to 3 + * only when the user did not set {@code retry}. Warns when the bearer token would be sent over + * a non-HTTPS base_url. + */ + public void buildWithConfig(ReadonlyConfig config) { + super.buildWithConfig(config); + String apiKey = requireNonBlank(config.get(TypeformSourceOptions.API_KEY), "api_key"); + String formId = requireNonBlank(config.get(TypeformSourceOptions.FORM_ID), "form_id"); + if (!FORM_ID_PATTERN.matcher(formId).matches()) { + throw new IllegalArgumentException( + "Typeform option 'form_id' may only contain letters, digits, '_' and '-'."); + } + int pageSize = config.get(TypeformSourceOptions.PAGE_SIZE); + if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) { + throw new IllegalArgumentException( + String.format( + "Typeform option 'page_size' must be between 1 and %d.", + MAX_PAGE_SIZE)); + } + + String base = requireNonBlank(config.get(TypeformSourceOptions.BASE_URL), "base_url"); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + setUrl(base + "/forms/" + formId + "/responses"); + setMethod(HttpRequestMethod.GET); + + if (headers == null) { + headers = new HashMap<>(); + } + headers.put("Authorization", "Bearer " + apiKey); + headers.put("Accept", "application/json"); + // The base class runs this check before the URL and Authorization header exist, so run it + // again now that both are set. + validateCredentialScheme(); + + params = new HashMap<>(); + params.put("page_size", String.valueOf(pageSize)); + putIfSet(config, TypeformSourceOptions.SINCE); + putIfSet(config, TypeformSourceOptions.UNTIL); + putIfSet(config, TypeformSourceOptions.RESPONSE_TYPE); + setKeepPageParamAsHttpParam(true); + setJsonFiledMissedReturnNull(true); + + if (!config.getOptional(TypeformSourceOptions.RETRY).isPresent()) { + setRetry(3); + } + setRetryBackoffMultiplierMillis( + config.get(TypeformSourceOptions.RETRY_BACKOFF_MULTIPLIER_MS)); + setRetryBackoffMaxMillis(config.get(TypeformSourceOptions.RETRY_BACKOFF_MAX_MS)); + + if (getRetryBackoffMultiplierMillis() < 0 || getRetryBackoffMaxMillis() < 0) { + throw new IllegalArgumentException( + "Typeform retry backoff values must not be negative."); + } + } + + /** Sends an optional filter to Typeform under its own option name, only when it is set. */ + private void putIfSet(ReadonlyConfig config, Option<String> option) { + Optional<String> value = config.getOptional(option); + if (value.isPresent() && !value.get().trim().isEmpty()) { + params.put(option.key(), value.get().trim()); + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceFactoryTest.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceFactoryTest.java new file mode 100644 index 0000000000..db59e05bbd --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceFactoryTest.java @@ -0,0 +1,157 @@ +/* + * 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.typeform; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.connectors.seatunnel.http.config.HttpRequestMethod; +import org.apache.seatunnel.connectors.seatunnel.typeform.config.TypeformSourceParameter; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +public class TypeformSourceFactoryTest { + + private static Map<String, Object> baseConfig() { + Map<String, Object> m = new HashMap<>(); + m.put("api_key", "tfp_test-typeform-token-123"); + m.put("form_id", "lT9Z2j"); + return m; + } + + private static TypeformSourceParameter build(Map<String, Object> m) { + TypeformSourceParameter parameter = new TypeformSourceParameter(); + parameter.buildWithConfig(ReadonlyConfig.fromMap(m)); + return parameter; + } + + private static void assertRejected(Map<String, Object> m, String optionName) { + Exception e = Assertions.assertThrows(IllegalArgumentException.class, () -> build(m)); + Assertions.assertTrue(e.getMessage().contains(optionName), e.getMessage()); + Assertions.assertFalse(e.getMessage().contains("tfp_test-typeform-token-123")); + } + + @Test + public void testFactoryIdentifier() { + Assertions.assertEquals("Typeform", new TypeformSourceFactory().factoryIdentifier()); + Assertions.assertEquals( + "Typeform", + new TypeformSource(ReadonlyConfig.fromMap(baseConfig())).getPluginName()); + } + + @Test + public void testParameterBuildingAndHeaders() { + TypeformSourceParameter parameter = build(baseConfig()); + + Assertions.assertEquals( + "Bearer tfp_test-typeform-token-123", parameter.getHeaders().get("Authorization")); + Assertions.assertEquals(HttpRequestMethod.GET, parameter.getMethod()); + Assertions.assertEquals( + "https://api.typeform.com/forms/lT9Z2j/responses", parameter.getUrl()); + Assertions.assertEquals("100", parameter.getParams().get("page_size")); + Assertions.assertEquals(1, parameter.getParams().size(), "no filters unless set"); + Assertions.assertFalse(parameter.getParams().containsValue("tfp_test-typeform-token-123")); + Assertions.assertEquals(3, parameter.getRetry()); + } + + @Test + public void testEuBaseUrlPageSizeAndFilters() { + Map<String, Object> m = baseConfig(); + m.put("base_url", "https://api.eu.typeform.com/"); + m.put("page_size", 1000); + m.put("since", "2026-01-01T00:00:00"); + m.put("until", "1767225600"); + m.put("response_type", "partial,completed"); + TypeformSourceParameter parameter = build(m); + + Assertions.assertEquals( + "https://api.eu.typeform.com/forms/lT9Z2j/responses", parameter.getUrl()); + Assertions.assertEquals("1000", parameter.getParams().get("page_size")); + Assertions.assertEquals("2026-01-01T00:00:00", parameter.getParams().get("since")); + Assertions.assertEquals("1767225600", parameter.getParams().get("until")); + Assertions.assertEquals("partial,completed", parameter.getParams().get("response_type")); + } + + @Test + public void testBlankFilterIsNotSent() { + Map<String, Object> m = baseConfig(); + m.put("since", " "); + Assertions.assertFalse(build(m).getParams().containsKey("since")); + } + + @Test + public void testBlankApiKeyRejected() { + Map<String, Object> m = baseConfig(); + m.put("api_key", " "); + assertRejected(m, "api_key"); + } + + @Test + public void testBlankFormIdRejected() { + Map<String, Object> m = baseConfig(); + m.put("form_id", ""); + assertRejected(m, "form_id"); + } + + @Test + public void testFormIdWithPathCharactersRejected() { + for (String bad : new String[] {"../me", "abc/def", "abc?x=1", "a b"}) { + Map<String, Object> m = baseConfig(); + m.put("form_id", bad); + assertRejected(m, "form_id"); + } + } + + @Test + public void testPageSizeOutOfRangeRejected() { + for (int bad : new int[] {0, 1001}) { + Map<String, Object> m = baseConfig(); + m.put("page_size", bad); + assertRejected(m, "page_size"); + } + } + + @Test + public void testNegativeBackoffRejected() { + Map<String, Object> m = baseConfig(); + m.put("retry_backoff_multiplier_ms", -1); + Assertions.assertThrows(IllegalArgumentException.class, () -> build(m)); + } + + @Test + public void testCustomBackoffHonoredWithoutExplicitRetry() { + Map<String, Object> m = baseConfig(); + m.put("retry_backoff_multiplier_ms", 2000); + m.put("retry_backoff_max_ms", 3000); + TypeformSourceParameter parameter = build(m); + + Assertions.assertEquals(3, parameter.getRetry()); + Assertions.assertEquals(2000, parameter.getRetryBackoffMultiplierMillis()); + Assertions.assertEquals(3000, parameter.getRetryBackoffMaxMillis()); + } + + @Test + public void testDefaultBackoffWhenNothingConfigured() { + TypeformSourceParameter parameter = build(baseConfig()); + + Assertions.assertEquals(100, parameter.getRetryBackoffMultiplierMillis()); + Assertions.assertEquals(10000, parameter.getRetryBackoffMaxMillis()); + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReadTest.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReadTest.java new file mode 100644 index 0000000000..25d1c9550e --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReadTest.java @@ -0,0 +1,253 @@ +/* + * 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.typeform; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.databind.ObjectMapper; + +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.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Reads Typeform-shaped pages through {@link TypeformSource} and the base HTTP reader, and checks + * the cursor, the filters and how {@code answers} and {@code hidden} are emitted. + */ +public class TypeformSourceReadTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final int ANSWERS = 5; + private static final int HIDDEN = 6; + + // Answer shapes from Typeform's Retrieve Responses reference, plus escaping, unicode, a + // decimal and an integer above 2^53. + private static final String R1_ANSWERS = + "[{\"field\":{\"id\":\"SMEUb7VJz92Q\",\"ref\":\"my_text\",\"type\":\"short_text\"},\"type\":\"text\",\"text\":\"Lian \\\"quoted\\\"\\nnew line, caf\\u00e9 \\u65e5\\u672c\"}," + + "{\"field\":{\"id\":\"RUqkXSeXBXSd\",\"ref\":\"my_yes_no\",\"type\":\"yes_no\"},\"type\":\"boolean\",\"boolean\":false}," + + "{\"field\":{\"id\":\"JwWggjAKtOkA\",\"ref\":\"my_email\",\"type\":\"email\"},\"type\":\"email\",\"email\":\"[email protected]\"}," + + "{\"field\":{\"id\":\"Q7M2XAwY04dW\",\"ref\":\"my_number\",\"type\":\"number\"},\"type\":\"number\",\"number\":9007199254740993}," + + "{\"field\":{\"id\":\"dec1\",\"ref\":\"my_decimal\",\"type\":\"number\"},\"type\":\"number\",\"number\":3.14}," + + "{\"field\":{\"id\":\"KoJxDM3c6x8h\",\"ref\":\"my_date\",\"type\":\"date\"},\"type\":\"date\",\"date\":\"2012-03-20T00:00:00Z\"}," + + "{\"field\":{\"id\":\"PNe8ZKBK8C2Q\",\"ref\":\"my_multi\",\"type\":\"multiple_choice\"},\"type\":\"choices\",\"choices\":{\"labels\":[\"New York\",\"Tokyo\"]}}," + + "{\"field\":{\"id\":\"ceIXxpbP3t2q\",\"ref\":\"my_choice\",\"type\":\"multiple_choice\"},\"type\":\"choice\",\"choice\":{\"label\":\"Other\",\"other\":\"Kathmandu\"}}," + + "{\"field\":{\"id\":\"pn48RmPazVdM\",\"ref\":\"my_file\",\"type\":\"file_upload\"},\"type\":\"file_url\",\"file_url\":\"https://api.typeform.com/forms/lT9Z2j/responses/r1/fields/pn48/files/a.pdf\"}," + + "{\"field\":{\"id\":\"pay1\",\"ref\":\"my_payment\",\"type\":\"payment\"},\"type\":\"payment\",\"payment\":{\"amount\":\"4.20\",\"last4\":\"4242\",\"name\":\"Lian\",\"success\":true}}]"; + private static final String R1_HIDDEN = + "{\"utm_source\":\"newsletter\",\"z_last\":\"z\",\"a_first\":\"a\",\"empty\":\"\"}"; + private static final String R5_ANSWERS = + "[{\"field\":{\"id\":\"m1\",\"type\":\"matrix\"},\"type\":\"future_type\",\"future_type\":{\"rows\":[[1,2],[3,null]],\"meta\":{}}}]"; + private static final String R5_HIDDEN = "{\"nested_like\":\"{\\\"not\\\":\\\"parsed\\\"}\"}"; + + /** A response item; {@code token} differs from {@code response_id} on purpose. */ + private static String item(String id, String answersJson, String hiddenJson) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"landing_id\":\"l-") + .append(id) + .append("\",\"token\":\"tok-") + .append(id) + .append("\",\"response_id\":\"") + .append(id) + .append("\",\"landed_at\":\"2026-09-14T22:33:59Z\",") + .append("\"submitted_at\":\"2026-09-14T22:38:22Z\""); + if (answersJson != null) { + sb.append(",\"answers\":").append(answersJson); + } + if (hiddenJson != null) { + sb.append(",\"hidden\":").append(hiddenJson); + } + return sb.append("}").toString(); + } + + private static String page(String... items) { + return "{\"total_items\":5,\"page_count\":3,\"items\":[" + String.join(",", items) + "]}"; + } + + private static List<SeaTunnelRow> read(MockWebServer server, Map<String, Object> extra) + throws Exception { + Map<String, Object> m = new HashMap<>(); + m.put("api_key", "tfp_test-token"); + m.put("form_id", "lT9Z2j"); + m.put("page_size", 2); + m.put("base_url", server.url("/").toString()); + m.putAll(extra); + TypeformSource source = new TypeformSource(ReadonlyConfig.fromMap(m)); + + SourceReader.Context ctx = Mockito.mock(SourceReader.Context.class); + TypeformSourceReader reader = + (TypeformSourceReader) source.createReader(new SingleSplitReaderContext(ctx)); + reader.setMinRequestIntervalMillis(0); + + List<SeaTunnelRow> rows = new ArrayList<>(); + Collector<SeaTunnelRow> collector = + new Collector<SeaTunnelRow>() { + @Override + public void collect(SeaTunnelRow record) { + rows.add(record); + } + + @Override + public Object getCheckpointLock() { + return this; + } + }; + reader.open(); + try { + reader.pollNext(collector); + } finally { + reader.close(); + } + return rows; + } + + private static Map<String, SeaTunnelRow> byResponseId(List<SeaTunnelRow> rows) { + Map<String, SeaTunnelRow> map = new LinkedHashMap<>(); + for (SeaTunnelRow row : rows) { + map.put((String) row.getField(0), row); + } + return map; + } + + @Test + public void testPagingWithBeforeTokenAndJsonStrings() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue( + new MockResponse() + .setBody( + page( + item("r1", R1_ANSWERS, R1_HIDDEN), + item("r2", "[]", "{}")))); + server.enqueue( + new MockResponse() + .setBody(page(item("r3", "null", "null"), item("r4", null, null)))); + server.enqueue(new MockResponse().setBody(page(item("r5", R5_ANSWERS, R5_HIDDEN)))); + server.enqueue(new MockResponse().setBody(page())); + server.start(); + + List<SeaTunnelRow> rows = read(server, new HashMap<>()); + + // Paging: no cursor first, then the token (not the response_id) of the last item. + Assertions.assertEquals(4, server.getRequestCount()); + RecordedRequest first = server.takeRequest(5, TimeUnit.SECONDS); + Assertions.assertEquals("/forms/lT9Z2j/responses", first.getRequestUrl().encodedPath()); + Assertions.assertNull(first.getRequestUrl().queryParameter("before")); + Assertions.assertEquals("2", first.getRequestUrl().queryParameter("page_size")); + Assertions.assertEquals("Bearer tfp_test-token", first.getHeader("Authorization")); + Assertions.assertFalse(first.getRequestUrl().toString().contains("tfp_test-token")); + Assertions.assertEquals( + "tok-r2", + server.takeRequest(5, TimeUnit.SECONDS) + .getRequestUrl() + .queryParameter("before")); + Assertions.assertEquals( + "tok-r4", + server.takeRequest(5, TimeUnit.SECONDS) + .getRequestUrl() + .queryParameter("before")); + Assertions.assertEquals( + "tok-r5", + server.takeRequest(5, TimeUnit.SECONDS) + .getRequestUrl() + .queryParameter("before")); + + // Rows: five responses, the empty last page ends the read. + Assertions.assertEquals(5, rows.size()); + Map<String, SeaTunnelRow> byId = byResponseId(rows); + SeaTunnelRow r1 = byId.get("r1"); + Assertions.assertEquals("l-r1", r1.getField(1)); + Assertions.assertEquals("tok-r1", r1.getField(2)); + Assertions.assertEquals("2026-09-14T22:33:59Z", r1.getField(3)); + Assertions.assertEquals("2026-09-14T22:38:22Z", r1.getField(4)); + + // answers and hidden are the source JSON, byte for byte: key order, escaping, + // unicode, the decimal and the integer above 2^53 all survive. + Assertions.assertEquals( + MAPPER.writeValueAsString(MAPPER.readTree(R1_ANSWERS)), r1.getField(ANSWERS)); + Assertions.assertEquals(R1_HIDDEN, r1.getField(HIDDEN)); + + // Empty answers array becomes NULL (base reader behaviour); empty hidden stays "{}". + Assertions.assertNull(byId.get("r2").getField(ANSWERS)); + Assertions.assertEquals("{}", byId.get("r2").getField(HIDDEN)); + // Explicit nulls and missing keys become NULL. + Assertions.assertNull(byId.get("r3").getField(ANSWERS)); + Assertions.assertNull(byId.get("r3").getField(HIDDEN)); + Assertions.assertNull(byId.get("r4").getField(ANSWERS)); + Assertions.assertNull(byId.get("r4").getField(HIDDEN)); + // An unknown answer type passes through, and a JSON-looking string stays a string. + Assertions.assertEquals( + MAPPER.writeValueAsString(MAPPER.readTree(R5_ANSWERS)), + byId.get("r5").getField(ANSWERS)); + Assertions.assertEquals(R5_HIDDEN, byId.get("r5").getField(HIDDEN)); + } + } + + @Test + public void testFiltersAreSentOnEveryPage() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setBody(page(item("r1", "[]", "{}")))); + server.enqueue(new MockResponse().setBody(page())); + server.start(); + + Map<String, Object> filters = new HashMap<>(); + filters.put("since", "2026-01-01T00:00:00"); + filters.put("until", "2026-12-31T23:59:59"); + filters.put("response_type", "partial,completed"); + List<SeaTunnelRow> rows = read(server, filters); + + Assertions.assertEquals(1, rows.size()); + Assertions.assertEquals(2, server.getRequestCount()); + for (int i = 0; i < 2; i++) { + RecordedRequest request = server.takeRequest(5, TimeUnit.SECONDS); + Assertions.assertEquals( + "2026-01-01T00:00:00", request.getRequestUrl().queryParameter("since")); + Assertions.assertEquals( + "2026-12-31T23:59:59", request.getRequestUrl().queryParameter("until")); + Assertions.assertEquals( + "partial,completed", + request.getRequestUrl().queryParameter("response_type")); + } + } + } + + @Test + public void testEmptyFormReadsNothing() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setBody(page())); + server.start(); + + Assertions.assertTrue(read(server, new HashMap<>()).isEmpty()); + Assertions.assertEquals(1, server.getRequestCount()); + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReaderTest.java b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReaderTest.java new file mode 100644 index 0000000000..c830618780 --- /dev/null +++ b/seatunnel-connectors-v2/connector-http/connector-http-typeform/src/test/java/org/apache/seatunnel/connectors/seatunnel/typeform/TypeformSourceReaderTest.java @@ -0,0 +1,208 @@ +/* + * 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.typeform; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.connectors.seatunnel.typeform.config.TypeformSourceParameter; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public class TypeformSourceReaderTest { + + private TypeformSourceReader newReader(MockWebServer server, int retry) { + return newReader(server, retry, 10, 50, 0); + } + + private TypeformSourceReader newReader( + MockWebServer server, + int retry, + int backoffMillis, + int maxBackoffMillis, + long minRequestIntervalMillis) { + Map<String, Object> configMap = new HashMap<>(); + configMap.put("base_url", server.url("/").toString()); + configMap.put("api_key", "test-token"); + configMap.put("form_id", "abc123"); + configMap.put("retry", retry); + configMap.put("retry_backoff_multiplier_ms", backoffMillis); + configMap.put("retry_backoff_max_ms", maxBackoffMillis); + TypeformSourceParameter parameter = new TypeformSourceParameter(); + parameter.buildWithConfig(ReadonlyConfig.fromMap(configMap)); + TypeformSourceReader reader = + new TypeformSourceReader(parameter, null, null, null, null, null); + reader.setMinRequestIntervalMillis(minRequestIntervalMillis); + return reader; + } + + @Test + public void testRetryOn429And5xx() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setResponseCode(429)); + server.enqueue(new MockResponse().setResponseCode(503)); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"items\":[]}")); + server.start(); + TypeformSourceReader reader = newReader(server, 3); + reader.open(); + try { + Assertions.assertEquals(200, reader.executeRequest().getCode()); + Assertions.assertEquals(3, server.getRequestCount()); + RecordedRequest first = server.takeRequest(); + Assertions.assertEquals("Bearer test-token", first.getHeader("Authorization")); + Assertions.assertTrue(first.getPath().startsWith("/forms/abc123/responses?")); + Assertions.assertTrue(first.getPath().contains("page_size=100")); + Assertions.assertFalse(first.getPath().contains("test-token")); + } finally { + reader.close(); + } + } + } + + @Test + public void testNoRetryOn401() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue( + new MockResponse() + .setResponseCode(401) + .setBody("{\"code\":\"AUTHENTICATION_FAILED\"}")); + server.start(); + TypeformSourceReader reader = newReader(server, 3); + reader.open(); + try { + Assertions.assertEquals(401, reader.executeRequest().getCode()); + Assertions.assertEquals(1, server.getRequestCount()); + } finally { + reader.close(); + } + } + } + + @Test + public void testGivesUpAfterRetries() throws Exception { + try (MockWebServer server = new MockWebServer()) { + for (int i = 0; i < 4; i++) { + server.enqueue(new MockResponse().setResponseCode(500)); + } + server.start(); + TypeformSourceReader reader = newReader(server, 3); + reader.open(); + try { + RuntimeException ex = + Assertions.assertThrows( + RuntimeException.class, () -> reader.executeRequest()); + Assertions.assertTrue(ex.getMessage().contains("HTTP 500")); + Assertions.assertFalse(ex.getMessage().contains("test-token")); + Assertions.assertEquals(4, server.getRequestCount()); + } finally { + reader.close(); + } + } + } + + @Test + public void testRequestsArePaced() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setResponseCode(429)); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{\"items\":[]}")); + server.start(); + // A 1 ms backoff, so the gap between the two requests comes from the pacing. + TypeformSourceReader reader = newReader(server, 3, 1, 50, 300); + reader.open(); + try { + long start = System.nanoTime(); + Assertions.assertEquals(200, reader.executeRequest().getCode()); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000L; + Assertions.assertEquals(2, server.getRequestCount()); + Assertions.assertTrue( + elapsedMillis >= 300, + "Second request must wait for the pacing interval, took " + elapsedMillis); + } finally { + reader.close(); + } + } + } + + @Test + public void testDefaultPacingMatchesTypeformRateLimit() { + Assertions.assertEquals(500, TypeformSourceReader.DEFAULT_MIN_REQUEST_INTERVAL_MILLIS); + } + + @Test + public void testInterruptedBackoffRestoresFlag() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue(new MockResponse().setResponseCode(429)); + server.start(); + // A long backoff keeps the worker in Thread.sleep while it is interrupted. + TypeformSourceReader reader = newReader(server, 3, 20_000, 30_000, 0); + reader.open(); + + AtomicReference<Exception> thrown = new AtomicReference<>(); + AtomicBoolean flagRestored = new AtomicBoolean(false); + Thread worker = + new Thread( + () -> { + try { + reader.executeRequest(); + } catch (Exception e) { + thrown.set(e); + flagRestored.set(Thread.currentThread().isInterrupted()); + } + }); + try { + worker.start(); + Assertions.assertNotNull( + server.takeRequest(10, TimeUnit.SECONDS), "429 request not received"); + // Wait until the worker has read the 429 and is sleeping in the backoff. + long deadline = System.currentTimeMillis() + 10_000; + while (worker.getState() != Thread.State.TIMED_WAITING + && System.currentTimeMillis() < deadline) { + Thread.sleep(5); + } + worker.interrupt(); + worker.join(10_000); + + Assertions.assertFalse(worker.isAlive(), "Worker must stop after interrupt"); + Exception e = thrown.get(); + Assertions.assertNotNull(e, "Expected exception due to thread interruption"); + String msg = String.valueOf(e.getMessage()); + Assertions.assertTrue( + msg.contains("Interrupted while backing off"), + "Exception must come from the backoff sleep, got: " + msg); + Assertions.assertTrue(flagRestored.get(), "Interrupt flag must be restored"); + Assertions.assertFalse( + msg.contains("test-token"), "Message must not contain token"); + Assertions.assertFalse( + msg.contains("Authorization"), "Message must not contain Authorization"); + Assertions.assertEquals(1, server.getRequestCount()); + } finally { + worker.interrupt(); + reader.close(); + } + } + } +} diff --git a/seatunnel-connectors-v2/connector-http/pom.xml b/seatunnel-connectors-v2/connector-http/pom.xml index 25c61ca085..264bd42087 100644 --- a/seatunnel-connectors-v2/connector-http/pom.xml +++ b/seatunnel-connectors-v2/connector-http/pom.xml @@ -52,6 +52,7 @@ <module>connector-http-splunk</module> <module>connector-http-asana</module> <module>connector-http-intercom</module> + <module>connector-http-typeform</module> <module>connector-mem0</module> </modules> diff --git a/seatunnel-dist/pom.xml b/seatunnel-dist/pom.xml index c13f2757fa..c3762bf50e 100644 --- a/seatunnel-dist/pom.xml +++ b/seatunnel-dist/pom.xml @@ -260,6 +260,12 @@ <version>${project.version}</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-typeform</artifactId> + <version>${project.version}</version> + <scope>provided</scope> + </dependency> <dependency> <groupId>org.apache.seatunnel</groupId> <artifactId>connector-kafka</artifactId> diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/pom.xml b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/pom.xml new file mode 100644 index 0000000000..4afe6badcb --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/pom.xml @@ -0,0 +1,53 @@ +<?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 http://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-typeform-e2e</artifactId> + <name>SeaTunnel : E2E : Connector V2 : Typeform</name> + + <dependencies> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-typeform</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.apache.seatunnel</groupId> + <artifactId>connector-http-base</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.apache.seatunnel</groupId> + <artifactId>connector-common</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + </dependencies> +</project> diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/src/test/java/org/apache/seatunnel/e2e/connector/typeform/TypeformIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/src/test/java/org/apache/seatunnel/e2e/connector/typeform/TypeformIT.java new file mode 100644 index 0000000000..bfae1c4169 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/src/test/java/org/apache/seatunnel/e2e/connector/typeform/TypeformIT.java @@ -0,0 +1,170 @@ +/* + * 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.typeform; + +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.BeforeEach; +import org.junit.jupiter.api.TestTemplate; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.Container; + +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; + +public class TypeformIT extends TestSuiteBase implements TestResource { + + private static final String TOKEN = "placeholder-typeform-token-12345"; + private static final String PAGE_2_CURSOR = "tok-r2"; + private static final String PAGE_3_CURSOR = "tok-r3"; + + private static HttpServer httpServer; + private static int serverPort; + private static final AtomicInteger requestCount = new AtomicInteger(0); + + private static String item(String id, String answers) { + return "{\"landing_id\":\"l-" + + id + + "\",\"token\":\"tok-" + + id + + "\",\"response_id\":\"" + + id + + "\",\"landed_at\":\"2026-09-14T22:33:59Z\",\"submitted_at\":\"2026-09-14T22:38:22Z\"," + + "\"answers\":" + + answers + + ",\"hidden\":{\"utm_source\":\"newsletter\"}}"; + } + + private static String page(String... items) { + return "{\"total_items\":3,\"page_count\":2,\"items\":[" + String.join(",", items) + "]}"; + } + + @BeforeAll + @Override + public void startUp() throws IOException { + httpServer = HttpServer.create(new InetSocketAddress(0), 0); + serverPort = httpServer.getAddress().getPort(); + Testcontainers.exposeHostPorts(serverPort); + + httpServer.createContext( + "/forms/abc123/responses", + exchange -> { + int currentReq = requestCount.incrementAndGet(); + String cursor = queryParam(exchange.getRequestURI(), "before"); + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + String responseBody; + int statusCode; + + // The first request gets a 429 to exercise the retry. After that the page is + // chosen by the before cursor, so the job only reads page 2 if the connector + // sends back the token of the last response on page 1. + if (!("Bearer " + TOKEN).equals(auth)) { + statusCode = 401; + responseBody = "{\"code\":\"AUTHENTICATION_FAILED\"}"; + } else if (currentReq == 1) { + statusCode = 429; + responseBody = "{\"code\":\"RATE_LIMITED\"}"; + } else if (cursor == null) { + statusCode = 200; + responseBody = + page( + item( + "r1", + "[{\"field\":{\"id\":\"f1\",\"type\":\"short_text\"},\"type\":\"text\",\"text\":\"Lian\"}]"), + item("r2", "[]")); + } else if (PAGE_2_CURSOR.equals(cursor)) { + statusCode = 200; + responseBody = + page( + item( + "r3", + "[{\"field\":{\"id\":\"f2\",\"type\":\"yes_no\"},\"type\":\"boolean\",\"boolean\":true}]")); + } else if (PAGE_3_CURSOR.equals(cursor)) { + statusCode = 200; + responseBody = page(); + } else { + statusCode = 400; + responseBody = "{\"code\":\"VALIDATION_ERROR\"}"; + } + + byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(statusCode, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + }); + + httpServer.start(); + } + + private static String queryParam(URI uri, String name) { + String query = uri.getRawQuery(); + if (query == null) { + return null; + } + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + if (eq > 0 && pair.substring(0, eq).equals(name)) { + try { + return URLDecoder.decode(pair.substring(eq + 1), "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + } + return null; + } + + @AfterAll + @Override + public void tearDown() { + if (httpServer != null) { + httpServer.stop(0); + } + } + + @BeforeEach + public void resetCounter() { + requestCount.set(0); + } + + @TestTemplate + public void testTypeformSourceE2E(TestContainer container) + throws IOException, InterruptedException { + Container.ExecResult execResult = + container.executeJob( + "/typeform_source_to_assert.conf", + Collections.singletonList("typeform.test.port=" + serverPort)); + Assertions.assertEquals(0, execResult.getExitCode(), execResult.getStderr()); + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/src/test/resources/typeform_source_to_assert.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/src/test/resources/typeform_source_to_assert.conf new file mode 100644 index 0000000000..3b7ce32500 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-typeform-e2e/src/test/resources/typeform_source_to_assert.conf @@ -0,0 +1,78 @@ +# +# 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 { + Typeform { + base_url = "http://host.testcontainers.internal:${typeform.test.port}" + api_key = "placeholder-typeform-token-12345" + form_id = "abc123" + page_size = 2 + retry_backoff_multiplier_ms = 10 + retry_backoff_max_ms = 50 + } +} + +sink { + Assert { + rules { + row_rules = [ + { + rule_type = "MAX_ROW" + rule_value = 3 + }, + { + rule_type = "MIN_ROW" + rule_value = 3 + } + ] + field_rules = [ + { + field_name = "response_id" + field_type = "string" + field_value = [ + { + rule_type = "NOT_NULL" + } + ] + }, + { + field_name = "token" + field_type = "string" + field_value = [ + { + rule_type = "NOT_NULL" + } + ] + }, + { + field_name = "hidden" + 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 af592af155..7368584279 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/pom.xml @@ -53,6 +53,7 @@ <module>connector-neo4j-e2e</module> <module>connector-asana-e2e</module> <module>connector-intercom-e2e</module> + <module>connector-typeform-e2e</module> <module>connector-http-e2e</module> <module>connector-paypal-e2e</module> <module>connector-tiktok-ads-e2e</module>
