diff --git a/.gitignore b/.gitignore
index 7f74ba2e..540cfbd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,6 +69,7 @@ evals/results/
.cursor/
.mcp.json
.playwright-mcp/
+.pi/tmp/
# C# / .NET
bin/
diff --git a/apps/docs/components/mdx/mdx-components.tsx b/apps/docs/components/mdx/mdx-components.tsx
index 90e55a2e..dde34e3e 100644
--- a/apps/docs/components/mdx/mdx-components.tsx
+++ b/apps/docs/components/mdx/mdx-components.tsx
@@ -362,6 +362,41 @@ export async function ApiCodeExample({
return ;
}
+ // Multi-language SDK-only mode: render CodeExamples-style dropdown from examples.json.
+ if (!operationId && operations?.length) {
+ const sdkLangs = ['typescript', 'python', 'go', 'kotlin', 'swift', 'csharp'] as const;
+ type SdkLang = (typeof sdkLangs)[number];
+ const isSdkLang = (value: string): value is SdkLang =>
+ (sdkLangs as readonly string[]).includes(value);
+ const requestedSdkLangs = langs?.filter(isSdkLang);
+ const visibleSdkLangs = requestedSdkLangs?.length ? requestedSdkLangs : [...sdkLangs];
+ const sdkDefaultLang = defaultLang && isSdkLang(defaultLang) ? defaultLang : undefined;
+ const sdkExamples = Object.fromEntries(
+ sdkLangs.map((sdkLang) => [sdkLang, getSdkExampleForLang(sdkLang, operations, showInit)])
+ );
+ const syntheticEndpoint = {
+ id: operations[0]?.id ?? 'sdk-example',
+ method: 'GET' as const,
+ path: '',
+ tags: [],
+ title: title ?? 'SDK example',
+ parameters: [],
+ responses: { '200': { description: 'OK' } },
+ };
+
+ return (
+
+ );
+ }
+
// Multi-language mode: render CodeExamples with dropdown
if (!operationId) {
return (
diff --git a/apps/docs/content/guides/webhook/polling.mdx b/apps/docs/content/guides/webhook/polling.mdx
index 276a02b0..d07e51a9 100644
--- a/apps/docs/content/guides/webhook/polling.mdx
+++ b/apps/docs/content/guides/webhook/polling.mdx
@@ -1,6 +1,6 @@
---
title: Поллинг
-description: "Поллинг исходящих вебхуков Пачки через API: получение накопленных событий, удаление обработанных, пример кода для локальной разработки и сред с жёсткими firewall-правилами"
+description: "Поллинг исходящих вебхуков Пачки через SDK: получение новых событий без публичного webhook URL, дедупликация доставок и пример воркера для локальной разработки"
related:
- /guides/webhook/events
- /guides/webhook/handler
@@ -9,31 +9,24 @@ related:
# Поллинг
-Если у вас нет возможности принимать входящие HTTP-запросы (локальная разработка, жёсткие firewall-правила), используйте **поллинг** — получение событий через API.
+Если у вас нет возможности принимать входящие HTTP-запросы (локальная разработка, жёсткие firewall-правила), используйте **поллинг** — получение событий через API или готовые helpers в SDK.
-Включите настройку **Сохранять историю событий** на вкладке **Исходящий Webhook** в настройках бота (подробнее — в разделе [Настройка и типы событий](/guides/webhook/events#obschie-nastroiki)), чтобы получать события через API:
+Включите настройку **Сохранять историю событий** на вкладке **Исходящий Webhook** в настройках бота (подробнее — в разделе [Настройка и типы событий](/guides/webhook/events#obschie-nastroiki)), чтобы получать события через метод [История событий](GET /webhooks/events).
-- [Список событий бота](GET /webhooks/events) — получить накопленные события
-- [Удалить событие](DELETE /webhooks/events/{id}) — удалить обработанное событие из очереди
+SDK helpers по умолчанию начинают читать только события, созданные после запуска воркера, и дедуплицируют уже обработанные delivery ID в памяти.
-Периодически запрашивайте список событий, обрабатывайте каждое и удаляйте обработанные.
+## Пример поллинга
-## Пример поллинга (TypeScript)
+
-```typescript
-import { PachcaClient } from "@pachca/sdk"
+Если вам нужен только payload вебхука, используйте helper для polling payload'ов:
-const client = new PachcaClient("YOUR_BOT_TOKEN")
+
-async function pollEvents() {
- const events = await client.bots.getWebhookEvents()
- for (const event of events.data) {
- console.log("Событие:", event.type, event.event)
- // Обработать событие...
- await client.bots.deleteWebhookEvent(event.id) // Удалить из очереди
- }
-}
+## Ручная работа через API
-// Запускать каждые 5 секунд
-setInterval(pollEvents, 5000)
-```
+SDK helpers используют метод [История событий](GET /webhooks/events) и курсорную пагинацию. Если вы пишете собственный polling loop без SDK, периодически запрашивайте список событий, обрабатывайте новые delivery ID и храните дедупликацию на своей стороне. Метод [Удаление события](DELETE /webhooks/events/{id}) можно использовать для ручной очистки обработанных записей истории.
diff --git a/apps/docs/content/updates/2026-06-05.md b/apps/docs/content/updates/2026-06-05.md
new file mode 100644
index 00000000..6c544e78
--- /dev/null
+++ b/apps/docs/content/updates/2026-06-05.md
@@ -0,0 +1,10 @@
+---
+date: "2026-06-05"
+title: "Webhook-модели и polling в SDK"
+---
+
+SDK стали точнее типизировать payload вебхуков и получили helpers для polling истории событий. Это упрощает обработку вебхуков без публичного webhook URL и сохраняет discriminator-поля для маршрутизации событий в коде.
+
+Был обновлен следующий метод:
+
+- [История событий](GET /webhooks/events)
diff --git a/apps/docs/data/releases.json b/apps/docs/data/releases.json
index 5a2175d7..6f932b52 100644
--- a/apps/docs/data/releases.json
+++ b/apps/docs/data/releases.json
@@ -1,4 +1,34 @@
[
+ {
+ "product": "sdk",
+ "version": "1.0.21",
+ "date": "2026-06-05",
+ "changes": [
+ {
+ "type": "~",
+ "description": "SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`"
+ },
+ {
+ "type": "+",
+ "description": "SDK получили helpers для polling истории вебхуков и примеры запуска без публичного webhook URL"
+ }
+ ]
+ },
+ {
+ "product": "generator",
+ "version": "1.1.6",
+ "date": "2026-06-05",
+ "changes": [
+ {
+ "type": "~",
+ "description": "Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`"
+ },
+ {
+ "type": "+",
+ "description": "Генераторы выпускают SDK helpers для polling истории вебхуков во всех поддерживаемых языках"
+ }
+ ]
+ },
{
"product": "sdk",
"version": "1.0.20",
diff --git a/apps/docs/lib/mdx-expander.ts b/apps/docs/lib/mdx-expander.ts
index a35e3100..fe315ecf 100644
--- a/apps/docs/lib/mdx-expander.ts
+++ b/apps/docs/lib/mdx-expander.ts
@@ -559,7 +559,10 @@ export async function expandMdxComponents(content: string): Promise {
const apiCodeRegex = //g;
const apiCodeMatches = [...result.matchAll(apiCodeRegex)];
- const SDK_LANGS = ['typescript', 'python', 'go', 'kotlin', 'swift', 'csharp'];
+ const SDK_LANGS = ['typescript', 'python', 'go', 'kotlin', 'swift', 'csharp'] as const;
+ type SdkLang = (typeof SDK_LANGS)[number];
+ const isSdkLang = (value: string): value is SdkLang =>
+ (SDK_LANGS as readonly string[]).includes(value);
for (const match of apiCodeMatches) {
const [fullMatch, attrs] = match;
@@ -567,17 +570,24 @@ export async function expandMdxComponents(content: string): Promise {
const operationId = attrs.match(/operationId="([^"]+)"/)?.[1];
const title = attrs.match(/title="([^"]+)"/)?.[1];
const paramsMatch = attrs.match(/params=\{\{([^}]*)\}\}/);
+ const langsMatch = attrs.match(/langs=\{\s*\[([\s\S]*?)\]\s*\}/);
+ const requestedSdkLangs = langsMatch
+ ? [...langsMatch[1].matchAll(/["']([^"']+)["']/g)]
+ .map((langMatch) => langMatch[1])
+ .filter(isSdkLang)
+ : [];
+ const sdkLangsForBlock = requestedSdkLangs.length > 0 ? requestedSdkLangs : SDK_LANGS;
+
+ const ops: Array<{ id: string; comment?: string }> = [];
+ const opRegex = /id:\s*"([^"]+)"(?:,\s*comment:\s*"([^"]*)")?/g;
+ let opMatch;
+ while ((opMatch = opRegex.exec(attrs)) !== null) {
+ ops.push({ id: opMatch[1], comment: opMatch[2] });
+ }
+ if (operationId) ops.push({ id: operationId });
// SDK language mode: generate from examples.json
- if (lang && SDK_LANGS.includes(lang)) {
- const ops: Array<{ id: string; comment?: string }> = [];
- const opRegex = /id:\s*"([^"]+)"(?:,\s*comment:\s*"([^"]*)")?/g;
- let opMatch;
- while ((opMatch = opRegex.exec(attrs)) !== null) {
- ops.push({ id: opMatch[1], comment: opMatch[2] });
- }
- if (operationId) ops.push({ id: operationId });
-
+ if (lang && isSdkLang(lang)) {
const showInit = !attrs.includes('showInit={false}');
const code = getSdkExampleForLang(lang, ops, showInit);
@@ -592,6 +602,20 @@ export async function expandMdxComponents(content: string): Promise {
continue;
}
+ // Multi-language SDK-only mode: render all SDK examples in markdown output.
+ if (!lang && !operationId && ops.length > 0) {
+ const showInit = !attrs.includes('showInit={false}');
+ let md = '';
+ if (title) md += `**${title}**\n\n`;
+ for (const sdkLang of sdkLangsForBlock) {
+ const code = getSdkExampleForLang(sdkLang, ops, showInit);
+ if (!code) continue;
+ md += `### ${sdkLang}\n\n\`\`\`${sdkLang}\n${code}\n\`\`\`\n\n`;
+ }
+ result = result.replace(fullMatch, md);
+ continue;
+ }
+
// curl/cli mode: generate from endpoint
if (!operationId) {
result = result.replace(fullMatch, '*Endpoint not found*\n');
diff --git a/apps/docs/public/guides/llms.txt b/apps/docs/public/guides/llms.txt
index 331a98d9..185d53b6 100644
--- a/apps/docs/public/guides/llms.txt
+++ b/apps/docs/public/guides/llms.txt
@@ -17,7 +17,7 @@
- [Исходящие вебхуки, Обзор](https://dev.pachca.com/guides/webhook/overview.md): Исходящие вебхуки в Пачке: что это, как настроить и какие настройки доступны на вкладке Исходящий Webhook в боте
- [Исходящие вебхуки, Настройка и типы событий](https://dev.pachca.com/guides/webhook/events.md): Настройки исходящих вебхуков Пачки и список доступных типов событий: сообщения, реакции, нажатия кнопок, заполнение форм, изменение участников чатов и пространства, отправка ссылок
- [Исходящие вебхуки, Безопасность и обработчик](https://dev.pachca.com/guides/webhook/handler.md): Безопасность исходящих вебхуков Пачки: подпись HMAC-SHA256, проверка timestamp, IP-адрес отправителя, примеры обработчика на TypeScript и Python, идемпотентная обработка и доставка
-- [Исходящие вебхуки, Поллинг](https://dev.pachca.com/guides/webhook/polling.md): Поллинг исходящих вебхуков Пачки через API: получение накопленных событий, удаление обработанных, пример кода для локальной разработки и сред с жёсткими firewall-правилами
+- [Исходящие вебхуки, Поллинг](https://dev.pachca.com/guides/webhook/polling.md): Поллинг исходящих вебхуков Пачки через SDK: получение новых событий без публичного webhook URL, дедупликация доставок и пример воркера для локальной разработки
- [Кнопки в сообщениях](https://dev.pachca.com/guides/buttons.md): Интерактивные кнопки в сообщениях ботов Пачки: ссылки и действия, обработка нажатий через исходящий вебхук, открытие форм и переходы на внешние ресурсы
- [Формы, Обзор](https://dev.pachca.com/guides/forms/overview.md): Модальные формы ботов в Пачке: поля ввода, списки, даты и кнопки, жизненный цикл представления — от нажатия кнопки до валидации и закрытия модального окна
- [Формы, Блоки представления](https://dev.pachca.com/guides/forms/blocks.md): 10 типов блоков представлений в формах ботов Пачки: заголовок, текст, поля ввода, выбор из списка, дата, кнопки. До 100 блоков в одном представлении
diff --git a/apps/docs/public/guides/webhook/polling.md b/apps/docs/public/guides/webhook/polling.md
index 20fd89a6..47689207 100644
--- a/apps/docs/public/guides/webhook/polling.md
+++ b/apps/docs/public/guides/webhook/polling.md
@@ -1,40 +1,180 @@
> Расположение: Исходящие вебхуки
-> Краткое содержание: Поллинг исходящих вебхуков Пачки через API: получение накопленных событий, удаление обработанных, пример кода для локальной разработки и сред с жёсткими firewall-правилами
+> Краткое содержание: Поллинг исходящих вебхуков Пачки через SDK: получение новых событий без публичного webhook URL, дедупликация доставок и пример воркера для локальной разработки
> Это Markdown-версия конкретной страницы. Для контекста за её пределами (правила API, полный перечень методов, авторизация) ОБЯЗАТЕЛЬНО открой [llms.txt](https://dev.pachca.com/llms.txt) перед ответом — это сэкономит токены и предотвратит неполный ответ.
# Поллинг
-Если у вас нет возможности принимать входящие HTTP-запросы (локальная разработка, жёсткие firewall-правила), используйте **поллинг** — получение событий через API.
+Если у вас нет возможности принимать входящие HTTP-запросы (локальная разработка, жёсткие firewall-правила), используйте **поллинг** — получение событий через API или готовые helpers в SDK.
-Включите настройку **Сохранять историю событий** на вкладке **Исходящий Webhook** в настройках бота (подробнее — в разделе [Настройка и типы событий](/guides/webhook/events#obschie-nastroiki)), чтобы получать события через API:
+Включите настройку **Сохранять историю событий** на вкладке **Исходящий Webhook** в настройках бота (подробнее — в разделе [Настройка и типы событий](/guides/webhook/events#obschie-nastroiki)), чтобы получать события через метод [История событий](GET /webhooks/events).
-- [Список событий бота](GET /webhooks/events) — получить накопленные события
-- [Удалить событие](DELETE /webhooks/events/{id}) — удалить обработанное событие из очереди
+> SDK helpers по умолчанию начинают читать только события, созданные после запуска воркера, и дедуплицируют уже обработанные delivery ID в памяти.
-> Периодически запрашивайте список событий, обрабатывайте каждое и удаляйте обработанные.
+## Пример поллинга
-## Пример поллинга (TypeScript)
+**Polling событий**
+
+### typescript
+
+```typescript
+import { PachcaClient } from "@pachca/sdk"
+
+const client = new PachcaClient("YOUR_TOKEN")
+
+for await (const event of client.bots.pollWebhookEvents({ intervalMs: 5_000 })) {
+ console.log(event)
+}
+```
+
+### python
+
+```python
+from pachca.client import PachcaClient
+
+client = PachcaClient("YOUR_TOKEN")
+
+async for event in client.bots.poll_webhook_events(interval_seconds=5.0):
+ print(event)
+```
+
+### go
+
+```go
+import pachca "github.com/pachca/openapi/sdk/go/generated"
+
+client := pachca.NewPachcaClient("YOUR_TOKEN")
+
+err := client.Bots.PollWebhookEvents(ctx, nil, func(event pachca.WebhookEvent) error {
+ _ = event
+ return nil
+})
+```
+
+### kotlin
+
+```kotlin
+import com.pachca.sdk.PachcaClient
+import com.pachca.sdk.pollWebhookEvents
+
+val client = PachcaClient("YOUR_TOKEN")
+
+client.bots.pollWebhookEvents().collect { event ->
+ println(event)
+}
+```
+
+### swift
+
+```swift
+import PachcaSDK
+
+let client = PachcaClient(token: "YOUR_TOKEN")
+
+for try await event in client.bots.pollWebhookEvents(interval: 5) {
+ print(event)
+}
+```
+
+### csharp
+
+```csharp
+using Pachca.Sdk;
+
+using var client = new PachcaClient("YOUR_TOKEN");
+
+await foreach (var webhookEvent in client.Bots.PollWebhookEventsAsync())
+{
+ _ = webhookEvent;
+}
+```
+
+
+Если вам нужен только payload вебхука, используйте helper для polling payload'ов:
+
+**Polling payload'ов**
+
+### typescript
```typescript
import { PachcaClient } from "@pachca/sdk"
-const client = new PachcaClient("YOUR_BOT_TOKEN")
+const client = new PachcaClient("YOUR_TOKEN")
-async function pollEvents() {
- const events = await client.bots.getWebhookEvents()
- for (const event of events.data) {
- console.log("Событие:", event.type, event.event)
- // Обработать событие...
- await client.bots.deleteWebhookEvent(event.id) // Удалить из очереди
- }
+for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {
+ console.log(payload)
}
+```
+
+### python
+
+```python
+from pachca.client import PachcaClient
+
+client = PachcaClient("YOUR_TOKEN")
-// Запускать каждые 5 секунд
-setInterval(pollEvents, 5000)
+async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):
+ print(payload)
```
+### go
+
+```go
+import pachca "github.com/pachca/openapi/sdk/go/generated"
+
+client := pachca.NewPachcaClient("YOUR_TOKEN")
+
+err := client.Bots.PollWebhookPayloads(ctx, nil, func(payload pachca.WebhookPayloadUnion) error {
+ _ = payload
+ return nil
+})
+```
+
+### kotlin
+
+```kotlin
+import com.pachca.sdk.PachcaClient
+import com.pachca.sdk.WebhookPayloadUnion
+import com.pachca.sdk.pollWebhookPayloads
+
+val client = PachcaClient("YOUR_TOKEN")
+
+client.bots.pollWebhookPayloads().collect { payload ->
+ println(payload)
+}
+```
+
+### swift
+
+```swift
+import PachcaSDK
+
+let client = PachcaClient(token: "YOUR_TOKEN")
+
+for try await payload in client.bots.pollWebhookPayloads(interval: 5) {
+ print(payload)
+}
+```
+
+### csharp
+
+```csharp
+using Pachca.Sdk;
+
+using var client = new PachcaClient("YOUR_TOKEN");
+
+await foreach (var payload in client.Bots.PollWebhookPayloadsAsync())
+{
+ _ = payload;
+}
+```
+
+
+## Ручная работа через API
+
+SDK helpers используют метод [История событий](GET /webhooks/events) и курсорную пагинацию. Если вы пишете собственный polling loop без SDK, периодически запрашивайте список событий, обрабатывайте новые delivery ID и храните дедупликацию на своей стороне. Метод [Удаление события](DELETE /webhooks/events/{id}) можно использовать для ручной очистки обработанных записей истории.
+
## Связанные разделы
diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt
index 116b7d1b..7da46be2 100644
--- a/apps/docs/public/llms-full.txt
+++ b/apps/docs/public/llms-full.txt
@@ -16,7 +16,7 @@
|--------|--------|
| LIBRARY RULES — правила: auth, пагинация, лимиты, ошибки, SDK | 96–198 |
| How-to Guides — рецепты задач с кодом TS/Python | 199–882 |
-| Руководства — гайды: SDK, вебхуки, боты, формы, n8n, CLI | 883–11131 |
+| Руководства — гайды: SDK, вебхуки, боты, формы, n8n, CLI | 883–11271 |
| · Обзор | 888–991 |
| · Быстрый старт | 992–1082 |
| · AI агенты | 1083–1218 |
@@ -31,67 +31,67 @@
| · Исходящие вебхуки | 1890–1934 |
| · Настройка и типы событий | 1935–2109 |
| · Безопасность и обработчик | 2110–2300 |
-| · Поллинг | 2301–2344 |
-| · Кнопки в сообщениях | 2345–2501 |
-| · Формы | 2502–2571 |
-| · Блоки представления | 2572–2786 |
-| · Обработка форм | 2787–2926 |
-| · Разворачивание ссылок | 2927–3039 |
-| · Экспорт сообщений | 3040–3181 |
-| · DLP-система | 3182–3389 |
-| · Пачка Audit Events API | 3390–3464 |
-| · Форматирование текста | 3465–3545 |
-| · Сценарии | 3546–4034 |
-| · CLI | 4035–4128 |
-| · Установка | 4129–4221 |
-| · Авторизация | 4222–4295 |
-| · Вывод | 4296–4399 |
-| · Флаги и скрипты | 4400–4626 |
-| · Сценарии | 4627–4674 |
-| · Файлы | 4675–4709 |
-| · Прямые запросы | 4710–4820 |
-| · Команды | 4821–4955 |
-| · SDK и генератор | 4956–5041 |
-| · TypeScript | 5042–5454 |
-| · Python | 5455–5861 |
-| · Go | 5862–6293 |
-| · Kotlin | 6294–6717 |
-| · Swift | 6718–7110 |
-| · C# | 7111–7564 |
-| · n8n | 7565–7640 |
-| · Начало работы | 7641–7868 |
-| · Ресурсы и операции | 7869–8243 |
-| · Триггер | 8244–8493 |
-| · Тестирование | 8494–8767 |
-| · Примеры workflow | 8768–8983 |
-| · Продвинутые функции | 8984–9220 |
-| · Устранение ошибок | 9221–9413 |
-| · Миграция с v1 | 9414–9535 |
-| · Последние обновления | 9536–9545 |
-| · Авторизация | 9546–9724 |
-| · Запросы и ответы | 9725–9917 |
-| · Пагинация | 9918–10154 |
-| · Загрузка файлов | 10155–10372 |
-| · Ошибки | 10373–10446 |
-| · Лимиты | 10447–10564 |
-| · Модели | 10565–11131 |
-| API-методы — все эндпоинты со схемами и примерами | 11132–26543 |
-| · Common | 11134–11928 |
-| · Profile | 11929–12930 |
-| · Users | 12931–15316 |
-| · Group tags | 15317–16664 |
-| · Chats | 16665–17932 |
-| · Members | 17933–19277 |
-| · Threads | 19278–19835 |
-| · Messages | 19836–21665 |
-| · Read members | 21666–21912 |
-| · Reactions | 21913–22559 |
-| · Link Previews | 22560–22815 |
-| · Search | 22816–23616 |
-| · Tasks | 23617–24908 |
-| · Views | 24909–25480 |
-| · Bots | 25481–26231 |
-| · Security | 26232–26543 |
+| · Поллинг | 2301–2484 |
+| · Кнопки в сообщениях | 2485–2641 |
+| · Формы | 2642–2711 |
+| · Блоки представления | 2712–2926 |
+| · Обработка форм | 2927–3066 |
+| · Разворачивание ссылок | 3067–3179 |
+| · Экспорт сообщений | 3180–3321 |
+| · DLP-система | 3322–3529 |
+| · Пачка Audit Events API | 3530–3604 |
+| · Форматирование текста | 3605–3685 |
+| · Сценарии | 3686–4174 |
+| · CLI | 4175–4268 |
+| · Установка | 4269–4361 |
+| · Авторизация | 4362–4435 |
+| · Вывод | 4436–4539 |
+| · Флаги и скрипты | 4540–4766 |
+| · Сценарии | 4767–4814 |
+| · Файлы | 4815–4849 |
+| · Прямые запросы | 4850–4960 |
+| · Команды | 4961–5095 |
+| · SDK и генератор | 5096–5181 |
+| · TypeScript | 5182–5594 |
+| · Python | 5595–6001 |
+| · Go | 6002–6433 |
+| · Kotlin | 6434–6857 |
+| · Swift | 6858–7250 |
+| · C# | 7251–7704 |
+| · n8n | 7705–7780 |
+| · Начало работы | 7781–8008 |
+| · Ресурсы и операции | 8009–8383 |
+| · Триггер | 8384–8633 |
+| · Тестирование | 8634–8907 |
+| · Примеры workflow | 8908–9123 |
+| · Продвинутые функции | 9124–9360 |
+| · Устранение ошибок | 9361–9553 |
+| · Миграция с v1 | 9554–9675 |
+| · Последние обновления | 9676–9685 |
+| · Авторизация | 9686–9864 |
+| · Запросы и ответы | 9865–10057 |
+| · Пагинация | 10058–10294 |
+| · Загрузка файлов | 10295–10512 |
+| · Ошибки | 10513–10586 |
+| · Лимиты | 10587–10704 |
+| · Модели | 10705–11271 |
+| API-методы — все эндпоинты со схемами и примерами | 11272–26683 |
+| · Common | 11274–12068 |
+| · Profile | 12069–13070 |
+| · Users | 13071–15456 |
+| · Group tags | 15457–16804 |
+| · Chats | 16805–18072 |
+| · Members | 18073–19417 |
+| · Threads | 19418–19975 |
+| · Messages | 19976–21805 |
+| · Read members | 21806–22052 |
+| · Reactions | 22053–22699 |
+| · Link Previews | 22700–22955 |
+| · Search | 22956–23756 |
+| · Tasks | 23757–25048 |
+| · Views | 25049–25620 |
+| · Bots | 25621–26371 |
+| · Security | 26372–26683 |
# LIBRARY RULES
@@ -2300,36 +2300,176 @@ app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
# Поллинг
-Если у вас нет возможности принимать входящие HTTP-запросы (локальная разработка, жёсткие firewall-правила), используйте **поллинг** — получение событий через API.
+Если у вас нет возможности принимать входящие HTTP-запросы (локальная разработка, жёсткие firewall-правила), используйте **поллинг** — получение событий через API или готовые helpers в SDK.
-Включите настройку **Сохранять историю событий** на вкладке **Исходящий Webhook** в настройках бота (подробнее — в разделе [Настройка и типы событий](/guides/webhook/events#obschie-nastroiki)), чтобы получать события через API:
+Включите настройку **Сохранять историю событий** на вкладке **Исходящий Webhook** в настройках бота (подробнее — в разделе [Настройка и типы событий](/guides/webhook/events#obschie-nastroiki)), чтобы получать события через метод [История событий](GET /webhooks/events).
-- [Список событий бота](GET /webhooks/events) — получить накопленные события
-- [Удалить событие](DELETE /webhooks/events/{id}) — удалить обработанное событие из очереди
+> SDK helpers по умолчанию начинают читать только события, созданные после запуска воркера, и дедуплицируют уже обработанные delivery ID в памяти.
-> Периодически запрашивайте список событий, обрабатывайте каждое и удаляйте обработанные.
+## Пример поллинга
-## Пример поллинга (TypeScript)
+**Polling событий**
+
+### typescript
```typescript
import { PachcaClient } from "@pachca/sdk"
-const client = new PachcaClient("YOUR_BOT_TOKEN")
+const client = new PachcaClient("YOUR_TOKEN")
-async function pollEvents() {
- const events = await client.bots.getWebhookEvents()
- for (const event of events.data) {
- console.log("Событие:", event.type, event.event)
- // Обработать событие...
- await client.bots.deleteWebhookEvent(event.id) // Удалить из очереди
- }
+for await (const event of client.bots.pollWebhookEvents({ intervalMs: 5_000 })) {
+ console.log(event)
+}
+```
+
+### python
+
+```python
+from pachca.client import PachcaClient
+
+client = PachcaClient("YOUR_TOKEN")
+
+async for event in client.bots.poll_webhook_events(interval_seconds=5.0):
+ print(event)
+```
+
+### go
+
+```go
+import pachca "github.com/pachca/openapi/sdk/go/generated"
+
+client := pachca.NewPachcaClient("YOUR_TOKEN")
+
+err := client.Bots.PollWebhookEvents(ctx, nil, func(event pachca.WebhookEvent) error {
+ _ = event
+ return nil
+})
+```
+
+### kotlin
+
+```kotlin
+import com.pachca.sdk.PachcaClient
+import com.pachca.sdk.pollWebhookEvents
+
+val client = PachcaClient("YOUR_TOKEN")
+
+client.bots.pollWebhookEvents().collect { event ->
+ println(event)
}
+```
+
+### swift
+
+```swift
+import PachcaSDK
-// Запускать каждые 5 секунд
-setInterval(pollEvents, 5000)
+let client = PachcaClient(token: "YOUR_TOKEN")
+
+for try await event in client.bots.pollWebhookEvents(interval: 5) {
+ print(event)
+}
```
+### csharp
+
+```csharp
+using Pachca.Sdk;
+
+using var client = new PachcaClient("YOUR_TOKEN");
+
+await foreach (var webhookEvent in client.Bots.PollWebhookEventsAsync())
+{
+ _ = webhookEvent;
+}
+```
+
+
+Если вам нужен только payload вебхука, используйте helper для polling payload'ов:
+
+**Polling payload'ов**
+
+### typescript
+
+```typescript
+import { PachcaClient } from "@pachca/sdk"
+
+const client = new PachcaClient("YOUR_TOKEN")
+
+for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {
+ console.log(payload)
+}
+```
+
+### python
+
+```python
+from pachca.client import PachcaClient
+
+client = PachcaClient("YOUR_TOKEN")
+
+async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):
+ print(payload)
+```
+
+### go
+
+```go
+import pachca "github.com/pachca/openapi/sdk/go/generated"
+
+client := pachca.NewPachcaClient("YOUR_TOKEN")
+
+err := client.Bots.PollWebhookPayloads(ctx, nil, func(payload pachca.WebhookPayloadUnion) error {
+ _ = payload
+ return nil
+})
+```
+
+### kotlin
+
+```kotlin
+import com.pachca.sdk.PachcaClient
+import com.pachca.sdk.WebhookPayloadUnion
+import com.pachca.sdk.pollWebhookPayloads
+
+val client = PachcaClient("YOUR_TOKEN")
+
+client.bots.pollWebhookPayloads().collect { payload ->
+ println(payload)
+}
+```
+
+### swift
+
+```swift
+import PachcaSDK
+
+let client = PachcaClient(token: "YOUR_TOKEN")
+
+for try await payload in client.bots.pollWebhookPayloads(interval: 5) {
+ print(payload)
+}
+```
+
+### csharp
+
+```csharp
+using Pachca.Sdk;
+
+using var client = new PachcaClient("YOUR_TOKEN");
+
+await foreach (var payload in client.Bots.PollWebhookPayloadsAsync())
+{
+ _ = payload;
+}
+```
+
+
+## Ручная работа через API
+
+SDK helpers используют метод [История событий](GET /webhooks/events) и курсорную пагинацию. Если вы пишете собственный polling loop без SDK, периодически запрашивайте список событий, обрабатывайте новые delivery ID и храните дедупликацию на своей стороне. Метод [Удаление события](DELETE /webhooks/events/{id}) можно использовать для ручной очистки обработанных записей истории.
+
## Связанные разделы
diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt
index fa127d53..09851a17 100644
--- a/apps/docs/public/llms.txt
+++ b/apps/docs/public/llms.txt
@@ -6,7 +6,7 @@
> **Как агенту читать эту доку:** любую страницу можно получить в Markdown — добавь `.md` к URL или пошли заголовок `Accept: text/markdown`. Ссылки в разделах «Частые задачи», «Руководства» и «API-методы» уже ведут на `.md` — запрашивай напрямую. Точечные запросы по API без загрузки всего: `npx -y @pachca/cli api ls`, далее `npx -y @pachca/cli api <МЕТОД> <путь> --describe` (схема — `--spec`, полный референс — `--docs`).
-> Полная документация одним файлом: [llms-full.txt](https://dev.pachca.com/llms-full.txt) (~348K токенов — обычно не помещается в контекст целиком).
+> Полная документация одним файлом: [llms-full.txt](https://dev.pachca.com/llms-full.txt) (~349K токенов — обычно не помещается в контекст целиком).
> English-only: [llms-en.txt](https://dev.pachca.com/llms-en.txt) (~12K токенов).
@@ -53,8 +53,8 @@
| Agent Skills | Скиллы для AI-агентов и установка | 105–127 |
| Руководства | Страницы-руководства (Markdown по `.md`) | 128–187 |
| API-методы | Все эндпоинты, сгруппированы по разделам | 188–288 |
-| Обновления | Журнал обновлений по датам | 289–337 |
-| Дополнительно | Прочие ссылки и контакты | 338–346 |
+| Обновления | Журнал обновлений по датам | 289–338 |
+| Дополнительно | Прочие ссылки и контакты | 339–347 |
## CLI Quick Start
@@ -140,7 +140,7 @@ C#: new PachcaClient("TOKEN") → await client.Messages.CreateMessage
- [Исходящие вебхуки, Обзор](https://dev.pachca.com/guides/webhook/overview.md): Исходящие вебхуки в Пачке: что это, как настроить и какие настройки доступны на вкладке Исходящий Webhook в боте
- [Исходящие вебхуки, Настройка и типы событий](https://dev.pachca.com/guides/webhook/events.md): Настройки исходящих вебхуков Пачки и список доступных типов событий: сообщения, реакции, нажатия кнопок, заполнение форм, изменение участников чатов и пространства, отправка ссылок
- [Исходящие вебхуки, Безопасность и обработчик](https://dev.pachca.com/guides/webhook/handler.md): Безопасность исходящих вебхуков Пачки: подпись HMAC-SHA256, проверка timestamp, IP-адрес отправителя, примеры обработчика на TypeScript и Python, идемпотентная обработка и доставка
-- [Исходящие вебхуки, Поллинг](https://dev.pachca.com/guides/webhook/polling.md): Поллинг исходящих вебхуков Пачки через API: получение накопленных событий, удаление обработанных, пример кода для локальной разработки и сред с жёсткими firewall-правилами
+- [Исходящие вебхуки, Поллинг](https://dev.pachca.com/guides/webhook/polling.md): Поллинг исходящих вебхуков Пачки через SDK: получение новых событий без публичного webhook URL, дедупликация доставок и пример воркера для локальной разработки
- [Кнопки в сообщениях](https://dev.pachca.com/guides/buttons.md): Интерактивные кнопки в сообщениях ботов Пачки: ссылки и действия, обработка нажатий через исходящий вебхук, открытие форм и переходы на внешние ресурсы
- [Формы, Обзор](https://dev.pachca.com/guides/forms/overview.md): Модальные формы ботов в Пачке: поля ввода, списки, даты и кнопки, жизненный цикл представления — от нажатия кнопки до валидации и закрытия модального окна
- [Формы, Блоки представления](https://dev.pachca.com/guides/forms/blocks.md): 10 типов блоков представлений в формах ботов Пачки: заголовок, текст, поля ввода, выбор из списка, дата, кнопки. До 100 блоков в одном представлении
@@ -287,6 +287,7 @@ C#: new PachcaClient("TOKEN") → await client.Messages.CreateMessage
- [Журнал аудита событий](https://dev.pachca.com/api/security/list.md): GET /audit_events
## Обновления
+- [05 июня 2026 — Webhook-модели и polling в SDK](https://dev.pachca.com/updates/2026-06-05.md)
- [20 мая 2026 — Список тредов](https://dev.pachca.com/updates/2026-05-20.md)
- [17 мая 2026 — Гостевая роль и чаты при создании сотрудника, справка по API в CLI](https://dev.pachca.com/updates/2026-05-17.md)
- [06 мая 2026 — Курсорная пагинация и параметр skip в unfurl-вебхуке](https://dev.pachca.com/updates/2026-05-06.md)
diff --git a/apps/docs/public/skill.md b/apps/docs/public/skill.md
index 62e20b7a..4f1c8dc9 100644
--- a/apps/docs/public/skill.md
+++ b/apps/docs/public/skill.md
@@ -238,7 +238,7 @@ Detailed documentation on specific topics is available at:
- [Исходящие вебхуки, Обзор](https://dev.pachca.com/guides/webhook/overview) — Исходящие вебхуки в Пачке: что это, как настроить и какие настройки доступны на вкладке Исходящий Webhook в боте
- [Исходящие вебхуки, Настройка и типы событий](https://dev.pachca.com/guides/webhook/events) — Настройки исходящих вебхуков Пачки и список доступных типов событий: сообщения, реакции, нажатия кнопок, заполнение форм, изменение участников чатов и пространства, отправка ссылок
- [Исходящие вебхуки, Безопасность и обработчик](https://dev.pachca.com/guides/webhook/handler) — Безопасность исходящих вебхуков Пачки: подпись HMAC-SHA256, проверка timestamp, IP-адрес отправителя, примеры обработчика на TypeScript и Python, идемпотентная обработка и доставка
-- [Исходящие вебхуки, Поллинг](https://dev.pachca.com/guides/webhook/polling) — Поллинг исходящих вебхуков Пачки через API: получение накопленных событий, удаление обработанных, пример кода для локальной разработки и сред с жёсткими firewall-правилами
+- [Исходящие вебхуки, Поллинг](https://dev.pachca.com/guides/webhook/polling) — Поллинг исходящих вебхуков Пачки через SDK: получение новых событий без публичного webhook URL, дедупликация доставок и пример воркера для локальной разработки
- [Кнопки в сообщениях](https://dev.pachca.com/guides/buttons) — Интерактивные кнопки в сообщениях ботов Пачки: ссылки и действия, обработка нажатий через исходящий вебхук, открытие форм и переходы на внешние ресурсы
- [Формы, Обзор](https://dev.pachca.com/guides/forms/overview) — Модальные формы ботов в Пачке: поля ввода, списки, даты и кнопки, жизненный цикл представления — от нажатия кнопки до валидации и закрытия модального окна
- [Формы, Блоки представления](https://dev.pachca.com/guides/forms/blocks) — 10 типов блоков представлений в формах ботов Пачки: заголовок, текст, поля ввода, выбор из списка, дата, кнопки. До 100 блоков в одном представлении
diff --git a/apps/docs/public/updates.md b/apps/docs/public/updates.md
index 929b17c9..e85116f8 100644
--- a/apps/docs/public/updates.md
+++ b/apps/docs/public/updates.md
@@ -6,6 +6,26 @@
## ☀️ Лето 2026
+### Webhook-модели и polling в SDK
+
+_05 июня 2026_
+
+SDK стали точнее типизировать payload вебхуков и получили helpers для polling истории событий. Это упрощает обработку вебхуков без публичного webhook URL и сохраняет discriminator-поля для маршрутизации событий в коде.
+
+Был обновлен следующий метод:
+
+- [История событий](GET /webhooks/events)
+
+### SDK v1.0.21
+
+- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`
+- SDK получили helpers для polling истории вебхуков и примеры запуска без публичного webhook URL
+
+### Generator v1.1.6
+
+- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`
+- Генераторы выпускают SDK helpers для polling истории вебхуков во всех поддерживаемых языках
+
### 02 июня 2026
### SDK v1.0.20
diff --git a/apps/docs/public/updates/2026-06-05.md b/apps/docs/public/updates/2026-06-05.md
new file mode 100644
index 00000000..4b35435a
--- /dev/null
+++ b/apps/docs/public/updates/2026-06-05.md
@@ -0,0 +1,21 @@
+> Это Markdown-версия конкретной страницы. Для контекста за её пределами (правила API, полный перечень методов, авторизация) ОБЯЗАТЕЛЬНО открой [llms.txt](https://dev.pachca.com/llms.txt) перед ответом — это сэкономит токены и предотвратит неполный ответ.
+
+# Webhook-модели и polling в SDK
+
+_05 июня 2026_
+
+SDK стали точнее типизировать payload вебхуков и получили helpers для polling истории событий. Это упрощает обработку вебхуков без публичного webhook URL и сохраняет discriminator-поля для маршрутизации событий в коде.
+
+Был обновлен следующий метод:
+
+- [История событий](GET /webhooks/events)
+
+### SDK v1.0.21
+
+- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`
+- SDK получили helpers для polling истории вебхуков и примеры запуска без публичного webhook URL
+
+### Generator v1.1.6
+
+- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`
+- Генераторы выпускают SDK helpers для polling истории вебхуков во всех поддерживаемых языках
diff --git a/apps/docs/public/updates/season/summer-2026.md b/apps/docs/public/updates/season/summer-2026.md
index 98c35e96..8435e40e 100644
--- a/apps/docs/public/updates/season/summer-2026.md
+++ b/apps/docs/public/updates/season/summer-2026.md
@@ -2,6 +2,26 @@
# ☀️ Лето 2026
+### Webhook-модели и polling в SDK
+
+_05 июня 2026_
+
+SDK стали точнее типизировать payload вебхуков и получили helpers для polling истории событий. Это упрощает обработку вебхуков без публичного webhook URL и сохраняет discriminator-поля для маршрутизации событий в коде.
+
+Был обновлен следующий метод:
+
+- [История событий](GET /webhooks/events)
+
+### SDK v1.0.21
+
+- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`
+- SDK получили helpers для polling истории вебхуков и примеры запуска без публичного webhook URL
+
+### Generator v1.1.6
+
+- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`
+- Генераторы выпускают SDK helpers для polling истории вебхуков во всех поддерживаемых языках
+
### 02 июня 2026
### SDK v1.0.20
diff --git a/packages/generator/src/ir.ts b/packages/generator/src/ir.ts
index 511e045a..504903ad 100644
--- a/packages/generator/src/ir.ts
+++ b/packages/generator/src/ir.ts
@@ -75,6 +75,8 @@ export interface IRUnion {
memberRefs: string[];
/** Discriminator field name detected from literal fields (e.g. "type", "entity_type") */
discriminatorField: string;
+ /** Optional named custom deserializer strategy from spec extension */
+ unionDeserializer?: string;
}
// ----- Operations -----
diff --git a/packages/generator/src/lang/csharp.ts b/packages/generator/src/lang/csharp.ts
index caba4eaa..4b64594a 100644
--- a/packages/generator/src/lang/csharp.ts
+++ b/packages/generator/src/lang/csharp.ts
@@ -287,12 +287,19 @@ function emitUnion(
.filter(Boolean) as IRModel[];
const discriminatorField = u.discriminatorField;
+ const useWebhookPayloadDeserializer = u.unionDeserializer === 'webhook-payload';
- lines.push(`[JsonPolymorphic(TypeDiscriminatorPropertyName = "${discriminatorField}")]`);
- for (const memberModel of memberModels) {
- const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
- const litValue = litField?.type.literalValue ?? '';
- lines.push(`[JsonDerivedType(typeof(${memberModel.name}), "${litValue}")]`);
+ if (useWebhookPayloadDeserializer) {
+ lines.push(`[JsonConverter(typeof(${u.name}Converter))]`);
+ } else {
+ lines.push(`[JsonPolymorphic(TypeDiscriminatorPropertyName = "${discriminatorField}")]`);
+ for (const memberModel of memberModels) {
+ const litField = memberModel.fields.find(
+ (f) => f.name === discriminatorField && f.type.kind === 'literal',
+ ) ?? memberModel.fields.find((f) => f.type.kind === 'literal');
+ const litValue = litField?.type.literalValue ?? '';
+ lines.push(`[JsonDerivedType(typeof(${memberModel.name}), "${litValue}")]`);
+ }
}
lines.push(`public abstract class ${u.name}`);
lines.push('{');
@@ -300,27 +307,61 @@ function emitUnion(
lines.push(` public abstract string ${snakeToPascal(discriminatorField)} { get; }`);
lines.push('}');
- for (const memberModel of memberModels) {
- const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
- const litValue = litField?.type.literalValue ?? '';
- const otherFields = memberModel.fields.filter(
- (f) => f.type.kind !== 'literal',
- );
+ if (useWebhookPayloadDeserializer) {
+ lines.push('');
+ lines.push(`internal sealed class ${u.name}Converter : JsonConverter<${u.name}>`);
+ lines.push('{');
+ lines.push(` public override ${u.name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
+ lines.push(' {');
+ lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
+ lines.push(' var root = document.RootElement;');
+ lines.push(` var type = root.GetProperty(${JSON.stringify(discriminatorField)}).GetString();`);
+ lines.push(' var eventValue = root.TryGetProperty("event", out var eventProperty) ? eventProperty.GetString() : null;');
+ lines.push(' var raw = root.GetRawText();');
+ lines.push(' return type switch');
+ lines.push(' {');
+ lines.push(' "message" when eventValue == "link_shared" => JsonSerializer.Deserialize(raw, options)!,');
+ lines.push(' "message" => JsonSerializer.Deserialize(raw, options)!,');
+ for (const memberModel of memberModels.filter((m) => m.name !== 'MessageWebhookPayload' && m.name !== 'LinkSharedWebhookPayload')) {
+ const litField = memberModel.fields.find(
+ (f) => f.name === discriminatorField && f.type.kind === 'literal',
+ ) ?? memberModel.fields.find((f) => f.type.kind === 'literal');
+ const litValue = litField?.type.literalValue ?? '';
+ lines.push(` ${JSON.stringify(litValue)} => JsonSerializer.Deserialize<${memberModel.name}>(raw, options)!,`);
+ }
+ lines.push(` _ => throw new JsonException($"Unknown ${u.name} ${discriminatorField}: {type}")`);
+ lines.push(' };');
+ lines.push(' }');
+ lines.push('');
+ lines.push(` public override void Write(Utf8JsonWriter writer, ${u.name} value, JsonSerializerOptions options)`);
+ lines.push(' {');
+ lines.push(' JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);');
+ lines.push(' }');
+ lines.push('}');
+ }
+ for (const memberModel of memberModels) {
lines.push('');
lines.push(`public class ${memberModel.name} : ${u.name}`);
lines.push('{');
- lines.push(` public override string ${snakeToPascal(discriminatorField)} => "${litValue}";`);
- for (const f of otherFields) {
+ if (!memberModel.fields.some((f) => f.name === discriminatorField)) {
+ const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
+ const litValue = litField?.type.literalValue ?? '';
+ lines.push(` public override string ${snakeToPascal(discriminatorField)} => "${litValue}";`);
+ }
+ for (const f of memberModel.fields) {
const sdkName = fieldSdkName(f);
const typeName = csType(f.type);
const isOpt = !f.required || f.nullable;
const nullSuffix = isOpt ? '?' : '';
+ const overrideModifier = f.name === discriminatorField ? 'override ' : '';
lines.push(` [JsonPropertyName("${f.name}")]`);
- if (isOpt) {
- lines.push(` public ${typeName}${nullSuffix} ${sdkName} { get; set; }`);
+ if (f.type.kind === 'literal') {
+ lines.push(` public ${overrideModifier}${typeName} ${sdkName} => ${JSON.stringify(f.type.literalValue ?? '')};`);
+ } else if (isOpt) {
+ lines.push(` public ${overrideModifier}${typeName}${nullSuffix} ${sdkName} { get; set; }`);
} else {
- lines.push(` public ${typeName} ${sdkName} { get; set; } = default!;`);
+ lines.push(` public ${overrideModifier}${typeName} ${sdkName} { get; set; } = default!;`);
}
}
lines.push('}');
@@ -562,6 +603,9 @@ function generateClient(ir: IR): string {
lines.push('using System.Text;');
lines.push('using System.Text.Json;');
lines.push('using System.Threading;');
+ if (ir.services.some(hasWebhookPolling)) {
+ lines.push('using System.Runtime.CompilerServices;');
+ }
// Note: System.Threading.Tasks is NOT imported to avoid conflict with Pachca.Sdk.Task model
// Async methods use fully qualified System.Threading.Tasks.Task instead
lines.push('');
@@ -581,6 +625,10 @@ function generateClient(ir: IR): string {
return lines.join('\n');
}
+function hasWebhookPolling(svc: IRService): boolean {
+ return svc.operations.some((op) => op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent');
+}
+
function emitService(
lines: string[],
svc: IRService,
@@ -599,6 +647,10 @@ function emitService(
lines.push('');
emitThrowingPaginationMethod(lines, svc.operations[i], ir);
}
+ if (svc.operations[i].methodName === 'getWebhookEvents' && svc.operations[i].successResponse.dataRef === 'WebhookEvent') {
+ lines.push('');
+ emitWebhookPollingMethods(lines, 'public virtual');
+ }
}
lines.push('}');
lines.push('');
@@ -625,6 +677,79 @@ function emitService(
lines.push('}');
}
+function emitWebhookPollingMethods(lines: string[], modifier: string): void {
+ const indent = ' ';
+ lines.push(`${indent}${modifier} async IAsyncEnumerable PollWebhookEventsAsync(`);
+ lines.push(`${indent} int? limit = 50,`);
+ lines.push(`${indent} TimeSpan? interval = null,`);
+ lines.push(`${indent} DateTimeOffset? createdAfter = null,`);
+ lines.push(`${indent} int maxSeenDeliveryIds = 5000,`);
+ lines.push(`${indent} [EnumeratorCancellation] CancellationToken cancellationToken = default)`);
+ lines.push(`${indent}{`);
+ lines.push(`${indent} if (maxSeenDeliveryIds <= 0)`);
+ lines.push(`${indent} throw new ArgumentOutOfRangeException(nameof(maxSeenDeliveryIds), "maxSeenDeliveryIds must be greater than 0");`);
+ lines.push('');
+ lines.push(`${indent} var pollInterval = interval ?? TimeSpan.FromSeconds(5);`);
+ lines.push(`${indent} var effectiveCreatedAfter = createdAfter ?? DateTimeOffset.UtcNow;`);
+ lines.push(`${indent} var seenIdOrder = new Queue();`);
+ lines.push(`${indent} var seenIds = new HashSet();`);
+ lines.push('');
+ lines.push(`${indent} bool Remember(string id)`);
+ lines.push(`${indent} {`);
+ lines.push(`${indent} if (!seenIds.Add(id)) return false;`);
+ lines.push(`${indent} seenIdOrder.Enqueue(id);`);
+ lines.push(`${indent} while (seenIdOrder.Count > maxSeenDeliveryIds)`);
+ lines.push(`${indent} seenIds.Remove(seenIdOrder.Dequeue());`);
+ lines.push(`${indent} return true;`);
+ lines.push(`${indent} }`);
+ lines.push('');
+ lines.push(`${indent} while (!cancellationToken.IsCancellationRequested)`);
+ lines.push(`${indent} {`);
+ lines.push(`${indent} string? cursor = null;`);
+ lines.push(`${indent} var hasNext = true;`);
+ lines.push(`${indent} while (hasNext && !cancellationToken.IsCancellationRequested)`);
+ lines.push(`${indent} {`);
+ lines.push(`${indent} var response = await GetWebhookEventsAsync(limit: limit, cursor: cursor, cancellationToken: cancellationToken).ConfigureAwait(false);`);
+ lines.push(`${indent} var pageHasRecentEvents = false;`);
+ lines.push(`${indent} for (var i = response.Data.Count - 1; i >= 0; i--)`);
+ lines.push(`${indent} {`);
+ lines.push(`${indent} var webhookEvent = response.Data[i];`);
+ lines.push(`${indent} var matchesCreatedAfter = webhookEvent.CreatedAt >= effectiveCreatedAfter;`);
+ lines.push(`${indent} if (matchesCreatedAfter)`);
+ lines.push(`${indent} pageHasRecentEvents = true;`);
+ lines.push(`${indent} if (matchesCreatedAfter && Remember(webhookEvent.Id))`);
+ lines.push(`${indent} yield return webhookEvent;`);
+ lines.push(`${indent} }`);
+ lines.push(`${indent} hasNext = (response.Meta.Paginate.HasNext ?? response.Data.Count > 0) && pageHasRecentEvents;`);
+ lines.push(`${indent} cursor = response.Meta.Paginate.NextPage;`);
+ lines.push(`${indent} }`);
+ lines.push(`${indent} await System.Threading.Tasks.Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);`);
+ lines.push(`${indent} }`);
+ lines.push(`${indent}}`);
+ lines.push('');
+ lines.push(`${indent}${modifier} async IAsyncEnumerable PollWebhookPayloadsAsync(`);
+ lines.push(`${indent} int? limit = 50,`);
+ lines.push(`${indent} TimeSpan? interval = null,`);
+ lines.push(`${indent} DateTimeOffset? createdAfter = null,`);
+ lines.push(`${indent} int maxSeenDeliveryIds = 5000,`);
+ lines.push(`${indent} [EnumeratorCancellation] CancellationToken cancellationToken = default)`);
+ if (!modifier.includes('override')) {
+ lines.push(`${indent} where TPayload : WebhookPayloadUnion`);
+ }
+ lines.push(`${indent}{`);
+ lines.push(`${indent} await foreach (var webhookEvent in PollWebhookEventsAsync(`);
+ lines.push(`${indent} limit: limit,`);
+ lines.push(`${indent} interval: interval,`);
+ lines.push(`${indent} createdAfter: createdAfter,`);
+ lines.push(`${indent} maxSeenDeliveryIds: maxSeenDeliveryIds,`);
+ lines.push(`${indent} cancellationToken: cancellationToken))`);
+ lines.push(`${indent} {`);
+ lines.push(`${indent} if (webhookEvent.Payload is TPayload payload)`);
+ lines.push(`${indent} yield return payload;`);
+ lines.push(`${indent} }`);
+ lines.push(`${indent}}`);
+}
+
function emitPaginationMethod(lines: string[], op: IROperation, ir: IR, modifier = 'public override'): void {
const indent = ' ';
const indent2 = ' ';
@@ -1413,6 +1538,15 @@ function generateExamples(ir: IR): string {
if (ex.output) entry.output = ex.output;
if (ex.imports.length > 0) entry.imports = ex.imports;
result[op.operationId] = entry;
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ result[`${op.operationId}_pollWebhookEvents`] = {
+ usage: `await foreach (var webhookEvent in client.${serviceProp}.PollWebhookEventsAsync())\n{\n _ = webhookEvent;\n}`,
+ };
+ result[`${op.operationId}_pollWebhookPayloads`] = {
+ usage: `await foreach (var payload in client.${serviceProp}.PollWebhookPayloadsAsync())\n{\n _ = payload;\n}`,
+ imports: ['WebhookPayloadUnion'],
+ };
+ }
}
}
diff --git a/packages/generator/src/lang/go.ts b/packages/generator/src/lang/go.ts
index d372c77a..ec94af78 100644
--- a/packages/generator/src/lang/go.ts
+++ b/packages/generator/src/lang/go.ts
@@ -238,21 +238,46 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void {
lines.push(`func (u *${u.name}) UnmarshalJSON(data []byte) error {`);
lines.push('\tvar disc struct {');
lines.push(`\t\t${discGoName} string \`json:"${discField}"\``);
+ if (u.unionDeserializer === 'webhook-payload') {
+ lines.push('\t\tEvent string `json:"event"`');
+ }
lines.push('\t}');
lines.push('\tif err := json.Unmarshal(data, &disc); err != nil {');
lines.push('\t\treturn err');
lines.push('\t}');
- lines.push(`\tswitch disc.${discGoName} {`);
- const seenDiscs = new Set();
- for (const ref of u.memberRefs) {
- const model = models.find((m) => m.name === ref);
- const typeField = model?.fields.find((f) => f.type.kind === 'literal');
- const disc = typeField?.type.literalValue ?? ref;
- if (seenDiscs.has(String(disc))) continue;
- seenDiscs.add(String(disc));
- lines.push(`\tcase ${JSON.stringify(disc)}:`);
- lines.push(`\t\tu.${ref} = &${ref}{}`);
- lines.push(`\t\treturn json.Unmarshal(data, u.${ref})`);
+ if (u.unionDeserializer === 'webhook-payload') {
+ lines.push('\tswitch {');
+ lines.push('\tcase disc.Type == "message" && disc.Event == "link_shared":');
+ lines.push('\t\tu.LinkSharedWebhookPayload = &LinkSharedWebhookPayload{}');
+ lines.push('\t\treturn json.Unmarshal(data, u.LinkSharedWebhookPayload)');
+ lines.push('\tcase disc.Type == "message":');
+ lines.push('\t\tu.MessageWebhookPayload = &MessageWebhookPayload{}');
+ lines.push('\t\treturn json.Unmarshal(data, u.MessageWebhookPayload)');
+ for (const ref of u.memberRefs.filter((ref) => ref !== 'MessageWebhookPayload' && ref !== 'LinkSharedWebhookPayload')) {
+ const model = models.find((m) => m.name === ref);
+ const typeField = model?.fields.find(
+ (f) => f.name === discField && f.type.kind === 'literal',
+ ) ?? model?.fields.find((f) => f.type.kind === 'literal');
+ const disc = typeField?.type.literalValue ?? ref;
+ lines.push(`\tcase disc.${discGoName} == ${JSON.stringify(disc)}:`);
+ lines.push(`\t\tu.${ref} = &${ref}{}`);
+ lines.push(`\t\treturn json.Unmarshal(data, u.${ref})`);
+ }
+ } else {
+ lines.push(`\tswitch disc.${discGoName} {`);
+ const seenDiscs = new Set();
+ for (const ref of u.memberRefs) {
+ const model = models.find((m) => m.name === ref);
+ const typeField = model?.fields.find(
+ (f) => f.name === discField && f.type.kind === 'literal',
+ ) ?? model?.fields.find((f) => f.type.kind === 'literal');
+ const disc = typeField?.type.literalValue ?? ref;
+ if (seenDiscs.has(String(disc))) continue;
+ seenDiscs.add(String(disc));
+ lines.push(`\tcase ${JSON.stringify(disc)}:`);
+ lines.push(`\t\tu.${ref} = &${ref}{}`);
+ lines.push(`\t\treturn json.Unmarshal(data, u.${ref})`);
+ }
}
lines.push('\tdefault:');
lines.push(`\t\treturn fmt.Errorf("unknown ${u.name} ${discField}: %s", disc.${discGoName})`);
@@ -700,6 +725,10 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void {
lines.push('}');
}
+function hasWebhookPolling(svc: IRService): boolean {
+ return svc.operations.some((op) => op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent');
+}
+
function emitServiceContract(lines: string[], svc: IRService, ir: IR): void {
const serviceName = tagToServiceName(svc.tag);
const stubName = serviceToStubName(serviceName);
@@ -727,8 +756,21 @@ function emitServiceContract(lines: string[], svc: IRService, ir: IR): void {
if (op.queryParams.length > 0) pageArgs.push(`params *${upperFirst(op.methodName)}Params`);
lines.push(`\t${goMethodName(op)}All(${pageArgs.join(', ')}) ([]${itemType}, error)`);
}
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ lines.push('\tPollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error');
+ lines.push('\tPollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error');
+ }
}
lines.push('}');
+ if (hasWebhookPolling(svc)) {
+ lines.push('');
+ lines.push('type PollWebhookEventsOptions struct {');
+ lines.push('\tLimit *int32');
+ lines.push('\tInterval time.Duration');
+ lines.push('\tCreatedAfter *time.Time');
+ lines.push('\tMaxSeenDeliveryIDs int');
+ lines.push('}');
+ }
lines.push('');
lines.push(`type ${stubName} struct{}`);
lines.push('');
@@ -739,6 +781,10 @@ function emitServiceContract(lines: string[], svc: IRService, ir: IR): void {
emitStubPaginationMethod(lines, op);
lines.push('');
}
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ emitStubWebhookPollingMethods(lines, svc);
+ lines.push('');
+ }
}
}
@@ -777,6 +823,110 @@ function emitStubPaginationMethod(lines: string[], op: IROperation): void {
lines.push('}');
}
+function emitStubWebhookPollingMethods(lines: string[], svc: IRService): void {
+ const stubName = serviceToStubName(tagToServiceName(svc.tag));
+ lines.push(`func (s *${stubName}) PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error {`);
+ lines.push(`\treturn NotImplementedError{Method: ${JSON.stringify(`${svc.tag}.pollWebhookEvents`)}}`);
+ lines.push('}');
+ lines.push('');
+ lines.push(`func (s *${stubName}) PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error {`);
+ lines.push(`\treturn NotImplementedError{Method: ${JSON.stringify(`${svc.tag}.pollWebhookPayloads`)}}`);
+ lines.push('}');
+}
+
+function emitWebhookPollingMethods(lines: string[], implName: string): void {
+ lines.push(`func (s *${implName}) PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error {`);
+ lines.push('\tif handler == nil {');
+ lines.push('\t\treturn errors.New("handler must not be nil")');
+ lines.push('\t}');
+ lines.push('\tif options == nil {');
+ lines.push('\t\toptions = &PollWebhookEventsOptions{}');
+ lines.push('\t}');
+ lines.push('\tinterval := options.Interval');
+ lines.push('\tif interval == 0 {');
+ lines.push('\t\tinterval = 5 * time.Second');
+ lines.push('\t}');
+ lines.push('\tcreatedAfter := options.CreatedAfter');
+ lines.push('\tif createdAfter == nil {');
+ lines.push('\t\tnow := time.Now()');
+ lines.push('\t\tcreatedAfter = &now');
+ lines.push('\t}');
+ lines.push('\tmaxSeenDeliveryIDs := options.MaxSeenDeliveryIDs');
+ lines.push('\tif maxSeenDeliveryIDs == 0 {');
+ lines.push('\t\tmaxSeenDeliveryIDs = 5000');
+ lines.push('\t}');
+ lines.push('\tif maxSeenDeliveryIDs < 0 {');
+ lines.push('\t\treturn errors.New("MaxSeenDeliveryIDs must be greater than 0")');
+ lines.push('\t}');
+ lines.push('');
+ lines.push('\tseenIDOrder := make([]string, 0, maxSeenDeliveryIDs)');
+ lines.push('\tseenIDs := make(map[string]struct{}, maxSeenDeliveryIDs)');
+ lines.push('\tremember := func(id string) bool {');
+ lines.push('\t\tif _, ok := seenIDs[id]; ok {');
+ lines.push('\t\t\treturn false');
+ lines.push('\t\t}');
+ lines.push('\t\tseenIDs[id] = struct{}{}');
+ lines.push('\t\tseenIDOrder = append(seenIDOrder, id)');
+ lines.push('\t\tfor len(seenIDOrder) > maxSeenDeliveryIDs {');
+ lines.push('\t\t\toldest := seenIDOrder[0]');
+ lines.push('\t\t\tseenIDOrder = seenIDOrder[1:]');
+ lines.push('\t\t\tdelete(seenIDs, oldest)');
+ lines.push('\t\t}');
+ lines.push('\t\treturn true');
+ lines.push('\t}');
+ lines.push('');
+ lines.push('\tfor {');
+ lines.push('\t\tvar cursor *string');
+ lines.push('\t\thasNext := true');
+ lines.push('\t\tfor hasNext {');
+ lines.push('\t\t\tparams := &GetWebhookEventsParams{Limit: options.Limit, Cursor: cursor}');
+ lines.push('\t\t\tresponse, err := s.GetWebhookEvents(ctx, params)');
+ lines.push('\t\t\tif err != nil {');
+ lines.push('\t\t\t\treturn err');
+ lines.push('\t\t\t}');
+ lines.push('\t\t\tpageHasRecentEvents := false');
+ lines.push('\t\t\tfor i := len(response.Data) - 1; i >= 0; i-- {');
+ lines.push('\t\t\t\tevent := response.Data[i]');
+ lines.push('\t\t\t\tmatchesCreatedAfter := !event.CreatedAt.Before(*createdAfter)');
+ lines.push('\t\t\t\tif matchesCreatedAfter {');
+ lines.push('\t\t\t\t\tpageHasRecentEvents = true');
+ lines.push('\t\t\t\t}');
+ lines.push('\t\t\t\tif matchesCreatedAfter && remember(event.ID) {');
+ lines.push('\t\t\t\t\tif err := handler(event); err != nil {');
+ lines.push('\t\t\t\t\t\treturn err');
+ lines.push('\t\t\t\t\t}');
+ lines.push('\t\t\t\t}');
+ lines.push('\t\t\t}');
+ lines.push('\t\t\tnextPage := response.Meta.Paginate.NextPage');
+ lines.push('\t\t\tcursor = &nextPage');
+ lines.push('\t\t\tif response.Meta.Paginate.HasNext != nil {');
+ lines.push('\t\t\t\thasNext = *response.Meta.Paginate.HasNext');
+ lines.push('\t\t\t} else {');
+ lines.push('\t\t\t\thasNext = len(response.Data) > 0');
+ lines.push('\t\t\t}');
+ lines.push('\t\t\thasNext = hasNext && pageHasRecentEvents');
+ lines.push('\t\t}');
+ lines.push('');
+ lines.push('\t\ttimer := time.NewTimer(interval)');
+ lines.push('\t\tselect {');
+ lines.push('\t\tcase <-ctx.Done():');
+ lines.push('\t\t\ttimer.Stop()');
+ lines.push('\t\t\treturn ctx.Err()');
+ lines.push('\t\tcase <-timer.C:');
+ lines.push('\t\t}');
+ lines.push('\t}');
+ lines.push('}');
+ lines.push('');
+ lines.push(`func (s *${implName}) PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error {`);
+ lines.push('\tif handler == nil {');
+ lines.push('\t\treturn errors.New("handler must not be nil")');
+ lines.push('\t}');
+ lines.push('\treturn s.PollWebhookEvents(ctx, options, func(event WebhookEvent) error {');
+ lines.push('\t\treturn handler(event.Payload)');
+ lines.push('\t})');
+ lines.push('}');
+}
+
function generateClient(ir: IR): string {
const lines: string[] = [];
lines.push('package pachca');
@@ -789,11 +939,12 @@ function generateClient(ir: IR): string {
const needBytes = ir.services.some((s) => s.operations.some((o) => o.requestBody?.contentType === 'json'));
const needURL = ir.services.some((s) => s.operations.some((o) => o.queryParams.length > 0));
const needErrors = ir.services.some((s) => s.operations.some((o) => o.successResponse.isRedirect));
+ const needPolling = ir.services.some(hasWebhookPolling);
const needMultipart = ir.services.some((s) => s.operations.some((o) => o.requestBody?.contentType === 'multipart'));
const imports: string[] = ['"context"', '"encoding/json"', '"fmt"', '"net/http"', '"time"'];
if (needBytes) imports.push('"bytes"');
if (needURL) imports.push('"net/url"');
- if (needErrors) imports.push('"errors"');
+ if (needErrors || needPolling) imports.push('"errors"');
if (needMultipart) {
imports.push('"io"');
imports.push('"mime/multipart"');
@@ -832,6 +983,10 @@ function generateClient(ir: IR): string {
emitPaginationMethod(lines, op, ir);
lines.push('');
}
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ emitWebhookPollingMethods(lines, implName);
+ lines.push('');
+ }
}
}
@@ -1320,6 +1475,14 @@ function generateExamples(ir: IR): string {
if (ex.output) entry.output = ex.output;
if (ex.imports.length > 0) entry.imports = ex.imports;
result[op.operationId] = entry;
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ result[`${op.operationId}_pollWebhookEvents`] = {
+ usage: `err := client.${serviceField}.PollWebhookEvents(ctx, nil, func(event pachca.WebhookEvent) error {\n\t_ = event\n\treturn nil\n})`,
+ };
+ result[`${op.operationId}_pollWebhookPayloads`] = {
+ usage: `err := client.${serviceField}.PollWebhookPayloads(ctx, nil, func(payload pachca.WebhookPayloadUnion) error {\n\t_ = payload\n\treturn nil\n})`,
+ };
+ }
}
}
diff --git a/packages/generator/src/lang/kotlin.ts b/packages/generator/src/lang/kotlin.ts
index a6f7f0be..a00a5064 100644
--- a/packages/generator/src/lang/kotlin.ts
+++ b/packages/generator/src/lang/kotlin.ts
@@ -132,6 +132,7 @@ function generateModels(ir: IR): string {
// Determine imports
let needSerialName = false;
let needTransient = false;
+ const needWebhookPayloadUnionSerializer = ir.unions.some((u) => u.unionDeserializer === 'webhook-payload');
if (ir.enums.length > 0) needSerialName = true;
if (ir.unions.length > 0) needSerialName = true;
@@ -158,14 +159,21 @@ function generateModels(ir: IR): string {
const imports: string[] = [];
if (needDateTime) imports.push('import java.time.OffsetDateTime');
if (needDateTime) imports.push('import java.time.format.DateTimeFormatter');
- if (needDateTime) imports.push('import kotlinx.serialization.KSerializer');
+ if (needDateTime || needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.KSerializer');
if (needSerialName) imports.push('import kotlinx.serialization.SerialName');
imports.push('import kotlinx.serialization.Serializable');
if (needTransient) imports.push('import kotlinx.serialization.Transient');
if (needDateTime) imports.push('import kotlinx.serialization.descriptors.PrimitiveKind');
if (needDateTime) imports.push('import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor');
- if (needDateTime) imports.push('import kotlinx.serialization.encoding.Decoder');
- if (needDateTime) imports.push('import kotlinx.serialization.encoding.Encoder');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.descriptors.buildClassSerialDescriptor');
+ if (needDateTime || needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.encoding.Decoder');
+ if (needDateTime || needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.encoding.Encoder');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.JsonDecoder');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.JsonEncoder');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.contentOrNull');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.decodeFromJsonElement');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.jsonObject');
+ if (needWebhookPayloadUnionSerializer) imports.push('import kotlinx.serialization.json.jsonPrimitive');
lines.push(imports.join('\n'));
if (needDateTime) {
@@ -245,39 +253,99 @@ function emitUnion(
.filter(Boolean) as IRModel[];
const discriminatorField = u.discriminatorField;
+ const useWebhookPayloadDeserializer = u.unionDeserializer === 'webhook-payload';
- lines.push('@Serializable');
+ if (useWebhookPayloadDeserializer) {
+ lines.push('@Serializable(with = WebhookPayloadUnionSerializer::class)');
+ } else {
+ lines.push('@Serializable');
+ }
lines.push(`sealed interface ${u.name} {`);
lines.push(` val ${snakeToCamel(discriminatorField)}: String`);
lines.push('}');
+ if (useWebhookPayloadDeserializer) {
+ lines.push('');
+ lines.push('object WebhookPayloadUnionSerializer : KSerializer {');
+ lines.push(' override val descriptor = buildClassSerialDescriptor("WebhookPayloadUnion")');
+ lines.push('');
+ lines.push(' override fun serialize(encoder: Encoder, value: WebhookPayloadUnion) {');
+ lines.push(' val jsonEncoder = encoder as? JsonEncoder ?: error("WebhookPayloadUnionSerializer only supports JSON")');
+ lines.push(' when (value) {');
+ lines.push(' is MessageWebhookPayload -> jsonEncoder.encodeSerializableValue(MessageWebhookPayload.serializer(), value)');
+ lines.push(' is ReactionWebhookPayload -> jsonEncoder.encodeSerializableValue(ReactionWebhookPayload.serializer(), value)');
+ lines.push(' is ButtonWebhookPayload -> jsonEncoder.encodeSerializableValue(ButtonWebhookPayload.serializer(), value)');
+ lines.push(' is ViewSubmitWebhookPayload -> jsonEncoder.encodeSerializableValue(ViewSubmitWebhookPayload.serializer(), value)');
+ lines.push(' is ChatMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(ChatMemberWebhookPayload.serializer(), value)');
+ lines.push(' is CompanyMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(CompanyMemberWebhookPayload.serializer(), value)');
+ lines.push(' is LinkSharedWebhookPayload -> jsonEncoder.encodeSerializableValue(LinkSharedWebhookPayload.serializer(), value)');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push('');
+ lines.push(' override fun deserialize(decoder: Decoder): WebhookPayloadUnion {');
+ lines.push(' val jsonDecoder = decoder as? JsonDecoder ?: error("WebhookPayloadUnionSerializer only supports JSON")');
+ lines.push(' val element = jsonDecoder.decodeJsonElement()');
+ lines.push(' val type = element.jsonObject["type"]?.jsonPrimitive?.contentOrNull');
+ lines.push(' val event = element.jsonObject["event"]?.jsonPrimitive?.contentOrNull');
+ lines.push(' return when {');
+ lines.push(' type == "message" && event == "link_shared" -> jsonDecoder.json.decodeFromJsonElement(LinkSharedWebhookPayload.serializer(), element)');
+ lines.push(' type == "message" -> jsonDecoder.json.decodeFromJsonElement(MessageWebhookPayload.serializer(), element)');
+ lines.push(' type == "reaction" -> jsonDecoder.json.decodeFromJsonElement(ReactionWebhookPayload.serializer(), element)');
+ lines.push(' type == "button" -> jsonDecoder.json.decodeFromJsonElement(ButtonWebhookPayload.serializer(), element)');
+ lines.push(' type == "view" -> jsonDecoder.json.decodeFromJsonElement(ViewSubmitWebhookPayload.serializer(), element)');
+ lines.push(' type == "chat_member" -> jsonDecoder.json.decodeFromJsonElement(ChatMemberWebhookPayload.serializer(), element)');
+ lines.push(' type == "company_member" -> jsonDecoder.json.decodeFromJsonElement(CompanyMemberWebhookPayload.serializer(), element)');
+ lines.push(' else -> error("Unknown WebhookPayloadUnion type: $type")');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push('}');
+ }
+
for (const memberModel of memberModels) {
- const litField = memberModel.fields.find((f) => f.type.kind === 'literal');
+ const litField = memberModel.fields.find(
+ (f) => f.name === discriminatorField && f.type.kind === 'literal',
+ ) ?? memberModel.fields.find((f) => f.type.kind === 'literal');
const litValue = litField?.type.literalValue ?? '';
- const otherFields = memberModel.fields.filter(
- (f) => f.type.kind !== 'literal',
- );
lines.push('');
lines.push('@Serializable');
lines.push(`@SerialName("${litValue}")`);
lines.push(`data class ${memberModel.name}(`);
- lines.push(
- ` override val ${snakeToCamel(discriminatorField)}: String = "${litValue}",`,
- );
- for (const f of otherFields) {
+ if (!memberModel.fields.some((f) => f.name === discriminatorField)) {
+ lines.push(
+ ` override val ${snakeToCamel(discriminatorField)}: String = "${litValue}",`,
+ );
+ }
+ const bodyLiteralFields: IRField[] = [];
+ for (const f of memberModel.fields) {
+ if (f.name !== discriminatorField && f.type.kind === 'literal') {
+ bodyLiteralFields.push(f);
+ continue;
+ }
const sdkName = fieldSdkName(f);
const typeName = ktType(f.type);
const isOpt = !f.required;
const fullType = isOpt ? `${typeName}?` : typeName;
- const default_ = isOpt ? ' = null' : '';
+ const literalDefault = f.type.kind === 'literal' ? ` = "${f.type.literalValue ?? ''}"` : '';
+ const default_ = isOpt ? ' = null' : literalDefault;
const isDateTime = f.type.kind === 'primitive' && f.type.primitive === 'string' && f.type.format === 'date-time';
const dtAnnotation = isDateTime ? '@Serializable(with = OffsetDateTimeSerializer::class) ' : '';
const serialName =
needsSerialName(f) ? `${dtAnnotation}@SerialName("${f.name}") ` : dtAnnotation;
- lines.push(` ${serialName}val ${sdkName}: ${fullType}${default_},`);
+ const overrideModifier = f.name === discriminatorField ? 'override ' : '';
+ lines.push(` ${serialName}${overrideModifier}val ${sdkName}: ${fullType}${default_},`);
+ }
+ if (bodyLiteralFields.length === 0) {
+ lines.push(`) : ${u.name}`);
+ } else {
+ lines.push(`) : ${u.name} {`);
+ for (const f of bodyLiteralFields) {
+ const sdkName = fieldSdkName(f);
+ const serialName = needsSerialName(f) ? `@SerialName("${f.name}") ` : '';
+ lines.push(` ${serialName}val ${sdkName}: String = "${f.type.literalValue ?? ''}"`);
+ }
+ lines.push('}');
}
- lines.push(`) : ${u.name}`);
}
}
@@ -427,12 +495,25 @@ function generateClient(ir: IR): string {
lines.push('import io.ktor.client.statement.*');
lines.push('import io.ktor.http.*');
lines.push('import io.ktor.serialization.kotlinx.json.*');
+ const clientNeedDateTime = ir.params.some((p) => p.params.some((q) => q.type.kind === 'primitive' && q.type.primitive === 'string' && q.type.format === 'date-time'));
+ const needPolling = ir.services.some(hasWebhookPolling);
+ if (needPolling) {
+ lines.push('import kotlinx.coroutines.currentCoroutineContext');
+ lines.push('import kotlinx.coroutines.delay');
+ lines.push('import kotlinx.coroutines.flow.Flow');
+ lines.push('import kotlinx.coroutines.isActive');
+ lines.push('import kotlinx.coroutines.flow.flow');
+ lines.push('import kotlinx.coroutines.flow.mapNotNull');
+ }
lines.push('import kotlinx.serialization.json.Json');
lines.push('import java.io.Closeable');
- const clientNeedDateTime = ir.params.some((p) => p.params.some((q) => q.type.kind === 'primitive' && q.type.primitive === 'string' && q.type.format === 'date-time'));
if (clientNeedDateTime) {
lines.push('import java.time.OffsetDateTime');
}
+ if (needPolling) {
+ lines.push('import kotlin.time.Duration');
+ lines.push('import kotlin.time.Duration.Companion.seconds');
+ }
// Services
for (const svc of ir.services) {
@@ -448,6 +529,10 @@ function generateClient(ir: IR): string {
return lines.join('\n');
}
+function hasWebhookPolling(svc: IRService): boolean {
+ return svc.operations.some((op) => op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent');
+}
+
function emitService(
lines: string[],
svc: IRService,
@@ -483,6 +568,63 @@ function emitService(
}
lines.push('}');
+ if (hasWebhookPolling(svc)) {
+ lines.push('');
+ emitWebhookPollingExtensions(lines);
+ }
+}
+
+function emitWebhookPollingExtensions(lines: string[]): void {
+ lines.push('fun BotsService.pollWebhookEvents(');
+ lines.push(' limit: Int? = 50,');
+ lines.push(' interval: Duration = 5.seconds,');
+ lines.push(' createdAfter: OffsetDateTime? = null,');
+ lines.push(' maxSeenDeliveryIds: Int = 5_000,');
+ lines.push('): Flow = flow {');
+ lines.push(' require(maxSeenDeliveryIds > 0) { "maxSeenDeliveryIds must be greater than 0" }');
+ lines.push('');
+ lines.push(' val effectiveCreatedAfter = createdAfter ?: OffsetDateTime.now()');
+ lines.push(' val seenIdOrder = ArrayDeque()');
+ lines.push(' val seenIds = mutableSetOf()');
+ lines.push('');
+ lines.push(' fun remember(id: String): Boolean {');
+ lines.push(' if (!seenIds.add(id)) return false');
+ lines.push(' seenIdOrder.addLast(id)');
+ lines.push(' while (seenIdOrder.size > maxSeenDeliveryIds) {');
+ lines.push(' seenIds.remove(seenIdOrder.removeFirst())');
+ lines.push(' }');
+ lines.push(' return true');
+ lines.push(' }');
+ lines.push('');
+ lines.push(' while (currentCoroutineContext().isActive) {');
+ lines.push(' var cursor: String? = null');
+ lines.push(' do {');
+ lines.push(' val response = getWebhookEvents(limit = limit, cursor = cursor)');
+ lines.push(' var pageHasRecentEvents = false');
+ lines.push(' for (event in response.data.asReversed()) {');
+ lines.push(' val matchesCreatedAfter = !event.createdAt.isBefore(effectiveCreatedAfter)');
+ lines.push(' if (matchesCreatedAfter) pageHasRecentEvents = true');
+ lines.push(' if (matchesCreatedAfter && remember(event.id)) emit(event)');
+ lines.push(' }');
+ lines.push(' val hasNext = (response.meta.paginate.hasNext ?: response.data.isNotEmpty()) && pageHasRecentEvents');
+ lines.push(' cursor = response.meta.paginate.nextPage');
+ lines.push(' } while (currentCoroutineContext().isActive && hasNext)');
+ lines.push(' delay(interval)');
+ lines.push(' }');
+ lines.push('}');
+ lines.push('');
+ lines.push('inline fun BotsService.pollWebhookPayloads(');
+ lines.push(' limit: Int? = 50,');
+ lines.push(' interval: Duration = 5.seconds,');
+ lines.push(' createdAfter: OffsetDateTime? = null,');
+ lines.push(' maxSeenDeliveryIds: Int = 5_000,');
+ lines.push('): Flow = pollWebhookEvents(');
+ lines.push(' limit = limit,');
+ lines.push(' interval = interval,');
+ lines.push(' createdAfter = createdAfter,');
+ lines.push(' maxSeenDeliveryIds = maxSeenDeliveryIds,');
+ lines.push(')');
+ lines.push(' .mapNotNull { it.payload as? T }');
}
function emitInterfaceOperation(lines: string[], op: IROperation, ir: IR): void {
@@ -998,7 +1140,10 @@ function emitPachcaClient(
if (hasRedirect) {
lines.push(' followRedirects = false');
}
- lines.push(' install(ContentNegotiation) { json(Json { explicitNulls = false }) }');
+ const jsonConfig = ir.services.some(hasWebhookPolling)
+ ? 'Json { explicitNulls = false; ignoreUnknownKeys = true }'
+ : 'Json { explicitNulls = false }';
+ lines.push(` install(ContentNegotiation) { json(${jsonConfig}) }`);
lines.push(' install(HttpRequestRetry) {');
lines.push(' maxRetries = 3');
lines.push(' retryIf { _, response ->');
@@ -1309,6 +1454,16 @@ function generateExamples(ir: IR): string {
if (ex.output) entry.output = ex.output;
if (ex.imports.length > 0) entry.imports = ex.imports;
result[op.operationId] = entry;
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ result[`${op.operationId}_pollWebhookEvents`] = {
+ usage: `client.${serviceProp}.pollWebhookEvents().collect { event ->\n println(event)\n}`,
+ imports: ['pollWebhookEvents'],
+ };
+ result[`${op.operationId}_pollWebhookPayloads`] = {
+ usage: `client.${serviceProp}.pollWebhookPayloads().collect { payload ->\n println(payload)\n}`,
+ imports: ['WebhookPayloadUnion', 'pollWebhookPayloads'],
+ };
+ }
}
}
diff --git a/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts
index 23d4010a..b6b4eb3f 100644
--- a/packages/generator/src/lang/python.ts
+++ b/packages/generator/src/lang/python.ts
@@ -382,6 +382,9 @@ function collectClientImports(ir: IR): string[] {
add(op.successResponse.dataRef);
}
}
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ add('WebhookPayloadUnion');
+ }
if (op.hasOAuthError || ir.models.some((m) => m.name === 'OAuthError')) {
add('OAuthError');
}
@@ -701,6 +704,10 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void {
lines.push(' return items');
}
+function hasWebhookPolling(svc: IRService): boolean {
+ return svc.operations.some((op) => op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent');
+}
+
function emitThrowingOperation(lines: string[], op: IROperation, ir: IR): void {
const args: string[] = [];
if (op.externalUrl) args.push(`${camelToSnake(op.externalUrl)}: str`);
@@ -754,15 +761,106 @@ function emitThrowingPaginationMethod(lines: string[], op: IROperation): void {
lines.push(` raise NotImplementedError(${JSON.stringify(`${op.tag}.${op.methodName}All is not implemented`)})`);
}
+function emitThrowingWebhookPollingMethods(lines: string[], svc: IRService): void {
+ lines.push(' async def poll_webhook_events(');
+ lines.push(' self,');
+ lines.push(' *,');
+ lines.push(' limit: int | None = 50,');
+ lines.push(' interval_seconds: float = 5.0,');
+ lines.push(' created_after: datetime | None = None,');
+ lines.push(' max_seen_delivery_ids: int = 5_000,');
+ lines.push(' ) -> AsyncIterator[WebhookEvent]:');
+ lines.push(` raise NotImplementedError(${JSON.stringify(`${svc.tag}.pollWebhookEvents is not implemented`)})`);
+ lines.push('');
+ lines.push(' async def poll_webhook_payloads(');
+ lines.push(' self,');
+ lines.push(' *,');
+ lines.push(' limit: int | None = 50,');
+ lines.push(' interval_seconds: float = 5.0,');
+ lines.push(' created_after: datetime | None = None,');
+ lines.push(' max_seen_delivery_ids: int = 5_000,');
+ lines.push(' ) -> AsyncIterator[WebhookPayloadUnion]:');
+ lines.push(` raise NotImplementedError(${JSON.stringify(`${svc.tag}.pollWebhookPayloads is not implemented`)})`);
+}
+
+function emitWebhookPollingMethods(lines: string[]): void {
+ lines.push(' async def poll_webhook_events(');
+ lines.push(' self,');
+ lines.push(' *,');
+ lines.push(' limit: int | None = 50,');
+ lines.push(' interval_seconds: float = 5.0,');
+ lines.push(' created_after: datetime | None = None,');
+ lines.push(' max_seen_delivery_ids: int = 5_000,');
+ lines.push(' ) -> AsyncIterator[WebhookEvent]:');
+ lines.push(' if max_seen_delivery_ids <= 0:');
+ lines.push(' raise ValueError("max_seen_delivery_ids must be greater than 0")');
+ lines.push('');
+ lines.push(' effective_created_after = created_after or datetime.now(timezone.utc)');
+ lines.push(' seen_id_order: deque[str] = deque()');
+ lines.push(' seen_ids: set[str] = set()');
+ lines.push('');
+ lines.push(' def remember(id: str) -> bool:');
+ lines.push(' if id in seen_ids:');
+ lines.push(' return False');
+ lines.push(' seen_ids.add(id)');
+ lines.push(' seen_id_order.append(id)');
+ lines.push(' while len(seen_id_order) > max_seen_delivery_ids:');
+ lines.push(' seen_ids.remove(seen_id_order.popleft())');
+ lines.push(' return True');
+ lines.push('');
+ lines.push(' while True:');
+ lines.push(' cursor: str | None = None');
+ lines.push(' has_next = True');
+ lines.push(' while has_next:');
+ lines.push(' response = await self.get_webhook_events(');
+ lines.push(' GetWebhookEventsParams(limit=limit, cursor=cursor),');
+ lines.push(' )');
+ lines.push(' page_has_recent_events = False');
+ lines.push(' for event in reversed(response.data):');
+ lines.push(' matches_created_after = event.created_at >= effective_created_after');
+ lines.push(' if matches_created_after:');
+ lines.push(' page_has_recent_events = True');
+ lines.push(' if matches_created_after and remember(event.id):');
+ lines.push(' yield event');
+ lines.push(' reported_has_next = getattr(response.meta.paginate, "has_next", None)');
+ lines.push(' has_next = (bool(response.data) if reported_has_next is None else reported_has_next) and page_has_recent_events');
+ lines.push(' cursor = response.meta.paginate.next_page');
+ lines.push(' await asyncio.sleep(interval_seconds)');
+ lines.push('');
+ lines.push(' async def poll_webhook_payloads(');
+ lines.push(' self,');
+ lines.push(' *,');
+ lines.push(' payload_type: type[TPayload] | tuple[type[TPayload], ...] | None = None,');
+ lines.push(' limit: int | None = 50,');
+ lines.push(' interval_seconds: float = 5.0,');
+ lines.push(' created_after: datetime | None = None,');
+ lines.push(' max_seen_delivery_ids: int = 5_000,');
+ lines.push(' ) -> AsyncIterator[WebhookPayloadUnion | TPayload]:');
+ lines.push(' async for event in self.poll_webhook_events(');
+ lines.push(' limit=limit,');
+ lines.push(' interval_seconds=interval_seconds,');
+ lines.push(' created_after=created_after,');
+ lines.push(' max_seen_delivery_ids=max_seen_delivery_ids,');
+ lines.push(' ):');
+ lines.push(' if payload_type is None or isinstance(event.payload, payload_type):');
+ lines.push(' yield event.payload');
+}
+
function generateClient(ir: IR): { content: string; needUtils: boolean } {
const lines: string[] = [];
const needToDict = needsAsdict(ir);
const imports = collectClientImports(ir);
const needUtils = ir.services.length > 0;
+ const needPolling = ir.services.some(hasWebhookPolling);
if (ir.services.length > 0) {
lines.push('from __future__ import annotations');
lines.push('');
+ if (needPolling) lines.push('import asyncio');
+ if (needPolling) lines.push('from collections import deque');
+ if (needPolling) lines.push('from datetime import datetime, timezone');
+ if (needPolling) lines.push('from typing import AsyncIterator, TypeVar');
+ if (needPolling) lines.push('');
lines.push('import httpx');
lines.push('');
}
@@ -781,6 +879,11 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } {
if (needUtils) lines.push(`from .utils import ${utilImports.join(', ')}`);
}
+ if (needPolling) {
+ lines.push('');
+ lines.push('TPayload = TypeVar("TPayload", bound=WebhookPayloadUnion)');
+ }
+
if (ir.services.length === 0) {
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
lines.push('');
@@ -798,6 +901,10 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } {
lines.push('');
emitThrowingPaginationMethod(lines, svc.operations[i]);
}
+ if (svc.operations[i].methodName === 'getWebhookEvents' && svc.operations[i].successResponse.dataRef === 'WebhookEvent') {
+ lines.push('');
+ emitWebhookPollingMethods(lines);
+ }
if (i < svc.operations.length - 1) lines.push('');
}
lines.push('');
@@ -881,26 +988,44 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } {
return { content: lines.join('\n'), needUtils };
}
-function generateUtils(): string {
- return [
+function generateUtils(ir: IR): string {
+ const lines: string[] = [
'from __future__ import annotations',
'',
'import dataclasses',
'import keyword',
'from dataclasses import asdict, fields',
'from datetime import datetime',
- 'from typing import Type, TypeVar, get_args, get_origin, get_type_hints',
+ 'from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints',
'',
'import httpx',
+ ];
+
+ const customUnions = ir.unions.filter((u) => u.unionDeserializer === 'webhook-payload');
+ if (customUnions.length > 0) {
+ const importNames = new Set();
+ for (const u of customUnions) {
+ importNames.add(u.name);
+ for (const ref of u.memberRefs) importNames.add(ref);
+ }
+ lines.push('');
+ lines.push('from .models import (');
+ for (const name of [...importNames].sort()) {
+ lines.push(` ${name},`);
+ }
+ lines.push(')');
+ }
+
+ lines.push(
'',
'T = TypeVar("T")',
'',
'',
- 'def _is_dataclass_type(tp: type) -> bool:',
+ 'def _is_dataclass_type(tp: object) -> bool:',
' return isinstance(tp, type) and dataclasses.is_dataclass(tp)',
'',
'',
- 'def _resolve_type(tp: type) -> type | None:',
+ 'def _resolve_type(tp: object) -> type | None:',
' """Extract a concrete dataclass type from Optional[X] or X | None."""',
' origin = get_origin(tp)',
' if origin is list:',
@@ -914,7 +1039,7 @@ function generateUtils(): string {
' return None',
'',
'',
- 'def _resolve_list_item_type(tp: type) -> type | None:',
+ 'def _resolve_list_item_type(tp: object) -> object | None:',
' """Extract the item type from list[X]."""',
' origin = get_origin(tp)',
' if origin is list:',
@@ -924,8 +1049,34 @@ function generateUtils(): string {
' return None',
'',
'',
- 'def deserialize(cls: Type[T], data: dict) -> T:',
- ' """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""',
+ 'CustomUnionDeserializer = Callable[[dict], object]',
+ '',
+ '',
+ 'def _deserialize_instance(tp: object, value: object) -> object:',
+ ' custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)',
+ ' if custom is not None and isinstance(value, dict):',
+ ' return custom(value)',
+ ' if isinstance(value, dict):',
+ ' nested = _resolve_type(tp)',
+ ' if nested is not None:',
+ ' return _deserialize_dataclass(nested, value)',
+ ' if isinstance(value, list):',
+ ' item_tp = _resolve_list_item_type(tp)',
+ ' if item_tp is not None:',
+ ' return [_deserialize_instance(item_tp, item) for item in value]',
+ ' if isinstance(value, str):',
+ ' raw_tp = tp',
+ ' if get_origin(tp) is not None:',
+ ' for arg in get_args(tp):',
+ ' if arg is not type(None):',
+ ' raw_tp = arg',
+ ' break',
+ ' if raw_tp is datetime:',
+ ' return datetime.fromisoformat(value)',
+ ' return value',
+ '',
+ '',
+ 'def _deserialize_dataclass(cls: Type[T], data: dict) -> T:',
' field_map = {f.name: f for f in fields(cls)}',
' hints = get_type_hints(cls)',
' norm = {k.replace("-", "_").lower(): v for k, v in data.items()}',
@@ -936,79 +1087,103 @@ function generateUtils(): string {
' if k not in field_map:',
' continue',
' f = field_map[k]',
- ' if isinstance(v, dict):',
- ' nested = _resolve_type(hints[f.name])',
- ' if nested is not None:',
- ' v = deserialize(nested, v)',
- ' elif isinstance(v, list) and v:',
- ' item_tp = _resolve_list_item_type(hints[f.name])',
- ' if item_tp is not None and _is_dataclass_type(item_tp):',
- ' v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]',
- ' elif isinstance(v, str):',
- ' hint = hints.get(f.name)',
- ' raw_hint = hint',
- ' if get_origin(hint) is not None:',
- ' for a in get_args(hint):',
- ' if a is not type(None):',
- ' raw_hint = a',
- ' break',
- ' if raw_hint is datetime:',
- ' v = datetime.fromisoformat(v)',
- ' kwargs[k] = v',
+ ' kwargs[k] = _deserialize_instance(hints[f.name], v)',
' return cls(**kwargs)',
- '',
- '',
- 'def _strip_nones(val: object) -> object:',
- ' if isinstance(val, dict):',
- ' return {',
- ' (k[:-1] if k.endswith("_") and keyword.iskeyword(k[:-1]) else k): _strip_nones(v)',
- ' for k, v in val.items() if v is not None',
- ' }',
- ' if isinstance(val, list):',
- ' return [_strip_nones(v) for v in val]',
- ' if isinstance(val, datetime):',
- ' return val.isoformat()',
- ' return val',
- '',
- '',
- 'def serialize(obj: object) -> dict:',
- ' """Convert a dataclass to a dict, recursively omitting None values."""',
- ' return _strip_nones(asdict(obj))',
- '',
- '',
- '_MAX_RETRIES = 3',
- '_RETRYABLE_5XX = {500, 502, 503, 504}',
- '',
- '',
- 'def _jitter(delay: float) -> float:',
- ' import random',
- ' return delay * (0.5 + random.random() * 0.5)',
- '',
- '',
- 'class RetryTransport(httpx.AsyncBaseTransport):',
- ' """Wraps an httpx transport with retry on 429 Too Many Requests and 5xx errors."""',
- '',
- ' def __init__(self, transport: httpx.AsyncBaseTransport, max_retries: int = _MAX_RETRIES) -> None:',
- ' self._transport = transport',
- ' self._max_retries = max_retries',
- '',
- ' async def handle_async_request(self, request: httpx.Request) -> httpx.Response:',
- ' import asyncio',
- ' for attempt in range(self._max_retries + 1):',
- ' response = await self._transport.handle_async_request(request)',
- ' if response.status_code == 429 and attempt < self._max_retries:',
- ' retry_after = response.headers.get("retry-after")',
- ' delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt',
- ' await asyncio.sleep(_add_jitter(delay))',
- ' continue',
- ' if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries:',
- ' delay = attempt + 1',
- ' await asyncio.sleep(_add_jitter(delay))',
- ' continue',
- ' return response',
- ' return response # unreachable',
- '',
- ].join('\n');
+ ''
+ );
+
+ if (customUnions.length > 0) {
+ for (const u of customUnions) {
+ const fnName = `_${camelToSnake(u.name)}_deserialize`;
+ if (u.unionDeserializer === 'webhook-payload') {
+ lines.push(`def ${fnName}(data: dict) -> ${u.name}:`);
+ lines.push(' match (data.get("type"), data.get("event")):');
+ lines.push(' case ("message", "link_shared"):');
+ lines.push(' return _deserialize_instance(LinkSharedWebhookPayload, data)');
+ lines.push(' case ("message", _):');
+ lines.push(' return _deserialize_instance(MessageWebhookPayload, data)');
+ for (const ref of u.memberRefs.filter((ref) => ref !== 'MessageWebhookPayload' && ref !== 'LinkSharedWebhookPayload')) {
+ const model = ir.models.find((m) => m.name === ref);
+ const typeField = model?.fields.find(
+ (f) => f.name === u.discriminatorField && f.type.kind === 'literal',
+ ) ?? model?.fields.find((f) => f.type.kind === 'literal');
+ const disc = typeField?.type.literalValue;
+ if (disc) {
+ lines.push(` case (${JSON.stringify(disc)}, _):`);
+ lines.push(` return _deserialize_instance(${ref}, data)`);
+ }
+ }
+ lines.push(' case _:');
+ lines.push(` raise ValueError(f"Unknown ${u.name} discriminator: {data.get('type')}")`);
+ lines.push('');
+ }
+ }
+ }
+
+ lines.push('_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {');
+ for (const u of customUnions) {
+ lines.push(` ${u.name}: _${camelToSnake(u.name)}_deserialize,`);
+ }
+ lines.push('}');
+ lines.push('');
+ lines.push('');
+ lines.push('def deserialize(cls: Type[T], data: dict) -> T:');
+ lines.push(' """Create a typed instance from a dict, recursively deserializing nested values."""');
+ lines.push(' return _deserialize_instance(cls, data)');
+ lines.push('');
+ lines.push('');
+ lines.push('def _strip_nones(val: object) -> object:');
+ lines.push(' if isinstance(val, dict):');
+ lines.push(' return {');
+ lines.push(' (k[:-1] if k.endswith("_") and keyword.iskeyword(k[:-1]) else k): _strip_nones(v)');
+ lines.push(' for k, v in val.items() if v is not None');
+ lines.push(' }');
+ lines.push(' if isinstance(val, list):');
+ lines.push(' return [_strip_nones(v) for v in val]');
+ lines.push(' if isinstance(val, datetime):');
+ lines.push(' return val.isoformat()');
+ lines.push(' return val');
+ lines.push('');
+ lines.push('');
+ lines.push('def serialize(obj: object) -> dict:');
+ lines.push(' """Convert a dataclass to a dict, recursively omitting None values."""');
+ lines.push(' return _strip_nones(asdict(obj))');
+ lines.push('');
+ lines.push('');
+ lines.push('_MAX_RETRIES = 3');
+ lines.push('_RETRYABLE_5XX = {500, 502, 503, 504}');
+ lines.push('');
+ lines.push('');
+ lines.push('def _jitter(delay: float) -> float:');
+ lines.push(' import random');
+ lines.push(' return delay * (0.5 + random.random() * 0.5)');
+ lines.push('');
+ lines.push('');
+ lines.push('class RetryTransport(httpx.AsyncBaseTransport):');
+ lines.push(' """Wraps an httpx transport with retry on 429 Too Many Requests and 5xx errors."""');
+ lines.push('');
+ lines.push(' def __init__(self, transport: httpx.AsyncBaseTransport, max_retries: int = _MAX_RETRIES) -> None:');
+ lines.push(' self._transport = transport');
+ lines.push(' self._max_retries = max_retries');
+ lines.push('');
+ lines.push(' async def handle_async_request(self, request: httpx.Request) -> httpx.Response:');
+ lines.push(' import asyncio');
+ lines.push(' for attempt in range(self._max_retries + 1):');
+ lines.push(' response = await self._transport.handle_async_request(request)');
+ lines.push(' if response.status_code == 429 and attempt < self._max_retries:');
+ lines.push(' retry_after = response.headers.get("retry-after")');
+ lines.push(' delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt');
+ lines.push(' await asyncio.sleep(_add_jitter(delay))');
+ lines.push(' continue');
+ lines.push(' if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries:');
+ lines.push(' delay = attempt + 1');
+ lines.push(' await asyncio.sleep(_add_jitter(delay))');
+ lines.push(' continue');
+ lines.push(' return response');
+ lines.push(' return response # unreachable');
+ lines.push('');
+
+ return lines.join('\n');
}
// ── Examples ──────────────────────────────────────────────────────────
@@ -1288,6 +1463,14 @@ function generateExamples(ir: IR): string {
if (ex.output) entry.output = ex.output;
if (ex.imports.length > 0) entry.imports = ex.imports;
result[op.operationId] = entry;
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ result[`${op.operationId}_pollWebhookEvents`] = {
+ usage: `async for event in client.${serviceProp}.poll_webhook_events(interval_seconds=5.0):\n print(event)`,
+ };
+ result[`${op.operationId}_pollWebhookPayloads`] = {
+ usage: `async for payload in client.${serviceProp}.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)`,
+ };
+ }
}
}
@@ -1306,7 +1489,7 @@ export class PythonGenerator implements LanguageGenerator {
const client = generateClient(ir);
files.push({ path: 'client.py', content: client.content });
if (client.needUtils) {
- files.push({ path: 'utils.py', content: generateUtils() });
+ files.push({ path: 'utils.py', content: generateUtils(ir) });
}
if (options?.examples) {
files.push({ path: 'examples.json', content: generateExamples(ir) });
diff --git a/packages/generator/src/lang/swift.ts b/packages/generator/src/lang/swift.ts
index 60232c8b..3c298cad 100644
--- a/packages/generator/src/lang/swift.ts
+++ b/packages/generator/src/lang/swift.ts
@@ -181,22 +181,46 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void {
} else {
lines.push(` case ${discSwiftName} = ${JSON.stringify(discField)}`);
}
+ if (u.unionDeserializer === 'webhook-payload') {
+ lines.push(' case event');
+ }
lines.push(' }');
lines.push('');
lines.push(' public init(from decoder: Decoder) throws {');
lines.push(' let container = try decoder.container(keyedBy: CodingKeys.self)');
lines.push(` let type = try container.decode(String.self, forKey: .${discSwiftName})`);
- lines.push(' switch type {');
- const seenDiscs = new Set();
- for (const ref of u.memberRefs) {
- const c = ref.charAt(0).toLowerCase() + ref.slice(1);
- const model = models.find((m) => m.name === ref);
- const typeField = model?.fields.find((f) => f.type.kind === 'literal');
- const disc = typeField?.type.literalValue ?? c;
- if (seenDiscs.has(String(disc))) continue;
- seenDiscs.add(String(disc));
- lines.push(` case ${JSON.stringify(disc)}:`);
- lines.push(` self = .${c}(try ${ref}(from: decoder))`);
+ if (u.unionDeserializer === 'webhook-payload') {
+ lines.push(' let event = try? container.decode(String.self, forKey: .event)');
+ lines.push(' switch (type, event) {');
+ lines.push(' case ("message", "link_shared"):');
+ lines.push(' self = .linkSharedWebhookPayload(try LinkSharedWebhookPayload(from: decoder))');
+ lines.push(' case ("message", _):');
+ lines.push(' self = .messageWebhookPayload(try MessageWebhookPayload(from: decoder))');
+ for (const ref of u.memberRefs.filter((ref) => ref !== 'MessageWebhookPayload' && ref !== 'LinkSharedWebhookPayload')) {
+ const c = ref.charAt(0).toLowerCase() + ref.slice(1);
+ const model = models.find((m) => m.name === ref);
+ const typeField = model?.fields.find(
+ (f) => f.name === discField && f.type.kind === 'literal',
+ ) ?? model?.fields.find((f) => f.type.kind === 'literal');
+ const disc = typeField?.type.literalValue ?? c;
+ lines.push(` case (${JSON.stringify(disc)}, _):`);
+ lines.push(` self = .${c}(try ${ref}(from: decoder))`);
+ }
+ } else {
+ lines.push(' switch type {');
+ const seenDiscs = new Set();
+ for (const ref of u.memberRefs) {
+ const c = ref.charAt(0).toLowerCase() + ref.slice(1);
+ const model = models.find((m) => m.name === ref);
+ const typeField = model?.fields.find(
+ (f) => f.name === discField && f.type.kind === 'literal',
+ ) ?? model?.fields.find((f) => f.type.kind === 'literal');
+ const disc = typeField?.type.literalValue ?? c;
+ if (seenDiscs.has(String(disc))) continue;
+ seenDiscs.add(String(disc));
+ lines.push(` case ${JSON.stringify(disc)}:`);
+ lines.push(` self = .${c}(try ${ref}(from: decoder))`);
+ }
}
lines.push(' default:');
lines.push(' throw DecodingError.dataCorrupted(');
@@ -513,6 +537,10 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR, fnPrefix
lines.push(' }');
}
+function hasWebhookPolling(svc: IRService): boolean {
+ return svc.operations.some((op) => op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent');
+}
+
function emitThrowingOperation(lines: string[], op: IROperation, ir: IR): void {
const args: string[] = [];
if (op.externalUrl) args.push(`${op.externalUrl}: String`);
@@ -547,6 +575,92 @@ function emitThrowingPaginationMethod(lines: string[], op: IROperation): void {
lines.push(' }');
}
+function emitWebhookPollingMethods(lines: string[], prefix: string): void {
+ lines.push(` ${prefix} pollWebhookEvents(`);
+ lines.push(' limit: Int? = 50,');
+ lines.push(' interval: TimeInterval = 5,');
+ lines.push(' createdAfter: Date? = nil,');
+ lines.push(' maxSeenDeliveryIds: Int = 5_000');
+ lines.push(' ) -> AsyncThrowingStream {');
+ lines.push(' AsyncThrowingStream { continuation in');
+ lines.push(' let task = _Concurrency.Task {');
+ lines.push(' do {');
+ lines.push(' guard maxSeenDeliveryIds > 0 else {');
+ lines.push(' throw NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: "maxSeenDeliveryIds must be greater than 0"])');
+ lines.push(' }');
+ lines.push('');
+ lines.push(' let effectiveCreatedAfter = createdAfter ?? Date()');
+ lines.push(' var seenIdOrder: [String] = []');
+ lines.push(' var seenIds = Set()');
+ lines.push('');
+ lines.push(' func remember(_ id: String) -> Bool {');
+ lines.push(' guard seenIds.insert(id).inserted else { return false }');
+ lines.push(' seenIdOrder.append(id)');
+ lines.push(' while seenIdOrder.count > maxSeenDeliveryIds {');
+ lines.push(' seenIds.remove(seenIdOrder.removeFirst())');
+ lines.push(' }');
+ lines.push(' return true');
+ lines.push(' }');
+ lines.push('');
+ lines.push(' while !_Concurrency.Task.isCancelled {');
+ lines.push(' var cursor: String? = nil');
+ lines.push(' var hasNext = true');
+ lines.push(' while hasNext && !_Concurrency.Task.isCancelled {');
+ lines.push(' let response = try await getWebhookEvents(limit: limit, cursor: cursor)');
+ lines.push(' var pageHasRecentEvents = false');
+ lines.push(' for event in response.data.reversed() {');
+ lines.push(' let matchesCreatedAfter = pachcaParseWebhookDate(event.createdAt).map { $0 >= effectiveCreatedAfter } == true');
+ lines.push(' if matchesCreatedAfter {');
+ lines.push(' pageHasRecentEvents = true');
+ lines.push(' }');
+ lines.push(' if matchesCreatedAfter && remember(event.id) {');
+ lines.push(' continuation.yield(event)');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push(' hasNext = (response.meta.paginate.hasNext ?? !response.data.isEmpty) && pageHasRecentEvents');
+ lines.push(' cursor = response.meta.paginate.nextPage');
+ lines.push(' }');
+ lines.push(' try await _Concurrency.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000))');
+ lines.push(' }');
+ lines.push(' continuation.finish()');
+ lines.push(' } catch {');
+ lines.push(' continuation.finish(throwing: error)');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push(' continuation.onTermination = { _ in task.cancel() }');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push('');
+ lines.push(` ${prefix} pollWebhookPayloads(`);
+ lines.push(' limit: Int? = 50,');
+ lines.push(' interval: TimeInterval = 5,');
+ lines.push(' createdAfter: Date? = nil,');
+ lines.push(' maxSeenDeliveryIds: Int = 5_000,');
+ lines.push(' includePayload: @escaping (WebhookPayloadUnion) -> Bool = { _ in true }');
+ lines.push(' ) -> AsyncThrowingStream {');
+ lines.push(' AsyncThrowingStream { continuation in');
+ lines.push(' let task = _Concurrency.Task {');
+ lines.push(' do {');
+ lines.push(' for try await event in pollWebhookEvents(');
+ lines.push(' limit: limit,');
+ lines.push(' interval: interval,');
+ lines.push(' createdAfter: createdAfter,');
+ lines.push(' maxSeenDeliveryIds: maxSeenDeliveryIds');
+ lines.push(' ) {');
+ lines.push(' if includePayload(event.payload) {');
+ lines.push(' continuation.yield(event.payload)');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push(' continuation.finish()');
+ lines.push(' } catch {');
+ lines.push(' continuation.finish(throwing: error)');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push(' continuation.onTermination = { _ in task.cancel() }');
+ lines.push(' }');
+ lines.push(' }');
+}
+
function generateClient(ir: IR): string {
const lines: string[] = [];
lines.push(...FOUNDATION_IMPORTS);
@@ -558,6 +672,15 @@ function generateClient(ir: IR): string {
lines.push('}');
lines.push('');
}
+ if (ir.services.some(hasWebhookPolling)) {
+ lines.push('private func pachcaParseWebhookDate(_ value: String) -> Date? {');
+ lines.push(' let fractionalFormatter = ISO8601DateFormatter()');
+ lines.push(' fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]');
+ lines.push(' if let date = fractionalFormatter.date(from: value) { return date }');
+ lines.push(' return ISO8601DateFormatter().date(from: value)');
+ lines.push('}');
+ lines.push('');
+ }
for (const s of ir.services) {
const cls = tagToServiceName(s.tag);
const implName = serviceToImplName(cls);
@@ -570,6 +693,10 @@ function generateClient(ir: IR): string {
lines.push('');
emitThrowingPaginationMethod(lines, s.operations[i]);
}
+ if (s.operations[i].methodName === 'getWebhookEvents' && s.operations[i].successResponse.dataRef === 'WebhookEvent') {
+ lines.push('');
+ emitWebhookPollingMethods(lines, 'open func');
+ }
if (i < s.operations.length - 1) lines.push('');
}
lines.push('}');
@@ -1060,6 +1187,14 @@ function swiftGenerateExamples(ir: IR): string {
if (ex.output) entry.output = ex.output;
if (ex.imports.length > 0) entry.imports = ex.imports;
result[op.operationId] = entry;
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ result[`${op.operationId}_pollWebhookEvents`] = {
+ usage: `for try await event in client.${serviceProp}.pollWebhookEvents(interval: 5) {\n print(event)\n}`,
+ };
+ result[`${op.operationId}_pollWebhookPayloads`] = {
+ usage: `for try await payload in client.${serviceProp}.pollWebhookPayloads(interval: 5) {\n print(payload)\n}`,
+ };
+ }
}
}
diff --git a/packages/generator/src/lang/typescript.ts b/packages/generator/src/lang/typescript.ts
index d2a4bc36..4bd1f20c 100644
--- a/packages/generator/src/lang/typescript.ts
+++ b/packages/generator/src/lang/typescript.ts
@@ -411,6 +411,9 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } {
addImport(op.successResponse.dataRef);
}
}
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ addImport('WebhookPayloadUnion');
+ }
if (op.hasOAuthError || ir.models.some((m) => m.name === 'OAuthError')) {
addImport('OAuthError');
}
@@ -446,7 +449,8 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } {
if (needsDeserialize || needsSerialize || hasServices) {
const utils = [
needsDeserialize ? 'deserialize' : null,
- needsSerialize ? 'serialize' : null,
+ needsDeserialize ? 'deserializeType' : null,
+ needsSerialize ? 'serializeType' : null,
hasServices ? 'fetchWithRetry' : null,
]
.filter((x): x is string => !!x)
@@ -454,6 +458,11 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } {
lines.push(`import { ${utils} } from "./utils.js";`);
}
+ if (ir.services.some(hasWebhookPolling)) {
+ lines.push('');
+ emitWebhookPollingPrelude(lines);
+ }
+
if (hasServices) lines.push('');
for (const svc of ir.services) {
@@ -523,6 +532,10 @@ function emitService(lines: string[], svc: IRService, ir: IR): void {
lines.push('');
emitThrowingPaginationMethod(lines, svc.operations[i], ir);
}
+ if (svc.operations[i].methodName === 'getWebhookEvents' && svc.operations[i].successResponse.dataRef === 'WebhookEvent') {
+ lines.push('');
+ emitWebhookPollingMethods(lines);
+ }
if (i < svc.operations.length - 1) lines.push('');
}
lines.push('}');
@@ -572,6 +585,91 @@ function emitThrowingPaginationMethod(lines: string[], op: IROperation, ir: IR):
lines.push(' }');
}
+function hasWebhookPolling(svc: IRService): boolean {
+ return svc.operations.some((op) => op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent');
+}
+
+function emitWebhookPollingPrelude(lines: string[]): void {
+ lines.push('export interface PollWebhookEventsParams {');
+ lines.push(' limit?: number;');
+ lines.push(' intervalMs?: number;');
+ lines.push(' createdAfter?: Date | string | null;');
+ lines.push(' maxSeenDeliveryIds?: number;');
+ lines.push('}');
+ lines.push('');
+ lines.push('export interface PollWebhookPayloadsParams extends PollWebhookEventsParams {');
+ lines.push(' filter?: (payload: WebhookPayloadUnion, event: WebhookEvent) => payload is TPayload;');
+ lines.push('}');
+ lines.push('');
+ lines.push('const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));');
+ lines.push('');
+ lines.push('function createdAtMatches(event: WebhookEvent, createdAfter: Date | string | null | undefined): boolean {');
+ lines.push(' if (createdAfter == null) return true;');
+ lines.push(' return new Date(event.createdAt).getTime() >= new Date(createdAfter).getTime();');
+ lines.push('}');
+}
+
+function emitThrowingWebhookPollingMethods(lines: string[], svc: IRService): void {
+ lines.push(' async *pollWebhookEvents(params?: PollWebhookEventsParams): AsyncGenerator {');
+ lines.push(` throw new Error(${JSON.stringify(`${svc.tag}.pollWebhookEvents is not implemented`)});`);
+ lines.push(' }');
+ lines.push('');
+ lines.push(' async *pollWebhookPayloads(');
+ lines.push(' params?: PollWebhookPayloadsParams,');
+ lines.push(' ): AsyncGenerator {');
+ lines.push(` throw new Error(${JSON.stringify(`${svc.tag}.pollWebhookPayloads is not implemented`)});`);
+ lines.push(' }');
+}
+
+function emitWebhookPollingMethods(lines: string[]): void {
+ lines.push(' async *pollWebhookEvents(params?: PollWebhookEventsParams): AsyncGenerator {');
+ lines.push(' const limit = params?.limit ?? 50;');
+ lines.push(' const intervalMs = params?.intervalMs ?? 5_000;');
+ lines.push(' const createdAfter = params?.createdAfter ?? new Date();');
+ lines.push(' const maxSeenDeliveryIds = params?.maxSeenDeliveryIds ?? 5_000;');
+ lines.push(' if (maxSeenDeliveryIds <= 0) throw new Error("maxSeenDeliveryIds must be greater than 0");');
+ lines.push('');
+ lines.push(' const seenIdOrder: string[] = [];');
+ lines.push(' const seenIds = new Set();');
+ lines.push(' const remember = (id: string): boolean => {');
+ lines.push(' if (seenIds.has(id)) return false;');
+ lines.push(' seenIds.add(id);');
+ lines.push(' seenIdOrder.push(id);');
+ lines.push(' while (seenIdOrder.length > maxSeenDeliveryIds) {');
+ lines.push(' const oldest = seenIdOrder.shift();');
+ lines.push(' if (oldest !== undefined) seenIds.delete(oldest);');
+ lines.push(' }');
+ lines.push(' return true;');
+ lines.push(' };');
+ lines.push('');
+ lines.push(' while (true) {');
+ lines.push(' let cursor: string | undefined;');
+ lines.push(' let hasNext = true;');
+ lines.push(' while (hasNext) {');
+ lines.push(' const response = await this.getWebhookEvents({ limit, cursor });');
+ lines.push(' let pageHasRecentEvents = false;');
+ lines.push(' for (const event of [...response.data].reverse()) {');
+ lines.push(' const matchesCreatedAfter = createdAtMatches(event, createdAfter);');
+ lines.push(' if (matchesCreatedAfter) pageHasRecentEvents = true;');
+ lines.push(' if (matchesCreatedAfter && remember(event.id)) yield event;');
+ lines.push(' }');
+ lines.push(' hasNext = (response.meta.paginate.hasNext ?? response.data.length > 0) && pageHasRecentEvents;');
+ lines.push(' cursor = response.meta.paginate.nextPage;');
+ lines.push(' }');
+ lines.push(' await sleep(intervalMs);');
+ lines.push(' }');
+ lines.push(' }');
+ lines.push('');
+ lines.push(' async *pollWebhookPayloads(');
+ lines.push(' params?: PollWebhookPayloadsParams,');
+ lines.push(' ): AsyncGenerator {');
+ lines.push(' for await (const event of this.pollWebhookEvents(params)) {');
+ lines.push(' const payload = event.payload;');
+ lines.push(' if (params?.filter == null || params.filter(payload, event)) yield payload as TPayload;');
+ lines.push(' }');
+ lines.push(' }');
+}
+
function emitOperation(lines: string[], op: IROperation, ir: IR): void {
const args = methodArgs(op);
const ret = responseTypeName(op, ir);
@@ -692,7 +790,7 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void {
const sdk = snakeToCamel(f.name);
lines.push(` body: JSON.stringify({ ${f.name}: ${sdk} }),`);
} else {
- lines.push(' body: JSON.stringify(serialize(request)),');
+ lines.push(` body: JSON.stringify(serializeType(${JSON.stringify(rb.schemaRef)}, request)),`);
}
}
lines.push(' });');
@@ -757,6 +855,13 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void {
lines.push(' }');
}
+function responseNeedsTypedDataItems(op: IROperation, ir: IR): boolean {
+ if (!op.successResponse.dataRef) return false;
+ const customUnionNames = new Set(ir.unions.filter((u) => u.unionDeserializer).map((u) => u.name));
+ const model = ir.models.find((m) => m.name === op.successResponse.dataRef);
+ return !!model && hasDirectCustomUnionField(model, customUnionNames);
+}
+
function emitResponseSwitch(
lines: string[],
op: IROperation,
@@ -777,13 +882,17 @@ function emitResponseSwitch(
lines.push(' return;');
} else if (op.successResponse.isList) {
lines.push(` case ${op.successResponse.statusCode}:`);
- lines.push(` return deserialize(body) as ${responseTypeName(op, ir)};`);
+ if (responseNeedsTypedDataItems(op, ir) && op.successResponse.dataRef) {
+ lines.push(` return { ...(deserialize(body) as ${responseTypeName(op, ir)}), data: Array.isArray(body.data) ? body.data.map((item: unknown) => deserializeType(${JSON.stringify(op.successResponse.dataRef)}, item) as ${op.successResponse.dataRef}) : [] } as ${responseTypeName(op, ir)};`);
+ } else {
+ lines.push(` return deserialize(body) as ${responseTypeName(op, ir)};`);
+ }
} else if (op.successResponse.isUnwrap && op.successResponse.dataRef) {
lines.push(` case ${op.successResponse.statusCode}:`);
- lines.push(` return deserialize(body.data) as ${op.successResponse.dataRef};`);
+ lines.push(` return deserializeType(${JSON.stringify(op.successResponse.dataRef)}, body.data) as ${op.successResponse.dataRef};`);
} else {
lines.push(` case ${op.successResponse.statusCode}:`);
- lines.push(` return deserialize(body) as ${responseTypeName(op, ir)};`);
+ lines.push(` return deserializeType(${JSON.stringify(responseTypeName(op, ir))}, body) as ${responseTypeName(op, ir)};`);
}
if (op.hasOAuthError) {
@@ -820,8 +929,94 @@ function collectRecordKeys(ir: IR): Set {
return keys;
}
+function hasDirectCustomUnionField(model: IRModel, customUnionNames: Set): boolean {
+ return model.fields.some((field) =>
+ (field.type.kind === 'union' || field.type.kind === 'model')
+ && !!field.type.ref
+ && customUnionNames.has(field.type.ref),
+ );
+}
+
+function renderTransformExpr(
+ ft: IRFieldType,
+ mode: 'deserialize' | 'serialize',
+ valueName: string,
+ customUnionNames: Set,
+): string {
+ const typeFn = mode === 'deserialize' ? 'deserializeType' : 'serializeType';
+ const valueFn = mode === 'deserialize' ? 'deserialize' : 'serialize';
+ const arrayFn = mode === 'deserialize' ? 'deserializeArray' : 'serializeArray';
+ const recordFn = mode === 'deserialize' ? 'deserializeRecordWith' : 'serializeRecordWith';
+
+ switch (ft.kind) {
+ case 'model':
+ case 'union':
+ if (ft.ref && customUnionNames.has(ft.ref)) return `${valueFn}(${valueName})`;
+ return ft.ref ? `${typeFn}(${JSON.stringify(ft.ref)}, ${valueName})` : `${valueFn}(${valueName})`;
+ case 'array':
+ return `${arrayFn}(${valueName}, (item) => ${renderTransformExpr(ft.items!, mode, 'item', customUnionNames)})`;
+ case 'record':
+ return `${recordFn}(${valueName}, (entryValue) => ${renderTransformExpr(ft.valueType!, mode, 'entryValue', customUnionNames)})`;
+ default:
+ return `${valueFn}(${valueName})`;
+ }
+}
+
+function emitObjectTransform(
+ lines: string[],
+ name: string,
+ fields: IRField[],
+ mode: 'deserialize' | 'serialize',
+ hasRecords: boolean,
+ customUnionNames: Set,
+): void {
+ const fnName = `${mode}${name}`;
+ const fallback = mode === 'deserialize'
+ ? hasRecords
+ ? '[ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)]'
+ : '[ck, deserialize(v)]'
+ : hasRecords
+ ? '[camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]'
+ : '[camelToSnake(k), serialize(v)]';
+
+ lines.push(`function ${fnName}(obj: unknown): unknown {`);
+ lines.push(` if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return ${mode}(obj);`);
+ lines.push(' return Object.fromEntries(');
+ lines.push(' Object.entries(obj)');
+ if (mode === 'serialize') lines.push(' .filter(([, v]) => v !== undefined)');
+ lines.push(' .map(([k, v]) => {');
+ if (mode === 'deserialize') {
+ lines.push(' const ck = snakeToCamel(k);');
+ lines.push(' switch (ck) {');
+ for (const field of fields) {
+ const sdkName = fieldSdkName(field);
+ lines.push(` case ${JSON.stringify(sdkName)}:`);
+ lines.push(` return [ck, ${renderTransformExpr(field.type, mode, 'v', customUnionNames)}];`);
+ }
+ } else {
+ lines.push(' switch (k) {');
+ for (const field of fields) {
+ const sdkName = fieldSdkName(field);
+ lines.push(` case ${JSON.stringify(sdkName)}:`);
+ lines.push(` return [camelToSnake(k), ${renderTransformExpr(field.type, mode, 'v', customUnionNames)}];`);
+ }
+ }
+ lines.push(' default:');
+ lines.push(` return ${fallback};`);
+ lines.push(' }');
+ lines.push(' }),');
+ lines.push(' );');
+ lines.push('}');
+ lines.push('');
+}
+
+function emitCustomUnionTransforms(_lines: string[], _ir: IR): void {}
+
function generateUtils(ir: IR): string {
const recordKeys = collectRecordKeys(ir);
+ const customUnionNames = new Set(ir.unions.filter((u) => u.unionDeserializer).map((u) => u.name));
+ const targetModels = ir.models.filter((m) => hasDirectCustomUnionField(m, customUnionNames));
+
const lines: string[] = [
'function snakeToCamel(str: string): string {',
' const camel = str.replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase());',
@@ -841,39 +1036,41 @@ function generateUtils(ir: IR): string {
const keyList = [...recordKeys].map((k) => JSON.stringify(k)).join(', ');
lines.push(`const RECORD_KEYS = new Set([${keyList}]);`);
lines.push('');
- lines.push('function deserializeRecord(obj: unknown): unknown {');
- lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {');
- lines.push(' return Object.fromEntries(');
- lines.push(' Object.entries(obj).map(([k, v]) => [k, deserialize(v)]),');
- lines.push(' );');
- lines.push(' }');
- lines.push(' return deserialize(obj);');
- lines.push('}');
- lines.push('');
- lines.push('function serializeRecord(obj: unknown): unknown {');
- lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {');
- lines.push(' return Object.fromEntries(');
- lines.push(' Object.entries(obj)');
- lines.push(' .filter(([, v]) => v !== undefined)');
- lines.push(' .map(([k, v]) => [k, serialize(v)]),');
- lines.push(' );');
- lines.push(' }');
- lines.push(' return serialize(obj);');
- lines.push('}');
- lines.push('');
}
- const deserializeValue = hasRecords
- ? '([k, v]) => {\n const ck = snakeToCamel(k);\n return [ck, RECORD_KEYS.has(ck) ? deserializeRecord(v) : deserialize(v)];\n }'
- : '([k, v]) => [snakeToCamel(k), deserialize(v)]';
- const serializeValue = hasRecords
- ? '([k, v]) => {\n return [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecord(v) : serialize(v)];\n }'
- : '([k, v]) => [camelToSnake(k), serialize(v)]';
+ lines.push('function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {');
+ lines.push(' return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);');
+ lines.push('}');
+ lines.push('');
+ lines.push('function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {');
+ lines.push(' return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);');
+ lines.push('}');
+ lines.push('');
+ lines.push('function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {');
+ lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {');
+ lines.push(' return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));');
+ lines.push(' }');
+ lines.push(' return deserialize(obj);');
+ lines.push('}');
+ lines.push('');
+ lines.push('function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {');
+ lines.push(' if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {');
+ lines.push(' return Object.fromEntries(');
+ lines.push(' Object.entries(obj)');
+ lines.push(' .filter(([, v]) => v !== undefined)');
+ lines.push(' .map(([k, v]) => [k, mapValue(v)]),');
+ lines.push(' );');
+ lines.push(' }');
+ lines.push(' return serialize(obj);');
+ lines.push('}');
+ lines.push('');
lines.push(
'export function deserialize(obj: unknown): unknown {',
' if (Array.isArray(obj)) return obj.map(deserialize);',
' if (obj !== null && typeof obj === "object") {',
' return Object.fromEntries(',
- ` Object.entries(obj).map(${deserializeValue}),`,
+ hasRecords
+ ? ' Object.entries(obj).map(([k, v]) => {\n const ck = snakeToCamel(k);\n return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)];\n }),'
+ : ' Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deserialize(v)]),',
' );',
' }',
' return obj;',
@@ -885,12 +1082,34 @@ function generateUtils(ir: IR): string {
' return Object.fromEntries(',
' Object.entries(obj)',
' .filter(([, v]) => v !== undefined)',
- ` .map(${serializeValue}),`,
+ hasRecords
+ ? ' .map(([k, v]) => [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]),'
+ : ' .map(([k, v]) => [camelToSnake(k), serialize(v)]),',
' );',
' }',
' return obj;',
'}',
+ '',
);
+
+ for (const model of targetModels) {
+ emitObjectTransform(lines, model.name, model.fields, 'deserialize', hasRecords, customUnionNames);
+ }
+
+ emitCustomUnionTransforms(lines, ir);
+
+ lines.push('const TYPE_DESERIALIZERS: Record unknown> = {');
+ for (const model of targetModels) lines.push(` ${JSON.stringify(model.name)}: deserialize${model.name},`);
+ lines.push('};');
+ lines.push('');
+ lines.push('export function deserializeType(type: string, obj: unknown): unknown {');
+ lines.push(' return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);');
+ lines.push('}');
+ lines.push('');
+ lines.push('export function serializeType(_type: string, obj: unknown): unknown {');
+ lines.push(' return serialize(obj);');
+ lines.push('}');
+
return [...lines,
'',
'const MAX_RETRIES = 3;',
@@ -1196,6 +1415,14 @@ function generateExamples(ir: IR): string {
if (ex.output) entry.output = ex.output;
if (ex.imports.length > 0) entry.imports = ex.imports;
result[op.operationId] = entry;
+ if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') {
+ result[`${op.operationId}_pollWebhookEvents`] = {
+ usage: `for await (const event of client.${serviceProp}.pollWebhookEvents({ intervalMs: 5_000 })) {\n console.log(event)\n}`,
+ };
+ result[`${op.operationId}_pollWebhookPayloads`] = {
+ usage: `for await (const payload of client.${serviceProp}.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}`,
+ };
+ }
}
}
diff --git a/packages/generator/src/transform.ts b/packages/generator/src/transform.ts
index f332a1f6..3e34a9d9 100644
--- a/packages/generator/src/transform.ts
+++ b/packages/generator/src/transform.ts
@@ -343,7 +343,12 @@ function transformUnion(name: string, schema: Schema, schemas: Record s.$ref)
.map((s) => refName(s.$ref!));
const discriminatorField = detectDiscriminatorField(schema, memberRefs, schemas);
- return { name, memberRefs, discriminatorField };
+ return {
+ name,
+ memberRefs,
+ discriminatorField,
+ unionDeserializer: schema['x-union-deserializer'],
+ };
}
// ----- Operations → IR -----
diff --git a/packages/generator/tests/array-no-brackets/snapshots/py/utils.py b/packages/generator/tests/array-no-brackets/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/array-no-brackets/snapshots/py/utils.py
+++ b/packages/generator/tests/array-no-brackets/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts b/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts
index 6f272e3f..4f292ed5 100644
--- a/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts
+++ b/packages/generator/tests/array-no-brackets/snapshots/ts/client.ts
@@ -4,7 +4,7 @@ import {
MessageResult,
OAuthError,
} from "./types.js";
-import { deserialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, fetchWithRetry } from "./utils.js";
export class SearchService {
async searchMessages(params: SearchMessagesParams): Promise {
diff --git a/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts b/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts
+++ b/packages/generator/tests/array-no-brackets/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/crud/snapshots/py/utils.py b/packages/generator/tests/crud/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/crud/snapshots/py/utils.py
+++ b/packages/generator/tests/crud/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/crud/snapshots/ts/client.ts b/packages/generator/tests/crud/snapshots/ts/client.ts
index ce015efd..ea5659f7 100644
--- a/packages/generator/tests/crud/snapshots/ts/client.ts
+++ b/packages/generator/tests/crud/snapshots/ts/client.ts
@@ -7,7 +7,7 @@ import {
ChatCreateRequest,
ChatUpdateRequest,
} from "./types.js";
-import { deserialize, serialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js";
export class ChatsService {
async listChats(params?: ListChatsParams): Promise {
@@ -90,7 +90,7 @@ export class ChatsServiceImpl extends ChatsService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
@@ -102,12 +102,12 @@ export class ChatsServiceImpl extends ChatsService {
const response = await fetchWithRetry(`${this.baseUrl}/chats`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ChatCreateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
@@ -119,12 +119,12 @@ export class ChatsServiceImpl extends ChatsService {
const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ChatUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
diff --git a/packages/generator/tests/crud/snapshots/ts/utils.ts b/packages/generator/tests/crud/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/crud/snapshots/ts/utils.ts
+++ b/packages/generator/tests/crud/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/date-format/snapshots/py/utils.py b/packages/generator/tests/date-format/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/date-format/snapshots/py/utils.py
+++ b/packages/generator/tests/date-format/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/date-format/snapshots/ts/client.ts b/packages/generator/tests/date-format/snapshots/ts/client.ts
index 74c535eb..31216410 100644
--- a/packages/generator/tests/date-format/snapshots/ts/client.ts
+++ b/packages/generator/tests/date-format/snapshots/ts/client.ts
@@ -5,7 +5,7 @@ import {
ExportRequest,
Export,
} from "./types.js";
-import { deserialize, serialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js";
export class ExportService {
async listEvents(params: ListEventsParams): Promise {
@@ -47,12 +47,12 @@ export class ExportServiceImpl extends ExportService {
const response = await fetchWithRetry(`${this.baseUrl}/exports`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ExportRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Export;
+ return deserializeType("Export", body.data) as Export;
case 401:
throw new OAuthError(body.error);
default:
diff --git a/packages/generator/tests/date-format/snapshots/ts/utils.ts b/packages/generator/tests/date-format/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/date-format/snapshots/ts/utils.ts
+++ b/packages/generator/tests/date-format/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/edge-cases/snapshots/cs/Models.cs b/packages/generator/tests/edge-cases/snapshots/cs/Models.cs
index fa92f6e9..94676096 100644
--- a/packages/generator/tests/edge-cases/snapshots/cs/Models.cs
+++ b/packages/generator/tests/edge-cases/snapshots/cs/Models.cs
@@ -89,6 +89,7 @@ public abstract class NotificationUnion
public class MessageNotification : NotificationUnion
{
+ [JsonPropertyName("kind")]
public override string Kind => "message";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -96,6 +97,7 @@ public class MessageNotification : NotificationUnion
public class ReactionNotification : NotificationUnion
{
+ [JsonPropertyName("kind")]
public override string Kind => "message";
[JsonPropertyName("emoji")]
public string Emoji { get; set; } = default!;
diff --git a/packages/generator/tests/edge-cases/snapshots/py/utils.py b/packages/generator/tests/edge-cases/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/edge-cases/snapshots/py/utils.py
+++ b/packages/generator/tests/edge-cases/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/edge-cases/snapshots/ts/client.ts b/packages/generator/tests/edge-cases/snapshots/ts/client.ts
index 769ad584..c7ea0b57 100644
--- a/packages/generator/tests/edge-cases/snapshots/ts/client.ts
+++ b/packages/generator/tests/edge-cases/snapshots/ts/client.ts
@@ -5,7 +5,7 @@ import {
Event,
UploadRequest,
} from "./types.js";
-import { deserialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, fetchWithRetry } from "./utils.js";
export class EventsService {
async listEvents(params?: ListEventsParams): Promise {
@@ -54,7 +54,7 @@ export class EventsServiceImpl extends EventsService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Event;
+ return deserializeType("Event", body.data) as Event;
default:
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`);
}
diff --git a/packages/generator/tests/edge-cases/snapshots/ts/utils.ts b/packages/generator/tests/edge-cases/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/edge-cases/snapshots/ts/utils.ts
+++ b/packages/generator/tests/edge-cases/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/multi-path-params/snapshots/py/utils.py b/packages/generator/tests/multi-path-params/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/multi-path-params/snapshots/py/utils.py
+++ b/packages/generator/tests/multi-path-params/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/multi-path-params/snapshots/ts/client.ts b/packages/generator/tests/multi-path-params/snapshots/ts/client.ts
index b510ce5d..a147b3e6 100644
--- a/packages/generator/tests/multi-path-params/snapshots/ts/client.ts
+++ b/packages/generator/tests/multi-path-params/snapshots/ts/client.ts
@@ -1,5 +1,5 @@
import { Task, TaskUpdateRequest } from "./types.js";
-import { deserialize, serialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js";
export class TasksService {
async getTask(projectId: number, taskId: number): Promise {
@@ -30,7 +30,7 @@ export class TasksServiceImpl extends TasksService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Task;
+ return deserializeType("Task", body.data) as Task;
default:
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`);
}
@@ -40,12 +40,12 @@ export class TasksServiceImpl extends TasksService {
const response = await fetchWithRetry(`${this.baseUrl}/projects/${projectId}/tasks/${taskId}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("TaskUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Task;
+ return deserializeType("Task", body.data) as Task;
default:
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`);
}
diff --git a/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts b/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts
+++ b/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/oneof/snapshots/cs/Models.cs b/packages/generator/tests/oneof/snapshots/cs/Models.cs
index 6d73667d..ccfd5c3e 100644
--- a/packages/generator/tests/oneof/snapshots/cs/Models.cs
+++ b/packages/generator/tests/oneof/snapshots/cs/Models.cs
@@ -20,6 +20,7 @@ public abstract class ContentBlock
public class TextContent : ContentBlock
{
+ [JsonPropertyName("kind")]
public override string Kind => "text";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -27,6 +28,7 @@ public class TextContent : ContentBlock
public class ImageContent : ContentBlock
{
+ [JsonPropertyName("kind")]
public override string Kind => "image";
[JsonPropertyName("url")]
public string Url { get; set; } = default!;
@@ -36,6 +38,7 @@ public class ImageContent : ContentBlock
public class VideoContent : ContentBlock
{
+ [JsonPropertyName("kind")]
public override string Kind => "video";
[JsonPropertyName("url")]
public string Url { get; set; } = default!;
diff --git a/packages/generator/tests/patch/snapshots/py/utils.py b/packages/generator/tests/patch/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/patch/snapshots/py/utils.py
+++ b/packages/generator/tests/patch/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/patch/snapshots/ts/client.ts b/packages/generator/tests/patch/snapshots/ts/client.ts
index 754d99e7..8b732806 100644
--- a/packages/generator/tests/patch/snapshots/ts/client.ts
+++ b/packages/generator/tests/patch/snapshots/ts/client.ts
@@ -1,5 +1,5 @@
import { ItemPatchRequest, Item, ApiError } from "./types.js";
-import { deserialize, serialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js";
export class ItemsService {
async patchItem(id: number, request: ItemPatchRequest): Promise- {
@@ -19,12 +19,12 @@ export class ItemsServiceImpl extends ItemsService {
const response = await fetchWithRetry(`${this.baseUrl}/items/${id}`, {
method: "PATCH",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ItemPatchRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Item;
+ return deserializeType("Item", body.data) as Item;
default:
throw new ApiError(body.errors);
}
diff --git a/packages/generator/tests/patch/snapshots/ts/utils.ts b/packages/generator/tests/patch/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/patch/snapshots/ts/utils.ts
+++ b/packages/generator/tests/patch/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/record/snapshots/py/utils.py b/packages/generator/tests/record/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/record/snapshots/py/utils.py
+++ b/packages/generator/tests/record/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/record/snapshots/ts/client.ts b/packages/generator/tests/record/snapshots/ts/client.ts
index c518d3a0..7358746a 100644
--- a/packages/generator/tests/record/snapshots/ts/client.ts
+++ b/packages/generator/tests/record/snapshots/ts/client.ts
@@ -1,5 +1,5 @@
import { LinkPreviewsRequest, OAuthError, ApiError } from "./types.js";
-import { serialize, fetchWithRetry } from "./utils.js";
+import { serializeType, fetchWithRetry } from "./utils.js";
export class LinkPreviewsService {
async createLinkPreviews(id: number, request: LinkPreviewsRequest): Promise {
@@ -19,7 +19,7 @@ export class LinkPreviewsServiceImpl extends LinkPreviewsService {
const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}/link_previews`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("LinkPreviewsRequest", request)),
});
switch (response.status) {
case 201:
diff --git a/packages/generator/tests/record/snapshots/ts/utils.ts b/packages/generator/tests/record/snapshots/ts/utils.ts
index a8d35198..37cfe567 100644
--- a/packages/generator/tests/record/snapshots/ts/utils.ts
+++ b/packages/generator/tests/record/snapshots/ts/utils.ts
@@ -12,21 +12,27 @@ function camelToSnake(str: string): string {
const RECORD_KEYS = new Set(["link_previews", "linkPreviews"]);
-function deserializeRecord(obj: unknown): unknown {
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
- return Object.fromEntries(
- Object.entries(obj).map(([k, v]) => [k, deserialize(v)]),
- );
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
}
return deserialize(obj);
}
-function serializeRecord(obj: unknown): unknown {
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
- .map(([k, v]) => [k, serialize(v)]),
+ .map(([k, v]) => [k, mapValue(v)]),
);
}
return serialize(obj);
@@ -38,7 +44,7 @@ export function deserialize(obj: unknown): unknown {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => {
const ck = snakeToCamel(k);
- return [ck, RECORD_KEYS.has(ck) ? deserializeRecord(v) : deserialize(v)];
+ return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)];
}),
);
}
@@ -51,14 +57,23 @@ export function serialize(obj: unknown): unknown {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
- .map(([k, v]) => {
- return [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecord(v) : serialize(v)];
- }),
+ .map(([k, v]) => [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]),
);
}
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/redirect/snapshots/py/utils.py b/packages/generator/tests/redirect/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/redirect/snapshots/py/utils.py
+++ b/packages/generator/tests/redirect/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/redirect/snapshots/ts/utils.ts b/packages/generator/tests/redirect/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/redirect/snapshots/ts/utils.ts
+++ b/packages/generator/tests/redirect/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/search/snapshots/py/utils.py b/packages/generator/tests/search/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/search/snapshots/py/utils.py
+++ b/packages/generator/tests/search/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/search/snapshots/ts/client.ts b/packages/generator/tests/search/snapshots/ts/client.ts
index ae97ab5b..360a22e8 100644
--- a/packages/generator/tests/search/snapshots/ts/client.ts
+++ b/packages/generator/tests/search/snapshots/ts/client.ts
@@ -4,7 +4,7 @@ import {
MessageSearchResult,
OAuthError,
} from "./types.js";
-import { deserialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, fetchWithRetry } from "./utils.js";
export class SearchService {
async searchMessages(params: SearchMessagesParams): Promise {
diff --git a/packages/generator/tests/search/snapshots/ts/utils.ts b/packages/generator/tests/search/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/search/snapshots/ts/utils.ts
+++ b/packages/generator/tests/search/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/unions/fixture.yaml b/packages/generator/tests/unions/fixture.yaml
index 58c746bc..9fffb735 100644
--- a/packages/generator/tests/unions/fixture.yaml
+++ b/packages/generator/tests/unions/fixture.yaml
@@ -48,6 +48,7 @@ components:
type: object
required:
- type
+ - event
- url
properties:
type:
@@ -57,6 +58,11 @@ components:
description: Тип блока
x-enum-descriptions:
image: Для изображений всегда image
+ event:
+ type: string
+ enum:
+ - image_shared
+ description: Вторичный литеральный признак события
url:
type: string
description: URL изображения
diff --git a/packages/generator/tests/unions/snapshots/cs/Models.cs b/packages/generator/tests/unions/snapshots/cs/Models.cs
index 4d2155b0..d3d65b8e 100644
--- a/packages/generator/tests/unions/snapshots/cs/Models.cs
+++ b/packages/generator/tests/unions/snapshots/cs/Models.cs
@@ -20,6 +20,7 @@ public abstract class ViewBlockUnion
public class ViewBlockHeader : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "header";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -27,6 +28,7 @@ public class ViewBlockHeader : ViewBlockUnion
public class ViewBlockPlainText : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "plain_text";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -34,7 +36,10 @@ public class ViewBlockPlainText : ViewBlockUnion
public class ViewBlockImage : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "image";
+ [JsonPropertyName("event")]
+ public string @Event => "image_shared";
[JsonPropertyName("url")]
public string Url { get; set; } = default!;
[JsonPropertyName("alt")]
diff --git a/packages/generator/tests/unions/snapshots/go/types.go b/packages/generator/tests/unions/snapshots/go/types.go
index a5979bbb..46151661 100644
--- a/packages/generator/tests/unions/snapshots/go/types.go
+++ b/packages/generator/tests/unions/snapshots/go/types.go
@@ -16,9 +16,10 @@ type ViewBlockPlainText struct {
}
type ViewBlockImage struct {
- Type string `json:"type"` // always "image"
- URL string `json:"url"`
- Alt *string `json:"alt,omitempty"`
+ Type string `json:"type"` // always "image"
+ Event string `json:"event"` // always "image_shared"
+ URL string `json:"url"`
+ Alt *string `json:"alt,omitempty"`
}
type ViewBlockUnion struct {
diff --git a/packages/generator/tests/unions/snapshots/kt/Models.kt b/packages/generator/tests/unions/snapshots/kt/Models.kt
index d92bce0e..dcec774c 100644
--- a/packages/generator/tests/unions/snapshots/kt/Models.kt
+++ b/packages/generator/tests/unions/snapshots/kt/Models.kt
@@ -28,4 +28,6 @@ data class ViewBlockImage(
override val type: String = "image",
val url: String,
val alt: String? = null,
-) : ViewBlockUnion
+) : ViewBlockUnion {
+ val event: String = "image_shared"
+}
diff --git a/packages/generator/tests/unions/snapshots/py/models.py b/packages/generator/tests/unions/snapshots/py/models.py
index fddeff9d..cddcbca9 100644
--- a/packages/generator/tests/unions/snapshots/py/models.py
+++ b/packages/generator/tests/unions/snapshots/py/models.py
@@ -18,6 +18,7 @@ class ViewBlockPlainText:
@dataclass
class ViewBlockImage:
type: str # literal "image"
+ event: str # literal "image_shared"
url: str
alt: str | None = None
diff --git a/packages/generator/tests/unions/snapshots/swift/Models.swift b/packages/generator/tests/unions/snapshots/swift/Models.swift
index 550584ca..1b5b8711 100644
--- a/packages/generator/tests/unions/snapshots/swift/Models.swift
+++ b/packages/generator/tests/unions/snapshots/swift/Models.swift
@@ -25,11 +25,13 @@ public struct ViewBlockPlainText: Codable {
public struct ViewBlockImage: Codable {
public let type: String
+ public let event: String
public let url: String
public let alt: String?
- public init(type: String, url: String, alt: String? = nil) {
+ public init(type: String, event: String, url: String, alt: String? = nil) {
self.type = type
+ self.event = event
self.url = url
self.alt = alt
}
diff --git a/packages/generator/tests/unions/snapshots/ts/types.ts b/packages/generator/tests/unions/snapshots/ts/types.ts
index 08af7d7e..1c304cef 100644
--- a/packages/generator/tests/unions/snapshots/ts/types.ts
+++ b/packages/generator/tests/unions/snapshots/ts/types.ts
@@ -10,6 +10,7 @@ export interface ViewBlockPlainText {
export interface ViewBlockImage {
type: "image";
+ event: "image_shared";
url: string;
alt?: string;
}
diff --git a/packages/generator/tests/unwrap/snapshots/py/utils.py b/packages/generator/tests/unwrap/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/unwrap/snapshots/py/utils.py
+++ b/packages/generator/tests/unwrap/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/unwrap/snapshots/ts/client.ts b/packages/generator/tests/unwrap/snapshots/ts/client.ts
index adb4d058..0eb825c3 100644
--- a/packages/generator/tests/unwrap/snapshots/ts/client.ts
+++ b/packages/generator/tests/unwrap/snapshots/ts/client.ts
@@ -4,7 +4,7 @@ import {
ChatCreateRequest,
Chat,
} from "./types.js";
-import { deserialize, serialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js";
export class MembersService {
async addMembers(id: number, memberIds: number[]): Promise {
@@ -59,12 +59,12 @@ export class ChatsServiceImpl extends ChatsService {
const response = await fetchWithRetry(`${this.baseUrl}/chats`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ChatCreateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
diff --git a/packages/generator/tests/unwrap/snapshots/ts/utils.ts b/packages/generator/tests/unwrap/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/unwrap/snapshots/ts/utils.ts
+++ b/packages/generator/tests/unwrap/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/upload/snapshots/py/utils.py b/packages/generator/tests/upload/snapshots/py/utils.py
index 28f6ac8b..a42b76a5 100644
--- a/packages/generator/tests/upload/snapshots/py/utils.py
+++ b/packages/generator/tests/upload/snapshots/py/utils.py
@@ -4,18 +4,18 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +29,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +39,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +77,17 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/packages/generator/tests/upload/snapshots/ts/client.ts b/packages/generator/tests/upload/snapshots/ts/client.ts
index e0f1ee4f..62018e8b 100644
--- a/packages/generator/tests/upload/snapshots/ts/client.ts
+++ b/packages/generator/tests/upload/snapshots/ts/client.ts
@@ -1,5 +1,5 @@
import { FileUploadRequest, OAuthError, UploadParams } from "./types.js";
-import { deserialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, fetchWithRetry } from "./utils.js";
export class CommonService {
async uploadFile(directUrl: string, request: FileUploadRequest): Promise {
@@ -52,7 +52,7 @@ export class CommonServiceImpl extends CommonService {
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as UploadParams;
+ return deserializeType("UploadParams", body.data) as UploadParams;
case 401:
throw new OAuthError(body.error);
default:
diff --git a/packages/generator/tests/upload/snapshots/ts/utils.ts b/packages/generator/tests/upload/snapshots/ts/utils.ts
index a471ae4d..35778d63 100644
--- a/packages/generator/tests/upload/snapshots/ts/utils.ts
+++ b/packages/generator/tests/upload/snapshots/ts/utils.ts
@@ -10,6 +10,32 @@ function camelToSnake(str: string): string {
.toLowerCase();
}
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
export function deserialize(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(deserialize);
if (obj !== null && typeof obj === "object") {
@@ -32,6 +58,17 @@ export function serialize(obj: unknown): unknown {
return obj;
}
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
diff --git a/packages/generator/tests/webhook-polling/fixture.yaml b/packages/generator/tests/webhook-polling/fixture.yaml
new file mode 100644
index 00000000..2a43e9fd
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/fixture.yaml
@@ -0,0 +1,94 @@
+openapi: 3.0.0
+info:
+ title: Test API — Webhook Polling
+ version: 1.0.0
+servers:
+ - url: https://api.pachca.com/api/shared/v1
+paths:
+ /webhooks/events:
+ get:
+ operationId: BotOperations_getWebhookEvents
+ tags: [Bots]
+ x-paginated: true
+ parameters:
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ format: int32
+ default: 50
+ - name: cursor
+ in: query
+ required: false
+ schema:
+ type: string
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [data, meta]
+ properties:
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/WebhookEvent'
+ meta:
+ $ref: '#/components/schemas/PaginationMeta'
+components:
+ schemas:
+ WebhookEvent:
+ type: object
+ required: [id, event_type, payload, created_at]
+ properties:
+ id:
+ type: string
+ event_type:
+ type: string
+ payload:
+ $ref: '#/components/schemas/WebhookPayloadUnion'
+ created_at:
+ type: string
+ format: date-time
+
+ MessageWebhookPayload:
+ type: object
+ required: [type, message_id]
+ properties:
+ type:
+ type: string
+ enum: [message_new]
+ message_id:
+ type: integer
+ format: int32
+
+ ReactionWebhookPayload:
+ type: object
+ required: [type, reaction]
+ properties:
+ type:
+ type: string
+ enum: [reaction_added]
+ reaction:
+ type: string
+
+ WebhookPayloadUnion:
+ anyOf:
+ - $ref: '#/components/schemas/MessageWebhookPayload'
+ - $ref: '#/components/schemas/ReactionWebhookPayload'
+
+ PaginationMeta:
+ type: object
+ required: [paginate]
+ properties:
+ paginate:
+ type: object
+ required: [next_page]
+ properties:
+ next_page:
+ type: string
+ has_next:
+ type: boolean
diff --git a/packages/generator/tests/webhook-polling/snapshots/cs/Client.cs b/packages/generator/tests/webhook-polling/snapshots/cs/Client.cs
new file mode 100644
index 00000000..65c0a7aa
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/cs/Client.cs
@@ -0,0 +1,197 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Runtime.CompilerServices;
+
+namespace Pachca.Sdk;
+
+public class BotsService
+{
+
+ public virtual async System.Threading.Tasks.Task GetWebhookEventsAsync(
+ int? limit = null,
+ string? cursor = null,
+ CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException("Bots.getWebhookEvents is not implemented");
+ }
+
+ public virtual async System.Threading.Tasks.Task
> GetWebhookEventsAllAsync(
+ int? limit = null,
+ CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException("Bots.getWebhookEventsAll is not implemented");
+ }
+
+ public virtual async IAsyncEnumerable PollWebhookEventsAsync(
+ int? limit = 50,
+ TimeSpan? interval = null,
+ DateTimeOffset? createdAfter = null,
+ int maxSeenDeliveryIds = 5000,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (maxSeenDeliveryIds <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxSeenDeliveryIds), "maxSeenDeliveryIds must be greater than 0");
+
+ var pollInterval = interval ?? TimeSpan.FromSeconds(5);
+ var effectiveCreatedAfter = createdAfter ?? DateTimeOffset.UtcNow;
+ var seenIdOrder = new Queue();
+ var seenIds = new HashSet();
+
+ bool Remember(string id)
+ {
+ if (!seenIds.Add(id)) return false;
+ seenIdOrder.Enqueue(id);
+ while (seenIdOrder.Count > maxSeenDeliveryIds)
+ seenIds.Remove(seenIdOrder.Dequeue());
+ return true;
+ }
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ string? cursor = null;
+ var hasNext = true;
+ while (hasNext && !cancellationToken.IsCancellationRequested)
+ {
+ var response = await GetWebhookEventsAsync(limit: limit, cursor: cursor, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var pageHasRecentEvents = false;
+ for (var i = response.Data.Count - 1; i >= 0; i--)
+ {
+ var webhookEvent = response.Data[i];
+ var matchesCreatedAfter = webhookEvent.CreatedAt >= effectiveCreatedAfter;
+ if (matchesCreatedAfter)
+ pageHasRecentEvents = true;
+ if (matchesCreatedAfter && Remember(webhookEvent.Id))
+ yield return webhookEvent;
+ }
+ hasNext = (response.Meta.Paginate.HasNext ?? response.Data.Count > 0) && pageHasRecentEvents;
+ cursor = response.Meta.Paginate.NextPage;
+ }
+ await System.Threading.Tasks.Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ public virtual async IAsyncEnumerable PollWebhookPayloadsAsync(
+ int? limit = 50,
+ TimeSpan? interval = null,
+ DateTimeOffset? createdAfter = null,
+ int maxSeenDeliveryIds = 5000,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ where TPayload : WebhookPayloadUnion
+ {
+ await foreach (var webhookEvent in PollWebhookEventsAsync(
+ limit: limit,
+ interval: interval,
+ createdAfter: createdAfter,
+ maxSeenDeliveryIds: maxSeenDeliveryIds,
+ cancellationToken: cancellationToken))
+ {
+ if (webhookEvent.Payload is TPayload payload)
+ yield return payload;
+ }
+ }
+}
+
+public sealed class BotsServiceImpl : BotsService
+{
+ private readonly string _baseUrl;
+ private readonly HttpClient _client;
+
+ internal BotsServiceImpl(string baseUrl, HttpClient client)
+ {
+ _baseUrl = baseUrl;
+ _client = client;
+ }
+
+ public override async System.Threading.Tasks.Task GetWebhookEventsAsync(
+ int? limit = null,
+ string? cursor = null,
+ CancellationToken cancellationToken = default)
+ {
+ var queryParts = new List();
+ if (limit != null)
+ queryParts.Add($"limit={Uri.EscapeDataString(limit.Value.ToString()!)}");
+ if (cursor != null)
+ queryParts.Add($"cursor={Uri.EscapeDataString(cursor)}");
+ var url = $"{_baseUrl}/webhooks/events" + (queryParts.Count > 0 ? "?" + string.Join("&", queryParts) : "");
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ using var response = await PachcaUtils.SendWithRetryAsync(_client, request, cancellationToken).ConfigureAwait(false);
+ var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ switch ((int)response.StatusCode)
+ {
+ case 200:
+ return PachcaUtils.Deserialize(json);
+ default:
+ throw new InvalidOperationException($"Unexpected status code: {(int)response.StatusCode}");
+ }
+ }
+
+ public override async System.Threading.Tasks.Task> GetWebhookEventsAllAsync(
+ int? limit = null,
+ CancellationToken cancellationToken = default)
+ {
+ var items = new List();
+ string? cursor = null;
+ var hasNext = true;
+ while (hasNext)
+ {
+ var response = await GetWebhookEventsAsync(limit: limit, cursor: cursor, cancellationToken: cancellationToken).ConfigureAwait(false);
+ items.AddRange(response.Data);
+ if (response.Data.Count == 0) break;
+ cursor = response.Meta.Paginate.NextPage;
+ hasNext = response.Meta.Paginate.HasNext ?? true;
+ }
+ return items;
+ }
+}
+
+public static class PachcaConstants
+{
+ public const string PachcaApiUrl = "https://api.pachca.com/api/shared/v1";
+}
+
+public sealed class PachcaClient : IDisposable
+{
+ private readonly HttpClient? _client;
+
+ public BotsService Bots { get; }
+
+ private PachcaClient(BotsService bots)
+ {
+ Bots = bots;
+ }
+
+ public PachcaClient(string token, string baseUrl = PachcaConstants.PachcaApiUrl, BotsService? bots = null)
+ {
+ _client = new HttpClient();
+ _client.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", token);
+
+ Bots = bots ?? new BotsServiceImpl(baseUrl, _client);
+ }
+
+ public PachcaClient(string baseUrl, HttpClient client, BotsService? bots = null)
+ {
+ _client = client;
+
+ Bots = bots ?? new BotsServiceImpl(baseUrl, _client);
+ }
+
+ public static PachcaClient Stub(BotsService? bots = null)
+ {
+ return new PachcaClient(bots ?? new BotsService());
+ }
+
+ public void Dispose()
+ {
+ _client?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/cs/Models.cs b/packages/generator/tests/webhook-polling/snapshots/cs/Models.cs
new file mode 100644
index 00000000..7a79bf21
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/cs/Models.cs
@@ -0,0 +1,68 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Pachca.Sdk;
+
+[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
+[JsonDerivedType(typeof(MessageWebhookPayload), "message_new")]
+[JsonDerivedType(typeof(ReactionWebhookPayload), "reaction_added")]
+public abstract class WebhookPayloadUnion
+{
+ [JsonPropertyName("type")]
+ public abstract string Type { get; }
+}
+
+public class MessageWebhookPayload : WebhookPayloadUnion
+{
+ [JsonPropertyName("type")]
+ public override string Type => "message_new";
+ [JsonPropertyName("message_id")]
+ public int MessageId { get; set; } = default!;
+}
+
+public class ReactionWebhookPayload : WebhookPayloadUnion
+{
+ [JsonPropertyName("type")]
+ public override string Type => "reaction_added";
+ [JsonPropertyName("reaction")]
+ public string Reaction { get; set; } = default!;
+}
+
+public class WebhookEvent
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = default!;
+ [JsonPropertyName("event_type")]
+ public string EventType { get; set; } = default!;
+ [JsonPropertyName("payload")]
+ public WebhookPayloadUnion Payload { get; set; } = default!;
+ [JsonPropertyName("created_at")]
+ public DateTimeOffset CreatedAt { get; set; } = default!;
+}
+
+public class PaginationMetaPaginate
+{
+ [JsonPropertyName("next_page")]
+ public string NextPage { get; set; } = default!;
+ [JsonPropertyName("has_next")]
+ public bool? HasNext { get; set; }
+}
+
+public class PaginationMeta
+{
+ [JsonPropertyName("paginate")]
+ public PaginationMetaPaginate Paginate { get; set; } = default!;
+}
+
+public class GetWebhookEventsResponse
+{
+ [JsonPropertyName("data")]
+ public List Data { get; set; } = new();
+ [JsonPropertyName("meta")]
+ public PaginationMeta Meta { get; set; } = default!;
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/cs/Utils.cs b/packages/generator/tests/webhook-polling/snapshots/cs/Utils.cs
new file mode 100644
index 00000000..7a862e7b
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/cs/Utils.cs
@@ -0,0 +1,103 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Pachca.Sdk;
+
+internal static class PachcaUtils
+{
+ private const int MaxRetries = 3;
+ private static readonly HashSet Retryable5xx = new() { 500, 502, 503, 504 };
+ private static readonly Random JitterRandom = new();
+
+ internal static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private static TimeSpan AddJitter(TimeSpan delay)
+ {
+ var factor = 0.5 + JitterRandom.NextDouble() * 0.5;
+ return TimeSpan.FromMilliseconds(delay.TotalMilliseconds * factor);
+ }
+
+ internal static async Task SendWithRetryAsync(
+ HttpClient client,
+ HttpRequestMessage request,
+ CancellationToken cancellationToken = default)
+ {
+ for (var attempt = 0; attempt <= MaxRetries; attempt++)
+ {
+ HttpRequestMessage req;
+ if (attempt == 0)
+ {
+ req = request;
+ }
+ else
+ {
+ req = await CloneRequestAsync(request).ConfigureAwait(false);
+ }
+
+ var response = await client.SendAsync(req, cancellationToken).ConfigureAwait(false);
+
+ if ((int)response.StatusCode == 429 && attempt < MaxRetries)
+ {
+ var delay = response.Headers.RetryAfter?.Delta
+ ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
+ await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false);
+ response.Dispose();
+ continue;
+ }
+
+ if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries)
+ {
+ var delay = AddJitter(TimeSpan.FromSeconds(10 * Math.Pow(2, attempt)));
+ await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false);
+ response.Dispose();
+ continue;
+ }
+
+ return response;
+ }
+
+ return await client.SendAsync(
+ await CloneRequestAsync(request).ConfigureAwait(false),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static async Task CloneRequestAsync(HttpRequestMessage request)
+ {
+ var clone = new HttpRequestMessage(request.Method, request.RequestUri);
+ foreach (var header in request.Headers)
+ clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
+
+ if (request.Content != null)
+ {
+ var content = await request.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
+ clone.Content = new ByteArrayContent(content);
+ if (request.Content.Headers.ContentType != null)
+ clone.Content.Headers.ContentType = request.Content.Headers.ContentType;
+ }
+
+ return clone;
+ }
+
+ internal static T Deserialize(string json) =>
+ JsonSerializer.Deserialize(json, JsonOptions)
+ ?? throw new InvalidOperationException("Deserialization returned null");
+
+ internal static string Serialize(T value) =>
+ JsonSerializer.Serialize(value, JsonOptions);
+
+ internal static string EnumToApiString(T value) where T : struct, Enum =>
+ JsonSerializer.Serialize(value, JsonOptions).Trim('"');
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/cs/examples.json b/packages/generator/tests/webhook-polling/snapshots/cs/examples.json
new file mode 100644
index 00000000..543258d6
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/cs/examples.json
@@ -0,0 +1,21 @@
+{
+ "Client_Init": {
+ "usage": "using var client = new PachcaClient(\"YOUR_TOKEN\");",
+ "imports": [
+ "PachcaClient"
+ ]
+ },
+ "BotOperations_getWebhookEvents": {
+ "usage": "var response = await client.Bots.GetWebhookEventsAsync(123, \"example\");",
+ "output": "GetWebhookEventsResponse(Data: List, Meta: PaginationMeta)"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "await foreach (var webhookEvent in client.Bots.PollWebhookEventsAsync())\n{\n _ = webhookEvent;\n}"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "await foreach (var payload in client.Bots.PollWebhookPayloadsAsync())\n{\n _ = payload;\n}",
+ "imports": [
+ "WebhookPayloadUnion"
+ ]
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/go/client.go b/packages/generator/tests/webhook-polling/snapshots/go/client.go
new file mode 100644
index 00000000..78e25cd2
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/go/client.go
@@ -0,0 +1,285 @@
+package pachca
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+type authTransport struct {
+ token string
+ base http.RoundTripper
+}
+
+func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ req.Header.Set("Authorization", "Bearer "+t.token)
+ return t.base.RoundTrip(req)
+}
+
+type BotsService interface {
+ GetWebhookEvents(ctx context.Context, params *GetWebhookEventsParams) (*GetWebhookEventsResponse, error)
+ GetWebhookEventsAll(ctx context.Context, params *GetWebhookEventsParams) ([]WebhookEvent, error)
+ PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error
+ PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error
+}
+
+type PollWebhookEventsOptions struct {
+ Limit *int32
+ Interval time.Duration
+ CreatedAfter *time.Time
+ MaxSeenDeliveryIDs int
+}
+
+type BotsServiceStub struct{}
+
+func (s *BotsServiceStub) GetWebhookEvents(ctx context.Context, params *GetWebhookEventsParams) (*GetWebhookEventsResponse, error) {
+ return nil, NotImplementedError{Method: "Bots.getWebhookEvents"}
+}
+
+func (s *BotsServiceStub) GetWebhookEventsAll(ctx context.Context, params *GetWebhookEventsParams) ([]WebhookEvent, error) {
+ return nil, NotImplementedError{Method: "Bots.getWebhookEventsAll"}
+}
+
+func (s *BotsServiceStub) PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error {
+ return NotImplementedError{Method: "Bots.pollWebhookEvents"}
+}
+
+func (s *BotsServiceStub) PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error {
+ return NotImplementedError{Method: "Bots.pollWebhookPayloads"}
+}
+
+type BotsServiceImpl struct {
+ baseURL string
+ client *http.Client
+}
+
+func (s *BotsServiceImpl) GetWebhookEvents(ctx context.Context, params *GetWebhookEventsParams) (*GetWebhookEventsResponse, error) {
+ u, err := url.Parse(fmt.Sprintf("%s/webhooks/events", s.baseURL))
+ if err != nil {
+ return nil, err
+ }
+ q := u.Query()
+ if params != nil && params.Limit != nil {
+ q.Set("limit", fmt.Sprintf("%v", *params.Limit))
+ }
+ if params != nil && params.Cursor != nil {
+ q.Set("cursor", fmt.Sprintf("%v", *params.Cursor))
+ }
+ u.RawQuery = q.Encode()
+ req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := doWithRetry(s.client, req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case http.StatusOK:
+ var result GetWebhookEventsResponse
+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+ default:
+ return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+}
+
+func (s *BotsServiceImpl) GetWebhookEventsAll(ctx context.Context, params *GetWebhookEventsParams) ([]WebhookEvent, error) {
+ if params == nil {
+ params = &GetWebhookEventsParams{}
+ }
+ var items []WebhookEvent
+ var cursor *string
+ hasNext := true
+ for hasNext {
+ params.Cursor = cursor
+ result, err := s.GetWebhookEvents(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, result.Data...)
+ if len(result.Data) == 0 {
+ return items, nil
+ }
+ nextPage := result.Meta.Paginate.NextPage
+ cursor = &nextPage
+ if result.Meta.Paginate.HasNext != nil {
+ hasNext = *result.Meta.Paginate.HasNext
+ }
+ }
+ return items, nil
+}
+
+func (s *BotsServiceImpl) PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error {
+ if handler == nil {
+ return errors.New("handler must not be nil")
+ }
+ if options == nil {
+ options = &PollWebhookEventsOptions{}
+ }
+ interval := options.Interval
+ if interval == 0 {
+ interval = 5 * time.Second
+ }
+ createdAfter := options.CreatedAfter
+ if createdAfter == nil {
+ now := time.Now()
+ createdAfter = &now
+ }
+ maxSeenDeliveryIDs := options.MaxSeenDeliveryIDs
+ if maxSeenDeliveryIDs == 0 {
+ maxSeenDeliveryIDs = 5000
+ }
+ if maxSeenDeliveryIDs < 0 {
+ return errors.New("MaxSeenDeliveryIDs must be greater than 0")
+ }
+
+ seenIDOrder := make([]string, 0, maxSeenDeliveryIDs)
+ seenIDs := make(map[string]struct{}, maxSeenDeliveryIDs)
+ remember := func(id string) bool {
+ if _, ok := seenIDs[id]; ok {
+ return false
+ }
+ seenIDs[id] = struct{}{}
+ seenIDOrder = append(seenIDOrder, id)
+ for len(seenIDOrder) > maxSeenDeliveryIDs {
+ oldest := seenIDOrder[0]
+ seenIDOrder = seenIDOrder[1:]
+ delete(seenIDs, oldest)
+ }
+ return true
+ }
+
+ for {
+ var cursor *string
+ hasNext := true
+ for hasNext {
+ params := &GetWebhookEventsParams{Limit: options.Limit, Cursor: cursor}
+ response, err := s.GetWebhookEvents(ctx, params)
+ if err != nil {
+ return err
+ }
+ pageHasRecentEvents := false
+ for i := len(response.Data) - 1; i >= 0; i-- {
+ event := response.Data[i]
+ matchesCreatedAfter := !event.CreatedAt.Before(*createdAfter)
+ if matchesCreatedAfter {
+ pageHasRecentEvents = true
+ }
+ if matchesCreatedAfter && remember(event.ID) {
+ if err := handler(event); err != nil {
+ return err
+ }
+ }
+ }
+ nextPage := response.Meta.Paginate.NextPage
+ cursor = &nextPage
+ if response.Meta.Paginate.HasNext != nil {
+ hasNext = *response.Meta.Paginate.HasNext
+ } else {
+ hasNext = len(response.Data) > 0
+ }
+ hasNext = hasNext && pageHasRecentEvents
+ }
+
+ timer := time.NewTimer(interval)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return ctx.Err()
+ case <-timer.C:
+ }
+ }
+}
+
+func (s *BotsServiceImpl) PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error {
+ if handler == nil {
+ return errors.New("handler must not be nil")
+ }
+ return s.PollWebhookEvents(ctx, options, func(event WebhookEvent) error {
+ return handler(event.Payload)
+ })
+}
+
+type PachcaClient struct {
+ Bots BotsService
+}
+
+type clientConfig struct {
+ baseURL string
+ bots BotsService
+}
+
+type ClientOption func(*clientConfig)
+
+type stubClientConfig struct {
+ bots BotsService
+}
+
+type StubClientOption func(*stubClientConfig)
+
+const PachcaAPIURL = "https://api.pachca.com/api/shared/v1"
+
+func WithBaseURL(baseURL string) ClientOption {
+ return func(cfg *clientConfig) { cfg.baseURL = baseURL }
+}
+
+func WithBots(service BotsService) ClientOption {
+ return func(cfg *clientConfig) { cfg.bots = service }
+}
+
+func WithStubBots(service BotsService) StubClientOption {
+ return func(cfg *stubClientConfig) { cfg.bots = service }
+}
+
+func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient {
+ cfg := clientConfig{baseURL: PachcaAPIURL}
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+ client := &http.Client{
+ Transport: &authTransport{token: token, base: http.DefaultTransport},
+ }
+ var bots BotsService = &BotsServiceImpl{baseURL: cfg.baseURL, client: client}
+ if cfg.bots != nil {
+ bots = cfg.bots
+ }
+ return &PachcaClient{
+ Bots: bots,
+ }
+}
+
+func NewPachcaClientWithHTTP(baseURL string, client *http.Client, opts ...ClientOption) *PachcaClient {
+ cfg := clientConfig{baseURL: baseURL}
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+ var bots BotsService = &BotsServiceImpl{baseURL: cfg.baseURL, client: client}
+ if cfg.bots != nil {
+ bots = cfg.bots
+ }
+ return &PachcaClient{
+ Bots: bots,
+ }
+}
+
+func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient {
+ cfg := stubClientConfig{}
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+ var bots BotsService = &BotsServiceStub{}
+ if cfg.bots != nil {
+ bots = cfg.bots
+ }
+ return &PachcaClient{
+ Bots: bots,
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/go/examples.json b/packages/generator/tests/webhook-polling/snapshots/go/examples.json
new file mode 100644
index 00000000..33bae7d1
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/go/examples.json
@@ -0,0 +1,21 @@
+{
+ "Client_Init": {
+ "usage": "client := pachca.NewPachcaClient(\"YOUR_TOKEN\")",
+ "imports": [
+ "PachcaClient"
+ ]
+ },
+ "BotOperations_getWebhookEvents": {
+ "usage": "params := &GetWebhookEventsParams{\n\tLimit: Ptr(int32(123)),\n\tCursor: Ptr(\"example\"),\n}\nresponse, err := client.Bots.GetWebhookEvents(ctx, params)",
+ "output": "GetWebhookEventsResponse{Data: []WebhookEvent, Meta: PaginationMeta}",
+ "imports": [
+ "GetWebhookEventsParams"
+ ]
+ },
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "err := client.Bots.PollWebhookEvents(ctx, nil, func(event pachca.WebhookEvent) error {\n\t_ = event\n\treturn nil\n})"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "err := client.Bots.PollWebhookPayloads(ctx, nil, func(payload pachca.WebhookPayloadUnion) error {\n\t_ = payload\n\treturn nil\n})"
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/go/types.go b/packages/generator/tests/webhook-polling/snapshots/go/types.go
new file mode 100644
index 00000000..6db29898
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/go/types.go
@@ -0,0 +1,77 @@
+package pachca
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+type WebhookEvent struct {
+ ID string `json:"id"`
+ EventType string `json:"event_type"`
+ Payload WebhookPayloadUnion `json:"payload"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type MessageWebhookPayload struct {
+ Type string `json:"type"` // always "message_new"
+ MessageID int32 `json:"message_id"`
+}
+
+type ReactionWebhookPayload struct {
+ Type string `json:"type"` // always "reaction_added"
+ Reaction string `json:"reaction"`
+}
+
+type PaginationMetaPaginate struct {
+ NextPage string `json:"next_page"`
+ HasNext *bool `json:"has_next,omitempty"`
+}
+
+type PaginationMeta struct {
+ Paginate PaginationMetaPaginate `json:"paginate"`
+}
+
+type WebhookPayloadUnion struct {
+ MessageWebhookPayload *MessageWebhookPayload
+ ReactionWebhookPayload *ReactionWebhookPayload
+}
+
+func (u *WebhookPayloadUnion) UnmarshalJSON(data []byte) error {
+ var disc struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal(data, &disc); err != nil {
+ return err
+ }
+ switch disc.Type {
+ case "message_new":
+ u.MessageWebhookPayload = &MessageWebhookPayload{}
+ return json.Unmarshal(data, u.MessageWebhookPayload)
+ case "reaction_added":
+ u.ReactionWebhookPayload = &ReactionWebhookPayload{}
+ return json.Unmarshal(data, u.ReactionWebhookPayload)
+ default:
+ return fmt.Errorf("unknown WebhookPayloadUnion type: %s", disc.Type)
+ }
+}
+
+func (u WebhookPayloadUnion) MarshalJSON() ([]byte, error) {
+ if u.MessageWebhookPayload != nil {
+ return json.Marshal(u.MessageWebhookPayload)
+ }
+ if u.ReactionWebhookPayload != nil {
+ return json.Marshal(u.ReactionWebhookPayload)
+ }
+ return nil, fmt.Errorf("empty WebhookPayloadUnion")
+}
+
+type GetWebhookEventsParams struct {
+ Limit *int32
+ Cursor *string
+}
+
+type GetWebhookEventsResponse struct {
+ Data []WebhookEvent `json:"data"`
+ Meta PaginationMeta `json:"meta"`
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/go/utils.go b/packages/generator/tests/webhook-polling/snapshots/go/utils.go
new file mode 100644
index 00000000..f7f90bc0
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/go/utils.go
@@ -0,0 +1,60 @@
+package pachca
+
+import (
+ "math/rand"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// Ptr returns a pointer to the given value.
+func Ptr[T any](v T) *T {
+ return &v
+}
+
+// NotImplementedError is returned by stub methods that have not been implemented.
+type NotImplementedError struct {
+ Method string
+}
+
+func (e NotImplementedError) Error() string {
+ return e.Method + " is not implemented"
+}
+
+const maxRetries = 3
+
+var retryable5xx = map[int]bool{500: true, 502: true, 503: true, 504: true}
+
+func jitter(d time.Duration) time.Duration {
+ return time.Duration(float64(d) * (0.5 + rand.Float64()*0.5))
+}
+
+func doWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {
+ for attempt := 0; ; attempt++ {
+ if attempt > 0 && req.GetBody != nil {
+ req.Body, _ = req.GetBody()
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries {
+ resp.Body.Close()
+ delay := time.Duration(1< {
+ throw NotImplementedError("Bots.getWebhookEventsAll is not implemented")
+ }
+}
+
+class BotsServiceImpl internal constructor(
+ private val baseUrl: String,
+ private val client: HttpClient,
+) : BotsService {
+ override suspend fun getWebhookEvents(limit: Int?, cursor: String?): GetWebhookEventsResponse {
+ val response = client.get("$baseUrl/webhooks/events") {
+ limit?.let { parameter("limit", it) }
+ cursor?.let { parameter("cursor", it) }
+ }
+ return when (response.status.value) {
+ 200 -> response.body()
+ else -> throw RuntimeException("Unexpected status code: ${response.status.value}")
+ }
+ }
+
+ override suspend fun getWebhookEventsAll(limit: Int?): List {
+ val items = mutableListOf()
+ var cursor: String? = null
+ var hasNext = true
+ while (hasNext) {
+ val response = getWebhookEvents(limit = limit, cursor = cursor)
+ items.addAll(response.data)
+ if (response.data.isEmpty()) break
+ cursor = response.meta.paginate.nextPage
+ hasNext = response.meta.paginate.hasNext ?: true
+ }
+ return items
+ }
+}
+
+fun BotsService.pollWebhookEvents(
+ limit: Int? = 50,
+ interval: Duration = 5.seconds,
+ createdAfter: OffsetDateTime? = null,
+ maxSeenDeliveryIds: Int = 5_000,
+): Flow = flow {
+ require(maxSeenDeliveryIds > 0) { "maxSeenDeliveryIds must be greater than 0" }
+
+ val effectiveCreatedAfter = createdAfter ?: OffsetDateTime.now()
+ val seenIdOrder = ArrayDeque()
+ val seenIds = mutableSetOf()
+
+ fun remember(id: String): Boolean {
+ if (!seenIds.add(id)) return false
+ seenIdOrder.addLast(id)
+ while (seenIdOrder.size > maxSeenDeliveryIds) {
+ seenIds.remove(seenIdOrder.removeFirst())
+ }
+ return true
+ }
+
+ while (currentCoroutineContext().isActive) {
+ var cursor: String? = null
+ do {
+ val response = getWebhookEvents(limit = limit, cursor = cursor)
+ var pageHasRecentEvents = false
+ for (event in response.data.asReversed()) {
+ val matchesCreatedAfter = !event.createdAt.isBefore(effectiveCreatedAfter)
+ if (matchesCreatedAfter) pageHasRecentEvents = true
+ if (matchesCreatedAfter && remember(event.id)) emit(event)
+ }
+ val hasNext = (response.meta.paginate.hasNext ?: response.data.isNotEmpty()) && pageHasRecentEvents
+ cursor = response.meta.paginate.nextPage
+ } while (currentCoroutineContext().isActive && hasNext)
+ delay(interval)
+ }
+}
+
+inline fun BotsService.pollWebhookPayloads(
+ limit: Int? = 50,
+ interval: Duration = 5.seconds,
+ createdAfter: OffsetDateTime? = null,
+ maxSeenDeliveryIds: Int = 5_000,
+): Flow = pollWebhookEvents(
+ limit = limit,
+ interval = interval,
+ createdAfter = createdAfter,
+ maxSeenDeliveryIds = maxSeenDeliveryIds,
+)
+ .mapNotNull { it.payload as? T }
+
+const val PACHCA_API_URL = "https://api.pachca.com/api/shared/v1"
+
+class PachcaClient private constructor(
+ private val _client: HttpClient?,
+ val bots: BotsService
+) : Closeable {
+
+ companion object {
+ operator fun invoke(
+ token: String,
+ baseUrl: String = PACHCA_API_URL,
+ bots: BotsService? = null
+ ): PachcaClient {
+ val client = createClient(token)
+ return PachcaClient(
+ _client = client,
+ bots = bots ?: BotsServiceImpl(baseUrl, client)
+ )
+ }
+
+ fun stub(
+ bots: BotsService = object : BotsService {}
+ ): PachcaClient = PachcaClient(
+ _client = null,
+ bots = bots
+ )
+
+ private fun createClient(token: String): HttpClient = HttpClient {
+ expectSuccess = false
+ install(ContentNegotiation) { json(Json { explicitNulls = false; ignoreUnknownKeys = true }) }
+ install(HttpRequestRetry) {
+ maxRetries = 3
+ retryIf { _, response ->
+ response.status.value == 429 || response.status.value in setOf(500, 502, 503, 504)
+ }
+ delayMillis { retry ->
+ val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull()
+ if (retryAfter != null && response?.status?.value == 429) {
+ retryAfter * 1000L
+ } else {
+ val base = 10_000L * (1L shl retry)
+ val jitter = 0.5 + kotlin.random.Random.nextDouble() * 0.5
+ (base * jitter).toLong()
+ }
+ }
+ }
+ defaultRequest { bearerAuth(token) }
+ }
+ }
+
+ constructor(
+ client: HttpClient,
+ baseUrl: String = PACHCA_API_URL,
+ bots: BotsService? = null
+ ) : this(
+ _client = client,
+ bots = bots ?: BotsServiceImpl(baseUrl, client)
+ )
+
+ override fun close() {
+ _client?.close()
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/kt/Models.kt b/packages/generator/tests/webhook-polling/snapshots/kt/Models.kt
new file mode 100644
index 00000000..2c2b4452
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/kt/Models.kt
@@ -0,0 +1,61 @@
+package com.pachca.sdk
+
+import java.time.OffsetDateTime
+import java.time.format.DateTimeFormatter
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+object OffsetDateTimeSerializer : KSerializer {
+ override val descriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING)
+ override fun serialize(encoder: Encoder, value: OffsetDateTime) = encoder.encodeString(value.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
+ override fun deserialize(decoder: Decoder): OffsetDateTime = OffsetDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME)
+}
+
+@Serializable
+sealed interface WebhookPayloadUnion {
+ val type: String
+}
+
+@Serializable
+@SerialName("message_new")
+data class MessageWebhookPayload(
+ override val type: String = "message_new",
+ @SerialName("message_id") val messageId: Int,
+) : WebhookPayloadUnion
+
+@Serializable
+@SerialName("reaction_added")
+data class ReactionWebhookPayload(
+ override val type: String = "reaction_added",
+ val reaction: String,
+) : WebhookPayloadUnion
+
+@Serializable
+data class WebhookEvent(
+ val id: String,
+ @SerialName("event_type") val eventType: String,
+ val payload: WebhookPayloadUnion,
+ @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime,
+)
+
+@Serializable
+data class PaginationMetaPaginate(
+ @SerialName("next_page") val nextPage: String,
+ @SerialName("has_next") val hasNext: Boolean? = null,
+)
+
+@Serializable
+data class PaginationMeta(
+ val paginate: PaginationMetaPaginate,
+)
+
+@Serializable
+data class GetWebhookEventsResponse(
+ val data: List,
+ val meta: PaginationMeta,
+)
diff --git a/packages/generator/tests/webhook-polling/snapshots/kt/examples.json b/packages/generator/tests/webhook-polling/snapshots/kt/examples.json
new file mode 100644
index 00000000..c6a7ca9e
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/kt/examples.json
@@ -0,0 +1,25 @@
+{
+ "Client_Init": {
+ "usage": "val client = PachcaClient(\"YOUR_TOKEN\")",
+ "imports": [
+ "PachcaClient"
+ ]
+ },
+ "BotOperations_getWebhookEvents": {
+ "usage": "val response = client.bots.getWebhookEvents(limit = 123, cursor = \"example\")",
+ "output": "GetWebhookEventsResponse(data: List, meta: PaginationMeta)"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "client.bots.pollWebhookEvents().collect { event ->\n println(event)\n}",
+ "imports": [
+ "pollWebhookEvents"
+ ]
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "client.bots.pollWebhookPayloads().collect { payload ->\n println(payload)\n}",
+ "imports": [
+ "WebhookPayloadUnion",
+ "pollWebhookPayloads"
+ ]
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/py/__init__.py b/packages/generator/tests/webhook-polling/snapshots/py/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/generator/tests/webhook-polling/snapshots/py/client.py b/packages/generator/tests/webhook-polling/snapshots/py/client.py
new file mode 100644
index 00000000..726719b4
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/py/client.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+import asyncio
+from collections import deque
+from datetime import datetime, timezone
+from typing import AsyncIterator, TypeVar
+
+import httpx
+
+from .models import (
+ GetWebhookEventsParams,
+ GetWebhookEventsResponse,
+ WebhookEvent,
+ WebhookPayloadUnion,
+)
+from .utils import deserialize, RetryTransport
+
+TPayload = TypeVar("TPayload", bound=WebhookPayloadUnion)
+
+class BotsService:
+ async def get_webhook_events(
+ self,
+ params: GetWebhookEventsParams | None = None,
+ ) -> GetWebhookEventsResponse:
+ raise NotImplementedError("Bots.getWebhookEvents is not implemented")
+
+ async def get_webhook_events_all(
+ self,
+ params: GetWebhookEventsParams | None = None,
+ ) -> list[WebhookEvent]:
+ raise NotImplementedError("Bots.getWebhookEventsAll is not implemented")
+
+ async def poll_webhook_events(
+ self,
+ *,
+ limit: int | None = 50,
+ interval_seconds: float = 5.0,
+ created_after: datetime | None = None,
+ max_seen_delivery_ids: int = 5_000,
+ ) -> AsyncIterator[WebhookEvent]:
+ if max_seen_delivery_ids <= 0:
+ raise ValueError("max_seen_delivery_ids must be greater than 0")
+
+ effective_created_after = created_after or datetime.now(timezone.utc)
+ seen_id_order: deque[str] = deque()
+ seen_ids: set[str] = set()
+
+ def remember(id: str) -> bool:
+ if id in seen_ids:
+ return False
+ seen_ids.add(id)
+ seen_id_order.append(id)
+ while len(seen_id_order) > max_seen_delivery_ids:
+ seen_ids.remove(seen_id_order.popleft())
+ return True
+
+ while True:
+ cursor: str | None = None
+ has_next = True
+ while has_next:
+ response = await self.get_webhook_events(
+ GetWebhookEventsParams(limit=limit, cursor=cursor),
+ )
+ page_has_recent_events = False
+ for event in reversed(response.data):
+ matches_created_after = event.created_at >= effective_created_after
+ if matches_created_after:
+ page_has_recent_events = True
+ if matches_created_after and remember(event.id):
+ yield event
+ reported_has_next = getattr(response.meta.paginate, "has_next", None)
+ has_next = (bool(response.data) if reported_has_next is None else reported_has_next) and page_has_recent_events
+ cursor = response.meta.paginate.next_page
+ await asyncio.sleep(interval_seconds)
+
+ async def poll_webhook_payloads(
+ self,
+ *,
+ payload_type: type[TPayload] | tuple[type[TPayload], ...] | None = None,
+ limit: int | None = 50,
+ interval_seconds: float = 5.0,
+ created_after: datetime | None = None,
+ max_seen_delivery_ids: int = 5_000,
+ ) -> AsyncIterator[WebhookPayloadUnion | TPayload]:
+ async for event in self.poll_webhook_events(
+ limit=limit,
+ interval_seconds=interval_seconds,
+ created_after=created_after,
+ max_seen_delivery_ids=max_seen_delivery_ids,
+ ):
+ if payload_type is None or isinstance(event.payload, payload_type):
+ yield event.payload
+
+
+class BotsServiceImpl(BotsService):
+ def __init__(self, client: httpx.AsyncClient) -> None:
+ self._client = client
+
+ async def get_webhook_events(
+ self,
+ params: GetWebhookEventsParams | None = None,
+ ) -> GetWebhookEventsResponse:
+ query: dict[str, str] = {}
+ if params is not None and params.limit is not None:
+ query["limit"] = str(params.limit)
+ if params is not None and params.cursor is not None:
+ query["cursor"] = params.cursor
+ response = await self._client.get(
+ "/webhooks/events",
+ params=query,
+ )
+ body = response.json()
+ match response.status_code:
+ case 200:
+ return deserialize(GetWebhookEventsResponse, body)
+ case _:
+ raise RuntimeError(
+ f"Unexpected status code: {response.status_code}"
+ )
+
+ async def get_webhook_events_all(
+ self,
+ params: GetWebhookEventsParams | None = None,
+ ) -> list[WebhookEvent]:
+ items: list[WebhookEvent] = []
+ cursor: str | None = None
+ has_next = True
+ while has_next:
+ if params is None:
+ params = GetWebhookEventsParams()
+ params.cursor = cursor
+ response = await self.get_webhook_events(params=params)
+ items.extend(response.data)
+ if not response.data:
+ break
+ cursor = response.meta.paginate.next_page
+ reported_has_next = getattr(response.meta.paginate, "has_next", None)
+ has_next = True if reported_has_next is None else reported_has_next
+ return items
+
+
+PACHCA_API_URL = "https://api.pachca.com/api/shared/v1"
+
+
+class PachcaClient:
+ def __init__(self, token: str, base_url: str = PACHCA_API_URL, bots: BotsService | None = None) -> None:
+ self._client = httpx.AsyncClient(
+ base_url=base_url,
+ headers={"Authorization": f"Bearer {token}"},
+ transport=RetryTransport(httpx.AsyncHTTPTransport()),
+ )
+ self.bots: BotsService = bots or BotsServiceImpl(self._client)
+
+ async def close(self) -> None:
+ await self._client.aclose()
+
+ @classmethod
+ def from_client(
+ cls,
+ client: httpx.AsyncClient,
+ bots: BotsService | None = None,
+ ) -> "PachcaClient":
+ self = cls.__new__(cls)
+ self._client = client
+ self.bots: BotsService = bots or BotsServiceImpl(client)
+ return self
+
+ @classmethod
+ def stub(
+ cls,
+ bots: BotsService | None = None,
+ ) -> "PachcaClient":
+ self = cls.__new__(cls)
+ self._client = None
+ self.bots = bots or BotsService()
+ return self
diff --git a/packages/generator/tests/webhook-polling/snapshots/py/examples.json b/packages/generator/tests/webhook-polling/snapshots/py/examples.json
new file mode 100644
index 00000000..de6f45b4
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/py/examples.json
@@ -0,0 +1,21 @@
+{
+ "Client_Init": {
+ "usage": "client = PachcaClient(\"YOUR_TOKEN\")",
+ "imports": [
+ "PachcaClient"
+ ]
+ },
+ "BotOperations_getWebhookEvents": {
+ "usage": "params = GetWebhookEventsParams(limit=123, cursor=\"example\")\nresponse = await client.bots.get_webhook_events(params=params)",
+ "output": "GetWebhookEventsResponse(data: list[WebhookEvent], meta: PaginationMeta)",
+ "imports": [
+ "GetWebhookEventsParams"
+ ]
+ },
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)"
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/py/models.py b/packages/generator/tests/webhook-polling/snapshots/py/models.py
new file mode 100644
index 00000000..3fb64a38
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/py/models.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from datetime import datetime
+from dataclasses import dataclass
+from typing import Union
+
+@dataclass
+class WebhookEvent:
+ id: str
+ event_type: str
+ payload: WebhookPayloadUnion
+ created_at: datetime
+
+
+@dataclass
+class MessageWebhookPayload:
+ type: str # literal "message_new"
+ message_id: int
+
+
+@dataclass
+class ReactionWebhookPayload:
+ type: str # literal "reaction_added"
+ reaction: str
+
+
+@dataclass
+class PaginationMetaPaginate:
+ next_page: str
+ has_next: bool | None = None
+
+
+@dataclass
+class PaginationMeta:
+ paginate: PaginationMetaPaginate
+
+
+WebhookPayloadUnion = Union[MessageWebhookPayload, ReactionWebhookPayload]
+
+
+@dataclass
+class GetWebhookEventsParams:
+ limit: int | None = None
+ cursor: str | None = None
+
+
+@dataclass
+class GetWebhookEventsResponse:
+ data: list[WebhookEvent]
+ meta: PaginationMeta
diff --git a/packages/generator/tests/webhook-polling/snapshots/py/utils.py b/packages/generator/tests/webhook-polling/snapshots/py/utils.py
new file mode 100644
index 00000000..a42b76a5
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/py/utils.py
@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+import dataclasses
+import keyword
+from dataclasses import asdict, fields
+from datetime import datetime
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
+
+import httpx
+
+T = TypeVar("T")
+
+
+def _is_dataclass_type(tp: object) -> bool:
+ return isinstance(tp, type) and dataclasses.is_dataclass(tp)
+
+
+def _resolve_type(tp: object) -> type | None:
+ """Extract a concrete dataclass type from Optional[X] or X | None."""
+ origin = get_origin(tp)
+ if origin is list:
+ return None # lists are handled inline
+ args = get_args(tp)
+ for arg in args:
+ if _is_dataclass_type(arg):
+ return arg
+ if _is_dataclass_type(tp):
+ return tp
+ return None
+
+
+def _resolve_list_item_type(tp: object) -> object | None:
+ """Extract the item type from list[X]."""
+ origin = get_origin(tp)
+ if origin is list:
+ args = get_args(tp)
+ if args:
+ return args[0]
+ return None
+
+
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
+ field_map = {f.name: f for f in fields(cls)}
+ hints = get_type_hints(cls)
+ norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
+ kwargs = {}
+ for k, v in norm.items():
+ if k not in field_map:
+ k = f"{k}_"
+ if k not in field_map:
+ continue
+ f = field_map[k]
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
+ return cls(**kwargs)
+
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
+
+def _strip_nones(val: object) -> object:
+ if isinstance(val, dict):
+ return {
+ (k[:-1] if k.endswith("_") and keyword.iskeyword(k[:-1]) else k): _strip_nones(v)
+ for k, v in val.items() if v is not None
+ }
+ if isinstance(val, list):
+ return [_strip_nones(v) for v in val]
+ if isinstance(val, datetime):
+ return val.isoformat()
+ return val
+
+
+def serialize(obj: object) -> dict:
+ """Convert a dataclass to a dict, recursively omitting None values."""
+ return _strip_nones(asdict(obj))
+
+
+_MAX_RETRIES = 3
+_RETRYABLE_5XX = {500, 502, 503, 504}
+
+
+def _jitter(delay: float) -> float:
+ import random
+ return delay * (0.5 + random.random() * 0.5)
+
+
+class RetryTransport(httpx.AsyncBaseTransport):
+ """Wraps an httpx transport with retry on 429 Too Many Requests and 5xx errors."""
+
+ def __init__(self, transport: httpx.AsyncBaseTransport, max_retries: int = _MAX_RETRIES) -> None:
+ self._transport = transport
+ self._max_retries = max_retries
+
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
+ import asyncio
+ for attempt in range(self._max_retries + 1):
+ response = await self._transport.handle_async_request(request)
+ if response.status_code == 429 and attempt < self._max_retries:
+ retry_after = response.headers.get("retry-after")
+ delay = int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt
+ await asyncio.sleep(_add_jitter(delay))
+ continue
+ if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries:
+ delay = attempt + 1
+ await asyncio.sleep(_add_jitter(delay))
+ continue
+ return response
+ return response # unreachable
diff --git a/packages/generator/tests/webhook-polling/snapshots/swift/Client.swift b/packages/generator/tests/webhook-polling/snapshots/swift/Client.swift
new file mode 100644
index 00000000..6a89320b
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/swift/Client.swift
@@ -0,0 +1,185 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+private func pachcaNotImplemented(_ method: String) -> Error {
+ NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"])
+}
+
+private func pachcaParseWebhookDate(_ value: String) -> Date? {
+ let fractionalFormatter = ISO8601DateFormatter()
+ fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = fractionalFormatter.date(from: value) { return date }
+ return ISO8601DateFormatter().date(from: value)
+}
+
+open class BotsService {
+ public init() {}
+
+ open func getWebhookEvents(limit: Int? = nil, cursor: String? = nil) async throws -> GetWebhookEventsResponse {
+ throw pachcaNotImplemented("Bots.getWebhookEvents")
+ }
+
+ open func getWebhookEventsAll(limit: Int? = nil) async throws -> [WebhookEvent] {
+ throw pachcaNotImplemented("Bots.getWebhookEventsAll")
+ }
+
+ open func pollWebhookEvents(
+ limit: Int? = 50,
+ interval: TimeInterval = 5,
+ createdAfter: Date? = nil,
+ maxSeenDeliveryIds: Int = 5_000
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = _Concurrency.Task {
+ do {
+ guard maxSeenDeliveryIds > 0 else {
+ throw NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: "maxSeenDeliveryIds must be greater than 0"])
+ }
+
+ let effectiveCreatedAfter = createdAfter ?? Date()
+ var seenIdOrder: [String] = []
+ var seenIds = Set()
+
+ func remember(_ id: String) -> Bool {
+ guard seenIds.insert(id).inserted else { return false }
+ seenIdOrder.append(id)
+ while seenIdOrder.count > maxSeenDeliveryIds {
+ seenIds.remove(seenIdOrder.removeFirst())
+ }
+ return true
+ }
+
+ while !_Concurrency.Task.isCancelled {
+ var cursor: String? = nil
+ var hasNext = true
+ while hasNext && !_Concurrency.Task.isCancelled {
+ let response = try await getWebhookEvents(limit: limit, cursor: cursor)
+ var pageHasRecentEvents = false
+ for event in response.data.reversed() {
+ let matchesCreatedAfter = pachcaParseWebhookDate(event.createdAt).map { $0 >= effectiveCreatedAfter } == true
+ if matchesCreatedAfter {
+ pageHasRecentEvents = true
+ }
+ if matchesCreatedAfter && remember(event.id) {
+ continuation.yield(event)
+ }
+ }
+ hasNext = (response.meta.paginate.hasNext ?? !response.data.isEmpty) && pageHasRecentEvents
+ cursor = response.meta.paginate.nextPage
+ }
+ try await _Concurrency.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000))
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ open func pollWebhookPayloads(
+ limit: Int? = 50,
+ interval: TimeInterval = 5,
+ createdAfter: Date? = nil,
+ maxSeenDeliveryIds: Int = 5_000,
+ includePayload: @escaping (WebhookPayloadUnion) -> Bool = { _ in true }
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = _Concurrency.Task {
+ do {
+ for try await event in pollWebhookEvents(
+ limit: limit,
+ interval: interval,
+ createdAfter: createdAfter,
+ maxSeenDeliveryIds: maxSeenDeliveryIds
+ ) {
+ if includePayload(event.payload) {
+ continuation.yield(event.payload)
+ }
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+}
+
+public final class BotsServiceImpl: BotsService {
+ let baseURL: String
+ let headers: [String: String]
+ let session: URLSession
+
+ init(baseURL: String, headers: [String: String], session: URLSession = .shared) {
+ self.baseURL = baseURL
+ self.headers = headers
+ self.session = session
+ super.init()
+ }
+
+ public override func getWebhookEvents(limit: Int? = nil, cursor: String? = nil) async throws -> GetWebhookEventsResponse {
+ var components = URLComponents(string: "\(baseURL)/webhooks/events")!
+ var queryItems: [URLQueryItem] = []
+ if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) }
+ if let cursor { queryItems.append(URLQueryItem(name: "cursor", value: String(cursor))) }
+ if !queryItems.isEmpty { components.queryItems = queryItems }
+ var request = URLRequest(url: components.url!)
+ headers.forEach { request.setValue($1, forHTTPHeaderField: $0) }
+ let (data, urlResponse) = try await dataWithRetry(session: session, for: request)
+ let statusCode = (urlResponse as! HTTPURLResponse).statusCode
+ switch statusCode {
+ case 200:
+ return try deserialize(GetWebhookEventsResponse.self, from: data)
+ default:
+ throw URLError(.badServerResponse)
+ }
+ }
+
+ public override func getWebhookEventsAll(limit: Int? = nil) async throws -> [WebhookEvent] {
+ var items: [WebhookEvent] = []
+ var cursor: String? = nil
+ var hasNext = true
+ while hasNext {
+ let response = try await getWebhookEvents(limit: limit, cursor: cursor)
+ items.append(contentsOf: response.data)
+ if response.data.isEmpty { break }
+ cursor = response.meta.paginate.nextPage
+ hasNext = response.meta.paginate.hasNext ?? true
+ }
+ return items
+ }
+}
+
+public let pachcaAPIURL = "https://api.pachca.com/api/shared/v1"
+
+public struct PachcaClient {
+ public let bots: BotsService
+
+ private init(bots: BotsService) {
+ self.bots = bots
+ }
+
+ public init(token: String, baseURL: String = pachcaAPIURL, bots: BotsService? = nil) {
+ let headers = ["Authorization": "Bearer \(token)"]
+ self.init(
+ bots: bots ?? BotsServiceImpl(baseURL: baseURL, headers: headers)
+ )
+ }
+
+ public init(baseURL: String = pachcaAPIURL, headers: [String: String], session: URLSession = .shared, bots: BotsService? = nil) {
+ self.init(
+ bots: bots ?? BotsServiceImpl(baseURL: baseURL, headers: headers, session: session)
+ )
+ }
+
+ public static func stub(bots: BotsService = BotsService()) -> PachcaClient {
+ PachcaClient(
+ bots: bots
+ )
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/swift/Models.swift b/packages/generator/tests/webhook-polling/snapshots/swift/Models.swift
new file mode 100644
index 00000000..e909137e
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/swift/Models.swift
@@ -0,0 +1,111 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct WebhookEvent: Codable {
+ public let id: String
+ public let eventType: String
+ public let payload: WebhookPayloadUnion
+ public let createdAt: String
+
+ public init(id: String, eventType: String, payload: WebhookPayloadUnion, createdAt: String) {
+ self.id = id
+ self.eventType = eventType
+ self.payload = payload
+ self.createdAt = createdAt
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case eventType = "event_type"
+ case payload
+ case createdAt = "created_at"
+ }
+}
+
+public struct MessageWebhookPayload: Codable {
+ public let type: String
+ public let messageId: Int
+
+ public init(type: String, messageId: Int) {
+ self.type = type
+ self.messageId = messageId
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case messageId = "message_id"
+ }
+}
+
+public struct ReactionWebhookPayload: Codable {
+ public let type: String
+ public let reaction: String
+
+ public init(type: String, reaction: String) {
+ self.type = type
+ self.reaction = reaction
+ }
+}
+
+public struct PaginationMetaPaginate: Codable {
+ public let nextPage: String
+ public let hasNext: Bool?
+
+ public init(nextPage: String, hasNext: Bool? = nil) {
+ self.nextPage = nextPage
+ self.hasNext = hasNext
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case nextPage = "next_page"
+ case hasNext = "has_next"
+ }
+}
+
+public struct PaginationMeta: Codable {
+ public let paginate: PaginationMetaPaginate
+
+ public init(paginate: PaginationMetaPaginate) {
+ self.paginate = paginate
+ }
+}
+
+public enum WebhookPayloadUnion: Codable {
+ case messageWebhookPayload(MessageWebhookPayload)
+ case reactionWebhookPayload(ReactionWebhookPayload)
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: .type)
+ switch type {
+ case "message_new":
+ self = .messageWebhookPayload(try MessageWebhookPayload(from: decoder))
+ case "reaction_added":
+ self = .reactionWebhookPayload(try ReactionWebhookPayload(from: decoder))
+ default:
+ throw DecodingError.dataCorrupted(
+ DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown type: \(type)")
+ )
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .messageWebhookPayload(let value):
+ try value.encode(to: encoder)
+ case .reactionWebhookPayload(let value):
+ try value.encode(to: encoder)
+ }
+ }
+}
+
+public struct GetWebhookEventsResponse: Codable {
+ public let data: [WebhookEvent]
+ public let meta: PaginationMeta
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/swift/Utils.swift b/packages/generator/tests/webhook-polling/snapshots/swift/Utils.swift
new file mode 100644
index 00000000..11593c56
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/swift/Utils.swift
@@ -0,0 +1,69 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+let pachcaDecoder: JSONDecoder = {
+ let decoder = JSONDecoder()
+ return decoder
+}()
+
+let pachcaEncoder: JSONEncoder = {
+ let encoder = JSONEncoder()
+ return encoder
+}()
+
+func serialize(_ value: T) throws -> Data {
+ let data = try pachcaEncoder.encode(value)
+ let json = try JSONSerialization.jsonObject(with: data)
+ return try JSONSerialization.data(withJSONObject: stripNulls(json))
+}
+
+func deserialize(_ type: T.Type, from data: Data) throws -> T {
+ return try pachcaDecoder.decode(type, from: data)
+}
+
+private let maxRetries = 3
+private let retryable5xx: Set = [500, 502, 503, 504]
+
+private func jitter(_ delay: UInt64) -> UInt64 {
+ return UInt64(Double(delay) * (0.5 + Double.random(in: 0..<0.5)))
+}
+
+func dataWithRetry(session: URLSession, for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) {
+ for attempt in 0...maxRetries {
+ let (data, response) = try await session.data(for: request, delegate: delegate)
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 429, attempt < maxRetries {
+ let delay: UInt64
+ if let ra = http.value(forHTTPHeaderField: "Retry-After"), let secs = UInt64(ra) {
+ delay = secs * 1_000_000_000
+ } else {
+ delay = UInt64(pow(2.0, Double(attempt))) * 1_000_000_000
+ }
+ try await _Concurrency.Task.sleep(nanoseconds: jitter(delay))
+ continue
+ }
+ if retryable5xx.contains(http.statusCode), attempt < maxRetries {
+ let delay = UInt64(attempt + 1) * 1_000_000_000
+ try await _Concurrency.Task.sleep(nanoseconds: jitter(delay))
+ continue
+ }
+ }
+ return (data, response)
+ }
+ return try await session.data(for: request, delegate: delegate) // unreachable
+}
+
+private func stripNulls(_ value: Any) -> Any {
+ if let dict = value as? [String: Any] {
+ return dict.compactMapValues { v -> Any? in
+ if v is NSNull { return nil }
+ return stripNulls(v)
+ }
+ }
+ if let arr = value as? [Any] {
+ return arr.map(stripNulls)
+ }
+ return value
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/swift/examples.json b/packages/generator/tests/webhook-polling/snapshots/swift/examples.json
new file mode 100644
index 00000000..5cdffcd1
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/swift/examples.json
@@ -0,0 +1,18 @@
+{
+ "Client_Init": {
+ "usage": "let client = PachcaClient(token: \"YOUR_TOKEN\")",
+ "imports": [
+ "PachcaClient"
+ ]
+ },
+ "BotOperations_getWebhookEvents": {
+ "usage": "let response = try await client.bots.getWebhookEvents(limit: 123, cursor: \"example\")",
+ "output": "GetWebhookEventsResponse(data: [WebhookEvent], meta: PaginationMeta)"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "for try await event in client.bots.pollWebhookEvents(interval: 5) {\n print(event)\n}"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "for try await payload in client.bots.pollWebhookPayloads(interval: 5) {\n print(payload)\n}"
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/ts/client.ts b/packages/generator/tests/webhook-polling/snapshots/ts/client.ts
new file mode 100644
index 00000000..b09cdbd4
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/ts/client.ts
@@ -0,0 +1,150 @@
+import {
+ GetWebhookEventsParams,
+ GetWebhookEventsResponse,
+ WebhookEvent,
+ WebhookPayloadUnion,
+} from "./types.js";
+import { deserialize, deserializeType, fetchWithRetry } from "./utils.js";
+
+export interface PollWebhookEventsParams {
+ limit?: number;
+ intervalMs?: number;
+ createdAfter?: Date | string | null;
+ maxSeenDeliveryIds?: number;
+}
+
+export interface PollWebhookPayloadsParams extends PollWebhookEventsParams {
+ filter?: (payload: WebhookPayloadUnion, event: WebhookEvent) => payload is TPayload;
+}
+
+const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+function createdAtMatches(event: WebhookEvent, createdAfter: Date | string | null | undefined): boolean {
+ if (createdAfter == null) return true;
+ return new Date(event.createdAt).getTime() >= new Date(createdAfter).getTime();
+}
+
+export class BotsService {
+ async getWebhookEvents(params?: GetWebhookEventsParams): Promise {
+ throw new Error("Bots.getWebhookEvents is not implemented");
+ }
+
+ async getWebhookEventsAll(params?: Omit): Promise {
+ throw new Error("Bots.getWebhookEventsAll is not implemented");
+ }
+
+ async *pollWebhookEvents(params?: PollWebhookEventsParams): AsyncGenerator {
+ const limit = params?.limit ?? 50;
+ const intervalMs = params?.intervalMs ?? 5_000;
+ const createdAfter = params?.createdAfter ?? new Date();
+ const maxSeenDeliveryIds = params?.maxSeenDeliveryIds ?? 5_000;
+ if (maxSeenDeliveryIds <= 0) throw new Error("maxSeenDeliveryIds must be greater than 0");
+
+ const seenIdOrder: string[] = [];
+ const seenIds = new Set();
+ const remember = (id: string): boolean => {
+ if (seenIds.has(id)) return false;
+ seenIds.add(id);
+ seenIdOrder.push(id);
+ while (seenIdOrder.length > maxSeenDeliveryIds) {
+ const oldest = seenIdOrder.shift();
+ if (oldest !== undefined) seenIds.delete(oldest);
+ }
+ return true;
+ };
+
+ while (true) {
+ let cursor: string | undefined;
+ let hasNext = true;
+ while (hasNext) {
+ const response = await this.getWebhookEvents({ limit, cursor });
+ let pageHasRecentEvents = false;
+ for (const event of [...response.data].reverse()) {
+ const matchesCreatedAfter = createdAtMatches(event, createdAfter);
+ if (matchesCreatedAfter) pageHasRecentEvents = true;
+ if (matchesCreatedAfter && remember(event.id)) yield event;
+ }
+ hasNext = (response.meta.paginate.hasNext ?? response.data.length > 0) && pageHasRecentEvents;
+ cursor = response.meta.paginate.nextPage;
+ }
+ await sleep(intervalMs);
+ }
+ }
+
+ async *pollWebhookPayloads(
+ params?: PollWebhookPayloadsParams,
+ ): AsyncGenerator {
+ for await (const event of this.pollWebhookEvents(params)) {
+ const payload = event.payload;
+ if (params?.filter == null || params.filter(payload, event)) yield payload as TPayload;
+ }
+ }
+}
+
+export class BotsServiceImpl extends BotsService {
+ constructor(
+ private baseUrl: string,
+ private headers: Record,
+ ) {
+ super();
+ }
+
+ async getWebhookEvents(params?: GetWebhookEventsParams): Promise {
+ const query = new URLSearchParams();
+ if (params?.limit !== undefined) query.set("limit", String(params.limit));
+ if (params?.cursor !== undefined) query.set("cursor", params.cursor);
+ const url = `${this.baseUrl}/webhooks/events${query.toString() ? `?${query}` : ""}`;
+ const response = await fetchWithRetry(url, {
+ headers: this.headers,
+ });
+ const body = await response.json();
+ switch (response.status) {
+ case 200:
+ return deserialize(body) as GetWebhookEventsResponse;
+ default:
+ throw new Error(`HTTP ${response.status}: ${JSON.stringify(body)}`);
+ }
+ }
+
+ async getWebhookEventsAll(params?: Omit): Promise {
+ const items: WebhookEvent[] = [];
+ let cursor: string | undefined;
+ let hasNext = true;
+ while (hasNext) {
+ const response = await this.getWebhookEvents({ ...params, cursor } as GetWebhookEventsParams);
+ items.push(...response.data);
+ if (response.data.length === 0) break;
+ cursor = response.meta.paginate.nextPage;
+ hasNext = response.meta.paginate.hasNext ?? true;
+ }
+ return items;
+ }
+}
+
+export const PACHCA_API_URL = "https://api.pachca.com/api/shared/v1";
+
+export class PachcaClient {
+ readonly bots: BotsService;
+
+ constructor(token: string, baseUrl?: string);
+ constructor(config: { headers: Record; baseUrl?: string; bots?: BotsService });
+ constructor(tokenOrConfig: string | { headers: Record; baseUrl?: string; bots?: BotsService }, baseUrl?: string) {
+ let resolvedHeaders: Record;
+ let resolvedBaseUrl: string;
+ if (typeof tokenOrConfig === 'string') {
+ resolvedHeaders = { Authorization: `Bearer ${tokenOrConfig}` };
+ resolvedBaseUrl = baseUrl ?? PACHCA_API_URL;
+ this.bots = new BotsServiceImpl(resolvedBaseUrl, resolvedHeaders);
+ } else {
+ resolvedHeaders = tokenOrConfig.headers;
+ resolvedBaseUrl = tokenOrConfig.baseUrl ?? PACHCA_API_URL;
+ this.bots = tokenOrConfig.bots ?? new BotsServiceImpl(resolvedBaseUrl, resolvedHeaders);
+ }
+ }
+
+ static stub(overrides: { bots?: BotsService } = {}): PachcaClient {
+ const client = Object.create(PachcaClient.prototype);
+ client.bots = overrides.bots ?? new BotsService();
+ return client;
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/ts/examples.json b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json
new file mode 100644
index 00000000..8349b5a1
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json
@@ -0,0 +1,18 @@
+{
+ "Client_Init": {
+ "usage": "const client = new PachcaClient(\"YOUR_TOKEN\")",
+ "imports": [
+ "PachcaClient"
+ ]
+ },
+ "BotOperations_getWebhookEvents": {
+ "usage": "const response = client.bots.getWebhookEvents({ limit: 123, cursor: \"example\" })",
+ "output": "GetWebhookEventsResponse({ data: WebhookEvent[], meta: PaginationMeta })"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "for await (const event of client.bots.pollWebhookEvents({ intervalMs: 5_000 })) {\n console.log(event)\n}"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}"
+ }
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/ts/types.ts b/packages/generator/tests/webhook-polling/snapshots/ts/types.ts
new file mode 100644
index 00000000..64b60994
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/ts/types.ts
@@ -0,0 +1,35 @@
+export interface WebhookEvent {
+ id: string;
+ eventType: string;
+ payload: WebhookPayloadUnion;
+ createdAt: string;
+}
+
+export interface MessageWebhookPayload {
+ type: "message_new";
+ messageId: number;
+}
+
+export interface ReactionWebhookPayload {
+ type: "reaction_added";
+ reaction: string;
+}
+
+export interface PaginationMeta {
+ paginate: {
+ nextPage: string;
+ hasNext?: boolean;
+ };
+}
+
+export type WebhookPayloadUnion = MessageWebhookPayload | ReactionWebhookPayload;
+
+export interface GetWebhookEventsParams {
+ limit?: number;
+ cursor?: string;
+}
+
+export interface GetWebhookEventsResponse {
+ data: WebhookEvent[];
+ meta: PaginationMeta;
+}
diff --git a/packages/generator/tests/webhook-polling/snapshots/ts/utils.ts b/packages/generator/tests/webhook-polling/snapshots/ts/utils.ts
new file mode 100644
index 00000000..35778d63
--- /dev/null
+++ b/packages/generator/tests/webhook-polling/snapshots/ts/utils.ts
@@ -0,0 +1,95 @@
+function snakeToCamel(str: string): string {
+ const camel = str.replace(/[-_]([a-zA-Z])/g, (_, c) => c.toUpperCase());
+ return camel.charAt(0).toLowerCase() + camel.slice(1);
+}
+
+function camelToSnake(str: string): string {
+ return str
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .toLowerCase();
+}
+
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
+ }
+ return deserialize(obj);
+}
+
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
+ if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [k, mapValue(v)]),
+ );
+ }
+ return serialize(obj);
+}
+
+export function deserialize(obj: unknown): unknown {
+ if (Array.isArray(obj)) return obj.map(deserialize);
+ if (obj !== null && typeof obj === "object") {
+ return Object.fromEntries(
+ Object.entries(obj).map(([k, v]) => [snakeToCamel(k), deserialize(v)]),
+ );
+ }
+ return obj;
+}
+
+export function serialize(obj: unknown): unknown {
+ if (Array.isArray(obj)) return obj.map(serialize);
+ if (obj !== null && typeof obj === "object") {
+ return Object.fromEntries(
+ Object.entries(obj)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => [camelToSnake(k), serialize(v)]),
+ );
+ }
+ return obj;
+}
+
+const TYPE_DESERIALIZERS: Record unknown> = {
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
+const MAX_RETRIES = 3;
+const RETRYABLE_5XX = new Set([500, 502, 503, 504]);
+
+function jitter(delay: number): number {
+ return delay * (0.5 + Math.random() * 0.5);
+}
+
+export async function fetchWithRetry(input: RequestInfo | URL, init?: RequestInit): Promise {
+ for (let attempt = 0; ; attempt++) {
+ const response = await fetch(input, init);
+ if (response.status === 429 && attempt < MAX_RETRIES) {
+ const retryAfter = response.headers.get("retry-after");
+ const delay = retryAfter ? Number(retryAfter) * 1000 : 1000 * Math.pow(2, attempt);
+ await new Promise((r) => setTimeout(r, jitter(delay)));
+ continue;
+ }
+ if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) {
+ const delay = 1000 * (attempt + 1);
+ await new Promise((r) => setTimeout(r, jitter(delay)));
+ continue;
+ }
+ return response;
+ }
+}
diff --git a/packages/openapi-parser/src/schema-parser.ts b/packages/openapi-parser/src/schema-parser.ts
index 6ca0025a..f955c75b 100644
--- a/packages/openapi-parser/src/schema-parser.ts
+++ b/packages/openapi-parser/src/schema-parser.ts
@@ -75,6 +75,7 @@ export function parseSchema(
readOnly: getBoolean(schema, 'readOnly'),
writeOnly: getBoolean(schema, 'writeOnly'),
deprecated: getBoolean(schema, 'deprecated'),
+ 'x-union-deserializer': getString(schema, 'x-union-deserializer'),
};
// Properties
diff --git a/packages/openapi-parser/src/types.ts b/packages/openapi-parser/src/types.ts
index 4785322d..5af3cc3b 100644
--- a/packages/openapi-parser/src/types.ts
+++ b/packages/openapi-parser/src/types.ts
@@ -119,6 +119,7 @@ export interface Schema {
oneOf?: Schema[];
anyOf?: Schema[];
additionalProperties?: boolean | Schema;
+ 'x-union-deserializer'?: string;
}
export interface Example {
diff --git a/packages/spec/openapi.en.yaml b/packages/spec/openapi.en.yaml
index 62ad8475..045d1a3d 100644
--- a/packages/spec/openapi.en.yaml
+++ b/packages/spec/openapi.en.yaml
@@ -8737,6 +8737,7 @@ components:
- $ref: '#/components/schemas/CompanyMemberWebhookPayload'
- $ref: '#/components/schemas/LinkSharedWebhookPayload'
description: Union of all webhook payload types
+ x-union-deserializer: webhook-payload
securitySchemes:
BearerAuth:
type: http
diff --git a/packages/spec/openapi.yaml b/packages/spec/openapi.yaml
index f698fc68..65bd84b9 100644
--- a/packages/spec/openapi.yaml
+++ b/packages/spec/openapi.yaml
@@ -8559,6 +8559,7 @@ components:
- $ref: '#/components/schemas/CompanyMemberWebhookPayload'
- $ref: '#/components/schemas/LinkSharedWebhookPayload'
description: Объединение всех типов payload вебхуков
+ x-union-deserializer: webhook-payload
securitySchemes:
BearerAuth:
type: http
diff --git a/packages/spec/typespec.tsp b/packages/spec/typespec.tsp
index cd1ae9b7..690e9824 100644
--- a/packages/spec/typespec.tsp
+++ b/packages/spec/typespec.tsp
@@ -3036,6 +3036,7 @@ model LinkSharedWebhookPayload {
}
@doc("Объединение всех типов payload вебхуков")
+@extension("x-union-deserializer", "webhook-payload")
union WebhookPayloadUnion {
MessageWebhookPayload,
ReactionWebhookPayload,
diff --git a/sdk/csharp/examples/PollingExample.cs b/sdk/csharp/examples/PollingExample.cs
new file mode 100644
index 00000000..1250d15b
--- /dev/null
+++ b/sdk/csharp/examples/PollingExample.cs
@@ -0,0 +1,68 @@
+/**
+ * Webhook polling example — continuously process new webhook deliveries.
+ *
+ * Usage:
+ *
+ * PACHCA_TOKEN=your_token dotnet run -- polling
+ * PACHCA_TOKEN=your_token dotnet run -- polling --payloads
+ */
+
+using System.Text.Json;
+
+namespace Pachca.Sdk.Examples;
+
+public static class PollingExample
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var pollPayloadsOnly = args.Contains("--payloads");
+ var token = Environment.GetEnvironmentVariable("PACHCA_TOKEN")
+ ?? throw new InvalidOperationException("Set PACHCA_TOKEN environment variable");
+
+ var client = new PachcaClient(token);
+ var startedAt = DateTimeOffset.UtcNow;
+ using var cancellation = new CancellationTokenSource();
+ Console.CancelKeyPress += (_, e) =>
+ {
+ e.Cancel = true;
+ cancellation.Cancel();
+ };
+
+ Console.WriteLine("Webhook polling worker started");
+ Console.WriteLine("poll_limit=50 poll_interval=2s");
+ Console.WriteLine($"waiting_for_events_created_after={startedAt:O}");
+
+ try
+ {
+ if (pollPayloadsOnly)
+ {
+ await foreach (var payload in client.Bots.PollWebhookPayloadsAsync(
+ limit: 50,
+ interval: TimeSpan.FromSeconds(2),
+ createdAfter: startedAt,
+ maxSeenDeliveryIds: 5000,
+ cancellationToken: cancellation.Token))
+ {
+ Console.WriteLine(JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
+ }
+ }
+ else
+ {
+ await foreach (var @event in client.Bots.PollWebhookEventsAsync(
+ limit: 50,
+ interval: TimeSpan.FromSeconds(2),
+ createdAfter: startedAt,
+ maxSeenDeliveryIds: 5000,
+ cancellationToken: cancellation.Token))
+ {
+ Console.WriteLine(JsonSerializer.Serialize(@event, new JsonSerializerOptions { WriteIndented = true }));
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+
+ return 0;
+ }
+}
diff --git a/sdk/csharp/examples/Program.cs b/sdk/csharp/examples/Program.cs
index 206361e0..3ba5d10b 100644
--- a/sdk/csharp/examples/Program.cs
+++ b/sdk/csharp/examples/Program.cs
@@ -8,6 +8,8 @@
"upload" => await UploadExample.RunAsync(),
"stub" => await StubExample.RunAsync(),
"httpclient" => await HttpClientExample.RunAsync(),
+ "webhook-history" => await WebhookHistoryExample.RunAsync(),
+ "polling" => await PollingExample.RunAsync(args.Skip(1).ToArray()),
_ => PrintUsage()
};
@@ -16,14 +18,19 @@ static int PrintUsage()
Console.WriteLine("Usage: dotnet run -- ");
Console.WriteLine();
Console.WriteLine("Examples:");
- Console.WriteLine(" main - Echo bot (create, read, react, thread, pin, update, unpin)");
- Console.WriteLine(" upload - File upload (requires PACHCA_FILE_PATH)");
- Console.WriteLine(" stub - Stub client with dependency injection");
- Console.WriteLine(" httpclient - Pre-configured HttpClient");
+ Console.WriteLine(" main - Echo bot (create, read, react, thread, pin, update, unpin)");
+ Console.WriteLine(" upload - File upload (requires PACHCA_FILE_PATH)");
+ Console.WriteLine(" stub - Stub client with dependency injection");
+ Console.WriteLine(" httpclient - Pre-configured HttpClient");
+ Console.WriteLine(" webhook-history - Fetch recent webhook deliveries");
+ Console.WriteLine(" polling - Continuously process new webhook deliveries");
+ Console.WriteLine();
+ Console.WriteLine("Polling options:");
+ Console.WriteLine(" --payloads - Poll payloads instead of full webhook events");
Console.WriteLine();
Console.WriteLine("Environment variables:");
- Console.WriteLine(" PACHCA_TOKEN - API token (required)");
- Console.WriteLine(" PACHCA_CHAT_ID - Chat ID (required)");
+ Console.WriteLine(" PACHCA_TOKEN - API token (required)");
+ Console.WriteLine(" PACHCA_CHAT_ID - Chat ID (required for main/httpclient)");
Console.WriteLine(" PACHCA_FILE_PATH - File path (upload only)");
return 1;
}
diff --git a/sdk/csharp/examples/WebhookHistoryExample.cs b/sdk/csharp/examples/WebhookHistoryExample.cs
new file mode 100644
index 00000000..9c686ddf
--- /dev/null
+++ b/sdk/csharp/examples/WebhookHistoryExample.cs
@@ -0,0 +1,43 @@
+/**
+ * Webhook history example — fetch recent webhook deliveries and inspect payload variants.
+ *
+ * Usage:
+ *
+ * PACHCA_TOKEN=your_token dotnet run -- webhook-history
+ */
+
+namespace Pachca.Sdk.Examples;
+
+public static class WebhookHistoryExample
+{
+ public static async Task RunAsync()
+ {
+ var token = Environment.GetEnvironmentVariable("PACHCA_TOKEN")
+ ?? throw new InvalidOperationException("Set PACHCA_TOKEN environment variable");
+
+ var client = new PachcaClient(token);
+ var response = await client.Bots.GetWebhookEventsAsync(limit: 5);
+
+ Console.WriteLine($"Fetched {response.Data.Count} webhook events");
+ for (var index = 0; index < response.Data.Count; index++)
+ {
+ var @event = response.Data[index];
+ Console.WriteLine($"{index + 1}. id={@event.Id} created_at={@event.CreatedAt:O} payload={SummarizePayload(@event.Payload)}");
+ }
+
+ Console.WriteLine($"has_next={response.Meta.Paginate.HasNext} next_page=\"{response.Meta.Paginate.NextPage}\"");
+ return 0;
+ }
+
+ private static string SummarizePayload(WebhookPayloadUnion payload) => payload switch
+ {
+ LinkSharedWebhookPayload linkShared => $"link_shared message_id={linkShared.MessageId} links={linkShared.Links.Count} user_id={linkShared.UserId}",
+ MessageWebhookPayload message => $"message event={message.Event} id={message.Id} chat_id={message.ChatId}",
+ ReactionWebhookPayload reaction => $"reaction event={reaction.Event} message_id={reaction.MessageId} code={reaction.Code}",
+ ButtonWebhookPayload button => $"button message_id={button.MessageId} user_id={button.UserId}",
+ ViewSubmitWebhookPayload view => $"view user_id={view.UserId} fields={view.Data.Count}",
+ ChatMemberWebhookPayload member => $"chat_member event={member.Event} chat_id={member.ChatId} users={member.UserIds.Count}",
+ CompanyMemberWebhookPayload member => $"company_member event={member.Event} users={member.UserIds.Count}",
+ _ => $"unknown type={payload.GetType().Name}",
+ };
+}
diff --git a/sdk/csharp/generated/Client.cs b/sdk/csharp/generated/Client.cs
index 1e869aca..3d54eaff 100644
--- a/sdk/csharp/generated/Client.cs
+++ b/sdk/csharp/generated/Client.cs
@@ -9,6 +9,7 @@
using System.Text;
using System.Text.Json;
using System.Threading;
+using System.Runtime.CompilerServices;
namespace Pachca.Sdk;
@@ -146,6 +147,74 @@ public virtual async System.Threading.Tasks.Task> GetWebhookE
throw new NotImplementedException("Bots.getWebhookEventsAll is not implemented");
}
+ public virtual async IAsyncEnumerable PollWebhookEventsAsync(
+ int? limit = 50,
+ TimeSpan? interval = null,
+ DateTimeOffset? createdAfter = null,
+ int maxSeenDeliveryIds = 5000,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (maxSeenDeliveryIds <= 0)
+ throw new ArgumentOutOfRangeException(nameof(maxSeenDeliveryIds), "maxSeenDeliveryIds must be greater than 0");
+
+ var pollInterval = interval ?? TimeSpan.FromSeconds(5);
+ var effectiveCreatedAfter = createdAfter ?? DateTimeOffset.UtcNow;
+ var seenIdOrder = new Queue();
+ var seenIds = new HashSet();
+
+ bool Remember(string id)
+ {
+ if (!seenIds.Add(id)) return false;
+ seenIdOrder.Enqueue(id);
+ while (seenIdOrder.Count > maxSeenDeliveryIds)
+ seenIds.Remove(seenIdOrder.Dequeue());
+ return true;
+ }
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ string? cursor = null;
+ var hasNext = true;
+ while (hasNext && !cancellationToken.IsCancellationRequested)
+ {
+ var response = await GetWebhookEventsAsync(limit: limit, cursor: cursor, cancellationToken: cancellationToken).ConfigureAwait(false);
+ var pageHasRecentEvents = false;
+ for (var i = response.Data.Count - 1; i >= 0; i--)
+ {
+ var webhookEvent = response.Data[i];
+ var matchesCreatedAfter = webhookEvent.CreatedAt >= effectiveCreatedAfter;
+ if (matchesCreatedAfter)
+ pageHasRecentEvents = true;
+ if (matchesCreatedAfter && Remember(webhookEvent.Id))
+ yield return webhookEvent;
+ }
+ hasNext = (response.Meta.Paginate.HasNext ?? response.Data.Count > 0) && pageHasRecentEvents;
+ cursor = response.Meta.Paginate.NextPage;
+ }
+ await System.Threading.Tasks.Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ public virtual async IAsyncEnumerable PollWebhookPayloadsAsync(
+ int? limit = 50,
+ TimeSpan? interval = null,
+ DateTimeOffset? createdAfter = null,
+ int maxSeenDeliveryIds = 5000,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ where TPayload : WebhookPayloadUnion
+ {
+ await foreach (var webhookEvent in PollWebhookEventsAsync(
+ limit: limit,
+ interval: interval,
+ createdAfter: createdAfter,
+ maxSeenDeliveryIds: maxSeenDeliveryIds,
+ cancellationToken: cancellationToken))
+ {
+ if (webhookEvent.Payload is TPayload payload)
+ yield return payload;
+ }
+ }
+
public virtual async System.Threading.Tasks.Task UpdateBotAsync(
int id,
BotUpdateRequest request,
diff --git a/sdk/csharp/generated/Models.cs b/sdk/csharp/generated/Models.cs
index 60f1a3c8..5b650b6c 100644
--- a/sdk/csharp/generated/Models.cs
+++ b/sdk/csharp/generated/Models.cs
@@ -1587,6 +1587,7 @@ public abstract class ViewBlockUnion
public class ViewBlockHeader : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "header";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -1594,6 +1595,7 @@ public class ViewBlockHeader : ViewBlockUnion
public class ViewBlockPlainText : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "plain_text";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -1601,6 +1603,7 @@ public class ViewBlockPlainText : ViewBlockUnion
public class ViewBlockMarkdown : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "markdown";
[JsonPropertyName("text")]
public string Text { get; set; } = default!;
@@ -1608,11 +1611,13 @@ public class ViewBlockMarkdown : ViewBlockUnion
public class ViewBlockDivider : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "divider";
}
public class ViewBlockInput : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "input";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1636,6 +1641,7 @@ public class ViewBlockInput : ViewBlockUnion
public class ViewBlockSelect : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "select";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1651,6 +1657,7 @@ public class ViewBlockSelect : ViewBlockUnion
public class ViewBlockRadio : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "radio";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1666,6 +1673,7 @@ public class ViewBlockRadio : ViewBlockUnion
public class ViewBlockCheckbox : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "checkbox";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1681,6 +1689,7 @@ public class ViewBlockCheckbox : ViewBlockUnion
public class ViewBlockDate : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "date";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1696,6 +1705,7 @@ public class ViewBlockDate : ViewBlockUnion
public class ViewBlockTime : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "time";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1711,6 +1721,7 @@ public class ViewBlockTime : ViewBlockUnion
public class ViewBlockFileInput : ViewBlockUnion
{
+ [JsonPropertyName("type")]
public override string Type => "file_input";
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
@@ -1726,22 +1737,44 @@ public class ViewBlockFileInput : ViewBlockUnion
public string? Hint { get; set; }
}
-[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
-[JsonDerivedType(typeof(MessageWebhookPayload), "message")]
-[JsonDerivedType(typeof(ReactionWebhookPayload), "reaction")]
-[JsonDerivedType(typeof(ButtonWebhookPayload), "button")]
-[JsonDerivedType(typeof(ViewSubmitWebhookPayload), "view")]
-[JsonDerivedType(typeof(ChatMemberWebhookPayload), "chat_member")]
-[JsonDerivedType(typeof(CompanyMemberWebhookPayload), "company_member")]
-[JsonDerivedType(typeof(LinkSharedWebhookPayload), "message")]
+[JsonConverter(typeof(WebhookPayloadUnionConverter))]
public abstract class WebhookPayloadUnion
{
[JsonPropertyName("type")]
public abstract string Type { get; }
}
+internal sealed class WebhookPayloadUnionConverter : JsonConverter
+{
+ public override WebhookPayloadUnion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var root = document.RootElement;
+ var type = root.GetProperty("type").GetString();
+ var eventValue = root.TryGetProperty("event", out var eventProperty) ? eventProperty.GetString() : null;
+ var raw = root.GetRawText();
+ return type switch
+ {
+ "message" when eventValue == "link_shared" => JsonSerializer.Deserialize(raw, options)!,
+ "message" => JsonSerializer.Deserialize(raw, options)!,
+ "reaction" => JsonSerializer.Deserialize(raw, options)!,
+ "button" => JsonSerializer.Deserialize(raw, options)!,
+ "view" => JsonSerializer.Deserialize(raw, options)!,
+ "chat_member" => JsonSerializer.Deserialize(raw, options)!,
+ "company_member" => JsonSerializer.Deserialize(raw, options)!,
+ _ => throw new JsonException($"Unknown WebhookPayloadUnion type: {type}")
+ };
+ }
+
+ public override void Write(Utf8JsonWriter writer, WebhookPayloadUnion value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, (object)value, value.GetType(), options);
+ }
+}
+
public class MessageWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "message";
[JsonPropertyName("id")]
public int Id { get; set; } = default!;
@@ -1771,6 +1804,7 @@ public class MessageWebhookPayload : WebhookPayloadUnion
public class ReactionWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "reaction";
[JsonPropertyName("event")]
public ReactionEventType @Event { get; set; } = default!;
@@ -1792,7 +1826,10 @@ public class ReactionWebhookPayload : WebhookPayloadUnion
public class ButtonWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "button";
+ [JsonPropertyName("event")]
+ public string @Event => "click";
[JsonPropertyName("message_id")]
public int MessageId { get; set; } = default!;
[JsonPropertyName("trigger_id")]
@@ -1809,7 +1846,10 @@ public class ButtonWebhookPayload : WebhookPayloadUnion
public class ViewSubmitWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "view";
+ [JsonPropertyName("event")]
+ public string @Event => "submit";
[JsonPropertyName("callback_id")]
public string? CallbackId { get; set; }
[JsonPropertyName("private_metadata")]
@@ -1826,6 +1866,7 @@ public class ViewSubmitWebhookPayload : WebhookPayloadUnion
public class ChatMemberWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "chat_member";
[JsonPropertyName("event")]
public MemberEventType @Event { get; set; } = default!;
@@ -1843,6 +1884,7 @@ public class ChatMemberWebhookPayload : WebhookPayloadUnion
public class CompanyMemberWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "company_member";
[JsonPropertyName("event")]
public UserEventType @Event { get; set; } = default!;
@@ -1856,7 +1898,10 @@ public class CompanyMemberWebhookPayload : WebhookPayloadUnion
public class LinkSharedWebhookPayload : WebhookPayloadUnion
{
+ [JsonPropertyName("type")]
public override string Type => "message";
+ [JsonPropertyName("event")]
+ public string @Event => "link_shared";
[JsonPropertyName("chat_id")]
public int ChatId { get; set; } = default!;
[JsonPropertyName("message_id")]
diff --git a/sdk/csharp/generated/examples.json b/sdk/csharp/generated/examples.json
index 05369bf6..38776b74 100644
--- a/sdk/csharp/generated/examples.json
+++ b/sdk/csharp/generated/examples.json
@@ -16,6 +16,15 @@
"usage": "var response = await client.Bots.GetWebhookEventsAsync(1, \"eyJpZCI6MTAsImRpciI6ImFzYyJ9\");",
"output": "GetWebhookEventsResponse(Data: List, Meta: PaginationMeta)"
},
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "await foreach (var webhookEvent in client.Bots.PollWebhookEventsAsync())\n{\n _ = webhookEvent;\n}"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "await foreach (var payload in client.Bots.PollWebhookPayloadsAsync())\n{\n _ = payload;\n}",
+ "imports": [
+ "WebhookPayloadUnion"
+ ]
+ },
"BotOperations_updateBot": {
"usage": "var request = new BotUpdateRequest { Bot = new BotUpdateRequestBot { Webhook = new BotUpdateRequestBotWebhook { OutgoingUrl = \"https://www.website.com/tasks/new\" } } };\nvar response = await client.Bots.UpdateBotAsync(1738816, request);",
"output": "BotResponse(Id: int, Webhook: BotResponseWebhook(OutgoingUrl: string))",
diff --git a/sdk/go/examples/polling.go b/sdk/go/examples/polling.go
new file mode 100644
index 00000000..d1771702
--- /dev/null
+++ b/sdk/go/examples/polling.go
@@ -0,0 +1,64 @@
+// Webhook polling example — continuously process new webhook deliveries.
+//
+// Usage:
+//
+// PACHCA_TOKEN=your_token go run polling.go
+// PACHCA_TOKEN=your_token go run polling.go --payloads
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ pachca "github.com/pachca/openapi/sdk/go/generated"
+)
+
+func main() {
+ pollPayloadsOnly := flag.Bool("payloads", false, "poll payloads instead of full webhook events")
+ flag.Parse()
+
+ token := os.Getenv("PACHCA_TOKEN")
+ if token == "" {
+ log.Fatal("Set PACHCA_TOKEN environment variable")
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ client := pachca.NewPachcaClient(token)
+ limit := int32(50)
+ startedAt := time.Now()
+
+ fmt.Println("Webhook polling worker started")
+ fmt.Println("poll_limit=50 poll_interval=2s")
+ fmt.Printf("waiting_for_events_created_after=%s\n", startedAt.Format(time.RFC3339))
+
+ options := &pachca.PollWebhookEventsOptions{
+ Limit: &limit,
+ Interval: 2 * time.Second,
+ CreatedAfter: &startedAt,
+ MaxSeenDeliveryIDs: 5000,
+ }
+
+ var err error
+ if *pollPayloadsOnly {
+ err = client.Bots.PollWebhookPayloads(ctx, options, func(payload pachca.WebhookPayloadUnion) error {
+ fmt.Printf("%+v\n", payload)
+ return nil
+ })
+ } else {
+ err = client.Bots.PollWebhookEvents(ctx, options, func(event pachca.WebhookEvent) error {
+ fmt.Printf("%+v\n", event)
+ return nil
+ })
+ }
+ if err != nil && err != context.Canceled {
+ log.Fatal(err)
+ }
+}
diff --git a/sdk/go/examples/webhook_history.go b/sdk/go/examples/webhook_history.go
new file mode 100644
index 00000000..2e0c4f56
--- /dev/null
+++ b/sdk/go/examples/webhook_history.go
@@ -0,0 +1,70 @@
+// Webhook history example — fetch recent webhook deliveries and inspect payload variants.
+//
+// Usage:
+//
+// PACHCA_TOKEN=your_token go run webhook_history.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ pachca "github.com/pachca/openapi/sdk/go/generated"
+)
+
+func main() {
+ token := os.Getenv("PACHCA_TOKEN")
+ if token == "" {
+ log.Fatal("Set PACHCA_TOKEN environment variable")
+ }
+
+ client := pachca.NewPachcaClient(token)
+ limit := int32(5)
+ response, err := client.Bots.GetWebhookEvents(context.Background(), &pachca.GetWebhookEventsParams{
+ Limit: &limit,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Fetched %d webhook events\n", len(response.Data))
+ for i, event := range response.Data {
+ fmt.Printf("%d. id=%s created_at=%s payload=%s\n", i+1, event.ID, event.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), summarizePayload(event.Payload))
+ }
+
+ fmt.Printf("has_next=%v next_page=%q\n", boolValue(response.Meta.Paginate.HasNext), response.Meta.Paginate.NextPage)
+}
+
+func boolValue(value *bool) bool {
+ return value != nil && *value
+}
+
+func summarizePayload(payload pachca.WebhookPayloadUnion) string {
+ switch {
+ case payload.LinkSharedWebhookPayload != nil:
+ p := payload.LinkSharedWebhookPayload
+ return fmt.Sprintf("link_shared message_id=%d links=%d user_id=%d", p.MessageID, len(p.Links), p.UserID)
+ case payload.MessageWebhookPayload != nil:
+ p := payload.MessageWebhookPayload
+ return fmt.Sprintf("message event=%s id=%d chat_id=%d", p.Event, p.ID, p.ChatID)
+ case payload.ReactionWebhookPayload != nil:
+ p := payload.ReactionWebhookPayload
+ return fmt.Sprintf("reaction event=%s message_id=%d code=%s", p.Event, p.MessageID, p.Code)
+ case payload.ButtonWebhookPayload != nil:
+ p := payload.ButtonWebhookPayload
+ return fmt.Sprintf("button message_id=%d user_id=%d", p.MessageID, p.UserID)
+ case payload.ViewSubmitWebhookPayload != nil:
+ p := payload.ViewSubmitWebhookPayload
+ return fmt.Sprintf("view user_id=%d fields=%d", p.UserID, len(p.Data))
+ case payload.ChatMemberWebhookPayload != nil:
+ p := payload.ChatMemberWebhookPayload
+ return fmt.Sprintf("chat_member event=%s chat_id=%d users=%d", p.Event, p.ChatID, len(p.UserIDs))
+ case payload.CompanyMemberWebhookPayload != nil:
+ p := payload.CompanyMemberWebhookPayload
+ return fmt.Sprintf("company_member event=%s users=%d", p.Event, len(p.UserIDs))
+ default:
+ return "unknown"
+ }
+}
diff --git a/sdk/go/generated/client.go b/sdk/go/generated/client.go
index aba9a1a7..f6c26e52 100644
--- a/sdk/go/generated/client.go
+++ b/sdk/go/generated/client.go
@@ -137,10 +137,19 @@ func (s *SecurityServiceImpl) GetAuditEventsAll(ctx context.Context, params *Get
type BotsService interface {
GetWebhookEvents(ctx context.Context, params *GetWebhookEventsParams) (*GetWebhookEventsResponse, error)
GetWebhookEventsAll(ctx context.Context, params *GetWebhookEventsParams) ([]WebhookEvent, error)
+ PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error
+ PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error
UpdateBot(ctx context.Context, id int32, request BotUpdateRequest) (*BotResponse, error)
DeleteWebhookEvent(ctx context.Context, id string) error
}
+type PollWebhookEventsOptions struct {
+ Limit *int32
+ Interval time.Duration
+ CreatedAfter *time.Time
+ MaxSeenDeliveryIDs int
+}
+
type BotsServiceStub struct{}
func (s *BotsServiceStub) GetWebhookEvents(ctx context.Context, params *GetWebhookEventsParams) (*GetWebhookEventsResponse, error) {
@@ -151,6 +160,14 @@ func (s *BotsServiceStub) GetWebhookEventsAll(ctx context.Context, params *GetWe
return nil, NotImplementedError{Method: "Bots.getWebhookEventsAll"}
}
+func (s *BotsServiceStub) PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error {
+ return NotImplementedError{Method: "Bots.pollWebhookEvents"}
+}
+
+func (s *BotsServiceStub) PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error {
+ return NotImplementedError{Method: "Bots.pollWebhookPayloads"}
+}
+
func (s *BotsServiceStub) UpdateBot(ctx context.Context, id int32, request BotUpdateRequest) (*BotResponse, error) {
return nil, NotImplementedError{Method: "Bots.updateBot"}
}
@@ -234,6 +251,97 @@ func (s *BotsServiceImpl) GetWebhookEventsAll(ctx context.Context, params *GetWe
return items, nil
}
+func (s *BotsServiceImpl) PollWebhookEvents(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookEvent) error) error {
+ if handler == nil {
+ return errors.New("handler must not be nil")
+ }
+ if options == nil {
+ options = &PollWebhookEventsOptions{}
+ }
+ interval := options.Interval
+ if interval == 0 {
+ interval = 5 * time.Second
+ }
+ createdAfter := options.CreatedAfter
+ if createdAfter == nil {
+ now := time.Now()
+ createdAfter = &now
+ }
+ maxSeenDeliveryIDs := options.MaxSeenDeliveryIDs
+ if maxSeenDeliveryIDs == 0 {
+ maxSeenDeliveryIDs = 5000
+ }
+ if maxSeenDeliveryIDs < 0 {
+ return errors.New("MaxSeenDeliveryIDs must be greater than 0")
+ }
+
+ seenIDOrder := make([]string, 0, maxSeenDeliveryIDs)
+ seenIDs := make(map[string]struct{}, maxSeenDeliveryIDs)
+ remember := func(id string) bool {
+ if _, ok := seenIDs[id]; ok {
+ return false
+ }
+ seenIDs[id] = struct{}{}
+ seenIDOrder = append(seenIDOrder, id)
+ for len(seenIDOrder) > maxSeenDeliveryIDs {
+ oldest := seenIDOrder[0]
+ seenIDOrder = seenIDOrder[1:]
+ delete(seenIDs, oldest)
+ }
+ return true
+ }
+
+ for {
+ var cursor *string
+ hasNext := true
+ for hasNext {
+ params := &GetWebhookEventsParams{Limit: options.Limit, Cursor: cursor}
+ response, err := s.GetWebhookEvents(ctx, params)
+ if err != nil {
+ return err
+ }
+ pageHasRecentEvents := false
+ for i := len(response.Data) - 1; i >= 0; i-- {
+ event := response.Data[i]
+ matchesCreatedAfter := !event.CreatedAt.Before(*createdAfter)
+ if matchesCreatedAfter {
+ pageHasRecentEvents = true
+ }
+ if matchesCreatedAfter && remember(event.ID) {
+ if err := handler(event); err != nil {
+ return err
+ }
+ }
+ }
+ nextPage := response.Meta.Paginate.NextPage
+ cursor = &nextPage
+ if response.Meta.Paginate.HasNext != nil {
+ hasNext = *response.Meta.Paginate.HasNext
+ } else {
+ hasNext = len(response.Data) > 0
+ }
+ hasNext = hasNext && pageHasRecentEvents
+ }
+
+ timer := time.NewTimer(interval)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return ctx.Err()
+ case <-timer.C:
+ }
+ }
+}
+
+func (s *BotsServiceImpl) PollWebhookPayloads(ctx context.Context, options *PollWebhookEventsOptions, handler func(WebhookPayloadUnion) error) error {
+ if handler == nil {
+ return errors.New("handler must not be nil")
+ }
+ return s.PollWebhookEvents(ctx, options, func(event WebhookEvent) error {
+ return handler(event.Payload)
+ })
+}
+
func (s *BotsServiceImpl) UpdateBot(ctx context.Context, id int32, request BotUpdateRequest) (*BotResponse, error) {
body, err := json.Marshal(request)
if err != nil {
diff --git a/sdk/go/generated/examples.json b/sdk/go/generated/examples.json
index 85387635..e1557564 100644
--- a/sdk/go/generated/examples.json
+++ b/sdk/go/generated/examples.json
@@ -20,6 +20,12 @@
"GetWebhookEventsParams"
]
},
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "err := client.Bots.PollWebhookEvents(ctx, nil, func(event pachca.WebhookEvent) error {\n\t_ = event\n\treturn nil\n})"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "err := client.Bots.PollWebhookPayloads(ctx, nil, func(payload pachca.WebhookPayloadUnion) error {\n\t_ = payload\n\treturn nil\n})"
+ },
"BotOperations_updateBot": {
"usage": "request := BotUpdateRequest{\n\tBot: BotUpdateRequestBot{\n\t\tWebhook: BotUpdateRequestBotWebhook{\n\t\t\tOutgoingURL: \"https://www.website.com/tasks/new\",\n\t\t},\n\t},\n}\nresponse, err := client.Bots.UpdateBot(ctx, int32(1738816), request)",
"output": "BotResponse{ID: int32, Webhook: BotResponseWebhook{OutgoingURL: string}}",
diff --git a/sdk/go/generated/types.go b/sdk/go/generated/types.go
index d1c23e3c..4f2d8cc0 100644
--- a/sdk/go/generated/types.go
+++ b/sdk/go/generated/types.go
@@ -1564,27 +1564,31 @@ type WebhookPayloadUnion struct {
func (u *WebhookPayloadUnion) UnmarshalJSON(data []byte) error {
var disc struct {
Type string `json:"type"`
+ Event string `json:"event"`
}
if err := json.Unmarshal(data, &disc); err != nil {
return err
}
- switch disc.Type {
- case "message":
+ switch {
+ case disc.Type == "message" && disc.Event == "link_shared":
+ u.LinkSharedWebhookPayload = &LinkSharedWebhookPayload{}
+ return json.Unmarshal(data, u.LinkSharedWebhookPayload)
+ case disc.Type == "message":
u.MessageWebhookPayload = &MessageWebhookPayload{}
return json.Unmarshal(data, u.MessageWebhookPayload)
- case "reaction":
+ case disc.Type == "reaction":
u.ReactionWebhookPayload = &ReactionWebhookPayload{}
return json.Unmarshal(data, u.ReactionWebhookPayload)
- case "button":
+ case disc.Type == "button":
u.ButtonWebhookPayload = &ButtonWebhookPayload{}
return json.Unmarshal(data, u.ButtonWebhookPayload)
- case "view":
+ case disc.Type == "view":
u.ViewSubmitWebhookPayload = &ViewSubmitWebhookPayload{}
return json.Unmarshal(data, u.ViewSubmitWebhookPayload)
- case "chat_member":
+ case disc.Type == "chat_member":
u.ChatMemberWebhookPayload = &ChatMemberWebhookPayload{}
return json.Unmarshal(data, u.ChatMemberWebhookPayload)
- case "company_member":
+ case disc.Type == "company_member":
u.CompanyMemberWebhookPayload = &CompanyMemberWebhookPayload{}
return json.Unmarshal(data, u.CompanyMemberWebhookPayload)
default:
diff --git a/sdk/kotlin/examples/polling.kt b/sdk/kotlin/examples/polling.kt
new file mode 100644
index 00000000..02e368d7
--- /dev/null
+++ b/sdk/kotlin/examples/polling.kt
@@ -0,0 +1,55 @@
+/**
+ * Minimal webhook polling example.
+ *
+ * This example uses the generated Kotlin polling helper and prints each
+ * collected event with its default string representation.
+ *
+ * Usage:
+ *
+ * PACHCA_TOKEN=your_bot_token ./gradlew runExample -Dexample=examples.polling.PollingKt -Pversion=0.0.0
+ * PACHCA_TOKEN=your_bot_token ./gradlew runExample -Dexample=examples.polling.PollingKt -Pversion=0.0.0 --args="--payloads"
+ */
+package examples.polling
+
+import com.pachca.sdk.PachcaClient
+import com.pachca.sdk.WebhookPayloadUnion
+import com.pachca.sdk.pollWebhookEvents
+import com.pachca.sdk.pollWebhookPayloads
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.runBlocking
+import java.time.OffsetDateTime
+import kotlin.time.Duration.Companion.seconds
+
+fun main(args: Array) = runBlocking {
+ val token = System.getenv("PACHCA_TOKEN")
+ ?: error("Set PACHCA_TOKEN environment variable")
+ val pollPayloadsOnly = "--payloads" in args
+
+ PachcaClient(token).use { client ->
+ val startedAt = OffsetDateTime.now()
+
+ println("Webhook polling worker started")
+ println("poll_limit=50 poll_interval=2s")
+ println("waiting_for_events_created_after=$startedAt")
+
+ if (pollPayloadsOnly) {
+ client.bots.pollWebhookPayloads(
+ limit = 50,
+ interval = 2.seconds,
+ createdAfter = startedAt,
+ maxSeenDeliveryIds = 5_000,
+ ).collect { payload ->
+ println(payload.toString())
+ }
+ } else {
+ client.bots.pollWebhookEvents(
+ limit = 50,
+ interval = 2.seconds,
+ createdAfter = startedAt,
+ maxSeenDeliveryIds = 5_000,
+ ).collect { event ->
+ println(event.toString())
+ }
+ }
+ }
+}
diff --git a/sdk/kotlin/examples/webhook_history.kt b/sdk/kotlin/examples/webhook_history.kt
new file mode 100644
index 00000000..b831f400
--- /dev/null
+++ b/sdk/kotlin/examples/webhook_history.kt
@@ -0,0 +1,37 @@
+/**
+ * Webhook history example — fetch recent webhook deliveries and inspect payload variants.
+ *
+ * Usage:
+ *
+ * PACHCA_TOKEN=your_token kotlin webhook_history.kt
+ */
+package examples.webhookhistory
+
+import com.pachca.sdk.*
+import kotlinx.coroutines.runBlocking
+
+fun main() = runBlocking {
+ val token = System.getenv("PACHCA_TOKEN")
+ ?: error("Set PACHCA_TOKEN environment variable")
+
+ val client = PachcaClient(token)
+ val response = client.bots.getWebhookEvents(limit = 5)
+
+ println("Fetched ${response.data.size} webhook events")
+ response.data.forEachIndexed { index, event ->
+ println("${index + 1}. id=${event.id} created_at=${event.createdAt} payload=${summarizePayload(event.payload)}")
+ }
+
+ println("has_next=${response.meta.paginate.hasNext} next_page=${response.meta.paginate.nextPage}")
+ client.close()
+}
+
+private fun summarizePayload(payload: WebhookPayloadUnion): String = when (payload) {
+ is LinkSharedWebhookPayload -> "link_shared message_id=${payload.messageId} links=${payload.links.size} user_id=${payload.userId}"
+ is MessageWebhookPayload -> "message event=${payload.event} id=${payload.id} chat_id=${payload.chatId}"
+ is ReactionWebhookPayload -> "reaction event=${payload.event} message_id=${payload.messageId} code=${payload.code}"
+ is ButtonWebhookPayload -> "button message_id=${payload.messageId} user_id=${payload.userId}"
+ is ViewSubmitWebhookPayload -> "view user_id=${payload.userId} fields=${payload.data.size}"
+ is ChatMemberWebhookPayload -> "chat_member event=${payload.event} chat_id=${payload.chatId} users=${payload.userIds.size}"
+ is CompanyMemberWebhookPayload -> "company_member event=${payload.event} users=${payload.userIds.size}"
+}
diff --git a/sdk/kotlin/generated/build.gradle.kts b/sdk/kotlin/generated/build.gradle.kts
index 63e785c2..63d79ba7 100644
--- a/sdk/kotlin/generated/build.gradle.kts
+++ b/sdk/kotlin/generated/build.gradle.kts
@@ -38,6 +38,7 @@ val ktorVersion = "3.2.3"
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-cio:$ktorVersion")
implementation("io.ktor:ktor-client-auth:$ktorVersion")
diff --git a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt
index d3983bbf..500f727a 100644
--- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt
+++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt
@@ -11,9 +11,17 @@ import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.mapNotNull
import kotlinx.serialization.json.Json
import java.io.Closeable
import java.time.OffsetDateTime
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
interface SecurityService {
suspend fun getAuditEvents(
@@ -181,6 +189,57 @@ class BotsServiceImpl internal constructor(
}
}
+fun BotsService.pollWebhookEvents(
+ limit: Int? = 50,
+ interval: Duration = 5.seconds,
+ createdAfter: OffsetDateTime? = null,
+ maxSeenDeliveryIds: Int = 5_000,
+): Flow = flow {
+ require(maxSeenDeliveryIds > 0) { "maxSeenDeliveryIds must be greater than 0" }
+
+ val effectiveCreatedAfter = createdAfter ?: OffsetDateTime.now()
+ val seenIdOrder = ArrayDeque()
+ val seenIds = mutableSetOf()
+
+ fun remember(id: String): Boolean {
+ if (!seenIds.add(id)) return false
+ seenIdOrder.addLast(id)
+ while (seenIdOrder.size > maxSeenDeliveryIds) {
+ seenIds.remove(seenIdOrder.removeFirst())
+ }
+ return true
+ }
+
+ while (currentCoroutineContext().isActive) {
+ var cursor: String? = null
+ do {
+ val response = getWebhookEvents(limit = limit, cursor = cursor)
+ var pageHasRecentEvents = false
+ for (event in response.data.asReversed()) {
+ val matchesCreatedAfter = !event.createdAt.isBefore(effectiveCreatedAfter)
+ if (matchesCreatedAfter) pageHasRecentEvents = true
+ if (matchesCreatedAfter && remember(event.id)) emit(event)
+ }
+ val hasNext = (response.meta.paginate.hasNext ?: response.data.isNotEmpty()) && pageHasRecentEvents
+ cursor = response.meta.paginate.nextPage
+ } while (currentCoroutineContext().isActive && hasNext)
+ delay(interval)
+ }
+}
+
+inline fun BotsService.pollWebhookPayloads(
+ limit: Int? = 50,
+ interval: Duration = 5.seconds,
+ createdAfter: OffsetDateTime? = null,
+ maxSeenDeliveryIds: Int = 5_000,
+): Flow = pollWebhookEvents(
+ limit = limit,
+ interval = interval,
+ createdAfter = createdAfter,
+ maxSeenDeliveryIds = maxSeenDeliveryIds,
+)
+ .mapNotNull { it.payload as? T }
+
interface ChatsService {
suspend fun listChats(
sort: ChatSortField? = null,
@@ -1917,7 +1976,7 @@ class PachcaClient private constructor(
private fun createClient(token: String): HttpClient = HttpClient {
expectSuccess = false
followRedirects = false
- install(ContentNegotiation) { json(Json { explicitNulls = false }) }
+ install(ContentNegotiation) { json(Json { explicitNulls = false; ignoreUnknownKeys = true }) }
install(HttpRequestRetry) {
maxRetries = 3
retryIf { _, response ->
diff --git a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt
index ed82e849..d57a67a6 100644
--- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt
+++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt
@@ -8,8 +8,15 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonEncoder
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
object OffsetDateTimeSerializer : KSerializer {
override val descriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING)
@@ -741,11 +748,45 @@ data class ViewBlockFileInput(
val hint: String? = null,
) : ViewBlockUnion
-@Serializable
+@Serializable(with = WebhookPayloadUnionSerializer::class)
sealed interface WebhookPayloadUnion {
val type: String
}
+object WebhookPayloadUnionSerializer : KSerializer {
+ override val descriptor = buildClassSerialDescriptor("WebhookPayloadUnion")
+
+ override fun serialize(encoder: Encoder, value: WebhookPayloadUnion) {
+ val jsonEncoder = encoder as? JsonEncoder ?: error("WebhookPayloadUnionSerializer only supports JSON")
+ when (value) {
+ is MessageWebhookPayload -> jsonEncoder.encodeSerializableValue(MessageWebhookPayload.serializer(), value)
+ is ReactionWebhookPayload -> jsonEncoder.encodeSerializableValue(ReactionWebhookPayload.serializer(), value)
+ is ButtonWebhookPayload -> jsonEncoder.encodeSerializableValue(ButtonWebhookPayload.serializer(), value)
+ is ViewSubmitWebhookPayload -> jsonEncoder.encodeSerializableValue(ViewSubmitWebhookPayload.serializer(), value)
+ is ChatMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(ChatMemberWebhookPayload.serializer(), value)
+ is CompanyMemberWebhookPayload -> jsonEncoder.encodeSerializableValue(CompanyMemberWebhookPayload.serializer(), value)
+ is LinkSharedWebhookPayload -> jsonEncoder.encodeSerializableValue(LinkSharedWebhookPayload.serializer(), value)
+ }
+ }
+
+ override fun deserialize(decoder: Decoder): WebhookPayloadUnion {
+ val jsonDecoder = decoder as? JsonDecoder ?: error("WebhookPayloadUnionSerializer only supports JSON")
+ val element = jsonDecoder.decodeJsonElement()
+ val type = element.jsonObject["type"]?.jsonPrimitive?.contentOrNull
+ val event = element.jsonObject["event"]?.jsonPrimitive?.contentOrNull
+ return when {
+ type == "message" && event == "link_shared" -> jsonDecoder.json.decodeFromJsonElement(LinkSharedWebhookPayload.serializer(), element)
+ type == "message" -> jsonDecoder.json.decodeFromJsonElement(MessageWebhookPayload.serializer(), element)
+ type == "reaction" -> jsonDecoder.json.decodeFromJsonElement(ReactionWebhookPayload.serializer(), element)
+ type == "button" -> jsonDecoder.json.decodeFromJsonElement(ButtonWebhookPayload.serializer(), element)
+ type == "view" -> jsonDecoder.json.decodeFromJsonElement(ViewSubmitWebhookPayload.serializer(), element)
+ type == "chat_member" -> jsonDecoder.json.decodeFromJsonElement(ChatMemberWebhookPayload.serializer(), element)
+ type == "company_member" -> jsonDecoder.json.decodeFromJsonElement(CompanyMemberWebhookPayload.serializer(), element)
+ else -> error("Unknown WebhookPayloadUnion type: $type")
+ }
+ }
+}
+
@Serializable
@SerialName("message")
data class MessageWebhookPayload(
@@ -788,7 +829,9 @@ data class ButtonWebhookPayload(
@SerialName("user_id") val userId: Int,
@SerialName("chat_id") val chatId: Int,
@SerialName("webhook_timestamp") val webhookTimestamp: Int,
-) : WebhookPayloadUnion
+) : WebhookPayloadUnion {
+ val event: String = "click"
+}
@Serializable
@SerialName("view")
@@ -800,7 +843,9 @@ data class ViewSubmitWebhookPayload(
@SerialName("user_id") val userId: Int,
val data: Map,
@SerialName("webhook_timestamp") val webhookTimestamp: Int,
-) : WebhookPayloadUnion
+) : WebhookPayloadUnion {
+ val event: String = "submit"
+}
@Serializable
@SerialName("chat_member")
@@ -834,7 +879,9 @@ data class LinkSharedWebhookPayload(
@SerialName("user_id") val userId: Int,
@Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime,
@SerialName("webhook_timestamp") val webhookTimestamp: Int,
-) : WebhookPayloadUnion
+) : WebhookPayloadUnion {
+ val event: String = "link_shared"
+}
@Serializable
data class AccessTokenInfo(
diff --git a/sdk/kotlin/generated/src/main/kotlin/com/pachca/examples.json b/sdk/kotlin/generated/src/main/kotlin/com/pachca/examples.json
index c27e638f..124c602e 100644
--- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/examples.json
+++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/examples.json
@@ -16,6 +16,19 @@
"usage": "val response = client.bots.getWebhookEvents(limit = 1, cursor = \"eyJpZCI6MTAsImRpciI6ImFzYyJ9\")",
"output": "GetWebhookEventsResponse(data: List, meta: PaginationMeta)"
},
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "client.bots.pollWebhookEvents().collect { event ->\n println(event)\n}",
+ "imports": [
+ "pollWebhookEvents"
+ ]
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "client.bots.pollWebhookPayloads().collect { payload ->\n println(payload)\n}",
+ "imports": [
+ "WebhookPayloadUnion",
+ "pollWebhookPayloads"
+ ]
+ },
"BotOperations_updateBot": {
"usage": "val request = BotUpdateRequest(bot = BotUpdateRequestBot(webhook = BotUpdateRequestBotWebhook(outgoingUrl = \"https://www.website.com/tasks/new\")))\nval response = client.bots.updateBot(id = 1738816, request = request)",
"output": "BotResponse(id: Int, webhook: BotResponseWebhook(outgoingUrl: String))",
diff --git a/sdk/python/examples/polling.py b/sdk/python/examples/polling.py
new file mode 100644
index 00000000..56b80246
--- /dev/null
+++ b/sdk/python/examples/polling.py
@@ -0,0 +1,54 @@
+"""
+Webhook polling example — continuously process new webhook deliveries.
+
+Usage:
+ PACHCA_TOKEN=... python examples/polling.py
+ PACHCA_TOKEN=... python examples/polling.py --payloads
+"""
+
+import argparse
+import asyncio
+import os
+import sys
+from datetime import datetime, timezone
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "generated"))
+
+from pachca.client import PachcaClient
+
+
+async def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--payloads", action="store_true", help="poll payloads instead of full webhook events")
+ args = parser.parse_args()
+
+ token = os.environ["PACHCA_TOKEN"]
+ client = PachcaClient(token)
+ started_at = datetime.now(timezone.utc)
+
+ print("Webhook polling worker started", flush=True)
+ print("poll_limit=50 poll_interval=2s", flush=True)
+ print(f"waiting_for_events_created_after={started_at.isoformat()}", flush=True)
+
+ try:
+ if args.payloads:
+ async for payload in client.bots.poll_webhook_payloads(
+ limit=50,
+ interval_seconds=2,
+ created_after=started_at,
+ max_seen_delivery_ids=5_000,
+ ):
+ print(payload, flush=True)
+ else:
+ async for event in client.bots.poll_webhook_events(
+ limit=50,
+ interval_seconds=2,
+ created_after=started_at,
+ max_seen_delivery_ids=5_000,
+ ):
+ print(event, flush=True)
+ finally:
+ await client.close()
+
+
+asyncio.run(main())
diff --git a/sdk/python/examples/webhook_history.py b/sdk/python/examples/webhook_history.py
new file mode 100644
index 00000000..a61555f1
--- /dev/null
+++ b/sdk/python/examples/webhook_history.py
@@ -0,0 +1,67 @@
+"""
+Webhook history example — fetch recent webhook deliveries and inspect payload variants.
+
+Usage:
+ PACHCA_TOKEN=... python examples/webhook_history.py
+"""
+
+import asyncio
+import os
+import sys
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "generated"))
+
+from pachca.client import PachcaClient
+from pachca.models import (
+ ButtonWebhookPayload,
+ ChatMemberWebhookPayload,
+ CompanyMemberWebhookPayload,
+ GetWebhookEventsParams,
+ LinkSharedWebhookPayload,
+ MessageWebhookPayload,
+ ReactionWebhookPayload,
+ ViewSubmitWebhookPayload,
+ WebhookPayloadUnion,
+)
+
+
+def summarize_payload(payload: WebhookPayloadUnion) -> str:
+ match payload:
+ case LinkSharedWebhookPayload():
+ return f"link_shared message_id={payload.message_id} links={len(payload.links)} user_id={payload.user_id}"
+ case MessageWebhookPayload():
+ return f"message event={payload.event} id={payload.id} chat_id={payload.chat_id}"
+ case ReactionWebhookPayload():
+ return f"reaction event={payload.event} message_id={payload.message_id} code={payload.code}"
+ case ButtonWebhookPayload():
+ return f"button message_id={payload.message_id} user_id={payload.user_id}"
+ case ViewSubmitWebhookPayload():
+ return f"view user_id={payload.user_id} fields={len(payload.data)}"
+ case ChatMemberWebhookPayload():
+ return f"chat_member event={payload.event} chat_id={payload.chat_id} users={len(payload.user_ids)}"
+ case CompanyMemberWebhookPayload():
+ return f"company_member event={payload.event} users={len(payload.user_ids)}"
+ case _:
+ return f"unknown type={type(payload).__name__}"
+
+
+async def main():
+ token = os.environ["PACHCA_TOKEN"]
+
+ client = PachcaClient(token)
+ response = await client.bots.get_webhook_events(GetWebhookEventsParams(limit=5))
+
+ print(f"Fetched {len(response.data)} webhook events")
+ for index, event in enumerate(response.data, start=1):
+ print(
+ f"{index}. id={event.id} created_at={event.created_at.isoformat()} payload={summarize_payload(event.payload)}"
+ )
+
+ print(
+ f'has_next={response.meta.paginate.has_next} next_page="{response.meta.paginate.next_page}"'
+ )
+
+ await client.close()
+
+
+asyncio.run(main())
diff --git a/sdk/python/generated/pachca/client.py b/sdk/python/generated/pachca/client.py
index cecc50b7..33bc28ad 100644
--- a/sdk/python/generated/pachca/client.py
+++ b/sdk/python/generated/pachca/client.py
@@ -1,5 +1,10 @@
from __future__ import annotations
+import asyncio
+from collections import deque
+from datetime import datetime, timezone
+from typing import AsyncIterator, TypeVar
+
import httpx
from .models import (
@@ -12,6 +17,7 @@
GetWebhookEventsParams,
GetWebhookEventsResponse,
WebhookEvent,
+ WebhookPayloadUnion,
BotUpdateRequest,
BotResponse,
ListChatsParams,
@@ -81,6 +87,8 @@
)
from .utils import deserialize, serialize, RetryTransport
+TPayload = TypeVar("TPayload", bound=WebhookPayloadUnion)
+
class SecurityService:
async def get_audit_events(
self,
@@ -169,6 +177,67 @@ async def get_webhook_events_all(
) -> list[WebhookEvent]:
raise NotImplementedError("Bots.getWebhookEventsAll is not implemented")
+ async def poll_webhook_events(
+ self,
+ *,
+ limit: int | None = 50,
+ interval_seconds: float = 5.0,
+ created_after: datetime | None = None,
+ max_seen_delivery_ids: int = 5_000,
+ ) -> AsyncIterator[WebhookEvent]:
+ if max_seen_delivery_ids <= 0:
+ raise ValueError("max_seen_delivery_ids must be greater than 0")
+
+ effective_created_after = created_after or datetime.now(timezone.utc)
+ seen_id_order: deque[str] = deque()
+ seen_ids: set[str] = set()
+
+ def remember(id: str) -> bool:
+ if id in seen_ids:
+ return False
+ seen_ids.add(id)
+ seen_id_order.append(id)
+ while len(seen_id_order) > max_seen_delivery_ids:
+ seen_ids.remove(seen_id_order.popleft())
+ return True
+
+ while True:
+ cursor: str | None = None
+ has_next = True
+ while has_next:
+ response = await self.get_webhook_events(
+ GetWebhookEventsParams(limit=limit, cursor=cursor),
+ )
+ page_has_recent_events = False
+ for event in reversed(response.data):
+ matches_created_after = event.created_at >= effective_created_after
+ if matches_created_after:
+ page_has_recent_events = True
+ if matches_created_after and remember(event.id):
+ yield event
+ reported_has_next = getattr(response.meta.paginate, "has_next", None)
+ has_next = (bool(response.data) if reported_has_next is None else reported_has_next) and page_has_recent_events
+ cursor = response.meta.paginate.next_page
+ await asyncio.sleep(interval_seconds)
+
+ async def poll_webhook_payloads(
+ self,
+ *,
+ payload_type: type[TPayload] | tuple[type[TPayload], ...] | None = None,
+ limit: int | None = 50,
+ interval_seconds: float = 5.0,
+ created_after: datetime | None = None,
+ max_seen_delivery_ids: int = 5_000,
+ ) -> AsyncIterator[WebhookPayloadUnion | TPayload]:
+ async for event in self.poll_webhook_events(
+ limit=limit,
+ interval_seconds=interval_seconds,
+ created_after=created_after,
+ max_seen_delivery_ids=max_seen_delivery_ids,
+ ):
+ if payload_type is None or isinstance(event.payload, payload_type):
+ yield event.payload
+
async def update_bot(
self,
id: int,
diff --git a/sdk/python/generated/pachca/examples.json b/sdk/python/generated/pachca/examples.json
index efd01cc9..4fcce8a7 100644
--- a/sdk/python/generated/pachca/examples.json
+++ b/sdk/python/generated/pachca/examples.json
@@ -20,6 +20,12 @@
"GetWebhookEventsParams"
]
},
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)"
+ },
"BotOperations_updateBot": {
"usage": "request = BotUpdateRequest(bot=BotUpdateRequestBot(webhook=BotUpdateRequestBotWebhook(outgoing_url=\"https://www.website.com/tasks/new\")))\nresponse = await client.bots.update_bot(id=1738816, request=request)",
"output": "BotResponse(id: int, webhook: BotResponseWebhook(outgoing_url: str))",
diff --git a/sdk/python/generated/pachca/utils.py b/sdk/python/generated/pachca/utils.py
index 28f6ac8b..14f1ada6 100644
--- a/sdk/python/generated/pachca/utils.py
+++ b/sdk/python/generated/pachca/utils.py
@@ -4,18 +4,29 @@
import keyword
from dataclasses import asdict, fields
from datetime import datetime
-from typing import Type, TypeVar, get_args, get_origin, get_type_hints
+from typing import Callable, Type, TypeVar, get_args, get_origin, get_type_hints
import httpx
+from .models import (
+ ButtonWebhookPayload,
+ ChatMemberWebhookPayload,
+ CompanyMemberWebhookPayload,
+ LinkSharedWebhookPayload,
+ MessageWebhookPayload,
+ ReactionWebhookPayload,
+ ViewSubmitWebhookPayload,
+ WebhookPayloadUnion,
+)
+
T = TypeVar("T")
-def _is_dataclass_type(tp: type) -> bool:
+def _is_dataclass_type(tp: object) -> bool:
return isinstance(tp, type) and dataclasses.is_dataclass(tp)
-def _resolve_type(tp: type) -> type | None:
+def _resolve_type(tp: object) -> type | None:
"""Extract a concrete dataclass type from Optional[X] or X | None."""
origin = get_origin(tp)
if origin is list:
@@ -29,7 +40,7 @@ def _resolve_type(tp: type) -> type | None:
return None
-def _resolve_list_item_type(tp: type) -> type | None:
+def _resolve_list_item_type(tp: object) -> object | None:
"""Extract the item type from list[X]."""
origin = get_origin(tp)
if origin is list:
@@ -39,8 +50,34 @@ def _resolve_list_item_type(tp: type) -> type | None:
return None
-def deserialize(cls: Type[T], data: dict) -> T:
- """Create a dataclass instance from a dict, recursively deserializing nested dataclasses."""
+CustomUnionDeserializer = Callable[[dict], object]
+
+
+def _deserialize_instance(tp: object, value: object) -> object:
+ custom = _CUSTOM_UNION_DESERIALIZERS.get(tp)
+ if custom is not None and isinstance(value, dict):
+ return custom(value)
+ if isinstance(value, dict):
+ nested = _resolve_type(tp)
+ if nested is not None:
+ return _deserialize_dataclass(nested, value)
+ if isinstance(value, list):
+ item_tp = _resolve_list_item_type(tp)
+ if item_tp is not None:
+ return [_deserialize_instance(item_tp, item) for item in value]
+ if isinstance(value, str):
+ raw_tp = tp
+ if get_origin(tp) is not None:
+ for arg in get_args(tp):
+ if arg is not type(None):
+ raw_tp = arg
+ break
+ if raw_tp is datetime:
+ return datetime.fromisoformat(value)
+ return value
+
+
+def _deserialize_dataclass(cls: Type[T], data: dict) -> T:
field_map = {f.name: f for f in fields(cls)}
hints = get_type_hints(cls)
norm = {k.replace("-", "_").lower(): v for k, v in data.items()}
@@ -51,27 +88,37 @@ def deserialize(cls: Type[T], data: dict) -> T:
if k not in field_map:
continue
f = field_map[k]
- if isinstance(v, dict):
- nested = _resolve_type(hints[f.name])
- if nested is not None:
- v = deserialize(nested, v)
- elif isinstance(v, list) and v:
- item_tp = _resolve_list_item_type(hints[f.name])
- if item_tp is not None and _is_dataclass_type(item_tp):
- v = [deserialize(item_tp, i) if isinstance(i, dict) else i for i in v]
- elif isinstance(v, str):
- hint = hints.get(f.name)
- raw_hint = hint
- if get_origin(hint) is not None:
- for a in get_args(hint):
- if a is not type(None):
- raw_hint = a
- break
- if raw_hint is datetime:
- v = datetime.fromisoformat(v)
- kwargs[k] = v
+ kwargs[k] = _deserialize_instance(hints[f.name], v)
return cls(**kwargs)
+def _webhook_payload_union_deserialize(data: dict) -> WebhookPayloadUnion:
+ match (data.get("type"), data.get("event")):
+ case ("message", "link_shared"):
+ return _deserialize_instance(LinkSharedWebhookPayload, data)
+ case ("message", _):
+ return _deserialize_instance(MessageWebhookPayload, data)
+ case ("reaction", _):
+ return _deserialize_instance(ReactionWebhookPayload, data)
+ case ("button", _):
+ return _deserialize_instance(ButtonWebhookPayload, data)
+ case ("view", _):
+ return _deserialize_instance(ViewSubmitWebhookPayload, data)
+ case ("chat_member", _):
+ return _deserialize_instance(ChatMemberWebhookPayload, data)
+ case ("company_member", _):
+ return _deserialize_instance(CompanyMemberWebhookPayload, data)
+ case _:
+ raise ValueError(f"Unknown WebhookPayloadUnion discriminator: {data.get('type')}")
+
+_CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = {
+ WebhookPayloadUnion: _webhook_payload_union_deserialize,
+}
+
+
+def deserialize(cls: Type[T], data: dict) -> T:
+ """Create a typed instance from a dict, recursively deserializing nested values."""
+ return _deserialize_instance(cls, data)
+
def _strip_nones(val: object) -> object:
if isinstance(val, dict):
diff --git a/sdk/swift/examples/Package.swift b/sdk/swift/examples/Package.swift
index 7248bff7..15c45583 100644
--- a/sdk/swift/examples/Package.swift
+++ b/sdk/swift/examples/Package.swift
@@ -36,5 +36,19 @@ let package = Package(
],
path: "Sources/HttpClient"
),
+ .executableTarget(
+ name: "WebhookHistory",
+ dependencies: [
+ .product(name: "PachcaSDK", package: "swift"),
+ ],
+ path: "Sources/WebhookHistory"
+ ),
+ .executableTarget(
+ name: "Polling",
+ dependencies: [
+ .product(name: "PachcaSDK", package: "swift"),
+ ],
+ path: "Sources/Polling"
+ ),
]
)
diff --git a/sdk/swift/examples/Sources/Polling/main.swift b/sdk/swift/examples/Sources/Polling/main.swift
new file mode 100644
index 00000000..4f283636
--- /dev/null
+++ b/sdk/swift/examples/Sources/Polling/main.swift
@@ -0,0 +1,46 @@
+import Foundation
+import PachcaSDK
+
+// Webhook polling example — continuously process new webhook deliveries.
+//
+// Usage:
+// PACHCA_TOKEN=your_token swift run Polling
+// PACHCA_TOKEN=your_token swift run Polling --payloads
+
+let pollPayloadsOnly = CommandLine.arguments.contains("--payloads")
+
+guard let token = ProcessInfo.processInfo.environment["PACHCA_TOKEN"] else {
+ fatalError("Set PACHCA_TOKEN environment variable")
+}
+
+let client = PachcaClient(token: token)
+let startedAt = Date()
+
+func log(_ value: Any) {
+ print(value)
+ fflush(stdout)
+}
+
+log("Webhook polling worker started")
+log("poll_limit=50 poll_interval=2s")
+log("waiting_for_events_created_after=\(ISO8601DateFormatter().string(from: startedAt))")
+
+if pollPayloadsOnly {
+ for try await payload in client.bots.pollWebhookPayloads(
+ limit: 50,
+ interval: 2,
+ createdAfter: startedAt,
+ maxSeenDeliveryIds: 5_000
+ ) {
+ log(payload)
+ }
+} else {
+ for try await event in client.bots.pollWebhookEvents(
+ limit: 50,
+ interval: 2,
+ createdAfter: startedAt,
+ maxSeenDeliveryIds: 5_000
+ ) {
+ log(event)
+ }
+}
diff --git a/sdk/swift/examples/Sources/WebhookHistory/main.swift b/sdk/swift/examples/Sources/WebhookHistory/main.swift
new file mode 100644
index 00000000..077e8aac
--- /dev/null
+++ b/sdk/swift/examples/Sources/WebhookHistory/main.swift
@@ -0,0 +1,40 @@
+import Foundation
+import PachcaSDK
+
+// Webhook history example — fetch recent webhook deliveries and inspect payload variants.
+//
+// Usage:
+// PACHCA_TOKEN=your_token swift run WebhookHistory
+
+guard let token = ProcessInfo.processInfo.environment["PACHCA_TOKEN"] else {
+ fatalError("Set PACHCA_TOKEN environment variable")
+}
+
+let client = PachcaClient(token: token)
+let response = try await client.bots.getWebhookEvents(limit: 5)
+
+print("Fetched \(response.data.count) webhook events")
+for (index, event) in response.data.enumerated() {
+ print("\(index + 1). id=\(event.id) created_at=\(event.createdAt) payload=\(summarizePayload(event.payload))")
+}
+
+print("has_next=\(response.meta.paginate.hasNext ?? false) next_page=\(response.meta.paginate.nextPage)")
+
+func summarizePayload(_ payload: WebhookPayloadUnion) -> String {
+ switch payload {
+ case .linkSharedWebhookPayload(let linkShared):
+ return "link_shared message_id=\(linkShared.messageId) links=\(linkShared.links.count) user_id=\(linkShared.userId)"
+ case .messageWebhookPayload(let message):
+ return "message event=\(message.event) id=\(message.id) chat_id=\(message.chatId)"
+ case .reactionWebhookPayload(let reaction):
+ return "reaction event=\(reaction.event) message_id=\(reaction.messageId) code=\(reaction.code)"
+ case .buttonWebhookPayload(let button):
+ return "button message_id=\(button.messageId) user_id=\(button.userId)"
+ case .viewSubmitWebhookPayload(let view):
+ return "view user_id=\(view.userId) fields=\(view.data.count)"
+ case .chatMemberWebhookPayload(let member):
+ return "chat_member event=\(member.event) chat_id=\(String(describing: member.chatId)) users=\(member.userIds.count)"
+ case .companyMemberWebhookPayload(let member):
+ return "company_member event=\(member.event) users=\(member.userIds.count)"
+ }
+}
diff --git a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift
index 4e58822e..6f15dea4 100644
--- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift
+++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift
@@ -7,6 +7,13 @@ private func pachcaNotImplemented(_ method: String) -> Error {
NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"])
}
+private func pachcaParseWebhookDate(_ value: String) -> Date? {
+ let fractionalFormatter = ISO8601DateFormatter()
+ fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = fractionalFormatter.date(from: value) { return date }
+ return ISO8601DateFormatter().date(from: value)
+}
+
open class SecurityService {
public init() {}
@@ -84,6 +91,90 @@ open class BotsService {
throw pachcaNotImplemented("Bots.getWebhookEventsAll")
}
+ open func pollWebhookEvents(
+ limit: Int? = 50,
+ interval: TimeInterval = 5,
+ createdAfter: Date? = nil,
+ maxSeenDeliveryIds: Int = 5_000
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = _Concurrency.Task {
+ do {
+ guard maxSeenDeliveryIds > 0 else {
+ throw NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: "maxSeenDeliveryIds must be greater than 0"])
+ }
+
+ let effectiveCreatedAfter = createdAfter ?? Date()
+ var seenIdOrder: [String] = []
+ var seenIds = Set()
+
+ func remember(_ id: String) -> Bool {
+ guard seenIds.insert(id).inserted else { return false }
+ seenIdOrder.append(id)
+ while seenIdOrder.count > maxSeenDeliveryIds {
+ seenIds.remove(seenIdOrder.removeFirst())
+ }
+ return true
+ }
+
+ while !_Concurrency.Task.isCancelled {
+ var cursor: String? = nil
+ var hasNext = true
+ while hasNext && !_Concurrency.Task.isCancelled {
+ let response = try await getWebhookEvents(limit: limit, cursor: cursor)
+ var pageHasRecentEvents = false
+ for event in response.data.reversed() {
+ let matchesCreatedAfter = pachcaParseWebhookDate(event.createdAt).map { $0 >= effectiveCreatedAfter } == true
+ if matchesCreatedAfter {
+ pageHasRecentEvents = true
+ }
+ if matchesCreatedAfter && remember(event.id) {
+ continuation.yield(event)
+ }
+ }
+ hasNext = (response.meta.paginate.hasNext ?? !response.data.isEmpty) && pageHasRecentEvents
+ cursor = response.meta.paginate.nextPage
+ }
+ try await _Concurrency.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000))
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
+ open func pollWebhookPayloads(
+ limit: Int? = 50,
+ interval: TimeInterval = 5,
+ createdAfter: Date? = nil,
+ maxSeenDeliveryIds: Int = 5_000,
+ includePayload: @escaping (WebhookPayloadUnion) -> Bool = { _ in true }
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = _Concurrency.Task {
+ do {
+ for try await event in pollWebhookEvents(
+ limit: limit,
+ interval: interval,
+ createdAfter: createdAfter,
+ maxSeenDeliveryIds: maxSeenDeliveryIds
+ ) {
+ if includePayload(event.payload) {
+ continuation.yield(event.payload)
+ }
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ continuation.onTermination = { _ in task.cancel() }
+ }
+ }
+
open func updateBot(id: Int, request body: BotUpdateRequest) async throws -> BotResponse {
throw pachcaNotImplemented("Bots.updateBot")
}
diff --git a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift
index 8b90f4c4..c42d809c 100644
--- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift
+++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift
@@ -2783,23 +2783,27 @@ public enum WebhookPayloadUnion: Codable {
private enum CodingKeys: String, CodingKey {
case type
+ case event
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
- switch type {
- case "message":
+ let event = try? container.decode(String.self, forKey: .event)
+ switch (type, event) {
+ case ("message", "link_shared"):
+ self = .linkSharedWebhookPayload(try LinkSharedWebhookPayload(from: decoder))
+ case ("message", _):
self = .messageWebhookPayload(try MessageWebhookPayload(from: decoder))
- case "reaction":
+ case ("reaction", _):
self = .reactionWebhookPayload(try ReactionWebhookPayload(from: decoder))
- case "button":
+ case ("button", _):
self = .buttonWebhookPayload(try ButtonWebhookPayload(from: decoder))
- case "view":
+ case ("view", _):
self = .viewSubmitWebhookPayload(try ViewSubmitWebhookPayload(from: decoder))
- case "chat_member":
+ case ("chat_member", _):
self = .chatMemberWebhookPayload(try ChatMemberWebhookPayload(from: decoder))
- case "company_member":
+ case ("company_member", _):
self = .companyMemberWebhookPayload(try CompanyMemberWebhookPayload(from: decoder))
default:
throw DecodingError.dataCorrupted(
diff --git a/sdk/swift/generated/examples.json b/sdk/swift/generated/examples.json
index b0fcca12..efdfff07 100644
--- a/sdk/swift/generated/examples.json
+++ b/sdk/swift/generated/examples.json
@@ -16,6 +16,12 @@
"usage": "let response = try await client.bots.getWebhookEvents(limit: 1, cursor: \"eyJpZCI6MTAsImRpciI6ImFzYyJ9\")",
"output": "GetWebhookEventsResponse(data: [WebhookEvent], meta: PaginationMeta)"
},
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "for try await event in client.bots.pollWebhookEvents(interval: 5) {\n print(event)\n}"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "for try await payload in client.bots.pollWebhookPayloads(interval: 5) {\n print(payload)\n}"
+ },
"BotOperations_updateBot": {
"usage": "let body = BotUpdateRequest(bot: BotUpdateRequestBot(webhook: BotUpdateRequestBotWebhook(outgoingUrl: \"https://www.website.com/tasks/new\")))\nlet response = try await client.bots.updateBot(id: 1738816, body: body)",
"output": "BotResponse(id: Int, webhook: BotResponseWebhook(outgoingUrl: String))",
diff --git a/sdk/typescript/examples/polling.ts b/sdk/typescript/examples/polling.ts
new file mode 100644
index 00000000..1df192d4
--- /dev/null
+++ b/sdk/typescript/examples/polling.ts
@@ -0,0 +1,43 @@
+/**
+ * Webhook polling example — continuously process new webhook deliveries.
+ *
+ * Usage:
+ * PACHCA_TOKEN=your_token bun run examples/polling.ts
+ * PACHCA_TOKEN=your_token bun run examples/polling.ts --payloads
+ */
+
+import { PachcaClient } from "../src/index.js";
+
+const token = process.env.PACHCA_TOKEN;
+if (!token) {
+ console.error("Set PACHCA_TOKEN environment variable");
+ process.exit(1);
+}
+
+const pollPayloadsOnly = process.argv.includes("--payloads");
+const client = new PachcaClient(token);
+const startedAt = new Date();
+
+console.log("Webhook polling worker started");
+console.log("poll_limit=50 poll_interval=2s");
+console.log(`waiting_for_events_created_after=${startedAt.toISOString()}`);
+
+if (pollPayloadsOnly) {
+ for await (const payload of client.bots.pollWebhookPayloads({
+ limit: 50,
+ intervalMs: 2_000,
+ createdAfter: startedAt,
+ maxSeenDeliveryIds: 5_000,
+ })) {
+ console.log(payload);
+ }
+} else {
+ for await (const event of client.bots.pollWebhookEvents({
+ limit: 50,
+ intervalMs: 2_000,
+ createdAfter: startedAt,
+ maxSeenDeliveryIds: 5_000,
+ })) {
+ console.log(event);
+ }
+}
diff --git a/sdk/typescript/examples/webhook-history.ts b/sdk/typescript/examples/webhook-history.ts
new file mode 100644
index 00000000..5953f980
--- /dev/null
+++ b/sdk/typescript/examples/webhook-history.ts
@@ -0,0 +1,73 @@
+/**
+ * Webhook history example — fetch recent webhook deliveries and inspect payload variants.
+ *
+ * Usage:
+ * PACHCA_TOKEN=your_token bun run examples/webhook-history.ts
+ */
+
+import { PachcaClient } from "../src/index.js";
+import type {
+ ButtonWebhookPayload,
+ ChatMemberWebhookPayload,
+ CompanyMemberWebhookPayload,
+ LinkSharedWebhookPayload,
+ MessageWebhookPayload,
+ ReactionWebhookPayload,
+ ViewSubmitWebhookPayload,
+ WebhookPayloadUnion,
+} from "../src/generated/types.js";
+
+const token = process.env.PACHCA_TOKEN;
+if (!token) {
+ console.error("Set PACHCA_TOKEN environment variable");
+ process.exit(1);
+}
+
+const client = new PachcaClient(token);
+const response = await client.bots.getWebhookEvents({ limit: 5 });
+
+console.log(`Fetched ${response.data.length} webhook events`);
+for (const [index, event] of response.data.entries()) {
+ console.log(
+ `${index + 1}. id=${event.id} created_at=${event.createdAt} payload=${summarizePayload(event.payload)}`,
+ );
+}
+
+console.log(
+ `has_next=${response.meta.paginate.hasNext} next_page=${JSON.stringify(response.meta.paginate.nextPage)}`,
+);
+
+function summarizePayload(payload: WebhookPayloadUnion): string {
+ if (payload.type === "message" && payload.event === "link_shared") {
+ const linkShared = payload as LinkSharedWebhookPayload;
+ return `link_shared message_id=${linkShared.messageId} links=${linkShared.links.length} user_id=${linkShared.userId}`;
+ }
+ switch (payload.type) {
+ case "message": {
+ const message = payload as MessageWebhookPayload;
+ return `message event=${message.event} id=${message.id} chat_id=${message.chatId}`;
+ }
+ case "reaction": {
+ const reaction = payload as ReactionWebhookPayload;
+ return `reaction event=${reaction.event} message_id=${reaction.messageId} code=${reaction.code}`;
+ }
+ case "button": {
+ const button = payload as ButtonWebhookPayload;
+ return `button message_id=${button.messageId} user_id=${button.userId}`;
+ }
+ case "view": {
+ const view = payload as ViewSubmitWebhookPayload;
+ return `view user_id=${view.userId} fields=${Object.keys(view.data).length}`;
+ }
+ case "chat_member": {
+ const member = payload as ChatMemberWebhookPayload;
+ return `chat_member event=${member.event} chat_id=${member.chatId} users=${member.userIds.length}`;
+ }
+ case "company_member": {
+ const member = payload as CompanyMemberWebhookPayload;
+ return `company_member event=${member.event} users=${member.userIds.length}`;
+ }
+ default:
+ return "unknown";
+ }
+}
diff --git a/sdk/typescript/src/generated/client.ts b/sdk/typescript/src/generated/client.ts
index 23348494..93baa576 100644
--- a/sdk/typescript/src/generated/client.ts
+++ b/sdk/typescript/src/generated/client.ts
@@ -7,6 +7,7 @@ import {
GetWebhookEventsParams,
GetWebhookEventsResponse,
WebhookEvent,
+ WebhookPayloadUnion,
BotUpdateRequest,
BotResponse,
ListChatsParams,
@@ -66,7 +67,25 @@ import {
UserUpdateRequest,
OpenViewRequest,
} from "./types.js";
-import { deserialize, serialize, fetchWithRetry } from "./utils.js";
+import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js";
+
+export interface PollWebhookEventsParams {
+ limit?: number;
+ intervalMs?: number;
+ createdAfter?: Date | string | null;
+ maxSeenDeliveryIds?: number;
+}
+
+export interface PollWebhookPayloadsParams extends PollWebhookEventsParams {
+ filter?: (payload: WebhookPayloadUnion, event: WebhookEvent) => payload is TPayload;
+}
+
+const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+function createdAtMatches(event: WebhookEvent, createdAfter: Date | string | null | undefined): boolean {
+ if (createdAfter == null) return true;
+ return new Date(event.createdAt).getTime() >= new Date(createdAfter).getTime();
+}
export class SecurityService {
async getAuditEvents(params?: GetAuditEventsParams): Promise {
@@ -136,6 +155,53 @@ export class BotsService {
throw new Error("Bots.getWebhookEventsAll is not implemented");
}
+ async *pollWebhookEvents(params?: PollWebhookEventsParams): AsyncGenerator {
+ const limit = params?.limit ?? 50;
+ const intervalMs = params?.intervalMs ?? 5_000;
+ const createdAfter = params?.createdAfter ?? new Date();
+ const maxSeenDeliveryIds = params?.maxSeenDeliveryIds ?? 5_000;
+ if (maxSeenDeliveryIds <= 0) throw new Error("maxSeenDeliveryIds must be greater than 0");
+
+ const seenIdOrder: string[] = [];
+ const seenIds = new Set();
+ const remember = (id: string): boolean => {
+ if (seenIds.has(id)) return false;
+ seenIds.add(id);
+ seenIdOrder.push(id);
+ while (seenIdOrder.length > maxSeenDeliveryIds) {
+ const oldest = seenIdOrder.shift();
+ if (oldest !== undefined) seenIds.delete(oldest);
+ }
+ return true;
+ };
+
+ while (true) {
+ let cursor: string | undefined;
+ let hasNext = true;
+ while (hasNext) {
+ const response = await this.getWebhookEvents({ limit, cursor });
+ let pageHasRecentEvents = false;
+ for (const event of [...response.data].reverse()) {
+ const matchesCreatedAfter = createdAtMatches(event, createdAfter);
+ if (matchesCreatedAfter) pageHasRecentEvents = true;
+ if (matchesCreatedAfter && remember(event.id)) yield event;
+ }
+ hasNext = (response.meta.paginate.hasNext ?? response.data.length > 0) && pageHasRecentEvents;
+ cursor = response.meta.paginate.nextPage;
+ }
+ await sleep(intervalMs);
+ }
+ }
+
+ async *pollWebhookPayloads(
+ params?: PollWebhookPayloadsParams,
+ ): AsyncGenerator {
+ for await (const event of this.pollWebhookEvents(params)) {
+ const payload = event.payload;
+ if (params?.filter == null || params.filter(payload, event)) yield payload as TPayload;
+ }
+ }
+
async updateBot(id: number, request: BotUpdateRequest): Promise {
throw new Error("Bots.updateBot is not implemented");
}
@@ -164,7 +230,7 @@ export class BotsServiceImpl extends BotsService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body) as GetWebhookEventsResponse;
+ return { ...(deserialize(body) as GetWebhookEventsResponse), data: Array.isArray(body.data) ? body.data.map((item: unknown) => deserializeType("WebhookEvent", item) as WebhookEvent) : [] } as GetWebhookEventsResponse;
case 401:
throw new OAuthError(body.error);
default:
@@ -190,12 +256,12 @@ export class BotsServiceImpl extends BotsService {
const response = await fetchWithRetry(`${this.baseUrl}/bots/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("BotUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as BotResponse;
+ return deserializeType("BotResponse", body.data) as BotResponse;
case 401:
throw new OAuthError(body.error);
default:
@@ -303,7 +369,7 @@ export class ChatsServiceImpl extends ChatsService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
@@ -315,12 +381,12 @@ export class ChatsServiceImpl extends ChatsService {
const response = await fetchWithRetry(`${this.baseUrl}/chats`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ChatCreateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
@@ -332,12 +398,12 @@ export class ChatsServiceImpl extends ChatsService {
const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ChatUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Chat;
+ return deserializeType("Chat", body.data) as Chat;
case 401:
throw new OAuthError(body.error);
default:
@@ -447,7 +513,7 @@ export class CommonServiceImpl extends CommonService {
const response = await fetchWithRetry(`${this.baseUrl}/chats/exports`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ExportRequest", request)),
});
switch (response.status) {
case 204:
@@ -490,7 +556,7 @@ export class CommonServiceImpl extends CommonService {
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body) as UploadParams;
+ return deserializeType("UploadParams", body) as UploadParams;
case 401:
throw new OAuthError(body.error);
default:
@@ -595,7 +661,7 @@ export class MembersServiceImpl extends MembersService {
const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}/members`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("AddMembersRequest", request)),
});
switch (response.status) {
case 204:
@@ -754,7 +820,7 @@ export class GroupTagsServiceImpl extends GroupTagsService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as GroupTag;
+ return deserializeType("GroupTag", body.data) as GroupTag;
case 401:
throw new OAuthError(body.error);
default:
@@ -799,12 +865,12 @@ export class GroupTagsServiceImpl extends GroupTagsService {
const response = await fetchWithRetry(`${this.baseUrl}/group_tags`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("GroupTagRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as GroupTag;
+ return deserializeType("GroupTag", body.data) as GroupTag;
case 401:
throw new OAuthError(body.error);
default:
@@ -816,12 +882,12 @@ export class GroupTagsServiceImpl extends GroupTagsService {
const response = await fetchWithRetry(`${this.baseUrl}/group_tags/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("GroupTagRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as GroupTag;
+ return deserializeType("GroupTag", body.data) as GroupTag;
case 401:
throw new OAuthError(body.error);
default:
@@ -929,7 +995,7 @@ export class MessagesServiceImpl extends MessagesService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Message;
+ return deserializeType("Message", body.data) as Message;
case 401:
throw new OAuthError(body.error);
default:
@@ -941,12 +1007,12 @@ export class MessagesServiceImpl extends MessagesService {
const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("MessageCreateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Message;
+ return deserializeType("Message", body.data) as Message;
case 401:
throw new OAuthError(body.error);
default:
@@ -973,12 +1039,12 @@ export class MessagesServiceImpl extends MessagesService {
const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("MessageUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Message;
+ return deserializeType("Message", body.data) as Message;
case 401:
throw new OAuthError(body.error);
default:
@@ -1035,7 +1101,7 @@ export class LinkPreviewsServiceImpl extends LinkPreviewsService {
const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}/link_previews`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("LinkPreviewsRequest", request)),
});
switch (response.status) {
case 204:
@@ -1111,12 +1177,12 @@ export class ReactionsServiceImpl extends ReactionsService {
const response = await fetchWithRetry(`${this.baseUrl}/messages/${id}/reactions`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("ReactionRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body) as Reaction;
+ return deserializeType("Reaction", body) as Reaction;
case 401:
throw new OAuthError(body.error);
default:
@@ -1245,7 +1311,7 @@ export class ThreadsServiceImpl extends ThreadsService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Thread;
+ return deserializeType("Thread", body.data) as Thread;
case 401:
throw new OAuthError(body.error);
default:
@@ -1261,7 +1327,7 @@ export class ThreadsServiceImpl extends ThreadsService {
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Thread;
+ return deserializeType("Thread", body.data) as Thread;
case 401:
throw new OAuthError(body.error);
default:
@@ -1315,7 +1381,7 @@ export class ProfileServiceImpl extends ProfileService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as AccessTokenInfo;
+ return deserializeType("AccessTokenInfo", body.data) as AccessTokenInfo;
case 401:
throw new OAuthError(body.error);
default:
@@ -1330,7 +1396,7 @@ export class ProfileServiceImpl extends ProfileService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as User;
+ return deserializeType("User", body.data) as User;
case 401:
throw new OAuthError(body.error);
default:
@@ -1345,7 +1411,7 @@ export class ProfileServiceImpl extends ProfileService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body) as unknown;
+ return deserializeType("unknown", body) as unknown;
case 401:
throw new OAuthError(body.error);
default:
@@ -1364,7 +1430,7 @@ export class ProfileServiceImpl extends ProfileService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as AvatarData;
+ return deserializeType("AvatarData", body.data) as AvatarData;
case 401:
throw new OAuthError(body.error);
default:
@@ -1376,12 +1442,12 @@ export class ProfileServiceImpl extends ProfileService {
const response = await fetchWithRetry(`${this.baseUrl}/profile/status`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("StatusUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as UserStatus;
+ return deserializeType("UserStatus", body.data) as UserStatus;
case 401:
throw new OAuthError(body.error);
default:
@@ -1648,7 +1714,7 @@ export class TasksServiceImpl extends TasksService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Task;
+ return deserializeType("Task", body.data) as Task;
case 401:
throw new OAuthError(body.error);
default:
@@ -1660,12 +1726,12 @@ export class TasksServiceImpl extends TasksService {
const response = await fetchWithRetry(`${this.baseUrl}/tasks`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("TaskCreateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as Task;
+ return deserializeType("Task", body.data) as Task;
case 401:
throw new OAuthError(body.error);
default:
@@ -1677,12 +1743,12 @@ export class TasksServiceImpl extends TasksService {
const response = await fetchWithRetry(`${this.baseUrl}/tasks/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("TaskUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as Task;
+ return deserializeType("Task", body.data) as Task;
case 401:
throw new OAuthError(body.error);
default:
@@ -1801,7 +1867,7 @@ export class UsersServiceImpl extends UsersService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as User;
+ return deserializeType("User", body.data) as User;
case 401:
throw new OAuthError(body.error);
default:
@@ -1816,7 +1882,7 @@ export class UsersServiceImpl extends UsersService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body) as unknown;
+ return deserializeType("unknown", body) as unknown;
case 401:
throw new OAuthError(body.error);
default:
@@ -1828,12 +1894,12 @@ export class UsersServiceImpl extends UsersService {
const response = await fetchWithRetry(`${this.baseUrl}/users`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("UserCreateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 201:
- return deserialize(body.data) as User;
+ return deserializeType("User", body.data) as User;
case 401:
throw new OAuthError(body.error);
default:
@@ -1845,12 +1911,12 @@ export class UsersServiceImpl extends UsersService {
const response = await fetchWithRetry(`${this.baseUrl}/users/${id}`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("UserUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as User;
+ return deserializeType("User", body.data) as User;
case 401:
throw new OAuthError(body.error);
default:
@@ -1869,7 +1935,7 @@ export class UsersServiceImpl extends UsersService {
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as AvatarData;
+ return deserializeType("AvatarData", body.data) as AvatarData;
case 401:
throw new OAuthError(body.error);
default:
@@ -1881,12 +1947,12 @@ export class UsersServiceImpl extends UsersService {
const response = await fetchWithRetry(`${this.baseUrl}/users/${userId}/status`, {
method: "PUT",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("StatusUpdateRequest", request)),
});
const body = await response.json();
switch (response.status) {
case 200:
- return deserialize(body.data) as UserStatus;
+ return deserializeType("UserStatus", body.data) as UserStatus;
case 401:
throw new OAuthError(body.error);
default:
@@ -1958,7 +2024,7 @@ export class ViewsServiceImpl extends ViewsService {
const response = await fetchWithRetry(`${this.baseUrl}/views/open`, {
method: "POST",
headers: { ...this.headers, "Content-Type": "application/json" },
- body: JSON.stringify(serialize(request)),
+ body: JSON.stringify(serializeType("OpenViewRequest", request)),
});
switch (response.status) {
case 201:
diff --git a/sdk/typescript/src/generated/examples.json b/sdk/typescript/src/generated/examples.json
index 804cbac4..4ff09b77 100644
--- a/sdk/typescript/src/generated/examples.json
+++ b/sdk/typescript/src/generated/examples.json
@@ -16,6 +16,12 @@
"usage": "const response = client.bots.getWebhookEvents({ limit: 1, cursor: \"eyJpZCI6MTAsImRpciI6ImFzYyJ9\" })",
"output": "GetWebhookEventsResponse({ data: WebhookEvent[], meta: PaginationMeta })"
},
+ "BotOperations_getWebhookEvents_pollWebhookEvents": {
+ "usage": "for await (const event of client.bots.pollWebhookEvents({ intervalMs: 5_000 })) {\n console.log(event)\n}"
+ },
+ "BotOperations_getWebhookEvents_pollWebhookPayloads": {
+ "usage": "for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}"
+ },
"BotOperations_updateBot": {
"usage": "const request: BotUpdateRequest = { bot: { webhook: { outgoingUrl: \"https://www.website.com/tasks/new\" } } }\nconst response = client.bots.updateBot(1738816, request)",
"output": "BotResponse({ id: number, webhook: BotResponseWebhook({ outgoingUrl: string }) })",
diff --git a/sdk/typescript/src/generated/utils.ts b/sdk/typescript/src/generated/utils.ts
index 6da10996..7aa40cec 100644
--- a/sdk/typescript/src/generated/utils.ts
+++ b/sdk/typescript/src/generated/utils.ts
@@ -12,21 +12,27 @@ function camelToSnake(str: string): string {
const RECORD_KEYS = new Set(["payload", "filters", "link_previews", "linkPreviews", "data"]);
-function deserializeRecord(obj: unknown): unknown {
+function deserializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : deserialize(obj);
+}
+
+function serializeArray(obj: unknown, mapItem: (item: unknown) => unknown): unknown {
+ return Array.isArray(obj) ? obj.map(mapItem) : serialize(obj);
+}
+
+function deserializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
- return Object.fromEntries(
- Object.entries(obj).map(([k, v]) => [k, deserialize(v)]),
- );
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, mapValue(v)]));
}
return deserialize(obj);
}
-function serializeRecord(obj: unknown): unknown {
+function serializeRecordWith(obj: unknown, mapValue: (value: unknown) => unknown): unknown {
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
- .map(([k, v]) => [k, serialize(v)]),
+ .map(([k, v]) => [k, mapValue(v)]),
);
}
return serialize(obj);
@@ -38,7 +44,7 @@ export function deserialize(obj: unknown): unknown {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => {
const ck = snakeToCamel(k);
- return [ck, RECORD_KEYS.has(ck) ? deserializeRecord(v) : deserialize(v)];
+ return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)];
}),
);
}
@@ -51,14 +57,46 @@ export function serialize(obj: unknown): unknown {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
- .map(([k, v]) => {
- return [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecord(v) : serialize(v)];
- }),
+ .map(([k, v]) => [camelToSnake(k), RECORD_KEYS.has(k) ? serializeRecordWith(v, serialize) : serialize(v)]),
);
}
return obj;
}
+function deserializeWebhookEvent(obj: unknown): unknown {
+ if (obj === null || typeof obj !== "object" || Array.isArray(obj)) return deserialize(obj);
+ return Object.fromEntries(
+ Object.entries(obj)
+ .map(([k, v]) => {
+ const ck = snakeToCamel(k);
+ switch (ck) {
+ case "id":
+ return [ck, deserialize(v)];
+ case "eventType":
+ return [ck, deserialize(v)];
+ case "payload":
+ return [ck, deserialize(v)];
+ case "createdAt":
+ return [ck, deserialize(v)];
+ default:
+ return [ck, RECORD_KEYS.has(ck) ? deserializeRecordWith(v, deserialize) : deserialize(v)];
+ }
+ }),
+ );
+}
+
+const TYPE_DESERIALIZERS: Record unknown> = {
+ "WebhookEvent": deserializeWebhookEvent,
+};
+
+export function deserializeType(type: string, obj: unknown): unknown {
+ return (TYPE_DESERIALIZERS[type] ?? deserialize)(obj);
+}
+
+export function serializeType(_type: string, obj: unknown): unknown {
+ return serialize(obj);
+}
+
const MAX_RETRIES = 3;
const RETRYABLE_5XX = new Set([500, 502, 503, 504]);