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]);