From ad3dacdc55a2e6033910fa2a99ee1ca6612a7f77 Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Fri, 5 Jun 2026 13:42:14 +0200 Subject: [PATCH 1/7] Add webhook history examples and payload fixes Add webhook history examples across SDKs and teach generated webhook payload\ndeserializers to distinguish normal message deliveries from link_shared\nmessage payloads.\n\nUpdate the shared IR/spec plumbing for custom union deserializers and wire the\nGo, C#, Kotlin, Python, Swift, and TypeScript generators to use the composite\nwebhook discriminator without record-key hacks.\n\nValidation:\n- python3 -m py_compile pachca/*.py\n- bun run build\n- bun run examples/webhook-history.ts\n\nCo-authored-by: openai-codex/gpt-5.4 --- packages/generator/src/ir.ts | 2 + packages/generator/src/lang/csharp.ts | 288 +- packages/generator/src/lang/go.ts | 474 +- packages/generator/src/lang/kotlin.ts | 402 +- packages/generator/src/lang/python.ts | 469 +- packages/generator/src/lang/swift.ts | 291 +- packages/generator/src/lang/typescript.ts | 336 +- packages/generator/src/transform.ts | 35 +- packages/openapi-parser/src/schema-parser.ts | 1 + packages/openapi-parser/src/types.ts | 1 + packages/spec/openapi.en.yaml | 8748 +++++++++++++++++ packages/spec/openapi.yaml | 588 +- packages/spec/typespec.tsp | 439 +- sdk/csharp/examples/Program.cs | 14 +- sdk/csharp/examples/WebhookHistoryExample.cs | 43 + sdk/csharp/generated/Models.cs | 273 +- sdk/go/examples/webhook_history.go | 70 + sdk/go/generated/types.go | 400 +- sdk/kotlin/examples/webhook_history.kt | 37 + .../src/main/kotlin/com/pachca/Models.kt | 241 +- sdk/python/examples/webhook_history.py | 67 + sdk/python/generated/pachca/utils.py | 98 +- sdk/swift/examples/Package.swift | 21 + .../Sources/WebhookHistory/main.swift | 40 + .../Pachca/GeneratedSources/Models.swift | 265 +- sdk/typescript/examples/webhook-history.ts | 73 + sdk/typescript/src/generated/client.ts | 897 +- sdk/typescript/src/generated/utils.ts | 66 +- 28 files changed, 13759 insertions(+), 920 deletions(-) create mode 100644 packages/spec/openapi.en.yaml create mode 100644 sdk/csharp/examples/WebhookHistoryExample.cs create mode 100644 sdk/go/examples/webhook_history.go create mode 100644 sdk/kotlin/examples/webhook_history.kt create mode 100644 sdk/python/examples/webhook_history.py create mode 100644 sdk/swift/examples/Sources/WebhookHistory/main.swift create mode 100644 sdk/typescript/examples/webhook-history.ts 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 95fdfcfc..c2805944 100644 --- a/packages/generator/src/lang/csharp.ts +++ b/packages/generator/src/lang/csharp.ts @@ -18,6 +18,7 @@ import { snakeToUpperSnake, tagToProperty, tagToServiceName, + serviceToImplName, } from '../naming.js'; const CSHARP_KEYWORDS = new Set([ @@ -59,7 +60,6 @@ function csType(ft: IRFieldType): string { if (ft.primitive === 'any') return 'object'; if (ft.primitive === 'string') { if (ft.format === 'date-time') return 'DateTimeOffset'; - if (ft.format === 'date') return 'DateOnly'; } return 'string'; case 'enum': @@ -82,7 +82,7 @@ function csType(ft: IRFieldType): string { function isValueType(ft: IRFieldType): boolean { if (ft.kind === 'primitive') { if (ft.primitive === 'integer' || ft.primitive === 'number' || ft.primitive === 'boolean') return true; - if (ft.primitive === 'string' && (ft.format === 'date-time' || ft.format === 'date')) return true; + if (ft.primitive === 'string' && ft.format === 'date-time') return true; } if (ft.kind === 'enum') return true; return false; @@ -149,7 +149,7 @@ function queryParamValueExpr(p: IRParam): string { return `PachcaUtils.EnumToApiString(${paramName}${valueSuffix})`; if (p.type.kind === 'primitive' && p.type.primitive === 'string') return paramName; - return `${paramName}${valueSuffix}.ToString()`; + return `${paramName}${valueSuffix}.ToString()!`; } /** Check if ApiError model exists in IR */ @@ -181,6 +181,7 @@ function generateModels(ir: IR): string { lines.push(''); lines.push('using System;'); lines.push('using System.Collections.Generic;'); + lines.push('using System.Linq;'); lines.push('using System.Text.Json;'); lines.push('using System.Text.Json.Serialization;'); lines.push(''); @@ -203,10 +204,10 @@ function generateModels(ir: IR): string { if (unionMemberRefs.has(m.name)) continue; for (const inl of m.inlineObjects) { lines.push(''); - emitModel(lines, inl); + emitModel(lines, inl, ir.models); } lines.push(''); - emitModel(lines, m); + emitModel(lines, m, ir.models); } // Response types @@ -286,12 +287,17 @@ 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.type.kind === 'literal'); + const litValue = litField?.type.literalValue ?? ''; + lines.push(`[JsonDerivedType(typeof(${memberModel.name}), "${litValue}")]`); + } } lines.push(`public abstract class ${u.name}`); lines.push('{'); @@ -299,6 +305,37 @@ function emitUnion( lines.push(` public abstract string ${snakeToPascal(discriminatorField)} { get; }`); lines.push('}'); + 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.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) { const litField = memberModel.fields.find((f) => f.type.kind === 'literal'); const litValue = litField?.type.literalValue ?? ''; @@ -329,6 +366,7 @@ function emitUnion( function emitModel( lines: string[], m: IRModel, + allModels: IRModel[], ): void { const fields = m.fields; const ext = m.isError ? ' : Exception' : ''; @@ -380,6 +418,30 @@ function emitModel( } } + if (m.name === 'ApiError') { + const errorsField = m.fields.find((f) => f.name === 'errors'); + const itemsRef = errorsField?.type.kind === 'array' && errorsField.type.items?.kind === 'model' + ? errorsField.type.items.ref + : undefined; + const itemsModel = itemsRef ? allModels.find((am) => am.name === itemsRef) : undefined; + const hasMessage = itemsModel?.fields.some((f) => f.name === 'message'); + + if (hasMessage) { + lines.push(''); + lines.push(' public override string Message => Errors is not { Count: > 0 }'); + lines.push(' ? "api error"'); + lines.push(' : Errors.Count == 1 ? Errors[0].Message'); + lines.push(' : $"Errors: {string.Join("; ", Errors.Select(t => t.Message))}";'); + } + } + if (m.name === 'OAuthError') { + const errField = m.fields.find((f) => f.name === 'error'); + if (errField) { + lines.push(''); + lines.push(` public override string Message => ${fieldSdkName(errField)} ?? "oauth error";`); + } + } + lines.push('}'); } @@ -402,6 +464,7 @@ function generateUtils(): string { return `#nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -453,7 +516,7 @@ internal static class PachcaUtils { var delay = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt)); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } @@ -561,13 +624,26 @@ function emitService( globalHasApiError: boolean, ): void { const serviceName = tagToServiceName(svc.tag); + const implName = serviceToImplName(serviceName); - lines.push(`public sealed class ${serviceName}`); + lines.push(`public class ${serviceName}`); + lines.push('{'); + for (let i = 0; i < svc.operations.length; i++) { + lines.push(''); + emitThrowingOperation(lines, svc.operations[i], ir); + if (svc.operations[i].isPaginated && svc.operations[i].successResponse.dataRef) { + lines.push(''); + emitThrowingPaginationMethod(lines, svc.operations[i], ir); + } + } + lines.push('}'); + lines.push(''); + lines.push(`public sealed class ${implName} : ${serviceName}`); lines.push('{'); lines.push(' private readonly string _baseUrl;'); lines.push(' private readonly HttpClient _client;'); lines.push(''); - lines.push(` internal ${serviceName}(string baseUrl, HttpClient client)`); + lines.push(` internal ${implName}(string baseUrl, HttpClient client)`); lines.push(' {'); lines.push(' _baseUrl = baseUrl;'); lines.push(' _client = client;'); @@ -585,7 +661,7 @@ function emitService( lines.push('}'); } -function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { +function emitPaginationMethod(lines: string[], op: IROperation, ir: IR, modifier = 'public override'): void { const indent = ' '; const indent2 = ' '; const itemType = csClientTypeRef(op.successResponse.dataRef ?? 'object'); @@ -607,7 +683,7 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { const methodName = `${snakeToPascal(op.methodName)}AllAsync`; - lines.push(`${indent}public async System.Threading.Tasks.Task> ${methodName}(`); + lines.push(`${indent}${modifier} async System.Threading.Tasks.Task> ${methodName}(`); for (let i = 0; i < params.length; i++) { const comma = i < params.length - 1 ? ',' : ')'; lines.push(`${indent2}${params[i]}${comma}`); @@ -615,8 +691,6 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { lines.push(`${indent}{`); lines.push(`${indent2}var items = new List<${itemType}>();`); lines.push(`${indent2}string? cursor = null;`); - lines.push(`${indent2}do`); - lines.push(`${indent2}{`); // Build call args const callArgs: string[] = []; @@ -643,10 +717,31 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { callArgs.push('cancellationToken: cancellationToken'); const baseName = `${snakeToPascal(op.methodName)}Async`; - lines.push(`${indent2} var response = await ${baseName}(${callArgs.join(', ')}).ConfigureAwait(false);`); - lines.push(`${indent2} items.AddRange(response.Data);`); - lines.push(`${indent2} cursor = response.Meta?.Paginate?.NextPage;`); - lines.push(`${indent2}} while (cursor != null);`); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); + const useHasNext = rt?.metaRef === 'PaginationMeta'; + + if (useHasNext) { + const cursorExpr = rt?.metaIsRequired ? 'response.Meta.Paginate.NextPage' : 'response.Meta?.Paginate?.NextPage'; + const hasNextExpr = rt?.metaIsRequired ? 'response.Meta.Paginate.HasNext ?? true' : 'response.Meta?.Paginate?.HasNext ?? true'; + lines.push(`${indent2}var hasNext = true;`); + lines.push(`${indent2}while (hasNext)`); + lines.push(`${indent2}{`); + lines.push(`${indent2} var response = await ${baseName}(${callArgs.join(', ')}).ConfigureAwait(false);`); + lines.push(`${indent2} items.AddRange(response.Data);`); + lines.push(`${indent2} if (response.Data.Count == 0) break;`); + lines.push(`${indent2} cursor = ${cursorExpr};`); + lines.push(`${indent2} hasNext = ${hasNextExpr};`); + lines.push(`${indent2}}`); + } else { + const metaAccess = rt?.metaIsRequired ? 'response.Meta.Paginate.NextPage' : 'response.Meta?.Paginate?.NextPage'; + lines.push(`${indent2}do`); + lines.push(`${indent2}{`); + lines.push(`${indent2} var response = await ${baseName}(${callArgs.join(', ')}).ConfigureAwait(false);`); + lines.push(`${indent2} items.AddRange(response.Data);`); + lines.push(`${indent2} if (response.Data.Count == 0) break;`); + lines.push(`${indent2} cursor = ${metaAccess};`); + lines.push(rt?.metaIsRequired ? `${indent2}} while (true);` : `${indent2}} while (cursor != null);`); + } lines.push(`${indent2}return items;`); lines.push(`${indent}}`); } @@ -656,6 +751,7 @@ function emitOperation( op: IROperation, ir: IR, globalHasApiError: boolean, + modifier = 'public override', ): void { const indent = ' '; const indent2 = ' '; @@ -670,11 +766,11 @@ function emitOperation( if (op.deprecated) lines.push(`${indent}[Obsolete("This method is deprecated")]`); if (params.length === 0) { - lines.push(`${indent}public async ${taskType} ${methodName}(CancellationToken cancellationToken = default)`); + lines.push(`${indent}${modifier} async ${taskType} ${methodName}(CancellationToken cancellationToken = default)`); } else if (params.length === 1) { - lines.push(`${indent}public async ${taskType} ${methodName}(${params[0]}, CancellationToken cancellationToken = default)`); + lines.push(`${indent}${modifier} async ${taskType} ${methodName}(${params[0]}, CancellationToken cancellationToken = default)`); } else { - lines.push(`${indent}public async ${taskType} ${methodName}(`); + lines.push(`${indent}${modifier} async ${taskType} ${methodName}(`); for (const p of params) { lines.push(`${indent2}${p},`); } @@ -687,6 +783,52 @@ function emitOperation( lines.push(`${indent}}`); } +function emitThrowingOperation(lines: string[], op: IROperation, ir: IR): void { + const returnType = getReturnType(op, ir); + const taskType = returnType ? `System.Threading.Tasks.Task<${returnType}>` : 'System.Threading.Tasks.Task'; + const params = buildMethodParams(op, ir); + const methodName = `${snakeToPascal(op.methodName)}Async`; + const indent = ' '; + const indent2 = ' '; + if (op.deprecated) lines.push(`${indent}[Obsolete("This method is deprecated")]`); + if (params.length === 0) { + lines.push(`${indent}public virtual async ${taskType} ${methodName}(CancellationToken cancellationToken = default)`); + } else if (params.length === 1) { + lines.push(`${indent}public virtual async ${taskType} ${methodName}(${params[0]}, CancellationToken cancellationToken = default)`); + } else { + lines.push(`${indent}public virtual async ${taskType} ${methodName}(`); + for (const p of params) lines.push(`${indent2}${p},`); + lines.push(`${indent2}CancellationToken cancellationToken = default)`); + } + lines.push(`${indent}{`); + lines.push(`${indent2}throw new NotImplementedException(${JSON.stringify(`${op.tag}.${op.methodName} is not implemented`)});`); + lines.push(`${indent}}`); +} + +function emitThrowingPaginationMethod(lines: string[], op: IROperation, ir: IR): void { + const indent = ' '; + const indent2 = ' '; + const itemType = csClientTypeRef(op.successResponse.dataRef ?? 'object'); + const params: string[] = []; + if (op.externalUrl) params.push(`string ${paramSdkName(op.externalUrl)}`); + for (const p of op.pathParams) params.push(`${csType(p.type)} ${paramSdkName(p.sdkName)}`); + for (const p of op.queryParams) { + if (p.name === 'cursor') continue; + const typeName = csType(p.type); + params.push(p.required ? `${typeName} ${paramSdkName(p.sdkName)}` : `${typeName}? ${paramSdkName(p.sdkName)} = null`); + } + params.push('CancellationToken cancellationToken = default'); + const methodName = `${snakeToPascal(op.methodName)}AllAsync`; + lines.push(`${indent}public virtual async System.Threading.Tasks.Task> ${methodName}(`); + for (let i = 0; i < params.length; i++) { + const comma = i < params.length - 1 ? ',' : ')'; + lines.push(`${indent2}${params[i]}${comma}`); + } + lines.push(`${indent}{`); + lines.push(`${indent2}throw new NotImplementedException(${JSON.stringify(`${op.tag}.${op.methodName}All is not implemented`)});`); + lines.push(`${indent}}`); +} + function getReturnType( op: IROperation, ir: IR, @@ -695,9 +837,7 @@ function getReturnType( if (resp.isRedirect) return 'string'; if (!resp.hasBody) return null; if (resp.isList) { - const rt = ir.responses.find( - (r) => r.dataRef === resp.dataRef && r.dataIsArray, - ); + const rt = ir.responses.find((r) => r.name === resp.responseRef); return rt?.name ?? 'object'; } if (resp.isUnwrap && resp.dataRef) return csClientTypeRef(resp.dataRef); @@ -776,13 +916,15 @@ function emitMethodBody( // Escape curly braces in param name for C# string interpolation const paramKey = p.name.replace(/\{/g, '{{').replace(/\}/g, '}}'); if (p.isArray) { + const itemIsEnum = p.type.kind === 'array' && p.type.items?.kind === 'enum'; + const itemExpr = itemIsEnum ? 'PachcaUtils.EnumToApiString(item)' : 'item.ToString()!'; if (p.required) { lines.push(`${indent2}foreach (var item in ${paramName})`); - lines.push(`${indent2} queryParts.Add($"${paramKey}={Uri.EscapeDataString(item.ToString())}");`); + lines.push(`${indent2} queryParts.Add($"${paramKey}={Uri.EscapeDataString(${itemExpr})}");`); } else { lines.push(`${indent2}if (${paramName} != null)`); lines.push(`${indent2} foreach (var item in ${paramName})`); - lines.push(`${indent2} queryParts.Add($"${paramKey}={Uri.EscapeDataString(item.ToString())}");`); + lines.push(`${indent2} queryParts.Add($"${paramKey}={Uri.EscapeDataString(${itemExpr})}");`); } } else { const valueExpr = queryParamValueExpr(p); @@ -856,6 +998,7 @@ function emitMultipartBody( const binaryField = reqModel.fields.find((f) => f.type.kind === 'binary'); const nonBinaryFields = reqModel.fields.filter((f) => f.type.kind !== 'binary'); + const isUnwrapped = shouldUnwrapBody(op.requestBody!); if (op.externalUrl) { lines.push(`${indent2}var url = ${paramSdkName(op.externalUrl)};`); @@ -866,19 +1009,19 @@ function emitMultipartBody( lines.push(`${indent2}using var content = new MultipartFormDataContent();`); for (const f of nonBinaryFields) { - const sdkName = fieldSdkName(f); + const sdk = isUnwrapped ? paramSdkName(f.name) : `request.${fieldSdkName(f)}`; const isOptional = !f.required || f.nullable; if (isOptional) { - lines.push(`${indent2}if (request.${sdkName} != null)`); - lines.push(`${indent2} content.Add(new StringContent($"{request.${sdkName}}"), "${f.name}");`); + lines.push(`${indent2}if (${sdk} != null)`); + lines.push(`${indent2} content.Add(new StringContent($"{${sdk}}"), "${f.name}");`); } else { - lines.push(`${indent2}content.Add(new StringContent($"{request.${sdkName}}"), "${f.name}");`); + lines.push(`${indent2}content.Add(new StringContent($"{${sdk}}"), "${f.name}");`); } } if (binaryField) { - const sdkName = fieldSdkName(binaryField); - lines.push(`${indent2}content.Add(new ByteArrayContent(request.${sdkName}), "${binaryField.name}", "${binaryField.name}");`); + const sdk = isUnwrapped ? paramSdkName(binaryField.name) : `request.${fieldSdkName(binaryField)}`; + lines.push(`${indent2}content.Add(new ByteArrayContent(${sdk}), "${binaryField.name}", "${binaryField.name}");`); } lines.push(`${indent2}using var httpRequest = new HttpRequestMessage(HttpMethod.${httpMethodName(op.method.toUpperCase())}, url);`); @@ -915,9 +1058,7 @@ function emitResponseHandling( } else if (resp.isList) { lines.push(`${indent2} case ${resp.statusCode}:`); // Use same lookup as getReturnType for consistency - const foundResp = ir.responses.find( - (r) => r.dataRef === resp.dataRef && r.dataIsArray, - ); + const foundResp = ir.responses.find((r) => r.name === resp.responseRef); const rt = foundResp?.name ?? 'object'; lines.push(`${indent2} return PachcaUtils.Deserialize<${rt}>(json);`); } else if (resp.isUnwrap && resp.dataRef) { @@ -949,27 +1090,47 @@ function emitPachcaClient( ir: IR, hasRedirect: boolean, ): void { - const csDefault = ir.baseUrl ? ` = ${JSON.stringify(ir.baseUrl)}` : ''; + if (ir.baseUrl) { + lines.push(`public static class PachcaConstants`); + lines.push('{'); + lines.push(` public const string PachcaApiUrl = ${JSON.stringify(ir.baseUrl)};`); + lines.push('}'); + lines.push(''); + } + const csDefault = ir.baseUrl ? ' = PachcaConstants.PachcaApiUrl' : ''; lines.push('public sealed class PachcaClient : IDisposable'); lines.push('{'); - lines.push(' private readonly HttpClient _client;'); + lines.push(' private readonly HttpClient? _client;'); lines.push(''); - - // Service properties const serviceEntries = ir.services .map((svc) => ({ propName: snakeToPascal(tagToProperty(svc.tag)), + paramName: tagToProperty(svc.tag), className: tagToServiceName(svc.tag), })) .sort((a, b) => a.propName.localeCompare(b.propName)); - for (const s of serviceEntries) { lines.push(` public ${s.className} ${s.propName} { get; }`); } + // Private constructor taking only services lines.push(''); - lines.push(` public PachcaClient(string token, string baseUrl${csDefault})`); + const privateParams = serviceEntries.map((s) => `${s.className} ${s.paramName}`); + lines.push(` private PachcaClient(${privateParams.join(', ')})`); + lines.push(' {'); + for (const s of serviceEntries) { + lines.push(` ${s.propName} = ${s.paramName};`); + } + lines.push(' }'); + + // Public constructor with token, baseUrl, and optional service overrides + lines.push(''); + const constructorParams = ['string token', `string baseUrl${csDefault}`]; + for (const s of serviceEntries) { + constructorParams.push(`${s.className}? ${s.paramName} = null`); + } + lines.push(` public PachcaClient(${constructorParams.join(', ')})`); lines.push(' {'); if (hasRedirect) { @@ -987,14 +1148,39 @@ function emitPachcaClient( lines.push(''); for (const s of serviceEntries) { - lines.push(` ${s.propName} = new ${s.className}(baseUrl, _client);`); + lines.push(` ${s.propName} = ${s.paramName} ?? new ${serviceToImplName(s.className)}(baseUrl, _client);`); + } + + lines.push(' }'); + + // Public constructor with pre-configured HttpClient + lines.push(''); + const httpConstructorParams = ['string baseUrl', 'HttpClient client']; + for (const s of serviceEntries) { + httpConstructorParams.push(`${s.className}? ${s.paramName} = null`); } + lines.push(` public PachcaClient(${httpConstructorParams.join(', ')})`); + lines.push(' {'); + lines.push(' _client = client;'); + lines.push(''); + for (const s of serviceEntries) { + lines.push(` ${s.propName} = ${s.paramName} ?? new ${serviceToImplName(s.className)}(baseUrl, _client);`); + } + lines.push(' }'); + // Static Stub() factory method + lines.push(''); + const stubParams = serviceEntries.map((s) => `${s.className}? ${s.paramName} = null`); + lines.push(` public static PachcaClient Stub(${stubParams.join(', ')})`); + lines.push(' {'); + const stubArgs = serviceEntries.map((s) => `${s.paramName} ?? new ${s.className}()`); + lines.push(` return new PachcaClient(${stubArgs.join(', ')});`); lines.push(' }'); + lines.push(''); lines.push(' public void Dispose()'); lines.push(' {'); - lines.push(' _client.Dispose();'); + lines.push(' _client?.Dispose();'); lines.push(' GC.SuppressFinalize(this);'); lines.push(' }'); lines.push('}'); @@ -1016,7 +1202,10 @@ function csLiteral( return `${ft.example}d`; } if (ft.primitive === 'boolean' && typeof ft.example === 'boolean') return String(ft.example); - if (ft.primitive === 'string' && typeof ft.example === 'string') return `"${ft.example}"`; + if (ft.primitive === 'string' && typeof ft.example === 'string') { + if (ft.format === 'date-time') return `DateTimeOffset.Parse(${JSON.stringify(ft.example)})`; + return JSON.stringify(ft.example); + } } if (ft.kind === 'enum' && typeof ft.example === 'string') { const e = ir.enums.find((en) => en.name === ft.ref); @@ -1032,7 +1221,6 @@ function csLiteral( if (ft.primitive === 'any') return 'new object()'; if (ft.primitive === 'string') { if (ft.format === 'date-time') return 'DateTimeOffset.UtcNow'; - if (ft.format === 'date') return '"2024-01-01"'; } return '"example"'; } @@ -1062,7 +1250,7 @@ function csLiteral( return `default(${ft.ref ?? 'object'})!`; } case 'literal': - return `"${ft.literalValue}"`; + return JSON.stringify(ft.literalValue); case 'binary': return 'Array.Empty()'; } @@ -1085,7 +1273,7 @@ function csModelLiteral( const isCyclic = (f: IRField) => f.type.kind === 'model' && f.type.ref != null && nextVisited.has(f.type.ref); const fields = model.fields.filter( - (f) => (f.type.kind !== 'binary' || f.required) && !(isCyclic(f) && (!f.required || f.nullable)), + (f) => f.type.kind !== 'literal' && (f.type.kind !== 'binary' || f.required) && !(isCyclic(f) && (!f.required || f.nullable)), ); if (fields.length === 0) return `new ${modelName}()`; diff --git a/packages/generator/src/lang/go.ts b/packages/generator/src/lang/go.ts index 081daae1..d0923799 100644 --- a/packages/generator/src/lang/go.ts +++ b/packages/generator/src/lang/go.ts @@ -11,7 +11,7 @@ import { type IRResponseType, } from '../ir.js'; import { buildModelIndex, collectTypeRefs, type GeneratedFile, type GenerateOptions, type LanguageGenerator } from './types.js'; -import { snakeToCamel, snakeToPascal, tagToServiceName } from '../naming.js'; +import { snakeToCamel, snakeToPascal, tagToServiceName, serviceToImplName, serviceToStubName } from '../naming.js'; function upperFirst(s: string): string { if (!s) return s; @@ -65,8 +65,8 @@ function goPrimitive( if (ft.primitive === 'boolean') return 'bool'; if (ft.primitive === 'any') return 'any'; if (ft.primitive === 'string') { - if (opts.forParam && (ft.format === 'date' || ft.format === 'date-time')) return 'time.Time'; - if (opts.forModelField && !opts.nullable && (ft.format === 'date' || ft.format === 'date-time')) { + if (opts.forParam && ft.format === 'date-time') return 'time.Time'; + if (opts.forModelField && !opts.nullable && ft.format === 'date-time') { return 'time.Time'; } return 'string'; @@ -154,6 +154,31 @@ function emitModel(lines: string[], m: IRModel, allModels: IRModel[]): void { for (const line of goAligned(rows)) lines.push(line); lines.push('}'); + const optionalContainers = m.fields.filter( + (f) => isOptionalField(f) && (f.type.kind === 'array' || f.type.kind === 'record') && !f.nullable, + ); + if (optionalContainers.length > 0) { + lines.push(''); + lines.push(`func (m ${m.name}) MarshalJSON() ([]byte, error) {`); + lines.push(`\ttype Alias ${m.name}`); + lines.push('\tdata, err := json.Marshal(Alias(m))'); + lines.push('\tif err != nil {'); + lines.push('\t\treturn nil, err'); + lines.push('\t}'); + lines.push('\tvar raw map[string]any'); + lines.push('\tif err := json.Unmarshal(data, &raw); err != nil {'); + lines.push('\t\treturn nil, err'); + lines.push('\t}'); + for (const field of optionalContainers) { + const goName = goExportName(field.name); + lines.push(`\tif m.${goName} != nil {`); + lines.push(`\t\traw[${JSON.stringify(field.name)}] = m.${goName}`); + lines.push('\t}'); + } + lines.push('\treturn json.Marshal(raw)'); + lines.push('}'); + } + if (m.name === 'ApiError') { // Find the items model for the errors array to determine which field to use const errorsField = m.fields.find((f) => f.name === 'errors'); @@ -213,21 +238,42 @@ 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.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.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})`); @@ -281,13 +327,16 @@ function generateTypes(ir: IR): string { const needTime = [ ...ir.models.flatMap((m) => m.fields), ...ir.params.flatMap((p) => p.params), - ].some((f) => f.type.kind === 'primitive' && f.type.primitive === 'string' && (f.type.format === 'date' || f.type.format === 'date-time')); + ].some((f) => f.type.kind === 'primitive' && f.type.primitive === 'string' && f.type.format === 'date-time'); const needFmtStrings = ir.models.some((m) => m.name === 'ApiError'); const needIO = ir.models.some((m) => m.fields.some((f) => f.type.kind === 'binary')); const hasUnions = ir.unions.length > 0; + const hasOptionalContainers = ir.models.some((m) => m.fields.some( + (f) => isOptionalField(f) && (f.type.kind === 'array' || f.type.kind === 'record') && !f.nullable, + )); const imports: string[] = []; - if (hasUnions) imports.push('"encoding/json"'); + if (hasUnions || hasOptionalContainers) imports.push('"encoding/json"'); if (needFmtStrings || hasUnions) imports.push('"fmt"'); if (needIO) imports.push('"io"'); if (needFmtStrings) imports.push('"strings"'); @@ -342,7 +391,7 @@ function goReturn(op: IROperation, ir: IR): string { if (op.successResponse.isRedirect) return '(string, error)'; if (!op.successResponse.hasBody) return 'error'; if (op.successResponse.isList) { - const rt = ir.responses.find((r) => r.dataRef === op.successResponse.dataRef && r.dataIsArray); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); return `(*${rt?.name ?? 'any'}, error)`; } return `(*${op.successResponse.dataRef ?? 'any'}, error)`; @@ -403,7 +452,7 @@ function emitOp(lines: string[], op: IROperation, ir: IR): void { } if (op.deprecated) lines.push(`// Deprecated: ${goMethodName(op)} is deprecated.`); - lines.push(`func (s *${tagToServiceName(op.tag)}) ${goMethodName(op)}(${args.join(', ')}) ${goReturn(op, ir)} {`); + lines.push(`func (s *${serviceToImplName(tagToServiceName(op.tag))}) ${goMethodName(op)}(${args.join(', ')}) ${goReturn(op, ir)} {`); const { fmt: fmtPath, args: pathArgs } = goPathFormat(op.path, op); const urlExpr = op.externalUrl @@ -420,24 +469,29 @@ function emitOp(lines: string[], op: IROperation, ir: IR): void { lines.push('\tgo func() {'); lines.push('\t\tdefer pw.Close()'); lines.push('\t\tdefer writer.Close()'); + const isUnwrapped = shouldUnwrapBody(op.requestBody!); if (req) { const bin = req.fields.find((f) => f.type.kind === 'binary'); const non = req.fields.filter((f) => f.type.kind !== 'binary'); for (const f of non.filter((x) => isOptionalField(x))) { - lines.push(`\t\tif request.${goExportName(f.name)} != nil {`); - lines.push(`\t\t\twriter.WriteField(${JSON.stringify(f.name)}, fmt.Sprintf("%v", *request.${goExportName(f.name)}))`); + const ref = isUnwrapped ? snakeToCamel(f.name) : `request.${goExportName(f.name)}`; + const deref = isUnwrapped ? snakeToCamel(f.name) : `*request.${goExportName(f.name)}`; + lines.push(`\t\tif ${ref} != nil {`); + lines.push(`\t\t\twriter.WriteField(${JSON.stringify(f.name)}, fmt.Sprintf("%v", ${deref}))`); lines.push('\t\t}'); } for (const f of non.filter((x) => !isOptionalField(x))) { - lines.push(`\t\twriter.WriteField(${JSON.stringify(f.name)}, fmt.Sprintf("%v", request.${goExportName(f.name)}))`); + const ref = isUnwrapped ? snakeToCamel(f.name) : `request.${goExportName(f.name)}`; + lines.push(`\t\twriter.WriteField(${JSON.stringify(f.name)}, fmt.Sprintf("%v", ${ref}))`); } if (bin) { + const binRef = isUnwrapped ? snakeToCamel(bin.name) : `request.${goExportName(bin.name)}`; lines.push(`\t\tpart, err := writer.CreateFormFile(${JSON.stringify(bin.name)}, "upload")`); lines.push('\t\tif err != nil {'); lines.push('\t\t\tpw.CloseWithError(err)'); lines.push('\t\t\treturn'); lines.push('\t\t}'); - lines.push(`\t\tif _, err := io.Copy(part, request.${goExportName(bin.name)}); err != nil {`); + lines.push(`\t\tif _, err := io.Copy(part, ${binRef}); err != nil {`); lines.push('\t\t\tpw.CloseWithError(err)'); lines.push('\t\t\treturn'); lines.push('\t\t}'); @@ -473,11 +527,14 @@ function emitOp(lines: string[], op: IROperation, ir: IR): void { const hasReqParams = op.queryParams.some((q) => q.required); for (const p of op.queryParams) { const pn = goExportName(p.sdkName); - const isTime = p.type.kind === 'primitive' && p.type.primitive === 'string' && (p.type.format === 'date' || p.type.format === 'date-time'); + const isTime = p.type.kind === 'primitive' && p.type.primitive === 'string' && p.type.format === 'date-time'; if (p.isArray) { - lines.push(`\tfor _, v := range params.${pn} {`); - lines.push(`\t\tq.Add(${JSON.stringify(p.name)}, fmt.Sprintf("%v", v))`); - lines.push('\t}'); + const indent = hasReqParams ? '\t' : '\t\t'; + if (!hasReqParams) lines.push(`\tif params != nil {`); + lines.push(`${indent}for _, v := range params.${pn} {`); + lines.push(`${indent}\tq.Add(${JSON.stringify(p.name)}, fmt.Sprintf("%v", v))`); + lines.push(`${indent}}`); + if (!hasReqParams) lines.push('\t}'); } else if (p.required) { let conv: string; if (isTime) conv = `params.${pn}.Format(time.RFC3339)`; @@ -534,7 +591,7 @@ function emitOp(lines: string[], op: IROperation, ir: IR): void { lines.push(`\tcase ${op.successResponse.statusCode === 201 ? 'http.StatusCreated' : 'http.StatusNoContent'}:`); lines.push('\t\treturn nil'); } else if (op.successResponse.isList) { - const rt = ir.responses.find((r) => r.dataRef === op.successResponse.dataRef && r.dataIsArray); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); lines.push(`\tcase ${op.successResponse.statusCode === 201 ? 'http.StatusCreated' : 'http.StatusOK'}:`); lines.push(`\t\tvar result ${rt?.name ?? 'any'}`); lines.push('\t\tif err := json.NewDecoder(resp.Body).Decode(&result); err != nil {'); @@ -562,14 +619,23 @@ function emitOp(lines: string[], op: IROperation, ir: IR): void { if (op.hasOAuthError) { lines.push('\tcase http.StatusUnauthorized:'); lines.push('\t\tvar e OAuthError'); - lines.push('\t\tjson.NewDecoder(resp.Body).Decode(&e)'); + lines.push('\t\tif err := json.NewDecoder(resp.Body).Decode(&e); err != nil {'); + lines.push('\t\t\te.Err = fmt.Sprintf("HTTP 401: %v", err)'); + lines.push('\t\t}'); lines.push(`\t\t${retOAuth()}`); } if (op.hasApiError || ir.models.some((m) => m.name === 'ApiError')) { lines.push('\tdefault:'); lines.push('\t\tvar e ApiError'); - lines.push('\t\tjson.NewDecoder(resp.Body).Decode(&e)'); + lines.push('\t\tif err := json.NewDecoder(resp.Body).Decode(&e); err != nil {'); + const retFmt = op.successResponse.isRedirect + ? 'return "", fmt.Errorf("HTTP %d: %w", resp.StatusCode, err)' + : !op.successResponse.hasBody + ? 'return fmt.Errorf("HTTP %d: %w", resp.StatusCode, err)' + : 'return nil, fmt.Errorf("HTTP %d: %w", resp.StatusCode, err)'; + lines.push(`\t\t\t${retFmt}`); + lines.push('\t\t}'); lines.push(`\t\t${retApi()}`); } else { lines.push('\tdefault:'); @@ -583,7 +649,7 @@ function emitOp(lines: string[], op: IROperation, ir: IR): void { function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { const itemType = op.successResponse.dataRef ?? 'any'; const paramsName = `${upperFirst(op.methodName)}Params`; - const svcName = tagToServiceName(op.tag); + const svcName = serviceToImplName(tagToServiceName(op.tag)); const args: string[] = ['ctx context.Context']; if (op.externalUrl) args.push(`${op.externalUrl} string`); @@ -600,10 +666,6 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { } lines.push(`\tvar items []${itemType}`); lines.push('\tvar cursor *string'); - lines.push('\tfor {'); - if (op.queryParams.length > 0) { - lines.push('\t\tparams.Cursor = cursor'); - } // Build call args const callArgs: string[] = ['ctx']; @@ -614,16 +676,125 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { callArgs.push(hasReq ? '*params' : 'params'); } - lines.push(`\t\tresult, err := s.${goMethodName(op)}(${callArgs.join(', ')})`); - lines.push('\t\tif err != nil {'); - lines.push('\t\t\treturn nil, err'); - lines.push('\t\t}'); - lines.push('\t\titems = append(items, result.Data...)'); - lines.push('\t\tif result.Meta == nil || result.Meta.Paginate == nil || result.Meta.Paginate.NextPage == nil {'); - lines.push('\t\t\treturn items, nil'); - lines.push('\t\t}'); - lines.push('\t\tcursor = result.Meta.Paginate.NextPage'); - lines.push('\t}'); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); + const useHasNext = rt?.metaRef === 'PaginationMeta'; + + if (useHasNext) { + lines.push('\thasNext := true'); + lines.push('\tfor hasNext {'); + if (op.queryParams.length > 0) { + lines.push('\t\tparams.Cursor = cursor'); + } + lines.push(`\t\tresult, err := s.${goMethodName(op)}(${callArgs.join(', ')})`); + lines.push('\t\tif err != nil {'); + lines.push('\t\t\treturn nil, err'); + lines.push('\t\t}'); + lines.push('\t\titems = append(items, result.Data...)'); + lines.push('\t\tif len(result.Data) == 0 {'); + lines.push('\t\t\treturn items, nil'); + lines.push('\t\t}'); + lines.push('\t\tnextPage := result.Meta.Paginate.NextPage'); + lines.push('\t\tcursor = &nextPage'); + lines.push('\t\tif result.Meta.Paginate.HasNext != nil {'); + lines.push('\t\t\thasNext = *result.Meta.Paginate.HasNext'); + lines.push('\t\t}'); + lines.push('\t}'); + lines.push('\treturn items, nil'); + } else { + const metaNilCheck = rt?.metaIsRequired ? '' : ' || result.Meta == nil'; + lines.push('\tfor {'); + if (op.queryParams.length > 0) { + lines.push('\t\tparams.Cursor = cursor'); + } + lines.push(`\t\tresult, err := s.${goMethodName(op)}(${callArgs.join(', ')})`); + lines.push('\t\tif err != nil {'); + lines.push('\t\t\treturn nil, err'); + lines.push('\t\t}'); + lines.push('\t\titems = append(items, result.Data...)'); + lines.push(`\t\tif len(result.Data) == 0${metaNilCheck} {`); + lines.push('\t\t\treturn items, nil'); + lines.push('\t\t}'); + lines.push('\t\tnextPage := result.Meta.Paginate.NextPage'); + lines.push('\t\tcursor = &nextPage'); + lines.push('\t}'); + } + lines.push('}'); +} + +function emitServiceContract(lines: string[], svc: IRService, ir: IR): void { + const serviceName = tagToServiceName(svc.tag); + const stubName = serviceToStubName(serviceName); + lines.push(`type ${serviceName} interface {`); + for (const op of svc.operations) { + const args: string[] = ['ctx context.Context']; + if (op.externalUrl) args.push(`${op.externalUrl} string`); + for (const p of op.pathParams) args.push(`${snakeToCamel(p.sdkName)} ${goType(p.type)}`); + if (op.requestBody) { + const rb = op.requestBody; + if (shouldUnwrapBody(rb)) args.push(`${snakeToCamel(rb.unwrapField!.name)} ${goType(rb.unwrapField!.type)}`); + else if (rb.schemaRef) args.push(`request ${rb.schemaRef}`); + } + if (op.queryParams.length > 0) { + const pName = `${upperFirst(op.methodName)}Params`; + const hasReq = op.queryParams.some((p) => p.required); + args.push(`${snakeToCamel('params')} ${hasReq ? pName : `*${pName}`}`); + } + lines.push(`\t${goMethodName(op)}(${args.join(', ')}) ${goReturn(op, ir)}`); + if (op.isPaginated && op.successResponse.dataRef) { + const itemType = op.successResponse.dataRef ?? 'any'; + const pageArgs: string[] = ['ctx context.Context']; + if (op.externalUrl) pageArgs.push(`${op.externalUrl} string`); + for (const p of op.pathParams) pageArgs.push(`${snakeToCamel(p.sdkName)} ${goType(p.type)}`); + if (op.queryParams.length > 0) pageArgs.push(`params *${upperFirst(op.methodName)}Params`); + lines.push(`\t${goMethodName(op)}All(${pageArgs.join(', ')}) ([]${itemType}, error)`); + } + } + lines.push('}'); + lines.push(''); + lines.push(`type ${stubName} struct{}`); + lines.push(''); + for (const op of svc.operations) { + emitStubMethod(lines, op, ir); + lines.push(''); + if (op.isPaginated && op.successResponse.dataRef) { + emitStubPaginationMethod(lines, op); + lines.push(''); + } + } +} + +function emitStubMethod(lines: string[], op: IROperation, ir: IR): void { + const stubName = serviceToStubName(tagToServiceName(op.tag)); + const args: string[] = ['ctx context.Context']; + if (op.externalUrl) args.push(`${op.externalUrl} string`); + for (const p of op.pathParams) args.push(`${snakeToCamel(p.sdkName)} ${goType(p.type)}`); + if (op.requestBody) { + const rb = op.requestBody; + if (shouldUnwrapBody(rb)) args.push(`${snakeToCamel(rb.unwrapField!.name)} ${goType(rb.unwrapField!.type)}`); + else if (rb.schemaRef) args.push(`request ${rb.schemaRef}`); + } + if (op.queryParams.length > 0) { + const pName = `${upperFirst(op.methodName)}Params`; + const hasReq = op.queryParams.some((p) => p.required); + args.push(`${snakeToCamel('params')} ${hasReq ? pName : `*${pName}`}`); + } + const methodRef = JSON.stringify(`${op.tag}.${op.methodName}`); + lines.push(`func (s *${stubName}) ${goMethodName(op)}(${args.join(', ')}) ${goReturn(op, ir)} {`); + if (op.successResponse.isRedirect) lines.push(`\treturn "", NotImplementedError{Method: ${methodRef}}`); + else if (!op.successResponse.hasBody) lines.push(`\treturn NotImplementedError{Method: ${methodRef}}`); + else lines.push(`\treturn nil, NotImplementedError{Method: ${methodRef}}`); + lines.push('}'); +} + +function emitStubPaginationMethod(lines: string[], op: IROperation): void { + const stubName = serviceToStubName(tagToServiceName(op.tag)); + const itemType = op.successResponse.dataRef ?? 'any'; + const args: string[] = ['ctx context.Context']; + if (op.externalUrl) args.push(`${op.externalUrl} string`); + for (const p of op.pathParams) args.push(`${snakeToCamel(p.sdkName)} ${goType(p.type)}`); + if (op.queryParams.length > 0) args.push(`params *${upperFirst(op.methodName)}Params`); + lines.push(`func (s *${stubName}) ${goMethodName(op)}All(${args.join(', ')}) ([]${itemType}, error) {`); + lines.push(`\treturn nil, NotImplementedError{Method: ${JSON.stringify(`${op.tag}.${op.methodName}All`)}}`); lines.push('}'); } @@ -640,7 +811,7 @@ function generateClient(ir: IR): string { 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 needMultipart = ir.services.some((s) => s.operations.some((o) => o.requestBody?.contentType === 'multipart')); - const imports: string[] = ['"context"', '"encoding/json"', '"fmt"', '"math/rand"', '"net/http"', '"strconv"', '"time"']; + 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"'); @@ -665,48 +836,12 @@ function generateClient(ir: IR): string { lines.push('\treturn t.base.RoundTrip(req)'); lines.push('}'); lines.push(''); - lines.push('const maxRetries = 3'); - lines.push(''); - lines.push('var retryable5xx = map[int]bool{500: true, 502: true, 503: true, 504: true}'); - lines.push(''); - lines.push('func jitter(d time.Duration) time.Duration {'); - lines.push('\treturn time.Duration(float64(d) * (0.5 + rand.Float64()*0.5))'); - lines.push('}'); - lines.push(''); - lines.push('func doWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {'); - lines.push('\tfor attempt := 0; ; attempt++ {'); - lines.push('\t\tif attempt > 0 && req.GetBody != nil {'); - lines.push('\t\t\treq.Body, _ = req.GetBody()'); - lines.push('\t\t}'); - lines.push('\t\tresp, err := client.Do(req)'); - lines.push('\t\tif err != nil {'); - lines.push('\t\t\treturn nil, err'); - lines.push('\t\t}'); - lines.push('\t\tif resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries {'); - lines.push('\t\t\tresp.Body.Close()'); - lines.push('\t\t\tdelay := time.Duration(1< ({ f: goServiceField(s.tag), cls: tagToServiceName(s.tag) })) .sort((a, b) => a.f.localeCompare(b.f)); - const clientRows = fields.map((f) => [f.f, `*${f.cls}`]); + const clientRows = fields.map((f) => [f.f, f.cls]); for (const line of goAligned(clientRows)) lines.push(line); lines.push('}'); lines.push(''); + lines.push('type clientConfig struct {'); + if (ir.baseUrl) { + lines.push('\tbaseURL string'); + } else { + lines.push('\tbaseURL string'); + } + for (const f of fields) lines.push(`\t${f.f.charAt(0).toLowerCase() + f.f.slice(1)} ${f.cls}`); + lines.push('}'); + lines.push(''); + lines.push('type ClientOption func(*clientConfig)'); + lines.push(''); + + // stubClientConfig struct + lines.push('type stubClientConfig struct {'); + for (const f of fields) lines.push(`\t${f.f.charAt(0).toLowerCase() + f.f.slice(1)} ${f.cls}`); + lines.push('}'); + lines.push(''); + lines.push('type StubClientOption func(*stubClientConfig)'); + lines.push(''); + if (ir.baseUrl) { - lines.push(`const DefaultBaseURL = ${JSON.stringify(ir.baseUrl)}`); + lines.push(`const PachcaAPIURL = ${JSON.stringify(ir.baseUrl)}`); lines.push(''); } - lines.push('func NewPachcaClient(token string, baseURL ...string) *PachcaClient {'); + lines.push('func WithBaseURL(baseURL string) ClientOption {'); + lines.push('\treturn func(cfg *clientConfig) { cfg.baseURL = baseURL }'); + lines.push('}'); + lines.push(''); + for (const f of fields) { + lines.push(`func With${f.f}(service ${f.cls}) ClientOption {`); + lines.push(`\treturn func(cfg *clientConfig) { cfg.${f.f.charAt(0).toLowerCase() + f.f.slice(1)} = service }`); + lines.push('}'); + lines.push(''); + } + + // WithStub* option functions + for (const f of fields) { + lines.push(`func WithStub${f.f}(service ${f.cls}) StubClientOption {`); + lines.push(`\treturn func(cfg *stubClientConfig) { cfg.${f.f.charAt(0).toLowerCase() + f.f.slice(1)} = service }`); + lines.push('}'); + lines.push(''); + } + + lines.push('func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient {'); if (ir.baseUrl) { - lines.push(`\turl := DefaultBaseURL`); + lines.push(`\tcfg := clientConfig{baseURL: PachcaAPIURL}`); } else { - lines.push('\turl := ""'); + lines.push('\tcfg := clientConfig{}'); } - lines.push('\tif len(baseURL) > 0 { url = baseURL[0] }'); + lines.push('\tfor _, opt := range opts {'); + lines.push('\t\topt(&cfg)'); + lines.push('\t}'); lines.push('\tclient := &http.Client{'); lines.push('\t\tTransport: &authTransport{token: token, base: http.DefaultTransport},'); if (needErrors) { @@ -748,9 +924,69 @@ function generateClient(ir: IR): string { lines.push('\t\t},'); } lines.push('\t}'); - lines.push('\treturn &PachcaClient{'); const maxField = Math.max(...fields.map((f) => f.f.length)); - for (const f of fields) lines.push(`\t\t${f.f.padEnd(maxField)}: &${f.cls}{baseURL: url, client: client},`); + for (const f of fields) { + const cfgField = `cfg.${f.f.charAt(0).toLowerCase() + f.f.slice(1)}`; + const varName = f.f.charAt(0).toLowerCase() + f.f.slice(1); + const impl = `&${serviceToImplName(f.cls)}{baseURL: cfg.baseURL, client: client}`; + lines.push(`\tvar ${varName} ${f.cls} = ${impl}`); + lines.push(`\tif ${cfgField} != nil {`); + lines.push(`\t\t${varName} = ${cfgField}`); + lines.push('\t}'); + } + lines.push('\treturn &PachcaClient{'); + for (const f of fields) { + const varName = f.f.charAt(0).toLowerCase() + f.f.slice(1); + lines.push(`\t\t${f.f.padEnd(maxField)}: ${varName},`); + } + lines.push('\t}'); + lines.push('}'); + lines.push(''); + + // NewPachcaClientWithHTTP function + lines.push('func NewPachcaClientWithHTTP(baseURL string, client *http.Client, opts ...ClientOption) *PachcaClient {'); + lines.push('\tcfg := clientConfig{baseURL: baseURL}'); + lines.push('\tfor _, opt := range opts {'); + lines.push('\t\topt(&cfg)'); + lines.push('\t}'); + for (const f of fields) { + const cfgField = `cfg.${f.f.charAt(0).toLowerCase() + f.f.slice(1)}`; + const varName = f.f.charAt(0).toLowerCase() + f.f.slice(1); + const impl = `&${serviceToImplName(f.cls)}{baseURL: cfg.baseURL, client: client}`; + lines.push(`\tvar ${varName} ${f.cls} = ${impl}`); + lines.push(`\tif ${cfgField} != nil {`); + lines.push(`\t\t${varName} = ${cfgField}`); + lines.push('\t}'); + } + lines.push('\treturn &PachcaClient{'); + for (const f of fields) { + const varName = f.f.charAt(0).toLowerCase() + f.f.slice(1); + lines.push(`\t\t${f.f.padEnd(maxField)}: ${varName},`); + } + lines.push('\t}'); + lines.push('}'); + lines.push(''); + + // NewStubPachcaClient function + lines.push('func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient {'); + lines.push('\tcfg := stubClientConfig{}'); + lines.push('\tfor _, opt := range opts {'); + lines.push('\t\topt(&cfg)'); + lines.push('\t}'); + for (const f of fields) { + const cfgField = `cfg.${f.f.charAt(0).toLowerCase() + f.f.slice(1)}`; + const varName = f.f.charAt(0).toLowerCase() + f.f.slice(1); + const stub = `&${serviceToStubName(f.cls)}{}`; + lines.push(`\tvar ${varName} ${f.cls} = ${stub}`); + lines.push(`\tif ${cfgField} != nil {`); + lines.push(`\t\t${varName} = ${cfgField}`); + lines.push('\t}'); + } + lines.push('\treturn &PachcaClient{'); + for (const f of fields) { + const varName = f.f.charAt(0).toLowerCase() + f.f.slice(1); + lines.push(`\t\t${f.f.padEnd(maxField)}: ${varName},`); + } lines.push('\t}'); lines.push('}'); lines.push(''); @@ -761,11 +997,65 @@ function generateUtils(): string { return [ 'package pachca', '', + 'import (', + '\t"math/rand"', + '\t"net/http"', + '\t"strconv"', + '\t"time"', + ')', + '', '// Ptr returns a pointer to the given value.', 'func Ptr[T any](v T) *T {', '\treturn &v', '}', '', + '// NotImplementedError is returned by stub methods that have not been implemented.', + 'type NotImplementedError struct {', + '\tMethod string', + '}', + '', + 'func (e NotImplementedError) Error() string {', + '\treturn 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 {', + '\treturn time.Duration(float64(d) * (0.5 + rand.Float64()*0.5))', + '}', + '', + 'func doWithRetry(client *http.Client, req *http.Request) (*http.Response, error) {', + '\tfor attempt := 0; ; attempt++ {', + '\t\tif attempt > 0 && req.GetBody != nil {', + '\t\t\treq.Body, _ = req.GetBody()', + '\t\t}', + '\t\tresp, err := client.Do(req)', + '\t\tif err != nil {', + '\t\t\treturn nil, err', + '\t\t}', + '\t\tif resp.StatusCode == http.StatusTooManyRequests && attempt < maxRetries {', + '\t\t\tresp.Body.Close()', + '\t\t\tdelay := time.Duration(1< u.unionDeserializer === 'webhook-payload'); if (ir.enums.length > 0) needSerialName = true; if (ir.unions.length > 0) needSerialName = true; @@ -150,12 +153,38 @@ function generateModels(ir: IR): string { lines.push('package com.pachca.sdk'); lines.push(''); + const needDateTime = ir.models.some((m) => m.fields.some((f) => f.type.kind === 'primitive' && f.type.primitive === 'string' && f.type.format === 'date-time')) + || ir.params.some((p) => p.params.some((q) => q.type.kind === 'primitive' && q.type.primitive === 'string' && q.type.format === 'date-time')); + const imports: string[] = []; + if (needDateTime) imports.push('import java.time.OffsetDateTime'); + if (needDateTime) imports.push('import java.time.format.DateTimeFormatter'); + 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 (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) { + lines.push(''); + lines.push('object OffsetDateTimeSerializer : KSerializer {'); + lines.push(' override val descriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING)'); + lines.push(' override fun serialize(encoder: Encoder, value: OffsetDateTime) = encoder.encodeString(value.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))'); + lines.push(' override fun deserialize(decoder: Decoder): OffsetDateTime = OffsetDateTime.parse(decoder.decodeString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME)'); + lines.push('}'); + } + // Enums for (const e of ir.enums) { lines.push(''); @@ -174,10 +203,10 @@ function generateModels(ir: IR): string { // Emit inline objects before parent for (const inl of m.inlineObjects) { lines.push(''); - emitModel(lines, inl); + emitModel(lines, inl, ir.models); } lines.push(''); - emitModel(lines, m); + emitModel(lines, m, ir.models); } // Response types @@ -224,12 +253,54 @@ 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 litValue = litField?.type.literalValue ?? ''; @@ -250,8 +321,10 @@ function emitUnion( const isOpt = !f.required; const fullType = isOpt ? `${typeName}?` : typeName; const default_ = isOpt ? ' = null' : ''; + 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) ? `@SerialName("${f.name}") ` : ''; + needsSerialName(f) ? `${dtAnnotation}@SerialName("${f.name}") ` : dtAnnotation; lines.push(` ${serialName}val ${sdkName}: ${fullType}${default_},`); } lines.push(`) : ${u.name}`); @@ -261,6 +334,7 @@ function emitUnion( function emitModel( lines: string[], m: IRModel, + allModels: IRModel[], ): void { const fields = m.fields; @@ -315,17 +389,47 @@ function emitModel( default_ = ''; } + const isDateTime = f.type.kind === 'primitive' && f.type.primitive === 'string' && f.type.format === 'date-time'; + const dtAnnotation = isDateTime ? '@Serializable(with = OffsetDateTimeSerializer::class) ' : ''; const annotation = isBinary ? '@Transient ' : needsSerialName(f) - ? `@SerialName("${f.name}") ` - : ''; + ? `${dtAnnotation}@SerialName("${f.name}") ` + : dtAnnotation; lines.push(` ${annotation}val ${sdkName}: ${fullType}${default_},`); } const ext = m.isError ? ' : Exception()' : ''; lines.push(`)${ext}`); + + if (m.name === 'ApiError') { + const errorsField = m.fields.find((f) => f.name === 'errors'); + const itemsRef = errorsField?.type.kind === 'array' && errorsField.type.items?.kind === 'model' + ? errorsField.type.items.ref + : undefined; + const itemsModel = itemsRef ? allModels.find((am) => am.name === itemsRef) : undefined; + const hasMessage = itemsModel?.fields.some((f) => f.name === 'message'); + + if (hasMessage) { + lines.push(' {'); + lines.push(' override val message: String'); + lines.push(' get() = when {'); + lines.push(' errors.isEmpty() -> "api error"'); + lines.push(' errors.size == 1 -> errors[0].message'); + lines.push(' else -> "Errors: " + errors.joinToString("; ") { it.message }'); + lines.push(' }'); + lines.push('}'); + } + } + if (m.name === 'OAuthError') { + const errField = m.fields.find((f) => f.name === 'error'); + if (errField) { + lines.push(' {'); + lines.push(` override val message: String get() = ${fieldSdkName(errField)}`); + lines.push('}'); + } + } } function emitResponseType(lines: string[], rt: IRResponseType): void { @@ -375,6 +479,10 @@ function generateClient(ir: IR): string { lines.push('import io.ktor.serialization.kotlinx.json.*'); 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'); + } // Services for (const svc of ir.services) { @@ -397,11 +505,23 @@ function emitService( globalHasApiError: boolean, ): void { const serviceName = tagToServiceName(svc.tag); + const implName = serviceToImplName(serviceName); - lines.push(`class ${serviceName} internal constructor(`); + lines.push(`interface ${serviceName} {`); + for (let i = 0; i < svc.operations.length; i++) { + if (i > 0) lines.push(''); + emitInterfaceOperation(lines, svc.operations[i], ir); + if (svc.operations[i].isPaginated && svc.operations[i].successResponse.dataRef) { + lines.push(''); + emitInterfacePaginationMethod(lines, svc.operations[i], ir); + } + } + lines.push('}'); + lines.push(''); + lines.push(`class ${implName} internal constructor(`); lines.push(' private val baseUrl: String,'); lines.push(' private val client: HttpClient,'); - lines.push(') {'); + lines.push(`) : ${serviceName} {`); for (let i = 0; i < svc.operations.length; i++) { if (i > 0) lines.push(''); @@ -415,12 +535,33 @@ function emitService( lines.push('}'); } -function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { +function emitInterfaceOperation(lines: string[], op: IROperation, ir: IR): void { const indent = ' '; const indent2 = ' '; - const itemType = op.successResponse.dataRef ?? 'Any'; + const returnType = getReturnType(op, ir); + const returnSuffix = returnType ? `: ${returnType}` : ''; + const params = buildMethodParams(op, ir); - // Build params: same as original minus cursor + if (op.deprecated) lines.push(`${indent}@Deprecated("This method is deprecated")`); + if (params.length === 0) { + lines.push(`${indent}suspend fun ${op.methodName}()${returnSuffix} {`); + } else if (params.length === 1) { + lines.push(`${indent}suspend fun ${op.methodName}(${params[0]})${returnSuffix} {`); + } else if (params.length <= 2) { + lines.push(`${indent}suspend fun ${op.methodName}(${params.join(', ')})${returnSuffix} {`); + } else { + lines.push(`${indent}suspend fun ${op.methodName}(`); + for (const p of params) lines.push(`${indent2}${p},`); + lines.push(`${indent})${returnSuffix} {`); + } + lines.push(`${indent2}throw NotImplementedError(${JSON.stringify(`${op.tag}.${op.methodName} is not implemented`)})`); + lines.push(`${indent}}`); +} + +function emitInterfacePaginationMethod(lines: string[], op: IROperation, ir: IR): void { + const indent = ' '; + const indent2 = ' '; + const itemType = op.successResponse.dataRef ?? 'Any'; const params: string[] = []; if (op.externalUrl) params.push(`${op.externalUrl}: String`); for (const p of op.pathParams) params.push(`${p.sdkName}: ${ktType(p.type)}`); @@ -437,10 +578,41 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { for (const p of params) lines.push(`${indent2}${p},`); lines.push(`${indent}): List<${itemType}> {`); } + lines.push(`${indent2}throw NotImplementedError(${JSON.stringify(`${op.tag}.${op.methodName}All is not implemented`)})`); + lines.push(`${indent}}`); +} + +function stripKotlinDefaultValue(param: string): string { + const index = param.indexOf(' = '); + return index === -1 ? param : param.slice(0, index); +} + +function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { + const indent = ' '; + const indent2 = ' '; + const itemType = op.successResponse.dataRef ?? 'Any'; + + // Build params: same as original minus cursor + const params: string[] = []; + if (op.externalUrl) params.push(`${op.externalUrl}: String`); + for (const p of op.pathParams) params.push(`${p.sdkName}: ${ktType(p.type)}`); + for (const p of op.queryParams) { + if (p.name === 'cursor') continue; + const typeName = ktType(p.type); + params.push(p.required ? `${p.sdkName}: ${typeName}` : `${p.sdkName}: ${typeName}? = null`); + } + const overrideParams = params.map(stripKotlinDefaultValue); + + if (overrideParams.length <= 2) { + lines.push(`${indent}override suspend fun ${op.methodName}All(${overrideParams.join(', ')}): List<${itemType}> {`); + } else { + lines.push(`${indent}override suspend fun ${op.methodName}All(`); + for (const p of overrideParams) lines.push(`${indent2}${p},`); + lines.push(`${indent}): List<${itemType}> {`); + } lines.push(`${indent2}val items = mutableListOf<${itemType}>()`); lines.push(`${indent2}var cursor: String? = null`); - lines.push(`${indent2}do {`); // Build call args for original method const callArgs: string[] = []; @@ -457,10 +629,30 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { const callStr = callArgs.length <= 3 ? `${op.methodName}(${callArgs.join(', ')})` : `${op.methodName}(\n${callArgs.map(a => `${indent2} ${a},`).join('\n')}\n${indent2} )`; - lines.push(`${indent2} val response = ${callStr}`); - lines.push(`${indent2} items.addAll(response.data)`); - lines.push(`${indent2} cursor = response.meta?.paginate?.nextPage`); - lines.push(`${indent2}} while (cursor != null)`); + + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); + const useHasNext = rt?.metaRef === 'PaginationMeta'; + + if (useHasNext) { + const cursorExpr = rt?.metaIsRequired ? 'response.meta.paginate.nextPage' : 'response.meta?.paginate?.nextPage'; + const hasNextExpr = rt?.metaIsRequired ? 'response.meta.paginate.hasNext ?: true' : 'response.meta?.paginate?.hasNext ?: true'; + lines.push(`${indent2}var hasNext = true`); + lines.push(`${indent2}while (hasNext) {`); + lines.push(`${indent2} val response = ${callStr}`); + lines.push(`${indent2} items.addAll(response.data)`); + lines.push(`${indent2} if (response.data.isEmpty()) break`); + lines.push(`${indent2} cursor = ${cursorExpr}`); + lines.push(`${indent2} hasNext = ${hasNextExpr}`); + lines.push(`${indent2}}`); + } else { + const metaAccess = rt?.metaIsRequired ? 'response.meta.paginate.nextPage' : 'response.meta?.paginate?.nextPage'; + lines.push(`${indent2}do {`); + lines.push(`${indent2} val response = ${callStr}`); + lines.push(`${indent2} items.addAll(response.data)`); + lines.push(`${indent2} if (response.data.isEmpty()) break`); + lines.push(`${indent2} cursor = ${metaAccess}`); + lines.push(rt?.metaIsRequired ? `${indent2}} while (true)` : `${indent2}} while (cursor != null)`); + } lines.push(`${indent2}return items`); lines.push(`${indent}}`); } @@ -476,21 +668,21 @@ function emitOperation( const returnType = getReturnType(op, ir); const returnSuffix = returnType ? `: ${returnType}` : ''; - const params = buildMethodParams(op, ir); + const params = buildMethodParams(op, ir).map(stripKotlinDefaultValue); if (op.deprecated) lines.push(`${indent}@Deprecated("This method is deprecated")`); if (params.length === 0) { - lines.push(`${indent}suspend fun ${op.methodName}()${returnSuffix} {`); + lines.push(`${indent}override suspend fun ${op.methodName}()${returnSuffix} {`); } else if (params.length === 1) { lines.push( - `${indent}suspend fun ${op.methodName}(${params[0]})${returnSuffix} {`, + `${indent}override suspend fun ${op.methodName}(${params[0]})${returnSuffix} {`, ); } else if (params.length <= 2) { lines.push( - `${indent}suspend fun ${op.methodName}(${params.join(', ')})${returnSuffix} {`, + `${indent}override suspend fun ${op.methodName}(${params.join(', ')})${returnSuffix} {`, ); } else { - lines.push(`${indent}suspend fun ${op.methodName}(`); + lines.push(`${indent}override suspend fun ${op.methodName}(`); for (const p of params) { lines.push(`${indent2}${p},`); } @@ -510,9 +702,7 @@ function getReturnType( if (resp.isRedirect) return 'String'; if (!resp.hasBody) return null; if (resp.isList) { - const rt = ir.responses.find( - (r) => r.dataRef === resp.dataRef && r.dataIsArray, - ); + const rt = ir.responses.find((r) => r.name === resp.responseRef); return rt?.name ?? 'Any'; } if (resp.isUnwrap && resp.dataRef) return resp.dataRef; @@ -593,21 +783,26 @@ function emitMethodBody( ); for (const p of op.queryParams) { if (p.isArray) { + const itemIsEnum = p.type.kind === 'array' && p.type.items?.kind === 'enum'; + const itemExpr = itemIsEnum ? 'it.value' : 'it'; if (p.required) { lines.push( - `${indent3}${p.sdkName}.forEach { parameter("${p.name}", it) }`, + `${indent3}${p.sdkName}.forEach { parameter("${p.name}", ${itemExpr}) }`, ); } else { lines.push( - `${indent3}${p.sdkName}?.forEach { parameter("${p.name}", it) }`, + `${indent3}${p.sdkName}?.forEach { parameter("${p.name}", ${itemExpr}) }`, ); } } else { - const valueExpr = p.type.kind === 'enum' ? 'it.value' : 'it'; + const isDateTime = p.type.kind === 'primitive' && p.type.primitive === 'string' && p.type.format === 'date-time'; + const valueExpr = p.type.kind === 'enum' ? 'it.value' : isDateTime ? 'it.toString()' : 'it'; if (p.required) { const reqExpr = p.type.kind === 'enum' ? `${p.sdkName}.value` - : p.sdkName; + : isDateTime + ? `${p.sdkName}.toString()` + : p.sdkName; lines.push(`${indent3}parameter("${p.name}", ${reqExpr})`); } else { lines.push( @@ -678,29 +873,34 @@ function emitMultipartBody( (f) => f.type.kind !== 'binary', ); + const isUnwrapped = shouldUnwrapBody(op.requestBody!); + // Optional fields first (in schema order) for (const f of nonBinaryFields) { const sdkName = fieldSdkName(f); + const ref = isUnwrapped ? sdkName : `request.${sdkName}`; const isOptional = !f.required || f.nullable; if (isOptional) { lines.push( - `${indent4}request.${sdkName}?.let { append("${f.name}", it) }`, + `${indent4}${ref}?.let { append("${f.name}", it) }`, ); } } // Required fields for (const f of nonBinaryFields) { const sdkName = fieldSdkName(f); + const ref = isUnwrapped ? sdkName : `request.${sdkName}`; const isOptional = !f.required || f.nullable; if (!isOptional) { - lines.push(`${indent4}append("${f.name}", request.${sdkName})`); + lines.push(`${indent4}append("${f.name}", ${ref})`); } } // Binary field if (binaryField) { const sdkName = fieldSdkName(binaryField); + const ref = isUnwrapped ? sdkName : `request.${sdkName}`; lines.push( - `${indent4}append("${binaryField.name}", request.${sdkName}, Headers.build {`, + `${indent4}append("${binaryField.name}", ${ref}, Headers.build {`, ); lines.push( `${indent4} append(HttpHeaders.ContentDisposition, "filename=\\"${binaryField.name}\\"")`, @@ -778,38 +978,11 @@ function emitPachcaClient( ir: IR, hasRedirect: boolean, ): void { - const ktDefault = ir.baseUrl ? ` = ${JSON.stringify(ir.baseUrl)}` : ''; - lines.push(`class PachcaClient(token: String, baseUrl: String${ktDefault}) : Closeable {`); - lines.push(' private val client = HttpClient {'); - lines.push(' expectSuccess = false'); - if (hasRedirect) { - lines.push(' followRedirects = false'); + if (ir.baseUrl) { + lines.push(`const val PACHCA_API_URL = ${JSON.stringify(ir.baseUrl)}`); + lines.push(''); } - lines.push(' install(ContentNegotiation) {'); - lines.push(' json(Json { explicitNulls = false })'); - lines.push(' }'); - lines.push(' install(HttpRequestRetry) {'); - lines.push(' maxRetries = 3'); - lines.push(' retryIf { _, response ->'); - lines.push(' response.status.value == 429 || response.status.value in setOf(500, 502, 503, 504)'); - lines.push(' }'); - lines.push(' delayMillis { retry ->'); - lines.push(' val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull()'); - lines.push(' if (retryAfter != null && response?.status?.value == 429) {'); - lines.push(' retryAfter * 1000L'); - lines.push(' } else {'); - lines.push(' val base = 10_000L * (1L shl retry)'); - lines.push(' val jitter = 0.5 + kotlin.random.Random.nextDouble() * 0.5'); - lines.push(' (base * jitter).toLong()'); - lines.push(' }'); - lines.push(' }'); - lines.push(' }'); - lines.push(' defaultRequest {'); - lines.push(' bearerAuth(token)'); - lines.push(' }'); - lines.push(' }'); - lines.push(''); - + const ktDefault = ir.baseUrl ? ' = PACHCA_API_URL' : ''; const serviceEntries = ir.services .map((svc) => ({ propName: tagToProperty(svc.tag), @@ -817,13 +990,107 @@ function emitPachcaClient( })) .sort((a, b) => a.propName.localeCompare(b.propName)); + // Private constructor taking nullable client + all services + lines.push('class PachcaClient private constructor('); + lines.push(' private val _client: HttpClient?,'); + for (let i = 0; i < serviceEntries.length; i++) { + const s = serviceEntries[i]; + const suffix = i < serviceEntries.length - 1 ? ',' : ''; + lines.push(` val ${s.propName}: ${s.className}${suffix}`); + } + lines.push(') : Closeable {'); + lines.push(''); + lines.push(' companion object {'); + + // operator fun invoke - creates HttpClient and real services + const invokeArgs = [`token: String`, `baseUrl: String${ktDefault}`]; for (const s of serviceEntries) { - lines.push(` val ${s.propName} = ${s.className}(baseUrl, client)`); + invokeArgs.push(`${s.propName}: ${s.className}? = null`); + } + lines.push(' operator fun invoke('); + for (let i = 0; i < invokeArgs.length; i++) { + const suffix = i < invokeArgs.length - 1 ? ',' : ''; + lines.push(` ${invokeArgs[i]}${suffix}`); } + lines.push(' ): PachcaClient {'); + lines.push(' val client = createClient(token)'); + lines.push(' return PachcaClient('); + lines.push(' _client = client,'); + for (let i = 0; i < serviceEntries.length; i++) { + const s = serviceEntries[i]; + const suffix = i < serviceEntries.length - 1 ? ',' : ''; + lines.push(` ${s.propName} = ${s.propName} ?: ${serviceToImplName(s.className)}(baseUrl, client)${suffix}`); + } + lines.push(' )'); + lines.push(' }'); + lines.push(''); + // fun stub - creates client without HttpClient + lines.push(' fun stub('); + for (let i = 0; i < serviceEntries.length; i++) { + const s = serviceEntries[i]; + const suffix = i < serviceEntries.length - 1 ? ',' : ''; + lines.push(` ${s.propName}: ${s.className} = object : ${s.className} {}${suffix}`); + } + lines.push(' ): PachcaClient = PachcaClient('); + lines.push(' _client = null,'); + for (let i = 0; i < serviceEntries.length; i++) { + const s = serviceEntries[i]; + const suffix = i < serviceEntries.length - 1 ? ',' : ''; + lines.push(` ${s.propName} = ${s.propName}${suffix}`); + } + lines.push(' )'); + lines.push(''); + + // private fun createClient + lines.push(' private fun createClient(token: String): HttpClient = HttpClient {'); + lines.push(' expectSuccess = false'); + if (hasRedirect) { + lines.push(' followRedirects = false'); + } + lines.push(' install(ContentNegotiation) { json(Json { explicitNulls = false }) }'); + lines.push(' install(HttpRequestRetry) {'); + lines.push(' maxRetries = 3'); + lines.push(' retryIf { _, response ->'); + lines.push(' response.status.value == 429 || response.status.value in setOf(500, 502, 503, 504)'); + lines.push(' }'); + lines.push(' delayMillis { retry ->'); + lines.push(' val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull()'); + lines.push(' if (retryAfter != null && response?.status?.value == 429) {'); + lines.push(' retryAfter * 1000L'); + lines.push(' } else {'); + lines.push(' val base = 10_000L * (1L shl retry)'); + lines.push(' val jitter = 0.5 + kotlin.random.Random.nextDouble() * 0.5'); + lines.push(' (base * jitter).toLong()'); + lines.push(' }'); + lines.push(' }'); + lines.push(' }'); + lines.push(' defaultRequest { bearerAuth(token) }'); + lines.push(' }'); + lines.push(' }'); + lines.push(''); + + // Secondary constructor from pre-configured HttpClient + const secondaryArgs = [`client: HttpClient`, `baseUrl: String${ktDefault}`]; + for (const s of serviceEntries) { + secondaryArgs.push(`${s.propName}: ${s.className}? = null`); + } + lines.push(` constructor(`); + for (let i = 0; i < secondaryArgs.length; i++) { + const suffix = i < secondaryArgs.length - 1 ? ',' : ''; + lines.push(` ${secondaryArgs[i]}${suffix}`); + } + lines.push(' ) : this('); + lines.push(' _client = client,'); + for (let i = 0; i < serviceEntries.length; i++) { + const s = serviceEntries[i]; + const suffix = i < serviceEntries.length - 1 ? ',' : ''; + lines.push(` ${s.propName} = ${s.propName} ?: ${serviceToImplName(s.className)}(baseUrl, client)${suffix}`); + } + lines.push(' )'); lines.push(''); lines.push(' override fun close() {'); - lines.push(' client.close()'); + lines.push(' _client?.close()'); lines.push(' }'); lines.push('}'); } @@ -844,7 +1111,10 @@ function ktLiteral( return String(ft.example); } if (ft.primitive === 'boolean' && typeof ft.example === 'boolean') return String(ft.example); - if (ft.primitive === 'string' && typeof ft.example === 'string') return JSON.stringify(ft.example); + if (ft.primitive === 'string' && typeof ft.example === 'string') { + if (ft.format === 'date-time') return `OffsetDateTime.parse(${JSON.stringify(ft.example)})`; + return JSON.stringify(ft.example); + } } if (ft.kind === 'enum' && typeof ft.example === 'string') { const e = ir.enums.find((en) => en.name === ft.ref); @@ -860,7 +1130,7 @@ function ktLiteral( if (ft.primitive === 'any') return 'mapOf()'; // string with format variants if (ft.primitive === 'string') { - if (ft.format === 'date-time') return '"2024-01-01T00:00:00Z"'; + if (ft.format === 'date-time') return 'OffsetDateTime.parse("2024-01-01T00:00:00Z")'; if (ft.format === 'date') return '"2024-01-01"'; } return '"example"'; diff --git a/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts index 7edd0ab0..9f381c9c 100644 --- a/packages/generator/src/lang/python.ts +++ b/packages/generator/src/lang/python.ts @@ -15,6 +15,7 @@ import { camelToSnake, snakeToUpperSnake, tagToServiceName, + serviceToImplName, } from '../naming.js'; const PYTHON_KEYWORDS = new Set([ @@ -63,6 +64,13 @@ function hasAnyTypeInField(ft: IRFieldType): boolean { return false; } +function hasDateTimeInField(ft: IRFieldType): boolean { + if (ft.kind === 'primitive' && ft.primitive === 'string' && ft.format === 'date-time') return true; + if (ft.items) return hasDateTimeInField(ft.items); + if (ft.valueType) return hasDateTimeInField(ft.valueType); + return false; +} + function hasAnyType(model: IRModel): boolean { return model.fields.some((f) => hasAnyTypeInField(f.type)) || model.inlineObjects.some((m) => hasAnyType(m)); @@ -75,6 +83,7 @@ function pyType(ft: IRFieldType): string { if (ft.primitive === 'number') return 'float'; if (ft.primitive === 'boolean') return 'bool'; if (ft.primitive === 'any') return 'Any'; + if (ft.primitive === 'string' && ft.format === 'date-time') return 'datetime'; return 'str'; case 'enum': case 'model': @@ -125,6 +134,7 @@ function emitEnum(lines: string[], e: IREnum): void { function emitModel( lines: string[], m: IRModel, + allModels: IRModel[], ): void { lines.push('@dataclass'); if (m.isError) { @@ -165,6 +175,33 @@ function emitModel( lines.push(` ${name}: ${fullType}`); } } + + if (m.name === 'ApiError') { + const errorsField = m.fields.find((f) => f.name === 'errors'); + const itemsRef = errorsField?.type.kind === 'array' && errorsField.type.items?.kind === 'model' + ? errorsField.type.items.ref + : undefined; + const itemsModel = itemsRef ? allModels.find((am) => am.name === itemsRef) : undefined; + const hasMessage = itemsModel?.fields.some((f) => f.name === 'message'); + + if (hasMessage) { + lines.push(''); + lines.push(' def __str__(self) -> str:'); + lines.push(' if not self.errors:'); + lines.push(' return "api error"'); + lines.push(' if len(self.errors) == 1:'); + lines.push(' return self.errors[0].message'); + lines.push(' return "Errors: " + "; ".join(e.message for e in self.errors)'); + } + } + if (m.name === 'OAuthError') { + const errField = m.fields.find((f) => f.name === 'error'); + if (errField) { + lines.push(''); + lines.push(' def __str__(self) -> str:'); + lines.push(` return self.${pyFieldName(errField)}`); + } + } } function emitUnion(lines: string[], u: IRUnion): void { @@ -209,9 +246,11 @@ function generateModels(ir: IR): string { const needEnum = ir.enums.length > 0; const needUnion = ir.unions.length > 0; const needAny = ir.models.some((m) => hasAnyType(m)) || ir.params.some((p) => p.params.some((q) => hasAnyTypeInField(q.type))); + const needDatetime = ir.models.some((m) => m.fields.some((f) => hasDateTimeInField(f.type))) || ir.params.some((p) => p.params.some((q) => hasDateTimeInField(q.type))); lines.push('from __future__ import annotations'); lines.push(''); + if (needDatetime) lines.push('from datetime import datetime'); if (needDataclass) { lines.push('from dataclasses import dataclass'); if (needEnum) lines.push('from enum import StrEnum'); @@ -235,17 +274,17 @@ function generateModels(ir: IR): string { for (const m of ir.models) { for (const inl of m.inlineObjects) { - emitModel(lines, inl); + emitModel(lines, inl, ir.models); lines.push(''); lines.push(''); } if (unionMembers.has(m.name)) { - emitModel(lines, m); + emitModel(lines, m, ir.models); lines.push(''); lines.push(''); continue; } - emitModel(lines, m); + emitModel(lines, m, ir.models); lines.push(''); lines.push(''); } @@ -277,9 +316,7 @@ function opReturnType(op: IROperation, ir: IR): string { if (op.successResponse.isRedirect) return 'str'; if (!op.successResponse.hasBody) return 'None'; if (op.successResponse.isList) { - const rt = ir.responses.find( - (r) => r.dataRef === op.successResponse.dataRef && r.dataIsArray, - ); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); return rt?.name ?? 'object'; } return op.successResponse.dataRef ?? 'object'; @@ -336,9 +373,7 @@ function collectClientImports(ir: IR): string[] { } if (op.successResponse.hasBody && !op.successResponse.isRedirect) { if (op.successResponse.isList) { - const rt = ir.responses.find( - (r) => r.dataRef === op.successResponse.dataRef && r.dataIsArray, - ); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); if (rt) add(rt.name); if (op.isPaginated && op.successResponse.dataRef) { add(op.successResponse.dataRef); @@ -413,22 +448,28 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { if (isMultipart) { lines.push(` data: dict[str, str] = {}`); const req = ir.models.find((m) => m.name === op.requestBody!.schemaRef); + const isUnwrapped = shouldUnwrapBody(op.requestBody!); if (req) { const binary = req.fields.find((f) => f.type.kind === 'binary'); const nonBinary = req.fields.filter((f) => f.type.kind !== 'binary'); for (const f of nonBinary.filter((x) => !isOptionalField(x))) { + const ref = isUnwrapped ? pyFieldName(f) : `request.${pyFieldName(f)}`; lines.push( - ` data[${JSON.stringify(f.name)}] = request.${pyFieldName(f)}`, + ` data[${JSON.stringify(f.name)}] = ${ref}`, ); } for (const f of nonBinary.filter((x) => isOptionalField(x))) { - lines.push(` if request.${pyFieldName(f)} is not None:`); + const ref = isUnwrapped ? pyFieldName(f) : `request.${pyFieldName(f)}`; + lines.push(` if ${ref} is not None:`); lines.push( - ` data[${JSON.stringify(f.name)}] = request.${pyFieldName(f)}`, + ` data[${JSON.stringify(f.name)}] = ${ref}`, ); } + const binaryRef = binary + ? (isUnwrapped ? pyFieldName(binary) : `request.${pyFieldName(binary)}`) + : undefined; const filesExpr = binary - ? `{"${binary.name}": request.${pyFieldName(binary)}}` + ? `{"${binary.name}": ${binaryRef}}` : '{}'; const mpPathStr = op.externalUrl ? camelToSnake(op.externalUrl) @@ -472,16 +513,20 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { const v = `params.${paramName}`; const maybe = p.required ? v : `params.${paramName}`; if (p.isArray) { - lines.push(` if ${maybe} is not None:`); + const guard = p.required ? `${maybe} is not None` : `params is not None and ${maybe} is not None`; + lines.push(` if ${guard}:`); lines.push(` for v in ${v}:`); lines.push(` query.append((${JSON.stringify(p.name)}, str(v)))`); } else { + const isDateTime = p.type.kind === 'primitive' && p.type.primitive === 'string' && p.type.format === 'date-time'; const rhs = p.type.kind === 'primitive' && p.type.primitive === 'boolean' ? `str(${v}).lower()` : p.type.kind === 'primitive' && (p.type.primitive === 'integer' || p.type.primitive === 'number') ? `str(${v})` - : v; + : isDateTime + ? `${v}.isoformat()` + : v; if (p.required) { if (op.queryParams.some((x) => x.isArray) || op.queryParams.some((x) => x.required)) { lines.push(` query.append((${JSON.stringify(p.name)}, ${rhs}))`); @@ -560,9 +605,7 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { lines.push(` case ${op.successResponse.statusCode}:`); lines.push(' return'); } else if (op.successResponse.isList) { - const rt = ir.responses.find( - (r) => r.dataRef === op.successResponse.dataRef && r.dataIsArray, - ); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); lines.push(` case ${op.successResponse.statusCode}:`); lines.push(` return deserialize(${rt?.name ?? 'object'}, body)`); } else if (op.successResponse.isUnwrap && op.successResponse.dataRef) { @@ -614,26 +657,103 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { lines.push(` ) -> list[${itemType}]:`); lines.push(` items: list[${itemType}] = []`); lines.push(' cursor: str | None = None'); - lines.push(' while True:'); - { - const callParts: string[] = []; - if (op.externalUrl) callParts.push(camelToSnake(op.externalUrl)); - for (const p of op.pathParams) callParts.push(pyParamName(p.sdkName)); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); + const useHasNext = rt?.metaRef === 'PaginationMeta'; + + const callParts: string[] = []; + if (op.externalUrl) callParts.push(camelToSnake(op.externalUrl)); + for (const p of op.pathParams) callParts.push(pyParamName(p.sdkName)); + if (paramsType) callParts.push('params=params'); + + if (useHasNext) { + lines.push(' has_next = True'); + lines.push(' while has_next:'); + if (paramsType) { + lines.push(' if params is None:'); + lines.push(` params = ${paramsType}()`); + lines.push(' params.cursor = cursor'); + } + lines.push(` response = await self.${pyMethodName(op)}(${callParts.join(', ')})`); + lines.push(' items.extend(response.data)'); + lines.push(' if not response.data:'); + lines.push(' break'); + lines.push(' cursor = response.meta.paginate.next_page'); + lines.push(' reported_has_next = getattr(response.meta.paginate, "has_next", None)'); + lines.push(' has_next = True if reported_has_next is None else reported_has_next'); + } else { + const metaAccess = rt?.metaIsRequired ? 'response.meta.paginate.next_page' : 'response.meta.paginate.next_page if response.meta else None'; + lines.push(' while True:'); if (paramsType) { lines.push(' if params is None:'); lines.push(` params = ${paramsType}()`); lines.push(' params.cursor = cursor'); - callParts.push('params=params'); } lines.push(` response = await self.${pyMethodName(op)}(${callParts.join(', ')})`); + lines.push(' items.extend(response.data)'); + lines.push(' if not response.data:'); + lines.push(' break'); + lines.push(` cursor = ${metaAccess}`); + if (!rt?.metaIsRequired) { + lines.push(' if not cursor:'); + lines.push(' break'); + } } - lines.push(' items.extend(response.data)'); - lines.push(' cursor = response.meta.paginate.next_page if response.meta and response.meta.paginate else None'); - lines.push(' if not cursor:'); - lines.push(' break'); lines.push(' return items'); } +function emitThrowingOperation(lines: string[], op: IROperation, ir: IR): void { + const args: string[] = []; + if (op.externalUrl) args.push(`${camelToSnake(op.externalUrl)}: str`); + for (const p of op.pathParams) args.push(`${pyParamName(p.sdkName)}: ${pyType(p.type)}`); + + if (op.requestBody) { + const rb = op.requestBody; + if (shouldUnwrapBody(rb)) { + const f = rb.unwrapField!; + args.push(`${pyFieldName(f)}: ${pyType(f.type)}`); + } else if (rb.schemaRef) { + args.push(`request: ${rb.schemaRef}`); + } + } + + if (op.queryParams.length > 0) { + const pascal = op.methodName.charAt(0).toUpperCase() + op.methodName.slice(1); + const hasRequired = op.queryParams.some((p) => p.required); + args.push(hasRequired ? `params: ${pascal}Params` : `params: ${pascal}Params | None = None`); + } + + if (op.deprecated) lines.push(' # Deprecated'); + lines.push(` async def ${pyMethodName(op)}(`); + if (args.length === 0) { + lines.push(` self) -> ${opReturnType(op, ir)}:`); + } else { + lines.push(' self,'); + for (const a of args) lines.push(` ${a},`); + lines.push(` ) -> ${opReturnType(op, ir)}:`); + } + lines.push(` raise NotImplementedError(${JSON.stringify(`${op.tag}.${op.methodName} is not implemented`)})`); +} + +function emitThrowingPaginationMethod(lines: string[], op: IROperation): void { + const itemType = op.successResponse.dataRef ?? 'object'; + const pascal = op.methodName.charAt(0).toUpperCase() + op.methodName.slice(1); + const paramsType = op.queryParams.length > 0 ? `${pascal}Params` : null; + + const args: string[] = []; + if (op.externalUrl) args.push(`${camelToSnake(op.externalUrl)}: str`); + for (const p of op.pathParams) args.push(`${pyParamName(p.sdkName)}: ${pyType(p.type)}`); + if (paramsType) { + const hasRequired = op.queryParams.some((p) => p.required && p.name !== 'cursor'); + args.push(hasRequired ? `params: ${paramsType}` : `params: ${paramsType} | None = None`); + } + + lines.push(` async def ${pyMethodName(op)}_all(`); + lines.push(' self,'); + for (const a of args) lines.push(` ${a},`); + lines.push(` ) -> list[${itemType}]:`); + lines.push(` raise NotImplementedError(${JSON.stringify(`${op.tag}.${op.methodName}All is not implemented`)})`); +} + function generateClient(ir: IR): { content: string; needUtils: boolean } { const lines: string[] = []; const needToDict = needsAsdict(ir); @@ -669,7 +789,20 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } { lines.push(''); for (const svc of ir.services) { - lines.push(`class ${tagToServiceName(svc.tag)}:`); + const serviceName = tagToServiceName(svc.tag); + const implName = serviceToImplName(serviceName); + lines.push(`class ${serviceName}:`); + for (let i = 0; i < svc.operations.length; i++) { + emitThrowingOperation(lines, svc.operations[i], ir); + if (svc.operations[i].isPaginated && svc.operations[i].successResponse.dataRef) { + lines.push(''); + emitThrowingPaginationMethod(lines, svc.operations[i]); + } + if (i < svc.operations.length - 1) lines.push(''); + } + lines.push(''); + lines.push(''); + lines.push(`class ${implName}(${serviceName}):`); lines.push(' def __init__(self, client: httpx.AsyncClient) -> None:'); lines.push(' self._client = client'); lines.push(''); @@ -685,48 +818,107 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } { lines.push(''); } + const serviceEntries = ir.services + .map((s) => ({ prop: pyServiceProp(s.tag), cls: tagToServiceName(s.tag) })) + .sort((a, b) => a.prop.localeCompare(b.prop)); + if (ir.baseUrl) { + lines.push(`PACHCA_API_URL = ${JSON.stringify(ir.baseUrl)}`); + lines.push(''); + lines.push(''); + } lines.push('class PachcaClient:'); - const pyDefault = ir.baseUrl ? ` = ${JSON.stringify(ir.baseUrl)}` : ''; - lines.push(` def __init__(self, token: str, base_url: str${pyDefault}) -> None:`); + const pyDefault = ir.baseUrl ? ' = PACHCA_API_URL' : ''; + const constructorArgs = serviceEntries.map((s) => `${s.prop}: ${s.cls} | None = None`); + const signature = ['self', `token: str`, `base_url: str${pyDefault}`, ...constructorArgs].join(', '); + lines.push(` def __init__(${signature}) -> None:`); lines.push(' self._client = httpx.AsyncClient('); lines.push(' base_url=base_url,'); lines.push(' headers={"Authorization": f"Bearer {token}"},'); lines.push(' transport=RetryTransport(httpx.AsyncHTTPTransport()),'); lines.push(' )'); - const services = ir.services - .map((s) => ({ prop: pyServiceProp(s.tag), cls: tagToServiceName(s.tag) })) - .sort((a, b) => a.prop.localeCompare(b.prop)); - for (const s of services) { - lines.push(` self.${s.prop} = ${s.cls}(self._client)`); + for (const s of serviceEntries) { + lines.push(` self.${s.prop}: ${s.cls} = ${s.prop} or ${serviceToImplName(s.cls)}(self._client)`); } lines.push(''); lines.push(' async def close(self) -> None:'); lines.push(' await self._client.aclose()'); + lines.push(''); + + // from_client classmethod + lines.push(' @classmethod'); + lines.push(' def from_client('); + lines.push(' cls,'); + lines.push(' client: httpx.AsyncClient,'); + for (const s of serviceEntries) { + lines.push(` ${s.prop}: ${s.cls} | None = None,`); + } + lines.push(' ) -> "PachcaClient":'); + lines.push(' self = cls.__new__(cls)'); + lines.push(' self._client = client'); + for (const s of serviceEntries) { + lines.push(` self.${s.prop}: ${s.cls} = ${s.prop} or ${serviceToImplName(s.cls)}(client)`); + } + lines.push(' return self'); + lines.push(''); + + // stub classmethod + lines.push(' @classmethod'); + lines.push(' def stub('); + lines.push(' cls,'); + for (const s of serviceEntries) { + lines.push(` ${s.prop}: ${s.cls} | None = None,`); + } + lines.push(' ) -> "PachcaClient":'); + lines.push(' self = cls.__new__(cls)'); + lines.push(' self._client = None'); + for (const s of serviceEntries) { + lines.push(` self.${s.prop} = ${s.prop} or ${s.cls}()`); + } + lines.push(' return self'); while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); lines.push(''); 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 typing import Type, TypeVar, get_args, get_origin, get_type_hints', + 'from datetime import datetime', + '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:', @@ -740,7 +932,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:', @@ -750,8 +942,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()}', @@ -762,67 +980,101 @@ 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]', - ' 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]', - ' 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(delay)', - ' continue', - ' if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries:', - ' delay = _jitter(10 * (2 ** attempt))', - ' await asyncio.sleep(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.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 ────────────────────────────────────────────────────────── @@ -838,7 +1090,10 @@ function pyLiteral( if (ft.kind === 'primitive') { if ((ft.primitive === 'integer' || ft.primitive === 'number') && typeof ft.example === 'number') return String(ft.example); if (ft.primitive === 'boolean' && typeof ft.example === 'boolean') return ft.example ? 'True' : 'False'; - if (ft.primitive === 'string' && typeof ft.example === 'string') return JSON.stringify(ft.example); + if (ft.primitive === 'string' && typeof ft.example === 'string') { + if (ft.format === 'date-time') return `datetime.fromisoformat(${JSON.stringify(ft.example)})`; + return JSON.stringify(ft.example); + } } if (ft.kind === 'enum' && typeof ft.example === 'string') { const e = ir.enums.find((en) => en.name === ft.ref); @@ -853,7 +1108,7 @@ function pyLiteral( if (ft.primitive === 'boolean') return 'True'; if (ft.primitive === 'any') return '{}'; if (ft.primitive === 'string') { - if (ft.format === 'date-time') return '"2024-01-01T00:00:00Z"'; + if (ft.format === 'date-time') return 'datetime.fromisoformat("2024-01-01T00:00:00Z")'; if (ft.format === 'date') return '"2024-01-01"'; } return '"example"'; @@ -1117,7 +1372,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 9e8aebea..eabd3787 100644 --- a/packages/generator/src/lang/swift.ts +++ b/packages/generator/src/lang/swift.ts @@ -11,7 +11,7 @@ import { type IRUnion, } from '../ir.js'; import { buildModelIndex, collectTypeRefs, type GeneratedFile, type GenerateOptions, type LanguageGenerator } from './types.js'; -import { snakeToCamel, tagToProperty, tagToServiceName } from '../naming.js'; +import { snakeToCamel, tagToProperty, tagToServiceName, serviceToImplName } from '../naming.js'; const SWIFT_KEYWORDS = new Set([ 'as', 'break', 'case', 'catch', 'class', 'continue', 'default', 'defer', 'do', 'else', @@ -35,7 +35,6 @@ function swiftType(ft: IRFieldType, opts: { nullable?: boolean } = {}): string { else if (ft.primitive === 'number') base = 'Double'; else if (ft.primitive === 'boolean') base = 'Bool'; else if (ft.primitive === 'any') base = 'AnyCodable'; - else if (ft.format === 'date' || ft.format === 'date-time') base = opts.nullable ? 'String' : 'Date'; else base = 'String'; break; case 'enum': @@ -109,7 +108,7 @@ function isSelfReferencing(m: IRModel): boolean { return m.fields.some((f) => fieldRefsModel(f.type, m.name)); } -function emitModel(lines: string[], m: IRModel): void { +function emitModel(lines: string[], m: IRModel, allModels: IRModel[]): void { const proto = m.isError ? 'Codable, Error' : 'Codable'; const keyword = isSelfReferencing(m) ? 'class' : 'struct'; lines.push(`public ${keyword} ${m.name}: ${proto} {`); @@ -137,6 +136,32 @@ function emitModel(lines: string[], m: IRModel): void { lines.push(` self.${fi.name} = ${fi.name}`); } lines.push(' }'); + + if (m.name === 'ApiError') { + const errorsField = m.fields.find((f) => f.name === 'errors'); + const itemsRef = errorsField?.type.kind === 'array' && errorsField.type.items?.kind === 'model' + ? errorsField.type.items.ref + : undefined; + const itemsModel = itemsRef ? allModels.find((am) => am.name === itemsRef) : undefined; + const hasMessage = itemsModel?.fields.some((f) => f.name === 'message'); + + if (hasMessage) { + lines.push(''); + lines.push(' public var localizedDescription: String {'); + lines.push(' guard !errors.isEmpty else { return "api error" }'); + lines.push(' if errors.count == 1 { return errors[0].message }'); + lines.push(' return "Errors: " + errors.map(\\.message).joined(separator: "; ")'); + lines.push(' }'); + } + } + if (m.name === 'OAuthError') { + const errField = m.fields.find((f) => f.name === 'error'); + if (errField) { + lines.push(''); + lines.push(` public var localizedDescription: String { ${swiftIdentifier(errField.name)} }`); + } + } + emitCodingKeys(lines, m.fields); lines.push('}'); } @@ -156,22 +181,42 @@ 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.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.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('); @@ -211,10 +256,10 @@ function generateModels(ir: IR): string { } for (const m of ir.models) { for (const inl of m.inlineObjects) { - emitModel(lines, inl); + emitModel(lines, inl, ir.models); lines.push(''); } - emitModel(lines, m); + emitModel(lines, m, ir.models); lines.push(''); } for (const u of ir.unions) { @@ -249,13 +294,13 @@ function opReturn(op: IROperation, ir: IR): string { if (op.successResponse.isRedirect) return 'String'; if (!op.successResponse.hasBody) return 'Void'; if (op.successResponse.isList) { - const rt = ir.responses.find((r) => r.dataRef === op.successResponse.dataRef && r.dataIsArray); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); return rt?.name ?? 'String'; } return op.successResponse.dataRef ?? 'String'; } -function emitOperation(lines: string[], op: IROperation, ir: IR): void { +function emitOperation(lines: string[], op: IROperation, ir: IR, fnPrefix = 'public func'): void { const args: string[] = []; if (op.externalUrl) { args.push(`${op.externalUrl}: String`); @@ -276,20 +321,20 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { } if (op.deprecated) lines.push(' @available(*, deprecated)'); - lines.push(` public func ${op.methodName}(${args.join(', ')}) async throws -> ${opReturn(op, ir)} {`); + lines.push(` ${fnPrefix} ${op.methodName}(${args.join(', ')}) async throws -> ${opReturn(op, ir)} {`); if (op.queryParams.length > 0) { - const swiftUrlBase = op.externalUrl ? `\\(${op.externalUrl})` : `\\(baseURL)${op.path}`; + let swiftUrlBase = op.externalUrl ? `\\(${op.externalUrl})` : `\\(baseURL)${op.path}`; + for (const p of op.pathParams) { + swiftUrlBase = swiftUrlBase.replace(`{${p.name}}`, `\\(${snakeToCamel(p.sdkName)})`); + } lines.push(` var components = URLComponents(string: "${swiftUrlBase}")!`); lines.push(' var queryItems: [URLQueryItem] = []'); for (const q of op.queryParams) { const n = snakeToCamel(q.sdkName); const isEnum = q.type.kind === 'enum'; - const isDate = q.type.kind === 'primitive' && (q.type.format === 'date' || q.type.format === 'date-time'); const isModel = q.type.kind === 'model' || q.type.kind === 'record'; function valueExpr(varName: string): string { if (isEnum) return `${varName}.rawValue`; - if (isDate && q.required) return `ISO8601DateFormatter().string(from: ${varName})`; - if (isDate) return varName; // optional dates are typed as String if (isModel) return `String(data: try serialize(${varName}), encoding: .utf8)!`; return `String(${varName})`; } @@ -330,7 +375,14 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { lines.push(' request.setValue("application/json", forHTTPHeaderField: "Content-Type")'); if (shouldUnwrapBody(rb)) { const f = rb.unwrapField!; - lines.push(` request.httpBody = try JSONSerialization.data(withJSONObject: [${JSON.stringify(f.name)}: ${swiftIdentifier(f.name)}])`); + const varName = swiftIdentifier(f.name); + let valueExpr = varName; + if (f.type.kind === 'enum') { + valueExpr = `${varName}.rawValue`; + } else if (f.type.kind === 'array' && f.type.items?.kind === 'enum') { + valueExpr = `${varName}.map { $0.rawValue }`; + } + lines.push(` request.httpBody = try JSONSerialization.data(withJSONObject: [${JSON.stringify(f.name)}: ${valueExpr}])`); } else { lines.push(' request.httpBody = try serialize(body)'); } @@ -346,19 +398,22 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { lines.push(' data.append("\\(value)\\r\\n".data(using: .utf8)!)'); lines.push(' }'); const req = ir.models.find((m) => m.name === op.requestBody!.schemaRef); + const isUnwrapped = shouldUnwrapBody(op.requestBody!); if (req) { for (const f of req.fields.filter((x) => x.type.kind !== 'binary')) { const n = swiftIdentifier(f.name); - if (isOptionalField(f)) lines.push(` if let v = body.${n} { appendField(${JSON.stringify(f.name)}, String(describing: v)) }`); - else lines.push(` appendField(${JSON.stringify(f.name)}, String(describing: body.${n}))`); + const ref = isUnwrapped ? n : `body.${n}`; + if (isOptionalField(f)) lines.push(` if let v = ${ref} { appendField(${JSON.stringify(f.name)}, String(describing: v)) }`); + else lines.push(` appendField(${JSON.stringify(f.name)}, String(describing: ${ref}))`); } const bin = req.fields.find((x) => x.type.kind === 'binary'); if (bin) { const n = swiftIdentifier(bin.name); + const binRef = isUnwrapped ? n : `body.${n}`; lines.push(' data.append("--\\(boundary)\\r\\n".data(using: .utf8)!)'); lines.push(` data.append("Content-Disposition: form-data; name=\\"${bin.name}\\"; filename=\\"upload\\"\\r\\n".data(using: .utf8)!)`); lines.push(' data.append("Content-Type: application/octet-stream\\r\\n\\r\\n".data(using: .utf8)!)'); - lines.push(` data.append(body.${n})`); + lines.push(` data.append(${binRef})`); lines.push(' data.append("\\r\\n".data(using: .utf8)!)'); } } @@ -418,7 +473,7 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { lines.push(' }'); } -function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { +function emitPaginationMethod(lines: string[], op: IROperation, ir: IR, fnPrefix = 'public func'): void { const itemType = op.successResponse.dataRef ?? 'Any'; // Build params minus cursor @@ -431,10 +486,9 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { args.push(`${snakeToCamel(q.sdkName)}: ${t}${q.required ? '' : ' = nil'}`); } - lines.push(` public func ${op.methodName}All(${args.join(', ')}) async throws -> [${itemType}] {`); + lines.push(` ${fnPrefix} ${op.methodName}All(${args.join(', ')}) async throws -> [${itemType}] {`); lines.push(` var items: [${itemType}] = []`); lines.push(' var cursor: String? = nil'); - lines.push(' repeat {'); // Build call args for original method const callArgs: string[] = []; @@ -452,21 +506,95 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { } } - lines.push(` let response = try await ${op.methodName}(${callArgs.join(', ')})`); - lines.push(' items.append(contentsOf: response.data)'); - lines.push(' cursor = response.meta?.paginate?.nextPage'); - lines.push(' } while cursor != nil'); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); + const useHasNext = rt?.metaRef === 'PaginationMeta'; + + if (useHasNext) { + const cursorExpr = rt?.metaIsRequired ? 'response.meta.paginate.nextPage' : 'response.meta?.paginate.nextPage'; + const hasNextExpr = rt?.metaIsRequired ? 'response.meta.paginate.hasNext ?? true' : 'response.meta?.paginate.hasNext ?? true'; + lines.push(' var hasNext = true'); + lines.push(' while hasNext {'); + lines.push(` let response = try await ${op.methodName}(${callArgs.join(', ')})`); + lines.push(' items.append(contentsOf: response.data)'); + lines.push(' if response.data.isEmpty { break }'); + lines.push(` cursor = ${cursorExpr}`); + lines.push(` hasNext = ${hasNextExpr}`); + lines.push(' }'); + } else { + const metaAccess = rt?.metaIsRequired ? 'response.meta.paginate.nextPage' : 'response.meta?.paginate.nextPage'; + lines.push(' repeat {'); + lines.push(` let response = try await ${op.methodName}(${callArgs.join(', ')})`); + lines.push(' items.append(contentsOf: response.data)'); + lines.push(' if response.data.isEmpty { break }'); + lines.push(` cursor = ${metaAccess}`); + lines.push(rt?.metaIsRequired ? ' } while true' : ' } while cursor != nil'); + } lines.push(' return items'); lines.push(' }'); } +function emitThrowingOperation(lines: string[], op: IROperation, ir: IR): void { + const args: string[] = []; + if (op.externalUrl) args.push(`${op.externalUrl}: String`); + for (const p of op.pathParams) args.push(`${snakeToCamel(p.sdkName)}: ${swiftType(p.type)}`); + if (op.requestBody) { + const rb = op.requestBody; + if (shouldUnwrapBody(rb)) args.push(`${swiftIdentifier(rb.unwrapField!.name)}: ${swiftType(rb.unwrapField!.type)}`); + else if (rb.schemaRef) args.push(`request body: ${rb.schemaRef}`); + } + for (const q of op.queryParams) { + const t = swiftType(q.type, { nullable: !q.required }); + args.push(`${snakeToCamel(q.sdkName)}: ${t}${q.required ? '' : ' = nil'}`); + } + if (op.deprecated) lines.push(' @available(*, deprecated)'); + lines.push(` open func ${op.methodName}(${args.join(', ')}) async throws -> ${opReturn(op, ir)} {`); + lines.push(` throw pachcaNotImplemented(${JSON.stringify(`${op.tag}.${op.methodName}`)})`); + lines.push(' }'); +} + +function emitThrowingPaginationMethod(lines: string[], op: IROperation): void { + const itemType = op.successResponse.dataRef ?? 'Any'; + const args: string[] = []; + if (op.externalUrl) args.push(`${op.externalUrl}: String`); + for (const p of op.pathParams) args.push(`${snakeToCamel(p.sdkName)}: ${swiftType(p.type)}`); + for (const q of op.queryParams) { + if (q.name === 'cursor') continue; + const t = swiftType(q.type, { nullable: !q.required }); + args.push(`${snakeToCamel(q.sdkName)}: ${t}${q.required ? '' : ' = nil'}`); + } + lines.push(` open func ${op.methodName}All(${args.join(', ')}) async throws -> [${itemType}] {`); + lines.push(` throw pachcaNotImplemented(${JSON.stringify(`${op.tag}.${op.methodName}All`)})`); + lines.push(' }'); +} + function generateClient(ir: IR): string { const lines: string[] = []; lines.push(...FOUNDATION_IMPORTS); lines.push(''); + const hasServices = ir.services.length > 0; + if (hasServices) { + lines.push('private func pachcaNotImplemented(_ method: String) -> Error {'); + lines.push(' NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"])'); + lines.push('}'); + lines.push(''); + } for (const s of ir.services) { const cls = tagToServiceName(s.tag); - lines.push(`public struct ${cls} {`); + const implName = serviceToImplName(cls); + lines.push(`open class ${cls} {`); + lines.push(' public init() {}'); + lines.push(''); + for (let i = 0; i < s.operations.length; i++) { + emitThrowingOperation(lines, s.operations[i], ir); + if (s.operations[i].isPaginated && s.operations[i].successResponse.dataRef) { + lines.push(''); + emitThrowingPaginationMethod(lines, s.operations[i]); + } + if (i < s.operations.length - 1) lines.push(''); + } + lines.push('}'); + lines.push(''); + lines.push(`public final class ${implName}: ${cls} {`); lines.push(' let baseURL: String'); lines.push(' let headers: [String: String]'); lines.push(' let session: URLSession'); @@ -475,13 +603,14 @@ function generateClient(ir: IR): string { lines.push(' self.baseURL = baseURL'); lines.push(' self.headers = headers'); lines.push(' self.session = session'); + lines.push(' super.init()'); lines.push(' }'); lines.push(''); for (let i = 0; i < s.operations.length; i++) { - emitOperation(lines, s.operations[i], ir); + emitOperation(lines, s.operations[i], ir, 'public override func'); if (s.operations[i].isPaginated && s.operations[i].successResponse.dataRef) { lines.push(''); - emitPaginationMethod(lines, s.operations[i], ir); + emitPaginationMethod(lines, s.operations[i], ir, 'public override func'); } if (i < s.operations.length - 1) lines.push(''); } @@ -504,16 +633,66 @@ function generateClient(ir: IR): string { lines.push(''); } - lines.push('public struct PachcaClient {'); const svcs = ir.services .map((s) => ({ prop: tagToProperty(s.tag), cls: tagToServiceName(s.tag) })) .sort((a, b) => a.prop.localeCompare(b.prop)); + if (ir.baseUrl) { + lines.push(`public let pachcaAPIURL = ${JSON.stringify(ir.baseUrl)}`); + lines.push(''); + } + lines.push('public struct PachcaClient {'); for (const s of svcs) lines.push(` public let ${s.prop}: ${s.cls}`); lines.push(''); - const swiftDefault = ir.baseUrl ? ` = ${JSON.stringify(ir.baseUrl)}` : ''; - lines.push(` public init(token: String, baseURL: String${swiftDefault}) {`); + + // Private init taking only services (for stub) + const privateInitArgs = svcs.map((s) => `${s.prop}: ${s.cls}`); + lines.push(` private init(${privateInitArgs.join(', ')}) {`); + for (const s of svcs) { + lines.push(` self.${s.prop} = ${s.prop}`); + } + lines.push(' }'); + lines.push(''); + + // Public init with token/baseURL delegating to private init + const swiftDefault = ir.baseUrl ? ' = pachcaAPIURL' : ''; + const initArgs = [`token: String`, `baseURL: String${swiftDefault}`]; + for (const s of svcs) initArgs.push(`${s.prop}: ${s.cls}? = nil`); + lines.push(` public init(${initArgs.join(', ')}) {`); lines.push(' let headers = ["Authorization": "Bearer \\(token)"]'); - for (const s of svcs) lines.push(` self.${s.prop} = ${s.cls}(baseURL: baseURL, headers: headers)`); + lines.push(' self.init('); + for (let i = 0; i < svcs.length; i++) { + const s = svcs[i]; + const suffix = i < svcs.length - 1 ? ',' : ''; + lines.push(` ${s.prop}: ${s.prop} ?? ${serviceToImplName(s.cls)}(baseURL: baseURL, headers: headers)${suffix}`); + } + lines.push(' )'); + lines.push(' }'); + lines.push(''); + + // Public init with headers/baseURL/session (no token) + const headersInitArgs = [`baseURL: String${swiftDefault}`, 'headers: [String: String]', 'session: URLSession = .shared']; + for (const s of svcs) headersInitArgs.push(`${s.prop}: ${s.cls}? = nil`); + lines.push(` public init(${headersInitArgs.join(', ')}) {`); + lines.push(' self.init('); + for (let i = 0; i < svcs.length; i++) { + const s = svcs[i]; + const suffix = i < svcs.length - 1 ? ',' : ''; + lines.push(` ${s.prop}: ${s.prop} ?? ${serviceToImplName(s.cls)}(baseURL: baseURL, headers: headers, session: session)${suffix}`); + } + lines.push(' )'); + lines.push(' }'); + lines.push(''); + + // Static stub() factory + const stubArgs = svcs.map((s) => `${s.prop}: ${s.cls} = ${s.cls}()`); + lines.push(` public static func stub(${stubArgs.join(', ')}) -> PachcaClient {`); + lines.push(' PachcaClient('); + for (let i = 0; i < svcs.length; i++) { + const s = svcs[i]; + const suffix = i < svcs.length - 1 ? ',' : ''; + lines.push(` ${s.prop}: ${s.prop}${suffix}`); + } + lines.push(' )'); lines.push(' }'); lines.push('}'); lines.push(''); @@ -526,13 +705,11 @@ function generateUtils(ir: IR): string { '', 'let pachcaDecoder: JSONDecoder = {', ' let decoder = JSONDecoder()', - ' decoder.dateDecodingStrategy = .iso8601', ' return decoder', '}()', '', 'let pachcaEncoder: JSONEncoder = {', ' let encoder = JSONEncoder()', - ' encoder.dateEncodingStrategy = .iso8601', ' return encoder', '}()', '', @@ -596,20 +773,22 @@ function generateUtils(ir: IR): string { '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, 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', + ' 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', ' }', - ' try await _Concurrency.Task.sleep(nanoseconds: delay)', - ' continue', - ' }', - ' if let http = response as? HTTPURLResponse, retryable5xx.contains(http.statusCode), attempt < maxRetries {', - ' let delay = jitter(10 * UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)', - ' try await _Concurrency.Task.sleep(nanoseconds: delay)', - ' continue', ' }', ' return (data, response)', ' }', diff --git a/packages/generator/src/lang/typescript.ts b/packages/generator/src/lang/typescript.ts index 07ad6716..10df71f8 100644 --- a/packages/generator/src/lang/typescript.ts +++ b/packages/generator/src/lang/typescript.ts @@ -18,6 +18,7 @@ import { kebabToCamel, tagToProperty, tagToServiceName, + serviceToImplName, } from '../naming.js'; function fieldSdkName(field: IRField): string { @@ -339,11 +340,7 @@ function responseTypeName(op: IROperation, ir: IR): string { if (op.successResponse.isRedirect) return 'string'; if (!op.successResponse.hasBody) return 'void'; if (op.successResponse.isList) { - const rt = ir.responses.find( - (r) => - r.dataRef === op.successResponse.dataRef && - r.dataIsArray, - ); + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); return rt?.name ?? 'unknown'; } return op.successResponse.dataRef ?? 'unknown'; @@ -438,23 +435,24 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } { const typesList = importedTypes; if (typesList.length > 0) { if (typesList.length <= 3) { - lines.push(`import { ${typesList.join(', ')} } from "./types";`); + lines.push(`import { ${typesList.join(', ')} } from "./types.js";`); } else { lines.push('import {'); for (const t of typesList) lines.push(` ${t},`); - lines.push('} from "./types";'); + lines.push('} from "./types.js";'); } } 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) .join(', '); - lines.push(`import { ${utils} } from "./utils";`); + lines.push(`import { ${utils} } from "./utils.js";`); } if (hasServices) lines.push(''); @@ -465,18 +463,48 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } { } if (hasServices) { - lines.push('export class PachcaClient {'); const serviceEntries = ir.services .map((s) => ({ prop: tagToProperty(s.tag), cls: tagToServiceName(s.tag) })) .sort((a, b) => a.prop.localeCompare(b.prop)); + if (ir.baseUrl) { + lines.push(`export const PACHCA_API_URL = ${JSON.stringify(ir.baseUrl)};`); + lines.push(''); + } + lines.push('export class PachcaClient {'); for (const s of serviceEntries) lines.push(` readonly ${s.prop}: ${s.cls};`); lines.push(''); - const defaultUrl = ir.baseUrl ? ` = ${JSON.stringify(ir.baseUrl)}` : ''; - lines.push(` constructor(token: string, baseUrl: string${defaultUrl}) {`); - lines.push(' const headers = { Authorization: `Bearer ${token}` };'); + const defaultUrl = ir.baseUrl ? ' = PACHCA_API_URL' : ''; + const configFields = ['headers: Record', 'baseUrl?: string']; + for (const s of serviceEntries) configFields.push(`${s.prop}?: ${s.cls}`); + const configType = `{ ${configFields.join('; ')} }`; + lines.push(` constructor(token: string, baseUrl?: string);`); + lines.push(` constructor(config: ${configType});`); + lines.push(` constructor(tokenOrConfig: string | ${configType}, baseUrl?: string) {`); + lines.push(' let resolvedHeaders: Record;'); + lines.push(' let resolvedBaseUrl: string;'); + lines.push(` if (typeof tokenOrConfig === 'string') {`); + lines.push(' resolvedHeaders = { Authorization: `Bearer ${tokenOrConfig}` };'); + lines.push(` resolvedBaseUrl = baseUrl ?? ${ir.baseUrl ? 'PACHCA_API_URL' : `''`};`); + for (const s of serviceEntries) { + lines.push(` this.${s.prop} = new ${serviceToImplName(s.cls)}(resolvedBaseUrl, resolvedHeaders);`); + } + lines.push(' } else {'); + lines.push(' resolvedHeaders = tokenOrConfig.headers;'); + lines.push(` resolvedBaseUrl = tokenOrConfig.baseUrl ?? ${ir.baseUrl ? 'PACHCA_API_URL' : `''`};`); for (const s of serviceEntries) { - lines.push(` this.${s.prop} = new ${s.cls}(baseUrl, headers);`); + lines.push(` this.${s.prop} = tokenOrConfig.${s.prop} ?? new ${serviceToImplName(s.cls)}(resolvedBaseUrl, resolvedHeaders);`); } + lines.push(' }'); + lines.push(' }'); + lines.push(''); + // Static stub() factory method + const stubFields = serviceEntries.map((s) => `${s.prop}?: ${s.cls}`).join('; '); + lines.push(` static stub(overrides: { ${stubFields} } = {}): PachcaClient {`); + lines.push(' const client = Object.create(PachcaClient.prototype);'); + for (const s of serviceEntries) { + lines.push(` client.${s.prop} = overrides.${s.prop} ?? new ${s.cls}();`); + } + lines.push(' return client;'); lines.push(' }'); lines.push('}'); } @@ -488,11 +516,25 @@ function generateClient(ir: IR): { content: string; needsUtils: boolean } { function emitService(lines: string[], svc: IRService, ir: IR): void { const serviceName = tagToServiceName(svc.tag); - lines.push(`class ${serviceName} {`); + const implName = serviceToImplName(serviceName); + lines.push(`export class ${serviceName} {`); + for (let i = 0; i < svc.operations.length; i++) { + emitThrowingMethod(lines, svc.operations[i], ir); + if (svc.operations[i].isPaginated && svc.operations[i].successResponse.dataRef) { + lines.push(''); + emitThrowingPaginationMethod(lines, svc.operations[i], ir); + } + if (i < svc.operations.length - 1) lines.push(''); + } + lines.push('}'); + lines.push(''); + lines.push(`export class ${implName} extends ${serviceName} {`); lines.push(' constructor('); lines.push(' private baseUrl: string,'); lines.push(' private headers: Record,'); - lines.push(' ) {}'); + lines.push(' ) {'); + lines.push(' super();'); + lines.push(' }'); lines.push(''); for (let i = 0; i < svc.operations.length; i++) { emitOperation(lines, svc.operations[i], ir); @@ -505,6 +547,32 @@ function emitService(lines: string[], svc: IRService, ir: IR): void { lines.push('}'); } +function emitThrowingMethod(lines: string[], op: IROperation, ir: IR): void { + const args = methodArgs(op); + const ret = responseTypeName(op, ir); + if (op.deprecated) lines.push(' /** @deprecated */'); + lines.push(` async ${op.methodName}(${args}): Promise<${ret}> {`); + lines.push(` throw new Error(${JSON.stringify(`${op.tag}.${op.methodName} is not implemented`)});`); + lines.push(' }'); +} + +function emitThrowingPaginationMethod(lines: string[], op: IROperation, ir: IR): void { + const itemType = op.successResponse.dataRef ?? 'unknown'; + const paramsType = op.queryParams.length > 0 ? irParamTypeName(op) : null; + const args: string[] = []; + if (op.externalUrl) args.push(`${op.externalUrl}: string`); + for (const p of op.pathParams) { + args.push(`${p.sdkName}: ${tsType(p.type, { allModels: new Map(), inlineAsObject: new Set() })}`); + } + if (paramsType) { + const hasRequired = op.queryParams.some((q) => q.required && q.name !== 'cursor'); + args.push(hasRequired ? `params: Omit<${paramsType}, 'cursor'>` : `params?: Omit<${paramsType}, 'cursor'>`); + } + lines.push(` async ${op.methodName}All(${args.join(', ')}): Promise<${itemType}[]> {`); + lines.push(` throw new Error(${JSON.stringify(`${op.tag}.${op.methodName}All is not implemented`)});`); + lines.push(' }'); +} + function emitOperation(lines: string[], op: IROperation, ir: IR): void { const args = methodArgs(op); const ret = responseTypeName(op, ir); @@ -515,28 +583,32 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { if (op.requestBody?.contentType === 'multipart') { lines.push(' const form = new FormData();'); const reqModel = ir.models.find((m) => m.name === op.requestBody!.schemaRef); + const isUnwrapped = shouldUnwrapBody(op.requestBody); if (reqModel) { const nonBinary = reqModel.fields.filter((f) => f.type.kind !== 'binary'); const binary = reqModel.fields.find((f) => f.type.kind === 'binary'); for (const f of nonBinary) { const sdk = fieldSdkName(f); + const ref = isUnwrapped ? sdk : `request.${sdk}`; const optional = !f.required || f.nullable; if (optional) { lines.push( - ` if (request.${sdk} !== undefined) form.set(${JSON.stringify(f.name)}, request.${sdk});`, + ` if (${ref} !== undefined) form.set(${JSON.stringify(f.name)}, ${ref});`, ); } } for (const f of nonBinary) { const sdk = fieldSdkName(f); + const ref = isUnwrapped ? sdk : `request.${sdk}`; const optional = !f.required || f.nullable; if (!optional) { - lines.push(` form.set(${JSON.stringify(f.name)}, request.${sdk});`); + lines.push(` form.set(${JSON.stringify(f.name)}, ${ref});`); } } if (binary) { const sdk = fieldSdkName(binary); - lines.push(` form.set(${JSON.stringify(binary.name)}, request.${sdk}, "upload");`); + const ref = isUnwrapped ? sdk : `request.${sdk}`; + lines.push(` form.set(${JSON.stringify(binary.name)}, ${ref}, "upload");`); } } const fetchUrl = op.externalUrl @@ -547,7 +619,9 @@ function emitOperation(lines: string[], op: IROperation, ir: IR): void { if (!op.noAuth) lines.push(' headers: this.headers,'); lines.push(' body: form,'); lines.push(' });'); - emitResponseSwitch(lines, op, ir, false); + const preloadBody = op.successResponse.hasBody && !op.successResponse.isRedirect; + if (preloadBody) lines.push(' const body = await response.json();'); + emitResponseSwitch(lines, op, ir, preloadBody); lines.push(' }'); return; } @@ -619,7 +693,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(' });'); @@ -648,21 +722,49 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { lines.push(` async ${op.methodName}All(${args.join(', ')}): Promise<${itemType}[]> {`); lines.push(` const items: ${itemType}[] = [];`); lines.push(' let cursor: string | undefined;'); - lines.push(' do {'); // Build call args const callArgs: string[] = []; if (op.externalUrl) callArgs.push(op.externalUrl); for (const p of op.pathParams) callArgs.push(p.sdkName); if (paramsType) callArgs.push('{ ...params, cursor } as ' + paramsType); - lines.push(` const response = await this.${op.methodName}(${callArgs.join(', ')});`); - lines.push(' items.push(...response.data);'); - lines.push(' cursor = response.meta?.paginate?.nextPage;'); - lines.push(' } while (cursor);'); + + const rt = ir.responses.find((r) => r.name === op.successResponse.responseRef); + const metaOpt = rt?.metaIsRequired ? '' : '?'; + const useHasNext = rt?.metaRef === 'PaginationMeta'; + + if (useHasNext) { + lines.push(' let hasNext = true;'); + lines.push(' while (hasNext) {'); + lines.push(` const response = await this.${op.methodName}(${callArgs.join(', ')});`); + lines.push(' items.push(...response.data);'); + lines.push(' if (response.data.length === 0) break;'); + lines.push(` cursor = response.meta${metaOpt}.paginate.nextPage;`); + lines.push(` hasNext = response.meta${metaOpt}.paginate.hasNext ?? true;`); + lines.push(' }'); + } else { + lines.push(' do {'); + lines.push(` const response = await this.${op.methodName}(${callArgs.join(', ')});`); + lines.push(' items.push(...response.data);'); + lines.push(' if (response.data.length === 0) break;'); + lines.push(` cursor = response.meta${metaOpt}.paginate.nextPage;`); + if (rt?.metaIsRequired) { + lines.push(' } while (true);'); + } else { + lines.push(' } while (cursor);'); + } + } lines.push(' return items;'); 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, @@ -683,13 +785,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) { @@ -726,8 +832,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());', @@ -747,39 +939,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;', @@ -791,12 +985,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;', @@ -812,12 +1028,12 @@ function generateUtils(ir: IR): string { ' 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, delay));', + ' await new Promise((r) => setTimeout(r, jitter(delay)));', ' continue;', ' }', ' if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) {', - ' const delay = jitter(10000 * Math.pow(2, attempt));', - ' await new Promise((r) => setTimeout(r, delay));', + ' const delay = 1000 * (attempt + 1);', + ' await new Promise((r) => setTimeout(r, jitter(delay)));', ' continue;', ' }', ' return response;', diff --git a/packages/generator/src/transform.ts b/packages/generator/src/transform.ts index 48a6d816..3e34a9d9 100644 --- a/packages/generator/src/transform.ts +++ b/packages/generator/src/transform.ts @@ -41,10 +41,15 @@ function isUnion(schema: Schema): boolean { // ----- Field type resolution ----- function resolveFieldType(schema: Schema): IRFieldType { - // $ref → enum or model reference + // $ref → enum, array, or model reference if (schema.$ref) { const name = refName(schema.$ref); - return { kind: isEnumSchema(schema) ? 'enum' : 'model', ref: name }; + if (isEnumSchema(schema)) return { kind: 'enum', ref: name }; + // Array-type refs (e.g. TagNamesFilter: type: array, items: string) should be inlined + if (getSchemaType(schema) === 'array' && schema.items) { + return { kind: 'array', items: resolveFieldType(schema.items) }; + } + return { kind: 'model', ref: name }; } // anyOf / oneOf → union @@ -338,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 ----- @@ -393,12 +403,24 @@ function transformParam(param: Parameter): IRParam { type = { kind: 'array', items: type }; } + // Wire name: use first x-param-names entry if available (e.g. "sort[{field}]" → "sort[id]") + let wireName = (param['x-param-names'] && param['x-param-names'].length > 0 && typeof param['x-param-names'][0] === 'object') + ? param['x-param-names'][0].name + : param.name; + + const isArrayParam = isArray || type.kind === 'array'; + + // Array query params need [] suffix for Rails/Rack to parse as arrays + if (isArrayParam && !wireName.endsWith('[]')) { + wireName += '[]'; + } + return { - name: param.name, + name: wireName, sdkName, type, required: !!param.required, - isArray, + isArray: isArrayParam, }; } @@ -695,6 +717,9 @@ export function transform(spec: ParsedAPI): IR { unions.push(transformUnion(name, schema, spec.schemas)); } else if (isEnumSchema(schema)) { enums.push(transformEnum(name, schema)); + } else if (getSchemaType(schema) === 'array') { + // Array-type schemas (e.g. TagNamesFilter) are inlined as array types in field references + continue; } else { models.push(transformModel(name, schema)); } 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 new file mode 100644 index 00000000..045d1a3d --- /dev/null +++ b/packages/spec/openapi.en.yaml @@ -0,0 +1,8748 @@ +openapi: 3.0.0 +info: + title: Pachca API + version: 1.0.0 +tags: + - name: Common + - name: Profile + - name: Users + - name: Group tags + - name: Chats + - name: Members + - name: Threads + - name: Messages + - name: Read members + - name: Reactions + - name: Link Previews + - name: Search + - name: Tasks + - name: Views + - name: Bots + - name: Security +paths: + /audit_events: + get: + operationId: SecurityOperations_getAuditEvents + description: |- + Audit event log + + Retrieve event logs based on the specified filters. + parameters: + - name: start_time + in: query + required: false + description: Start timestamp (inclusive) + schema: + type: string + format: date-time + example: '2025-05-01T09:11:00Z' + example: '2025-05-01T09:11:00Z' + explode: false + - name: end_time + in: query + required: false + description: End timestamp (exclusive) + schema: + type: string + format: date-time + example: '2025-05-02T09:11:00Z' + example: '2025-05-02T09:11:00Z' + explode: false + - name: event_key + in: query + required: false + description: Filter by specific event type + schema: + $ref: '#/components/schemas/AuditEventKey' + example: user_login + example: user_login + explode: false + - name: actor_id + in: query + required: false + description: ID of the user who performed the action + schema: + type: string + example: '98765' + example: '98765' + explode: false + - name: actor_type + in: query + required: false + description: Actor type + schema: + type: string + example: User + example: User + explode: false + - name: entity_id + in: query + required: false + description: ID of the affected entity + schema: + type: string + example: '98765' + example: '98765' + explode: false + - name: entity_type + in: query + required: false + description: Entity type + schema: + type: string + example: User + example: User + explode: false + - name: limit + in: query + required: false + description: Number of records to return + schema: + type: integer + format: int32 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/AuditEvent' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Security + x-paginated: true + x-requirements: + scope: audit_events:read + plan: corporation + /bots/{id}: + put: + operationId: BotOperations_updateBot + description: >- + Edit bot + + + Edit a bot's settings. + + + To edit a bot you need to know its `user_id` and specify it in the request `URL`. All editable bot parameters + are specified in the request body. You can find the bot's `user_id` in the bot settings under the "API" tab. + + + You cannot edit a bot whose settings are not accessible to you (the "Who can edit bot settings" field is located + on the "General" tab in the bot settings). + parameters: + - name: id + in: path + required: true + description: Bot ID + schema: + type: integer + format: int32 + example: 1738816 + example: 1738816 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/BotResponse' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Bots + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BotUpdateRequest' + x-requirements: + scope: bots:write + /chats: + post: + operationId: ChatOperations_createChat + description: |- + New chat + + Create a new chat. + + To create a one-on-one direct message with a user, use the [New message](POST /messages) method. + + When creating a chat, you automatically become a member. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Chat' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Chats + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatCreateRequest' + x-requirements: + scope: chats:create + get: + operationId: ChatOperations_listChats + description: |- + List chats + + Retrieve a list of chats based on the specified parameters. + parameters: + - name: sort + in: query + required: false + description: Sort field + schema: + allOf: + - $ref: '#/components/schemas/ChatSortField' + default: id + example: id + explode: false + - name: order + in: query + required: false + description: Sort direction + schema: + allOf: + - $ref: '#/components/schemas/SortOrder' + default: desc + example: desc + explode: false + - name: availability + in: query + required: false + description: Parameter that controls chat availability and filtering for the user + schema: + allOf: + - $ref: '#/components/schemas/ChatAvailability' + default: is_member + example: is_member + explode: false + - name: last_message_at_after + in: query + required: false + description: >- + Filter by last message creation time. Returns chats where the last message was created no earlier than the + specified time (in YYYY-MM-DDThh:mm:ss.sssZ format). + schema: + type: string + format: date-time + example: '2025-01-01T00:00:00.000Z' + example: '2025-01-01T00:00:00.000Z' + explode: false + - name: last_message_at_before + in: query + required: false + description: >- + Filter by last message creation time. Returns chats where the last message was created no later than the + specified time (in YYYY-MM-DDThh:mm:ss.sssZ format). + schema: + type: string + format: date-time + example: '2025-02-01T00:00:00.000Z' + example: '2025-02-01T00:00:00.000Z' + explode: false + - name: personal + in: query + required: false + description: Filter by direct and group chats. If not specified, all chats are returned. + schema: + type: boolean + example: false + example: false + explode: false + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Chat' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Chats + x-paginated: true + x-requirements: + scope: chats:read + /chats/exports: + post: + operationId: ExportOperations_requestExport + description: |- + Export messages + + Request a message export for the specified time period. + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Common + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExportRequest' + x-requirements: + scope: chat_exports:write + plan: corporation + /chats/exports/{id}: + get: + operationId: ExportOperations_downloadExport + description: >- + Download export archive + + + Download a completed message export archive. + + + To download the archive you need to know its `id` and specify it in the request `URL`. + + + The server will respond with `302 Found` and a `Location` header containing a temporary download link. Most HTTP + clients automatically follow the redirect and download the file. + parameters: + - name: id + in: path + required: true + description: Export ID + schema: + type: integer + format: int32 + example: 22322 + example: 22322 + responses: + '302': + description: Redirection + headers: + location: + required: true + schema: + type: string + format: uri + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/OAuthError' + - anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Common + x-requirements: + scope: chat_exports:read + plan: corporation + /chats/{id}: + get: + operationId: ChatOperations_getChat + description: |- + Chat info + + Retrieve information about a chat. + + To get a chat you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Chat' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Chats + x-requirements: + scope: chats:read + put: + operationId: ChatOperations_updateChat + description: >- + Update chat + + + Update chat parameters. + + + To update a chat you need to know its `id` and specify it in the `URL`. All updatable fields are passed in the + request body. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Chat' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Chats + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatUpdateRequest' + x-requirements: + scope: chats:update + /chats/{id}/archive: + put: + operationId: ChatOperations_archiveChat + description: |- + Archive chat + + Archive a chat. + + To archive a chat you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Chats + x-requirements: + scope: chats:archive + /chats/{id}/group_tags: + post: + operationId: ChatMemberOperations_addTags + description: >- + Add tags + + + Add tags to the member list of a conversation or channel. + + + After adding a tag, all its members automatically become members of the chat. The tag and chat member lists are + synchronized automatically: when a new member is added to the tag, they immediately appear in the chat; when + removed from the tag, they are removed from the chat. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddTagsRequest' + x-requirements: + scope: chat_members:write + /chats/{id}/group_tags/{tag_id}: + delete: + operationId: ChatMemberOperations_removeTag + description: |- + Remove tag + + Remove a tag from the member list of a conversation or channel. + + To remove a tag you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + - name: tag_id + in: path + required: true + description: Tag ID + schema: + type: integer + format: int32 + example: 86 + example: 86 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + x-requirements: + scope: chat_members:write + /chats/{id}/leave: + delete: + operationId: ChatMemberOperations_leaveChat + description: |- + Leave conversation or channel + + Leave a conversation or channel on your own. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + x-requirements: + scope: chats:leave + /chats/{id}/members: + get: + operationId: ChatMemberOperations_listMembers + description: >- + List chat members + + + Retrieve the current list of chat members. + + + The workspace owner can retrieve the member list of any chat in the workspace. Admins and bots can only retrieve + the member list of chats they belong to (or that are public). + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + - name: role + in: query + required: false + description: Role in the chat + schema: + allOf: + - $ref: '#/components/schemas/ChatMemberRoleFilter' + default: all + example: all + explode: false + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + x-paginated: true + x-requirements: + scope: chat_members:read + post: + operationId: ChatMemberOperations_addMembers + description: |- + Add users + + Add users to the member list of a conversation, channel, or thread. + parameters: + - name: id + in: path + required: true + description: Chat ID (conversation, channel, or thread chat) + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddMembersRequest' + x-requirements: + scope: chat_members:write + /chats/{id}/members/{user_id}: + delete: + operationId: ChatMemberOperations_removeMember + description: >- + Remove user + + + Remove a user from the member list of a conversation or channel. + + + If the user is the chat owner, they cannot be removed. They can only leave the chat on their own using the + [Leave conversation or channel](DELETE /chats/{id}/leave) method. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 186 + example: 186 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + x-requirements: + scope: chat_members:write + put: + operationId: ChatMemberOperations_updateMemberRole + description: >- + Edit role + + + Edit a user's or bot's role in a conversation or channel. + + + To edit a role in a conversation or channel you need to know the `id` of the chat and the user (or bot) and + specify them in the request `URL`. All editable role parameters are specified in the request body. + + + The chat owner's role cannot be changed. They always have Admin privileges in the chat. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 186 + example: 186 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Members + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMemberRoleRequest' + x-requirements: + scope: chat_members:write + /chats/{id}/unarchive: + put: + operationId: ChatOperations_unarchiveChat + description: |- + Unarchive chat + + Restore a chat from the archive. + + To unarchive a chat you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Chat ID + schema: + type: integer + format: int32 + example: 334 + example: 334 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Chats + x-requirements: + scope: chats:archive + /custom_properties: + get: + operationId: CommonOperations_listProperties + description: >- + List custom properties + + + Working with "File" type custom properties is currently unavailable. + + + Retrieve the current list of custom properties for employees and tasks in your workspace. + + + By default, all entities in your workspace only have basic fields. However, your workspace administrator can + add, edit, and delete custom properties. If you use custom properties that are no longer current (deleted or + non-existent) when creating employees (or tasks), you will receive an error. + parameters: + - name: entity_type + in: query + required: true + description: Entity type + schema: + $ref: '#/components/schemas/SearchEntityType' + example: User + example: User + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/CustomPropertyDefinition' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Common + x-requirements: + scope: custom_properties:read + /direct_url: + post: + operationId: DirectUploadOperations_uploadFile + description: >- + Upload file + + + Upload a file to the server using `multipart/form-data` format. Upload parameters are obtained via the [Get + signature, key and other parameters](POST /uploads) method. + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + tags: + - Common + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FileUploadRequest' + x-external-url: directUrl + x-requirements: + auth: false + /group_tags: + post: + operationId: GroupTagOperations_createTag + description: |- + New tag + + Create a new tag. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/GroupTag' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Group tags + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GroupTagRequest' + x-requirements: + scope: group_tags:write + get: + operationId: GroupTagOperations_listTags + description: |- + List employee tags + + Retrieve the current list of employee tags. Tag names are unique within the workspace. + parameters: + - name: names + in: query + required: false + description: Array of tag names to filter by + schema: + $ref: '#/components/schemas/TagNamesFilter' + example: + - Design + - Product + example: + - Design + - Product + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/GroupTag' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Group tags + x-paginated: true + x-requirements: + scope: group_tags:read + /group_tags/{id}: + get: + operationId: GroupTagOperations_getTag + description: |- + Tag info + + Retrieve information about a tag. Tag names are unique within the workspace. + + To get a tag you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Tag ID + schema: + type: integer + format: int32 + example: 9111 + example: 9111 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/GroupTag' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Group tags + x-requirements: + scope: group_tags:read + put: + operationId: GroupTagOperations_updateTag + description: >- + Edit tag + + + Edit a tag. + + + To edit a tag you need to know its `id` and specify it in the request `URL`. All editable tag parameters are + specified in the request body. + parameters: + - name: id + in: path + required: true + description: Tag ID + schema: + type: integer + format: int32 + example: 9111 + example: 9111 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/GroupTag' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Group tags + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GroupTagRequest' + x-requirements: + scope: group_tags:write + delete: + operationId: GroupTagOperations_deleteTag + description: |- + Delete tag + + Delete a tag. + + To delete a tag you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Tag ID + schema: + type: integer + format: int32 + example: 9111 + example: 9111 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Group tags + x-requirements: + scope: group_tags:write + /group_tags/{id}/users: + get: + operationId: GroupTagOperations_getTagUsers + description: |- + List tag employees + + Retrieve the current list of employees in a tag. + parameters: + - name: id + in: path + required: true + description: Tag ID + schema: + type: integer + format: int32 + example: 9111 + example: 9111 + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Group tags + x-paginated: true + x-requirements: + scope: group_tags:read + /messages: + post: + operationId: MessageOperations_createMessage + description: >- + New message + + + Send a message to a conversation or channel, a direct message to a user, or a comment to a thread. + + + When using `entity_type: "discussion"` (or simply without specifying `entity_type`), any `chat_id` can be passed + in the `entity_id` field. This means you can send a message knowing only the chat ID. Additionally, you can send + a message to a thread by its ID or a direct message by the user's ID. + + + To send a direct message to a user, you do not need to create a chat. Simply specify `entity_type: "user"` and + the user's ID. A chat will be created automatically if there has been no prior conversation between you. Only + one direct chat can exist between two users. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Message' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Messages + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageCreateRequest' + x-requirements: + scope: messages:create + get: + operationId: ChatMessageOperations_listChatMessages + description: >- + List chat messages + + + Retrieve a list of messages from conversations, channels, threads, and direct messages. + + + To retrieve messages you need to know the `chat_id` of the required conversation, channel, thread, or direct + message, and specify it in the request `URL`. Messages are returned in descending order by send date (i.e., the + most recent messages come first). To retrieve earlier messages, use the `limit` and `cursor` parameters. + parameters: + - name: chat_id + in: query + required: true + description: Chat ID (conversation, channel, direct message, or thread chat) + schema: + type: integer + format: int32 + example: 198 + example: 198 + explode: false + - name: sort + in: query + required: false + description: Sort field + schema: + allOf: + - $ref: '#/components/schemas/MessageSortField' + default: id + example: id + explode: false + - name: order + in: query + required: false + description: Sort direction + schema: + allOf: + - $ref: '#/components/schemas/SortOrder' + default: desc + example: desc + explode: false + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Message' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Messages + x-paginated: true + x-requirements: + scope: messages:read + /messages/{id}: + get: + operationId: MessageOperations_getMessage + description: |- + Message info + + Retrieve information about a message. + + To get a message you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Message' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Messages + x-requirements: + scope: messages:read + put: + operationId: MessageOperations_updateMessage + description: >- + Edit message + + + Edit a message or comment. + + + To edit a message you need to know its `id` and specify it in the request `URL`. All editable message parameters + are specified in the request body. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Message' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Messages + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageUpdateRequest' + x-requirements: + scope: messages:update + delete: + operationId: MessageOperations_deleteMessage + description: >- + Delete message + + + Delete a message. + + + Message deletion is available to the sender, admins, and editors in the chat. In direct messages, both users are + editors. There are no time restrictions on message deletion. + + + To delete a message you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Messages + x-requirements: + scope: messages:delete + /messages/{id}/link_previews: + post: + operationId: LinkPreviewOperations_createLinkPreviews + description: |- + Unfurl (link previews) + + Create link previews in messages. Only available for Unfurl bots. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Link Previews + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LinkPreviewsRequest' + x-requirements: + scope: link_previews:write + /messages/{id}/pin: + post: + operationId: MessageOperations_pinMessage + description: |- + Pin message + + Pin a message in a chat. + + To pin a message you need to know the message `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + tags: + - Messages + x-requirements: + scope: pins:write + delete: + operationId: MessageOperations_unpinMessage + description: |- + Unpin message + + Unpin a message from a chat. + + To unpin a message you need to know the message `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Messages + x-requirements: + scope: pins:write + /messages/{id}/reactions: + post: + operationId: ReactionOperations_addReaction + description: >- + Add reaction + + + Add a reaction to a message. + + + To add a reaction you need to know the message `id` and specify it in the request `URL`. Message reactions are + sent as `Emoji` characters. If the user has already added the same reaction, it will not be duplicated. To + remove a reaction, use the [Delete reaction](DELETE /messages/{id}/reactions) method. + + + **Reaction limits:** + + + - Each user can add no more than **20 unique** reactions + + - A message can have no more than **30 unique** reactions + + - The total number of reactions on a message cannot exceed **1000** + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 7231942 + example: 7231942 + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + $ref: '#/components/schemas/Reaction' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Reactions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReactionRequest' + x-requirements: + scope: reactions:write + delete: + operationId: ReactionOperations_removeReaction + description: >- + Delete reaction + + + Remove a reaction from a message. + + + To remove a reaction you need to know the message `id` and specify it in the request `URL`. Message reactions + are stored as `Emoji` characters. + + + You can only remove reactions that were added by the authenticated user. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 7231942 + example: 7231942 + - name: code + in: query + required: true + description: Emoji character of the reaction + schema: + type: string + example: 👍 + example: 👍 + explode: false + - name: name + in: query + required: false + description: Text name of the emoji (used for custom emoji) + schema: + type: string + example: ':+1:' + example: ':+1:' + explode: false + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Reactions + x-requirements: + scope: reactions:write + get: + operationId: ReactionOperations_listReactions + description: |- + List reactions + + Retrieve the current list of reactions on a message. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Reaction' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Reactions + x-paginated: true + x-requirements: + scope: reactions:read + /messages/{id}/read_member_ids: + get: + operationId: ReadMemberOperations_listReadMembers + description: |- + List users who read the message + + Retrieve the current list of users who have read the message. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 194275 + example: 194275 + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 300 + example: 300 + default: 300 + example: 300 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + type: integer + format: int32 + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Read members + x-paginated: true + x-requirements: + scope: messages:read + /messages/{id}/thread: + post: + operationId: ThreadOperations_createThread + description: >- + New thread + + + Create a new thread on a message. + + + If a thread has already been created for the message, the response will return information about the previously + created thread. + parameters: + - name: id + in: path + required: true + description: Message ID + schema: + type: integer + format: int32 + example: 154332686 + example: 154332686 + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Thread' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Threads + x-requirements: + scope: threads:create + /oauth/token/info: + get: + operationId: OAuthOperations_getTokenInfo + description: >- + Token info + + + Retrieve information about the current OAuth token, including its scopes, creation date, and last usage date. + The token in the response is masked — only the first 8 and last 4 characters are visible. + parameters: [] + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/AccessTokenInfo' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Profile + /profile: + get: + operationId: ProfileOperations_getProfile + description: |- + Profile info + + Retrieve information about your own profile. + parameters: [] + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Profile + x-requirements: + scope: profile:read + /profile/avatar: + put: + operationId: ProfileAvatarOperations_updateProfileAvatar + description: |- + Upload avatar + + Upload or update your profile avatar. The file is sent in `multipart/form-data` format. + parameters: [] + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/AvatarData' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Profile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: Avatar image file + required: + - image + x-requirements: + scope: profile_avatar:write + delete: + operationId: ProfileAvatarOperations_deleteProfileAvatar + description: |- + Delete avatar + + Delete your profile avatar. + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + tags: + - Profile + x-requirements: + scope: profile_avatar:write + /profile/status: + get: + operationId: ProfileOperations_getStatus + description: |- + Current status + + Retrieve information about your current status. + parameters: [] + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + allOf: + - $ref: '#/components/schemas/UserStatus' + nullable: true + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Profile + x-requirements: + scope: profile_status:read + put: + operationId: ProfileOperations_updateStatus + description: |- + New status + + Set a new status for yourself. + parameters: [] + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/UserStatus' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Profile + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StatusUpdateRequest' + x-requirements: + scope: profile_status:write + delete: + operationId: ProfileOperations_deleteStatus + description: |- + Delete status + + Delete your current status. + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Profile + x-requirements: + scope: profile_status:write + /search/chats: + get: + operationId: SearchOperations_searchChats + description: |- + Search chats + + Full-text search for channels and conversations. + parameters: + - name: query + in: query + required: false + description: Search query text + schema: + type: string + example: Development + example: Development + explode: false + - name: limit + in: query + required: false + description: Number of results to return per request + schema: + type: integer + format: int32 + maximum: 100 + example: 10 + default: 100 + example: 10 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + - name: order + in: query + required: false + description: Sort direction + schema: + $ref: '#/components/schemas/SortOrder' + example: desc + example: desc + explode: false + - name: created_from + in: query + required: false + description: Filter by creation date (from) + schema: + type: string + format: date-time + example: '2025-01-01T00:00:00.000Z' + example: '2025-01-01T00:00:00.000Z' + explode: false + - name: created_to + in: query + required: false + description: Filter by creation date (to) + schema: + type: string + format: date-time + example: '2025-02-01T00:00:00.000Z' + example: '2025-02-01T00:00:00.000Z' + explode: false + - name: active + in: query + required: false + description: Filter by chat activity + schema: + type: boolean + example: true + example: true + explode: false + - name: chat_subtype + in: query + required: false + description: Filter by chat type + schema: + $ref: '#/components/schemas/ChatSubtype' + example: discussion + example: discussion + explode: false + - name: personal + in: query + required: false + description: Filter by direct chats + schema: + type: boolean + example: false + example: false + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Chat' + meta: + $ref: '#/components/schemas/SearchPaginationMeta' + description: Search results response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Search + x-paginated: true + x-requirements: + scope: search:chats + /search/messages: + get: + operationId: SearchOperations_searchMessages + description: |- + Search messages + + Full-text search for messages. + parameters: + - name: query + in: query + required: false + description: Search query text + schema: + type: string + example: t-shirts + example: t-shirts + explode: false + - name: limit + in: query + required: false + description: Number of results to return per request + schema: + type: integer + format: int32 + maximum: 200 + example: 10 + default: 200 + example: 10 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + - name: order + in: query + required: false + description: Sort direction + schema: + $ref: '#/components/schemas/SortOrder' + example: desc + example: desc + explode: false + - name: created_from + in: query + required: false + description: Filter by creation date (from) + schema: + type: string + format: date-time + example: '2025-01-01T00:00:00.000Z' + example: '2025-01-01T00:00:00.000Z' + explode: false + - name: created_to + in: query + required: false + description: Filter by creation date (to) + schema: + type: string + format: date-time + example: '2025-02-01T00:00:00.000Z' + example: '2025-02-01T00:00:00.000Z' + explode: false + - name: chat_ids + in: query + required: false + description: Filter by chat IDs + schema: + type: array + items: + type: integer + format: int32 + example: + - 198 + - 334 + example: + - 198 + - 334 + explode: false + - name: user_ids + in: query + required: false + description: Filter by message author IDs + schema: + type: array + items: + type: integer + format: int32 + example: + - 12 + - 185 + example: + - 12 + - 185 + explode: false + - name: active + in: query + required: false + description: Filter by chat activity + schema: + type: boolean + example: true + example: true + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Message' + meta: + $ref: '#/components/schemas/SearchPaginationMeta' + description: Search results response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Search + x-paginated: true + x-requirements: + scope: search:messages + /search/users: + get: + operationId: SearchOperations_searchUsers + description: |- + Search employees + + Full-text search for employees by name, email, position, and other fields. + parameters: + - name: query + in: query + required: false + description: Search query text + schema: + type: string + example: Oleg + example: Oleg + explode: false + - name: limit + in: query + required: false + description: Number of results to return per request + schema: + type: integer + format: int32 + maximum: 200 + example: 10 + default: 200 + example: 10 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + - name: sort + in: query + required: false + description: Sort results by + schema: + $ref: '#/components/schemas/SearchSortOrder' + example: by_score + example: by_score + explode: false + - name: order + in: query + required: false + description: Sort direction + schema: + $ref: '#/components/schemas/SortOrder' + example: desc + example: desc + explode: false + - name: created_from + in: query + required: false + description: Filter by creation date (from) + schema: + type: string + format: date-time + example: '2025-01-01T00:00:00.000Z' + example: '2025-01-01T00:00:00.000Z' + explode: false + - name: created_to + in: query + required: false + description: Filter by creation date (to) + schema: + type: string + format: date-time + example: '2025-02-01T00:00:00.000Z' + example: '2025-02-01T00:00:00.000Z' + explode: false + - name: company_roles + in: query + required: false + description: Filter by employee roles + schema: + type: array + items: + $ref: '#/components/schemas/UserRole' + example: + - admin + - user + example: + - admin + - user + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + meta: + $ref: '#/components/schemas/SearchPaginationMeta' + description: Search results response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Search + x-paginated: true + x-requirements: + scope: search:users + /tasks: + post: + operationId: TaskOperations_createTask + description: >- + New task + + + Create a new task. + + + When creating a task, specifying the task type is required: call, meeting, simple reminder, event, or email. No + additional description is needed — you simply create a task with the corresponding text. If you specify a task + description, it will become the task's text. + + + A task must have assignees; if none are specified, you are assigned as the responsible person. + + + Any workspace employee can be assigned to a task that is not linked to any entity. You can get the current + employee list using the [List employees](GET /users) method. + + + A task can be linked to a chat by specifying `chat_id`. To link to a chat, you must be a member of that chat. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Task' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Tasks + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCreateRequest' + x-requirements: + scope: tasks:create + get: + operationId: TaskOperations_listTasks + description: |- + List tasks + + Retrieve a list of tasks. + parameters: + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Task' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Tasks + x-paginated: true + x-requirements: + scope: tasks:read + /tasks/{id}: + get: + operationId: TaskOperations_getTask + description: |- + Task info + + Retrieve information about a task. + + To get a task you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Task ID + schema: + type: integer + format: int32 + example: 22283 + example: 22283 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Task' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Tasks + x-requirements: + scope: tasks:read + put: + operationId: TaskOperations_updateTask + description: >- + Edit task + + + Edit a task. + + + To edit a task you need to know its `id` and specify it in the request `URL`. All editable task parameters are + specified in the request body. + parameters: + - name: id + in: path + required: true + description: Task ID + schema: + type: integer + format: int32 + example: 22283 + example: 22283 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Task' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Tasks + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TaskUpdateRequest' + x-requirements: + scope: tasks:update + delete: + operationId: TaskOperations_deleteTask + description: |- + Delete task + + Delete a task. + + To delete a task you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Task ID + schema: + type: integer + format: int32 + example: 22283 + example: 22283 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Tasks + x-requirements: + scope: tasks:delete + /threads: + get: + operationId: ThreadOperations_listThreads + description: >- + List threads + + + Get a list of available threads. + + + Returns threads whose thread chat — or the chat where the thread was created — you are a member of. Public chats + you have not joined are not returned — to surface such a thread, you must be a member of the thread chat or of + the chat where the thread was created. + + + Sorted by the time of the last message in the thread, descending. + parameters: + - name: last_message_at_after + in: query + required: false + description: >- + Filter by the time of the last message in the thread. Returns only threads whose last message time is no + earlier than the specified time (in YYYY-MM-DDThh:mm:ss.sssZ format). + schema: + type: string + format: date-time + example: '2025-01-01T00:00:00.000Z' + example: '2025-01-01T00:00:00.000Z' + explode: false + - name: last_message_at_before + in: query + required: false + description: >- + Filter by the time of the last message in the thread. Returns only threads whose last message time is no + later than the specified time (in YYYY-MM-DDThh:mm:ss.sssZ format). + schema: + type: string + format: date-time + example: '2025-02-01T00:00:00.000Z' + example: '2025-02-01T00:00:00.000Z' + explode: false + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Thread' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Threads + x-paginated: true + x-requirements: + scope: threads:read + /threads/{id}: + get: + operationId: ThreadOperations_getThread + description: |- + Thread info + + Retrieve information about a thread. + + To get a thread you need to know its `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: Thread ID + schema: + type: integer + format: int32 + example: 265142 + example: 265142 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Thread' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Threads + x-requirements: + scope: threads:read + /uploads: + post: + operationId: UploadOperations_getUploadParams + description: |- + Get signature, key and other parameters + + Retrieve the signature, key, and other parameters required for file upload. + + This method must be used for each file upload. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + $ref: '#/components/schemas/UploadParams' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + tags: + - Common + x-requirements: + scope: uploads:write + /users: + post: + operationId: UserOperations_createUser + description: >- + Create employee + + + Create a new employee in your workspace. + + + You can fill in custom properties for the employee that have been created in your workspace. You can get the + current list of employee custom property IDs using the [List custom properties](GET /custom_properties) method. + + + Through the `chat_ids` parameter the employee can be added to the specified chats right at creation. To create a + guest, pass `role: "guest"` — for this role `chat_ids` is required and must contain exactly one active chat the + token has permission to add members to. Violating the guest access rules returns `400` with an error on the + `chat_ids` field. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreateRequest' + x-requirements: + scope: users:create + get: + operationId: UserOperations_listUsers + description: |- + List employees + + Retrieve the current list of employees in your workspace. + parameters: + - name: query + in: query + required: false + description: >- + Search phrase to filter results. Search works on the following fields: `first_name`, `last_name`, `email`, + `phone_number`, and `nickname`. + schema: + type: string + example: Oleg + example: Oleg + explode: false + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/User' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-paginated: true + x-requirements: + scope: users:read + /users/{id}: + get: + operationId: UserOperations_getUser + description: |- + Employee info + + Retrieve information about an employee. + + To get an employee you need to know their `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-requirements: + scope: users:read + put: + operationId: UserOperations_updateUser + description: >- + Edit employee + + + Edit an employee. + + + To edit an employee you need to know their `id` and specify it in the request `URL`. All editable employee + parameters are specified in the request body. You can get the current list of employee custom property IDs using + the [List custom properties](GET /custom_properties) method. + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/User' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdateRequest' + x-requirements: + scope: users:update + delete: + operationId: UserOperations_deleteUser + description: |- + Delete employee + + Delete an employee. + + To delete an employee you need to know their `id` and specify it in the request `URL`. + parameters: + - name: id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-requirements: + scope: users:delete + /users/{user_id}/avatar: + put: + operationId: UserAvatarOperations_updateUserAvatar + description: |- + Upload employee avatar + + Upload or update an employee's avatar. The file is sent in `multipart/form-data` format. + parameters: + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/AvatarData' + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: Avatar image file + required: + - image + x-requirements: + scope: user_avatar:write + delete: + operationId: UserAvatarOperations_deleteUserAvatar + description: |- + Delete employee avatar + + Delete an employee's avatar. + parameters: + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-requirements: + scope: user_avatar:write + /users/{user_id}/status: + get: + operationId: UserStatusOperations_getUserStatus + description: |- + Employee status + + Retrieve information about an employee's status. + parameters: + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + type: object + allOf: + - $ref: '#/components/schemas/UserStatus' + nullable: true + description: Response wrapper with data + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-requirements: + scope: user_status:read + put: + operationId: UserStatusOperations_updateUserStatus + description: |- + New employee status + + Set a new status for an employee. + parameters: + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/UserStatus' + description: Response wrapper with data + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StatusUpdateRequest' + x-requirements: + scope: user_status:write + delete: + operationId: UserStatusOperations_deleteUserStatus + description: |- + Delete employee status + + Delete an employee's status. + parameters: + - name: user_id + in: path + required: true + description: User ID + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-requirements: + scope: user_status:write + /views/open: + post: + operationId: FormOperations_openView + description: |- + Open view + + Open a modal window with a view for the user. + + To open a modal window with a view, your application must have a valid, non-expired `trigger_id`. + parameters: [] + responses: + '201': + description: The request has succeeded and a new resource has been created as a result. + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '410': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Views + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OpenViewRequest' + x-requirements: + scope: views:write + /webhooks/events: + get: + operationId: BotOperations_getWebhookEvents + description: >- + Event history + + + Retrieve the history of a bot's recent events. This method is useful if you cannot receive events in real time + at your `URL`, but need to process all events you have subscribed to. + + + Event history is only saved when the "Save event history" option is enabled in the "Outgoing webhook" tab of the + bot settings. Specifying a "Webhook `URL`" is not required. + + + To retrieve the event history of a specific bot, you need to know its `access_token` and use it in the request. + Each event is a webhook `JSON` object. + parameters: + - name: limit + in: query + required: false + description: Number of records to return per request + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Pagination cursor (from `meta.paginate.next_page` or `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/WebhookEvent' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Response wrapper with data and pagination + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Bots + x-paginated: true + x-requirements: + scope: webhooks:events:read + /webhooks/events/{id}: + delete: + operationId: BotOperations_deleteWebhookEvent + description: |- + Delete event + + This method is only available with a bot's `access_token`. + + Delete an event from the bot's event history. + + To delete an event you need to know the bot's `access_token` that owns the event and the event `id`. + parameters: + - name: id + in: path + required: true + description: Event ID + schema: + type: string + example: 01KAJZ2XDSS2S3DSW9EXJZ0TBV + example: 01KAJZ2XDSS2S3DSW9EXJZ0TBV + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Bots + x-requirements: + scope: webhooks:events:delete +security: + - BearerAuth: [] +components: + schemas: + AccessTokenInfo: + type: object + required: + - id + - token + - name + - user_id + - scopes + - created_at + - revoked_at + - expires_in + - last_used_at + properties: + id: + type: integer + format: int64 + description: Token ID + example: 4827 + token: + type: string + description: Masked token (first 8 and last 4 characters visible) + example: cH5kR9mN...x7Qp + name: + type: string + nullable: true + description: User-defined token name + example: My API Token + user_id: + type: integer + format: int64 + description: Token owner ID + example: 12 + scopes: + type: array + items: + $ref: '#/components/schemas/OAuthScope' + description: List of token scopes + example: + - messages:read + - chats:read + created_at: + type: string + format: date-time + description: Token creation date + example: '2025-01-15T10:30:00.000Z' + revoked_at: + type: string + format: date-time + nullable: true + description: Token revocation date + example: null + expires_in: + type: integer + format: int32 + nullable: true + description: Token lifetime in seconds + example: null + last_used_at: + type: string + format: date-time + nullable: true + description: Token last used date + example: '2025-02-24T14:20:00.000Z' + description: Access token + AddMembersRequest: + type: object + required: + - member_ids + properties: + member_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs who will become members + example: + - 186 + - 187 + silent: + type: boolean + description: Whether to skip creating a system message about adding members + example: true + description: Request to add members to a chat + AddTagsRequest: + type: object + required: + - group_tag_ids + properties: + group_tag_ids: + type: array + items: + type: integer + format: int32 + description: Array of tag IDs to be added as members + example: + - 86 + - 18 + description: Request to add tags to a chat + ApiError: + type: object + required: + - errors + properties: + errors: + type: array + items: + $ref: '#/components/schemas/ApiErrorItem' + description: Array of errors + description: API error (used for 400, 402, 403, 404, 409, 410, 422) + x-error: true + ApiErrorItem: + type: object + required: + - key + - value + - message + - code + - payload + properties: + key: + type: string + description: Field key that caused the error + example: field.name + value: + type: string + nullable: true + description: Field value that caused the error + example: invalid_value + message: + type: string + description: Error message + example: Field cannot be empty + code: + allOf: + - $ref: '#/components/schemas/ValidationErrorCode' + description: Error code + example: blank + payload: + type: object + additionalProperties: {} + nullable: true + description: >- + Additional error data. Content depends on the error code: `{id: number}` for custom property errors, `{type: + string}` for type mismatch errors + example: null + description: Detailed error information + AuditDetailsChatId: + type: object + required: + - chat_id + properties: + chat_id: + type: integer + format: int32 + description: Chat ID + description: 'For: tag_removed_from_chat' + AuditDetailsChatPermission: + type: object + required: + - public_access + properties: + public_access: + type: boolean + description: Public access + description: 'For: chat_permission_changed' + AuditDetailsChatRenamed: + type: object + required: + - old_name + - new_name + properties: + old_name: + type: string + description: Previous chat name + new_name: + type: string + description: New chat name + description: 'For: chat_renamed' + AuditDetailsDlp: + type: object + required: + - dlp_rule_id + - dlp_rule_name + - message_id + - chat_id + - user_id + - action_message + - conditions_matched + properties: + dlp_rule_id: + type: integer + format: int32 + description: DLP rule ID + dlp_rule_name: + type: string + description: DLP rule name + message_id: + type: integer + format: int32 + description: Message ID + chat_id: + type: integer + format: int32 + description: Chat ID + user_id: + type: integer + format: int32 + description: User ID + action_message: + type: string + description: Action description + conditions_matched: + type: boolean + description: Rule conditions check result (true — conditions matched) + description: 'For: dlp_violation_detected' + AuditDetailsEmpty: + type: object + description: >- + Empty details. For: user_login, user_logout, user_2fa_fail, user_2fa_success, user_created, user_deleted, + chat_created, message_created, message_updated, message_deleted, reaction_created, reaction_deleted, + thread_created, audit_events_accessed + AuditDetailsInitiator: + type: object + required: + - initiator_id + properties: + initiator_id: + type: integer + format: int32 + description: Action initiator ID + description: 'For: user_added_to_tag, user_removed_from_tag, user_chat_leave' + AuditDetailsInviter: + type: object + required: + - inviter_id + properties: + inviter_id: + type: integer + format: int32 + description: Inviter ID + description: 'For: user_chat_join' + AuditDetailsKms: + type: object + required: + - chat_id + - message_id + - reason + properties: + chat_id: + type: integer + format: int32 + description: Chat ID + message_id: + type: integer + format: int32 + description: Message ID + reason: + type: string + description: Operation reason + description: 'For: kms_encrypt, kms_decrypt' + AuditDetailsRoleChanged: + type: object + required: + - new_company_role + - previous_company_role + - initiator_id + properties: + new_company_role: + type: string + description: New role + previous_company_role: + type: string + description: Previous role + initiator_id: + type: integer + format: int32 + description: Initiator ID + description: 'For: user_role_changed' + AuditDetailsSearch: + type: object + required: + - search_type + - query_present + - cursor_present + - limit + - filters + properties: + search_type: + type: string + description: Search type + query_present: + type: boolean + description: Whether a search query was specified + cursor_present: + type: boolean + description: Whether a cursor was used + limit: + type: integer + format: int32 + description: Number of returned results + filters: + type: object + additionalProperties: {} + description: >- + Applied filters. Possible keys depend on the search type: order, sort, created_from, created_to, + company_roles (users), active, chat_subtype, personal (chats), chat_ids, user_ids (messages) + description: 'For: search_users_api, search_chats_api, search_messages_api' + AuditDetailsTagChat: + type: object + required: + - chat_id + - tag_name + properties: + chat_id: + type: integer + format: int32 + description: Chat ID + tag_name: + type: string + description: Tag name + description: 'For: tag_added_to_chat' + AuditDetailsTagName: + type: object + required: + - name + properties: + name: + type: string + description: Tag name + description: 'For: tag_created, tag_deleted' + AuditDetailsTokenScopes: + type: object + required: + - scopes + properties: + scopes: + type: array + items: + type: string + description: Token scopes + description: 'For: access_token_created, access_token_updated, access_token_destroy' + AuditDetailsUserUpdated: + type: object + required: + - changed_attrs + properties: + changed_attrs: + type: array + items: + type: string + description: List of changed fields + description: 'For: user_updated' + AuditEvent: + type: object + required: + - id + - created_at + - event_key + - entity_id + - entity_type + - actor_id + - actor_type + - details + - ip_address + - user_agent + properties: + id: + type: string + description: Unique event ID + example: a1b2c3d4-5e6f-7g8h-9i10-j11k12l13m14 + created_at: + type: string + format: date-time + description: Event creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-05-15T14:30:00.000Z' + event_key: + allOf: + - $ref: '#/components/schemas/AuditEventKey' + description: Event type key + example: user_login + entity_id: + type: string + description: Affected entity ID + example: '98765' + entity_type: + type: string + description: Affected entity type + example: User + actor_id: + type: string + description: ID of the user who performed the action + example: '98765' + actor_type: + type: string + description: Actor type + example: User + details: + allOf: + - $ref: '#/components/schemas/AuditEventDetailsUnion' + description: >- + Additional event details. Structure depends on the event_key value — see event_key field value descriptions. + For events without details, an empty object is returned + ip_address: + type: string + description: IP address from which the action was performed + example: 192.168.1.100 + user_agent: + type: string + description: Client user agent + example: Pachca/3.60.0 (co.staply.pachca; build:15; iOS 18.5.0) Alamofire/5.0.0 + description: Audit event + AuditEventDetailsUnion: + anyOf: + - $ref: '#/components/schemas/AuditDetailsEmpty' + - $ref: '#/components/schemas/AuditDetailsUserUpdated' + - $ref: '#/components/schemas/AuditDetailsRoleChanged' + - $ref: '#/components/schemas/AuditDetailsTagName' + - $ref: '#/components/schemas/AuditDetailsInitiator' + - $ref: '#/components/schemas/AuditDetailsInviter' + - $ref: '#/components/schemas/AuditDetailsChatRenamed' + - $ref: '#/components/schemas/AuditDetailsChatPermission' + - $ref: '#/components/schemas/AuditDetailsTagChat' + - $ref: '#/components/schemas/AuditDetailsChatId' + - $ref: '#/components/schemas/AuditDetailsTokenScopes' + - $ref: '#/components/schemas/AuditDetailsKms' + - $ref: '#/components/schemas/AuditDetailsDlp' + - $ref: '#/components/schemas/AuditDetailsSearch' + description: Additional audit event details. Structure depends on the event_key value + AuditEventKey: + type: string + enum: + - user_login + - user_logout + - user_2fa_fail + - user_2fa_success + - user_created + - user_deleted + - user_role_changed + - user_updated + - tag_created + - tag_deleted + - user_added_to_tag + - user_removed_from_tag + - chat_created + - chat_renamed + - chat_permission_changed + - user_chat_join + - user_chat_leave + - tag_added_to_chat + - tag_removed_from_chat + - message_updated + - message_deleted + - message_created + - reaction_created + - reaction_deleted + - thread_created + - access_token_created + - access_token_updated + - access_token_destroy + - kms_encrypt + - kms_decrypt + - audit_events_accessed + - dlp_violation_detected + - search_users_api + - search_chats_api + - search_messages_api + description: Audit event type + x-enum-descriptions: + user_login: User logged in successfully + user_logout: User logged out + user_2fa_fail: Failed two-factor authentication attempt + user_2fa_success: Successful two-factor authentication + user_created: New user account created + user_deleted: User account deleted + user_role_changed: User role changed + user_updated: User data updated + tag_created: New tag created + tag_deleted: Tag deleted + user_added_to_tag: User added to tag + user_removed_from_tag: User removed from tag + chat_created: New chat created + chat_renamed: Chat renamed + chat_permission_changed: Chat access permissions changed + user_chat_join: User joined the chat + user_chat_leave: User left the chat + tag_added_to_chat: Tag added to chat + tag_removed_from_chat: Tag removed from chat + message_updated: Message edited + message_deleted: Message deleted + message_created: Message created + reaction_created: Reaction added + reaction_deleted: Reaction removed + thread_created: Thread created + access_token_created: New access token created + access_token_updated: Access token updated + access_token_destroy: Access token deleted + kms_encrypt: Data encrypted + kms_decrypt: Data decrypted + audit_events_accessed: Audit logs accessed + dlp_violation_detected: DLP rule violation detected + search_users_api: Employee search via API + search_chats_api: Chat search via API + search_messages_api: Message search via API + AvatarData: + type: object + required: + - image_url + properties: + image_url: + type: string + description: Avatar URL + example: https://pachca-prod.s3.amazonaws.com/uploads/0001/0001/image.jpg + description: Avatar data + BotResponse: + type: object + required: + - id + - webhook + properties: + id: + type: integer + format: int32 + description: Bot ID + example: 1738816 + webhook: + type: object + properties: + outgoing_url: + type: string + description: Outgoing webhook URL + example: https://www.website.com/tasks/new + required: + - outgoing_url + description: Webhook parameters object + description: Bot parameters + BotUpdateRequest: + type: object + required: + - bot + properties: + bot: + type: object + properties: + webhook: + type: object + properties: + outgoing_url: + type: string + description: Outgoing webhook URL + example: https://www.website.com/tasks/new + required: + - outgoing_url + description: Webhook parameters object + required: + - webhook + description: Bot parameters object to update + description: Bot update request + Button: + type: object + required: + - text + properties: + text: + type: string + maxLength: 255 + description: Text displayed on the button + example: Learn more + url: + type: string + description: URL that will be opened when the button is clicked + example: https://example.com/details + data: + type: string + maxLength: 255 + description: Data that will be sent in the outgoing webhook when the button is clicked + example: awesome + description: Button + ButtonWebhookPayload: + type: object + required: + - type + - event + - message_id + - trigger_id + - data + - user_id + - chat_id + - webhook_timestamp + properties: + type: + type: string + enum: + - button + description: Object type + example: button + x-enum-descriptions: + button: Always button for buttons + event: + type: string + enum: + - click + description: Event type + example: click + x-enum-descriptions: + click: Button click + message_id: + type: integer + format: int32 + description: ID of the message the button belongs to + example: 1245817 + trigger_id: + type: string + description: Unique event identifier. Lifetime — 3 seconds. Can be used, for example, to open a view for the user + example: a1b2c3d4-5e6f-7g8h-9i10-j11k12l13m14 + data: + type: string + description: Clicked button data + example: button_data + user_id: + type: integer + format: int32 + description: ID of the user who clicked the button + example: 2345 + chat_id: + type: integer + format: int32 + description: ID of the chat where the button was clicked + example: 9012 + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1747574400 + description: Outgoing webhook payload for button click + Chat: + type: object + required: + - id + - name + - created_at + - owner_id + - member_ids + - group_tag_ids + - channel + - personal + - public + - last_message_at + - meet_room_url + properties: + id: + type: integer + format: int32 + description: Created chat ID + example: 334 + name: + type: string + description: Name + example: 🤿 aqua + created_at: + type: string + format: date-time + description: Chat creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2021-08-28T15:56:53.000Z' + owner_id: + type: integer + format: int32 + description: ID of the user who created the chat + example: 185 + member_ids: + type: array + items: + type: integer + format: int32 + description: Array of member user IDs + example: + - 185 + - 186 + - 187 + group_tag_ids: + type: array + items: + type: integer + format: int32 + description: Array of member tag IDs + example: + - 9111 + channel: + type: boolean + description: Whether this is a channel + example: true + personal: + type: boolean + description: Whether this is a direct message + example: false + public: + type: boolean + description: Whether the chat is publicly accessible + example: false + last_message_at: + type: string + format: date-time + description: Date and time of the last message in the chat (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2021-08-28T15:56:53.000Z' + meet_room_url: + type: string + description: Video chat link + example: https://meet.pachca.com/aqua-94bb21b5 + description: Chat + ChatAvailability: + type: string + enum: + - is_member + - public + description: Chat availability for the user + x-enum-descriptions: + is_member: Chats where the user is a member + public: All public chats in the workspace, regardless of user membership + ChatCreateRequest: + type: object + required: + - chat + properties: + chat: + type: object + properties: + name: + type: string + description: Name + example: 🤿 aqua + member_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs who will become members + example: + - 186 + - 187 + group_tag_ids: + type: array + items: + type: integer + format: int32 + description: Array of tag IDs to be added as members + example: + - 86 + - 18 + channel: + type: boolean + description: Whether this is a channel + example: true + default: false + public: + type: boolean + description: Whether the chat is publicly accessible + example: false + default: false + required: + - name + description: Chat parameters object to create + description: Chat creation request + ChatMemberRole: + type: string + enum: + - admin + - editor + - member + description: Chat member role + x-enum-descriptions: + admin: Admin + editor: Editor (available for channels only) + member: Member or subscriber + ChatMemberRoleFilter: + type: string + enum: + - all + - owner + - admin + - editor + - member + description: Chat member role (with all filter) + x-enum-descriptions: + all: Any role + owner: Owner + admin: Admin + editor: Editor + member: Member/subscriber + ChatMemberWebhookPayload: + type: object + required: + - type + - event + - chat_id + - user_ids + - created_at + - webhook_timestamp + properties: + type: + type: string + enum: + - chat_member + description: Object type + example: chat_member + x-enum-descriptions: + chat_member: Always chat_member for chat members + event: + allOf: + - $ref: '#/components/schemas/MemberEventType' + description: Event type + example: add + chat_id: + type: integer + format: int32 + description: ID of the chat where membership changed + example: 9012 + thread_id: + type: integer + format: int32 + nullable: true + description: Thread ID + example: 5678 + user_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs affected by the event + example: + - 2345 + - 6789 + created_at: + type: string + format: date-time + description: Event date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-05-15T14:30:00.000Z' + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1747574400 + description: Outgoing webhook payload for chat members + ChatSortField: + type: string + enum: + - id + - last_message_at + description: Chat sort field + x-enum-descriptions: + id: By chat ID + last_message_at: By last message date and time + ChatSubtype: + type: string + enum: + - discussion + - thread + description: Chat type + x-enum-descriptions: + discussion: Channel or conversation + thread: Thread + ChatUpdateRequest: + type: object + required: + - chat + properties: + chat: + type: object + properties: + name: + type: string + description: Name + example: Pool + public: + type: boolean + description: Whether the chat is publicly accessible + example: true + description: Chat parameters object to update + description: Chat update request + CompanyMemberWebhookPayload: + type: object + required: + - type + - event + - user_ids + - created_at + - webhook_timestamp + properties: + type: + type: string + enum: + - company_member + description: Object type + example: company_member + x-enum-descriptions: + company_member: Always company_member for workspace members + event: + allOf: + - $ref: '#/components/schemas/UserEventType' + description: Event type + example: invite + user_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs affected by the event + example: + - 2345 + - 6789 + created_at: + type: string + format: date-time + description: Event date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-05-15T14:30:00.000Z' + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1747574400 + description: Outgoing webhook payload for workspace members + CustomProperty: + type: object + required: + - id + - name + - data_type + - value + properties: + id: + type: integer + format: int32 + description: Custom property ID + example: 1678 + name: + type: string + description: Custom property name + example: City + data_type: + allOf: + - $ref: '#/components/schemas/CustomPropertyDataType' + description: Custom property data type + example: string + value: + type: string + description: Custom property value + example: Saint Petersburg + description: Custom property + CustomPropertyDataType: + type: string + enum: + - string + - number + - date + - link + description: Custom property data type + x-enum-descriptions: + string: String value + number: Number value + date: Date + link: Link + CustomPropertyDefinition: + type: object + required: + - id + - name + - data_type + properties: + id: + type: integer + format: int32 + description: Property ID + example: 1678 + name: + type: string + description: Property name + example: City + data_type: + allOf: + - $ref: '#/components/schemas/CustomPropertyDataType' + description: Property type + example: string + description: Custom property + ExportRequest: + type: object + required: + - start_at + - end_at + - webhook_url + properties: + start_at: + type: string + format: date + description: Export start date (ISO-8601, UTC+0) in YYYY-MM-DD format + example: '2025-03-20' + end_at: + type: string + format: date + description: Export end date (ISO-8601, UTC+0) in YYYY-MM-DD format + example: '2025-03-20' + webhook_url: + type: string + description: URL to receive a webhook when the export is complete + example: https://webhook.site/9227d3b8-6e82-4e64-bf5d-ad972ad270f2 + chat_ids: + type: array + items: + type: integer + format: int32 + description: Array of chat IDs. Specify to export messages from specific chats only. + example: + - 1381521 + skip_chats_file: + type: boolean + description: Skip generating the chat list file (chats.json) + example: false + description: Message export request + File: + type: object + required: + - id + - key + - name + - file_type + - url + properties: + id: + type: integer + format: int32 + description: File ID + example: 3560 + key: + type: string + description: File path + example: attaches/files/12/21zu7934-02e1-44d9-8df2-0f970c259796/congrat.png + name: + type: string + description: File name with extension + example: congrat.png + file_type: + allOf: + - $ref: '#/components/schemas/FileType' + description: File type + example: image + url: + type: string + description: Direct file download URL + example: >- + https://pachca-prod-uploads.s3.storage.selcloud.ru/attaches/files/12/21zu7934-02e1-44d9-8df2-0f970c259796/congrat.png?response-cache-control=max-age%3D3600%3B&response-content-disposition=attachment&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=142155_staply%2F20231107%2Fru-1a%2Fs3%2Faws4_request&X-Amz-Date=20231107T160412&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=98765asgfadsfdSaDSd4sdfg35asdf67sadf8 + width: + type: integer + format: int32 + nullable: true + description: Image width in pixels + example: 1920 + height: + type: integer + format: int32 + nullable: true + description: Image height in pixels + example: 1080 + description: File + FileType: + type: string + enum: + - file + - image + description: File type + x-enum-descriptions: + file: Regular file + image: Image + FileUploadRequest: + type: object + properties: + Content-Disposition: + type: string + description: >- + Content-Disposition parameter received in the response to [Get signature, key and other parameters](POST + /uploads) + acl: + type: string + description: acl parameter received in the response to [Get signature, key and other parameters](POST /uploads) + policy: + type: string + description: policy parameter received in the response to [Get signature, key and other parameters](POST /uploads) + x-amz-credential: + type: string + description: >- + x-amz-credential parameter received in the response to [Get signature, key and other parameters](POST + /uploads) + x-amz-algorithm: + type: string + description: >- + x-amz-algorithm parameter received in the response to [Get signature, key and other parameters](POST + /uploads) + x-amz-date: + type: string + description: x-amz-date parameter received in the response to [Get signature, key and other parameters](POST /uploads) + x-amz-signature: + type: string + description: >- + x-amz-signature parameter received in the response to [Get signature, key and other parameters](POST + /uploads) + key: + type: string + description: key parameter received in the response to [Get signature, key and other parameters](POST /uploads) + file: + type: string + format: binary + description: File to upload + required: + - Content-Disposition + - acl + - policy + - x-amz-credential + - x-amz-algorithm + - x-amz-date + - x-amz-signature + - key + - file + Forwarding: + type: object + required: + - original_message_id + - original_chat_id + - author_id + - original_created_at + - original_thread_id + - original_thread_message_id + - original_thread_parent_chat_id + properties: + original_message_id: + type: integer + format: int32 + description: Original message ID + example: 194275 + original_chat_id: + type: integer + format: int32 + description: ID of the chat containing the original message + example: 334 + author_id: + type: integer + format: int32 + description: ID of the user who created the original message + example: 12 + original_created_at: + type: string + format: date-time + description: Original message creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-01-15T10:30:00.000Z' + original_thread_id: + type: integer + format: int32 + nullable: true + description: ID of the thread containing the original message + example: null + original_thread_message_id: + type: integer + format: int32 + nullable: true + description: ID of the message that the thread containing the original message was created for + example: null + original_thread_parent_chat_id: + type: integer + format: int32 + nullable: true + description: ID of the chat of the message that the thread containing the original message was created for + example: null + description: Forwarded message information + GroupTag: + type: object + required: + - id + - name + - users_count + properties: + id: + type: integer + format: int32 + description: Tag ID + example: 9111 + name: + type: string + description: Tag name + example: Design + users_count: + type: integer + format: int32 + description: Number of employees who have this tag + example: 6 + description: Group tag + GroupTagRequest: + type: object + required: + - group_tag + properties: + group_tag: + type: object + properties: + name: + type: string + description: Tag name + example: New tag name + required: + - name + description: Request to create or update a tag + InviteStatus: + type: string + enum: + - confirmed + - sent + description: User invitation status + x-enum-descriptions: + confirmed: Confirmed + sent: Sent + LinkPreview: + type: object + required: + - title + - description + properties: + title: + type: string + description: Title + example: 'Article: Sending files' + description: + type: string + description: Description + example: Example of sending files to a remote server + image_url: + type: string + description: Public image URL (if you want to upload an image file to Pachca, use the image parameter) + example: https://website.com/img/landing.png + image: + type: object + properties: + key: + type: string + description: Image path obtained from [file upload](POST /direct_url) + example: attaches/files/93746/e354fd79-9jh6-f2hd-fj83-709dae24c763/${filename} + name: + type: string + description: Image name (recommended to include the file extension) + example: files-to-server.jpg + size: + type: integer + format: int32 + description: Image size in bytes + example: 695604 + required: + - key + - name + description: Image + description: Link preview data + LinkPreviewsRequest: + type: object + required: + - link_previews + properties: + link_previews: + type: object + additionalProperties: + $ref: '#/components/schemas/LinkPreview' + description: '`JSON` map of link previews, where each key is a `URL` received in the outgoing webhook about a new message.' + x-record-key-example: https://website.com/articles/123 + description: Link unfurling request + LinkSharedWebhookPayload: + type: object + required: + - type + - event + - chat_id + - message_id + - links + - user_id + - created_at + - webhook_timestamp + properties: + type: + type: string + enum: + - message + description: Object type + example: message + x-enum-descriptions: + message: Always message for link unfurling + event: + type: string + enum: + - link_shared + description: Event type + example: link_shared + x-enum-descriptions: + link_shared: Link to a monitored domain detected + chat_id: + type: integer + format: int32 + description: ID of the chat where the link was found + example: 23438 + message_id: + type: integer + format: int32 + description: ID of the message containing the link + example: 268092 + links: + type: array + items: + $ref: '#/components/schemas/WebhookLink' + description: Array of detected links to monitored domains + user_id: + type: integer + format: int32 + description: Message sender ID + example: 2345 + created_at: + type: string + format: date-time + description: Message creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2024-09-18T19:53:14.000Z' + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1726685594 + description: Outgoing webhook payload for link unfurling + MemberEventType: + type: string + enum: + - add + - remove + description: Webhook event type for members + x-enum-descriptions: + add: Addition + remove: Deleted + Message: + type: object + required: + - id + - entity_type + - entity_id + - chat_id + - root_chat_id + - content + - user_id + - created_at + - url + - files + - buttons + - thread + - forwarding + - parent_message_id + - display_avatar_url + - display_name + - changed_at + - deleted_at + properties: + id: + type: integer + format: int32 + description: Message ID + example: 194275 + entity_type: + allOf: + - $ref: '#/components/schemas/MessageEntityType' + description: Entity type the message belongs to + example: discussion + entity_id: + type: integer + format: int32 + description: ID of the entity the message belongs to (chat/channel, thread, or user) + example: 334 + chat_id: + type: integer + format: int32 + description: ID of the chat containing the message + example: 334 + root_chat_id: + type: integer + format: int32 + description: >- + Root chat ID. For messages in threads — the ID of the chat where the thread was created. For regular + messages, equals `chat_id`. + example: 334 + content: + type: string + description: Message text + example: Yesterday we sold 756 t-shirts (10% more than last Sunday) + user_id: + type: integer + format: int32 + description: ID of the user who created the message + example: 12 + created_at: + type: string + format: date-time + description: Message creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2021-08-28T15:57:23.000Z' + url: + type: string + description: Direct link to the message + example: https://app.pachca.com/chats/334?message=194275 + files: + type: array + items: + $ref: '#/components/schemas/File' + description: Attached files + buttons: + type: array + items: + type: array + items: + $ref: '#/components/schemas/Button' + nullable: true + description: Array of rows, each represented as an array of buttons + thread: + type: object + properties: + id: + type: integer + format: int64 + description: Thread ID + example: 265142 + chat_id: + type: integer + format: int64 + description: Thread chat ID + example: 2637266155 + required: + - id + - chat_id + nullable: true + description: Message thread + forwarding: + type: object + allOf: + - $ref: '#/components/schemas/Forwarding' + nullable: true + description: Forwarded message information + parent_message_id: + type: integer + format: int32 + nullable: true + description: ID of the message being replied to + example: null + display_avatar_url: + type: string + nullable: true + description: Message sender avatar URL + example: null + display_name: + type: string + nullable: true + description: Message sender full name + example: null + changed_at: + type: string + format: date-time + nullable: true + description: Message last edit date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2021-08-28T16:10:00.000Z' + deleted_at: + type: string + format: date-time + nullable: true + description: Message deletion date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: null + description: Message + MessageCreateRequest: + type: object + required: + - message + properties: + message: + type: object + properties: + entity_type: + allOf: + - $ref: '#/components/schemas/MessageEntityType' + description: Entity type + example: discussion + default: discussion + entity_id: + type: integer + format: int32 + description: Entity ID + example: 334 + content: + type: string + description: 'Message text. Supports mentions: `@nickname` or `<@user_id>` (automatically converted to `@nickname`).' + example: Yesterday we sold 756 t-shirts (10% more than last Sunday) + files: + type: array + items: + type: object + properties: + key: + type: string + description: File path obtained from [file upload](POST /direct_url) + example: attaches/files/93746/e354fd79-4f3e-4b5a-9c8d-1a2b3c4d5e6f/logo.png + name: + type: string + description: File name to display to the user (recommended to include the file extension) + example: logo.png + file_type: + allOf: + - $ref: '#/components/schemas/FileType' + description: File type + example: image + size: + type: integer + format: int32 + description: File size in bytes, displayed to the user + example: 12345 + width: + type: integer + format: int32 + description: Image width in px (used when file_type is set to image) + example: 800 + height: + type: integer + format: int32 + description: Image height in px (used when file_type is set to image) + example: 600 + required: + - key + - name + - file_type + - size + description: Files to attach + buttons: + type: array + items: + type: array + items: + $ref: '#/components/schemas/Button' + description: >- + Array of rows, each represented as an array of buttons. Maximum 100 buttons per message, up to 8 buttons + per row. + example: + - - text: Learn more + url: https://example.com/details + - text: Great! + data: awesome + parent_message_id: + type: integer + format: int32 + description: Message ID. Specify when sending a reply to another message. + example: 194270 + display_avatar_url: + type: string + maxLength: 255 + description: Custom sender avatar URL for this message. This field can only be used with a bot access_token. + example: https://example.com/avatar.png + display_name: + type: string + maxLength: 255 + description: Custom sender display name for this message. This field can only be used with a bot access_token. + example: Support Bot + skip_invite_mentions: + type: boolean + description: Skip adding mentioned users to the thread. Only works when sending a message to a thread. + example: false + default: false + required: + - entity_id + - content + description: Message parameters object to create + link_preview: + type: boolean + description: Display a preview of the first link found in the message text + example: false + default: false + description: Message creation request + MessageEntityType: + type: string + enum: + - discussion + - thread + - user + description: Entity type for messages + x-enum-descriptions: + discussion: Conversation or channel + thread: Thread + user: User + MessageSortField: + type: string + enum: + - id + description: Message sort field + x-enum-descriptions: + id: By message ID + MessageUpdateRequest: + type: object + required: + - message + properties: + message: + type: object + properties: + content: + type: string + description: 'Message text. Supports mentions: `@nickname` or `<@user_id>` (automatically converted to `@nickname`).' + example: >- + Try to spell these correctly on the first attempt: bureaucracy, accommodate, definitely, entrepreneur, + liaison, necessary, surveillance, questionnaire. + files: + type: array + items: + type: object + properties: + key: + type: string + description: File path obtained from [file upload](POST /direct_url) + example: attaches/files/93746/e354fd79-4f3e-4b5a-9c8d-1a2b3c4d5e6f/logo.png + name: + type: string + description: File name to display to the user (recommended to include the file extension) + example: logo.png + file_type: + type: string + description: 'File type: file (file), image (image)' + example: image + size: + type: integer + format: int32 + description: File size in bytes, displayed to the user + example: 12345 + width: + type: integer + format: int32 + description: Image width in px (used when file_type is set to image) + example: 800 + height: + type: integer + format: int32 + description: Image height in px (used when file_type is set to image) + example: 600 + required: + - key + - name + description: Files to attach + buttons: + type: array + items: + type: array + items: + $ref: '#/components/schemas/Button' + description: >- + Array of rows, each represented as an array of buttons. Maximum 100 buttons per message, up to 8 buttons + per row. To remove buttons, send an empty array. + example: + - - text: Learn more + url: https://example.com/details + display_avatar_url: + type: string + description: Custom sender avatar URL for this message. This field can only be used with a bot access_token. + example: https://example.com/avatar.png + display_name: + type: string + description: Custom sender display name for this message. This field can only be used with a bot access_token. + example: Support Bot + description: Message parameters object to update + description: Message update request + MessageWebhookPayload: + type: object + required: + - type + - id + - event + - entity_type + - entity_id + - content + - user_id + - created_at + - url + - chat_id + - webhook_timestamp + properties: + type: + type: string + enum: + - message + description: Object type + example: message + x-enum-descriptions: + message: Always message for messages + id: + type: integer + format: int32 + description: Message ID + example: 1245817 + event: + allOf: + - $ref: '#/components/schemas/WebhookEventType' + description: Event type + example: new + entity_type: + allOf: + - $ref: '#/components/schemas/MessageEntityType' + description: Entity type the message belongs to + example: discussion + entity_id: + type: integer + format: int32 + description: ID of the entity the message belongs to + example: 5678 + content: + type: string + description: Message text + example: Message text + user_id: + type: integer + format: int32 + description: Message sender ID + example: 2345 + created_at: + type: string + format: date-time + description: Message creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-05-15T14:30:00.000Z' + url: + type: string + description: Direct link to the message + example: https://pachca.com/chats/1245817/messages/5678 + chat_id: + type: integer + format: int32 + description: ID of the chat containing the message + example: 9012 + parent_message_id: + type: integer + format: int32 + nullable: true + description: ID of the message being replied to + example: 3456 + thread: + type: object + allOf: + - $ref: '#/components/schemas/WebhookMessageThread' + nullable: true + description: Thread parameters object + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1747574400 + description: Outgoing webhook payload for messages + OAuthError: + type: object + required: + - error + - error_description + properties: + error: + type: string + description: Error code + example: invalid_token + error_description: + type: string + description: Error description + example: Access token is missing + description: OAuth authorization error (used for 401 and 403) + x-error: true + OAuthScope: + type: string + enum: + - chats:read + - chats:create + - chats:update + - chats:archive + - chats:leave + - chat_members:read + - chat_members:write + - chat_exports:read + - chat_exports:write + - messages:read + - messages:create + - messages:update + - messages:delete + - reactions:read + - reactions:write + - pins:write + - threads:read + - threads:create + - link_previews:write + - users:read + - users:create + - users:update + - users:delete + - group_tags:read + - group_tags:write + - bots:write + - profile:read + - profile_status:read + - profile_status:write + - profile_avatar:write + - user_status:read + - user_status:write + - user_avatar:write + - custom_properties:read + - audit_events:read + - tasks:read + - tasks:create + - tasks:update + - tasks:delete + - files:read + - files:write + - uploads:write + - views:write + - webhooks:read + - webhooks:write + - webhooks:events:read + - webhooks:events:delete + - search:users + - search:chats + - search:messages + description: OAuth token access scope + x-scope-roles: + chats_read: + - owner + - admin + - user + - bot + chats_create: + - owner + - admin + - user + - bot + chats_update: + - owner + - admin + - user + - bot + chats_archive: + - owner + - admin + - user + - bot + chats_leave: + - owner + - admin + - user + - bot + chat_members_read: + - owner + - admin + - user + - bot + chat_members_write: + - owner + - admin + - user + - bot + chat_exports_read: + - owner + chat_exports_write: + - owner + messages_read: + - owner + - admin + - user + - bot + messages_create: + - owner + - admin + - user + - bot + messages_update: + - owner + - admin + - user + - bot + messages_delete: + - owner + - admin + - user + - bot + reactions_read: + - owner + - admin + - user + - bot + reactions_write: + - owner + - admin + - user + - bot + pins_write: + - owner + - admin + - user + - bot + threads_read: + - owner + - admin + - user + - bot + threads_create: + - owner + - admin + - user + - bot + link_previews_write: + - owner + - admin + - user + - bot + views_write: + - owner + - admin + - user + - bot + users_read: + - owner + - admin + - user + - bot + users_create: + - owner + - admin + users_update: + - owner + - admin + users_delete: + - owner + - admin + group_tags_read: + - owner + - admin + group_tags_write: + - owner + - admin + bots_write: + - owner + - admin + - user + - bot + profile_read: + - owner + - admin + - user + - bot + profile_status_read: + - owner + - admin + - user + - bot + profile_status_write: + - owner + - admin + - user + - bot + profile_avatar_write: + - owner + - admin + - user + - bot + user_status_read: + - owner + - admin + user_status_write: + - owner + - admin + user_avatar_write: + - owner + - admin + custom_properties_read: + - owner + - admin + - user + - bot + audit_events_read: + - owner + tasks_read: + - owner + - admin + - user + - bot + tasks_create: + - owner + - admin + - user + - bot + tasks_update: + - owner + - admin + - user + - bot + tasks_delete: + - owner + - admin + - user + - bot + files_read: + - owner + - admin + - user + - bot + files_write: + - owner + - admin + - user + - bot + uploads_write: + - owner + - admin + - user + - bot + webhooks_read: + - owner + - admin + - user + - bot + webhooks_write: + - owner + - admin + - user + - bot + webhooks_events_read: + - owner + - admin + - user + - bot + webhooks_events_delete: + - owner + - admin + - user + - bot + search_users: + - owner + - admin + - user + - bot + search_chats: + - owner + - admin + - user + - bot + search_messages: + - owner + - admin + - user + - bot + x-enum-descriptions: + chats_read: View chats and chat list + chats_create: Create new chats + chats_update: Update chat settings + chats_archive: Archive and unarchive chats + chats_leave: Leave chats + chat_members_read: View chat members + chat_members_write: Add, update, and remove chat members + chat_exports_read: Download chat exports + chat_exports_write: Create chat exports + messages_read: View messages in chats + messages_create: Send messages + messages_update: Edit messages + messages_delete: Delete messages + reactions_read: View message reactions + reactions_write: Add and remove reactions + pins_write: Pin and unpin messages + threads_read: View threads (comments) + threads_create: Create threads (comments) + link_previews_write: Unfurl (link previews) + views_write: Open forms (views) + users_read: View employee information and employee list + users_create: Create new employees + users_update: Edit employee data + users_delete: Delete employees + group_tags_read: View tags + group_tags_write: Create, edit, and delete tags + bots_write: Update bot settings + profile_read: View own profile information + profile_status_read: View profile status + profile_status_write: Update and delete profile status + profile_avatar_write: Update and delete profile avatar + user_status_read: View employee status + user_status_write: Update and delete employee status + user_avatar_write: Update and delete employee avatar + custom_properties_read: View custom properties + audit_events_read: View audit log + tasks_read: View tasks + tasks_create: Create tasks + tasks_update: Update task + tasks_delete: Delete task + files_read: Download files + files_write: Upload files + uploads_write: Get file upload data + webhooks_read: View webhooks + webhooks_write: Create and manage webhooks + webhooks_events_read: View webhook logs + webhooks_events_delete: Delete webhook log entry + search_users: Search employees + search_chats: Search chats + search_messages: Search messages + OpenViewRequest: + type: object + required: + - type + - trigger_id + - view + properties: + type: + type: string + enum: + - modal + description: View opening method + example: modal + x-enum-descriptions: + modal: Modal window + trigger_id: + type: string + description: Unique event identifier (received, for example, in the outgoing webhook for a button click) + example: 791a056b-006c-49dd-834b-c633fde52fe8 + private_metadata: + type: string + maxLength: 3000 + description: >- + Optional string that will be sent to your application when the user submits the form. Use this field, for + example, to pass additional information in `JSON` format along with the form data. + example: '{"timeoff_id":4378}' + callback_id: + type: string + maxLength: 255 + description: >- + Optional identifier for recognizing this view, which will be sent to your application when the user submits + the form. Use this field, for example, to determine which form the user was supposed to fill out. + example: timeoff_reguest_form + view: + type: object + properties: + title: + type: string + maxLength: 24 + description: View title + example: Vacation notification + close_text: + type: string + maxLength: 24 + description: Close button text + example: Close + default: Cancel + submit_text: + type: string + maxLength: 24 + description: Submit button text + example: Submit request + default: Submit + blocks: + type: array + items: + $ref: '#/components/schemas/ViewBlockUnion' + maxItems: 100 + description: Array of view blocks + required: + - title + - blocks + description: View object + description: View + PaginationMeta: + type: object + required: + - paginate + properties: + paginate: + type: object + properties: + next_page: + type: string + description: Next page pagination cursor + example: eyJxZCO2MiwiZGlyIjomSNYjIn3 + prev_page: + type: string + description: Previous page pagination cursor. Used for polling new records "above" the list + example: eyJxZCO2MiwiZGlyIjoiYXNjIn0 + has_next: + type: boolean + description: Whether more data exists on the next page. `false` on the last page + example: true + has_prev: + type: boolean + description: Whether more data exists on the previous page. `false` on the first request without a cursor + example: false + required: + - next_page + description: Helper information + description: Pagination metadata + Reaction: + type: object + required: + - user_id + - created_at + - code + - name + properties: + user_id: + type: integer + format: int32 + description: ID of the user who added the reaction + example: 12 + created_at: + type: string + format: date-time + description: Reaction creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2024-01-20T10:30:00.000Z' + code: + type: string + description: Reaction emoji character + example: 👍 + name: + type: string + nullable: true + description: Reaction emoji name + example: ':+1::skin-tone-1:' + description: Message reaction + ReactionEventType: + type: string + enum: + - new + - delete + description: Webhook event type for reactions + x-enum-descriptions: + new: Created + delete: Deleted + ReactionRequest: + type: object + required: + - code + properties: + code: + type: string + description: Reaction emoji character + example: 👍 + name: + type: string + description: Emoji text name (used for custom emoji) + example: ':+1:' + description: Reaction creation request + ReactionWebhookPayload: + type: object + required: + - type + - event + - chat_id + - message_id + - code + - name + - user_id + - created_at + - webhook_timestamp + properties: + type: + type: string + enum: + - reaction + description: Object type + example: reaction + x-enum-descriptions: + reaction: Always reaction for reactions + event: + allOf: + - $ref: '#/components/schemas/ReactionEventType' + description: Event type + example: new + chat_id: + type: integer + format: int32 + nullable: true + description: >- + ID of the chat that contains the message. The field is always present in the payload; in rare cases (for + example, if the message was deleted before the webhook was dispatched) it may be `null`. + example: 9012 + message_id: + type: integer + format: int32 + description: ID of the message the reaction belongs to + example: 1245817 + code: + type: string + description: Reaction emoji character + example: 👍 + name: + type: string + description: Reaction name + example: thumbsup + user_id: + type: integer + format: int32 + description: ID of the user who added or removed the reaction + example: 2345 + created_at: + type: string + format: date-time + description: Message creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-05-15T14:30:00.000Z' + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1747574400 + description: Outgoing webhook payload for reactions + SearchEntityType: + type: string + enum: + - User + - Task + description: Entity type for search + x-enum-descriptions: + User: User + Task: Task + SearchPaginationMeta: + type: object + required: + - total + - paginate + properties: + total: + type: integer + format: int32 + description: Total number of results found + example: 42 + paginate: + type: object + properties: + next_page: + type: string + description: Next page pagination cursor + example: eyJxZCO2MiwiZGlyIjomSNYjIn3 + required: + - next_page + description: Helper information + description: Pagination metadata for search results + SearchSortOrder: + type: string + enum: + - by_score + - alphabetical + description: Search results sort order + x-enum-descriptions: + by_score: By relevance + alphabetical: Alphabetically + SortOrder: + type: string + enum: + - asc + - desc + description: Sort order + x-enum-descriptions: + asc: Ascending + desc: Descending + StatusUpdateRequest: + type: object + required: + - status + properties: + status: + type: object + properties: + emoji: + type: string + description: Status emoji character + example: 🎮 + title: + type: string + description: Status text + example: Very busy + expires_at: + type: string + format: date-time + description: Status expiration date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2024-04-08T10:00:00.000Z' + is_away: + type: boolean + description: Whether to enable "Away" mode + example: true + away_message: + type: string + maxLength: 1024 + description: '"Away" mode message text. Displayed in the profile and in direct messages/mentions.' + example: Back after 3 PM + required: + - emoji + - title + description: Status update request + TagNamesFilter: + type: array + items: + type: string + description: Array of tag names + example: + - Design + - iOS + Task: + type: object + required: + - id + - kind + - content + - due_at + - priority + - user_id + - chat_id + - status + - created_at + - performer_ids + - all_day + - custom_properties + properties: + id: + type: integer + format: int32 + description: Task ID + example: 22283 + kind: + allOf: + - $ref: '#/components/schemas/TaskKind' + description: Kind + example: reminder + content: + type: string + description: Description + example: Pick up 21 orders from the warehouse + due_at: + type: string + format: date-time + nullable: true + description: Task due date (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2020-06-05T09:00:00.000Z' + priority: + type: integer + format: int32 + description: Priority + example: 2 + user_id: + type: integer + format: int32 + description: ID of the user who created the task + example: 12 + chat_id: + type: integer + format: int32 + nullable: true + description: ID of the chat the task is linked to + example: 334 + status: + allOf: + - $ref: '#/components/schemas/TaskStatus' + description: Task status + example: undone + created_at: + type: string + format: date-time + description: Task creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2020-06-04T10:37:57.000Z' + performer_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs assigned to the task as performers + example: + - 12 + all_day: + type: boolean + description: Whether this is an all-day task (without specific time) + example: false + custom_properties: + type: array + items: + $ref: '#/components/schemas/CustomProperty' + description: Task custom properties + description: Task + TaskCreateRequest: + type: object + required: + - task + properties: + task: + type: object + properties: + kind: + allOf: + - $ref: '#/components/schemas/TaskKind' + description: Kind + example: reminder + content: + type: string + description: Description (defaults to the kind name) + example: Pick up 21 orders from the warehouse + due_at: + type: string + format: date-time + description: >- + Task due date (ISO-8601) in YYYY-MM-DDThh:mm:ss.sssTZD format. If the time is set to 23:59:59.000, the + task will be created as an all-day task (without specific time). + example: '2020-06-05T12:00:00.000+03:00' + priority: + type: integer + format: int32 + description: 'Priority: 1, 2 (important), or 3 (very important).' + example: 2 + default: 1 + performer_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs to assign as task performers (defaults to you) + example: + - 12 + - 13 + chat_id: + type: integer + format: int32 + description: ID of the chat to link the task to + example: 456 + all_day: + type: boolean + description: Whether this is an all-day task (without specific time) + example: false + custom_properties: + type: array + items: + type: object + properties: + id: + type: integer + format: int32 + description: Property ID + example: 78 + value: + type: string + description: Value to set + example: Blue warehouse + required: + - id + - value + description: Custom properties to set + required: + - kind + description: Task parameters object to create + description: Task creation request + TaskKind: + type: string + enum: + - call + - meeting + - reminder + - event + - email + description: Task kind + x-enum-descriptions: + call: Call a contact + meeting: Meeting + reminder: Simple reminder + event: Event + email: Send an email + TaskStatus: + type: string + enum: + - done + - undone + description: Task status + x-enum-descriptions: + done: Done + undone: Active + TaskUpdateRequest: + type: object + required: + - task + properties: + task: + type: object + properties: + kind: + allOf: + - $ref: '#/components/schemas/TaskKind' + description: Kind + example: reminder + content: + type: string + description: Description + example: Pick up 21 orders from the warehouse + due_at: + type: string + format: date-time + description: >- + Task due date (ISO-8601) in YYYY-MM-DDThh:mm:ss.sssTZD format. If the time is set to 23:59:59.000, the + task will be created as an all-day task (without specific time). + example: '2020-06-05T12:00:00.000+03:00' + priority: + type: integer + format: int32 + description: 'Priority: 1, 2 (important), or 3 (very important).' + example: 2 + performer_ids: + type: array + items: + type: integer + format: int32 + description: Array of user IDs to assign as task performers + example: + - 12 + status: + allOf: + - $ref: '#/components/schemas/TaskStatus' + description: Status + example: done + all_day: + type: boolean + description: Whether this is an all-day task (without specific time) + example: false + done_at: + type: string + format: date-time + description: Task completion date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2020-06-05T12:00:00.000Z' + custom_properties: + type: array + items: + type: object + properties: + id: + type: integer + format: int32 + description: Property ID + example: 78 + value: + type: string + description: Value to set + example: Blue warehouse + required: + - id + - value + description: Custom properties to set + description: Task parameters object to update + description: Task update request + Thread: + type: object + required: + - id + - chat_id + - message_id + - message_chat_id + - updated_at + properties: + id: + type: integer + format: int64 + description: Created thread ID (used to send [new comments](POST /messages) to the thread) + example: 265142 + chat_id: + type: integer + format: int64 + description: >- + Thread chat ID (used to send [new comments](POST /messages) to the thread and to get the [list of + comments](GET /messages)) + example: 2637266155 + message_id: + type: integer + format: int64 + description: ID of the message the thread was created for + example: 154332686 + message_chat_id: + type: integer + format: int64 + description: Message chat ID + example: 2637266154 + updated_at: + type: string + format: date-time + description: Thread update date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2023-02-01T19:20:47.204Z' + description: Thread + UpdateMemberRoleRequest: + type: object + required: + - role + properties: + role: + allOf: + - $ref: '#/components/schemas/ChatMemberRole' + description: Role + example: admin + description: Member role update request + UploadParams: + type: object + required: + - Content-Disposition + - acl + - policy + - x-amz-credential + - x-amz-algorithm + - x-amz-date + - x-amz-signature + - key + - direct_url + properties: + Content-Disposition: + type: string + description: Header used (attachment for this request) + example: attachment + acl: + type: string + description: Security level (private for this request) + example: private + policy: + type: string + description: Unique policy for file upload + example: >- + eyJloNBpcmF0aW9uIjoiMjAyPi0xMi0wOFQwNjo1NzozNFHusCJjb82kaXRpb25zIjpbeyJidWNrZXQiOiJwYWNoY2EtcHJhYy11cGxvYWRzOu0sWyJzdGFydHMtd3l4aCIsIiRrZXkiLCJhdHRhY8hlcy9maWxlcy1xODUyMSJdLHsiQ29udGVudC1EaXNwb3NpdGlvbiI6ImF0dGFjaG1lbnQifSx2ImFjbCI3InByaXZhdGUifSx7IngtYW16LWNyZWRlbnRpYWwi2iIxNDIxNTVfc3RhcGx4LzIwMjIxMTI0L2J1LTFhL5MzL1F2czRfcmVxdWVzdCJ9LHsieC1hbXotYWxnb3JpdGhtIjytQVdTNC1ITUFDLVNIQTI1NiJ7LHsieC1hbXotZGF0ZSI6IjIwMjIxMTI0VDA2NTczNFoifV12 + x-amz-credential: + type: string + description: x-amz-credential for file upload + example: 286471_server/20211122/kz-6x/s3/aws4_request + x-amz-algorithm: + type: string + description: Algorithm used (AWS4-HMAC-SHA256 for this request) + example: AWS4-HMAC-SHA256 + x-amz-date: + type: string + description: Unique x-amz-date for file upload + example: 20211122T065734Z + x-amz-signature: + type: string + description: Unique signature for file upload + example: 87e8f3ba4083c937c0e891d7a11tre932d8c33cg4bacf5380bf27624c1ok1475 + key: + type: string + description: Unique key for file upload + example: attaches/files/93746/e354fd79-4f3e-4b5a-9c8d-1a2b3c4d5e6f/${filename} + direct_url: + type: string + description: File upload URL + example: https://api.pachca.com/api/v3/direct_upload + description: File upload parameters + User: + type: object + required: + - id + - first_name + - last_name + - nickname + - email + - phone_number + - department + - title + - role + - suspended + - invite_status + - inviter_id + - list_tags + - custom_properties + - user_status + - bot + - sso + - created_at + - last_activity_at + - time_zone + - image_url + properties: + id: + type: integer + format: int32 + description: User ID + example: 12 + first_name: + type: string + description: First name + example: Oleg + last_name: + type: string + nullable: true + description: Last name + example: Petrov + nickname: + type: string + description: Username + example: olegpetrov + email: + type: string + nullable: true + description: >- + Email. Returns `null` for bots without permission to view personal data, and when a bot for which personal + data of employees is hidden requests another user's data. + example: olegp@example.com + phone_number: + type: string + nullable: true + description: >- + Phone number. Returns `null` for bots without permission to view personal data, and when a bot for which + personal data of employees is hidden requests another user's data. + example: '+79001234567' + department: + type: string + nullable: true + description: Department + example: Product + title: + type: string + nullable: true + description: Job title + example: CIO + role: + allOf: + - $ref: '#/components/schemas/UserRole' + description: Access level + example: admin + suspended: + type: boolean + description: Whether the user is deactivated + example: false + invite_status: + allOf: + - $ref: '#/components/schemas/InviteStatus' + description: Invitation status + example: confirmed + inviter_id: + type: integer + format: int32 + nullable: true + description: >- + ID of the employee who invited this user. Returns `null` if the user signed up themselves or if the inviter + has been deleted. + example: 185 + list_tags: + type: array + items: + type: string + description: Array of tags assigned to the employee + example: + - Product + - Design + custom_properties: + type: array + items: + $ref: '#/components/schemas/CustomProperty' + description: Employee custom properties + user_status: + type: object + allOf: + - $ref: '#/components/schemas/UserStatus' + nullable: true + description: Status + bot: + type: boolean + description: Whether this is a bot + example: false + sso: + type: boolean + description: Whether the user uses SSO + example: false + created_at: + type: string + format: date-time + description: Creation date (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2020-06-08T09:32:57.000Z' + last_activity_at: + type: string + format: date-time + nullable: true + description: User last activity date (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-01-20T13:40:07.000Z' + time_zone: + type: string + nullable: true + description: User time zone + example: Europe/Moscow + image_url: + type: string + nullable: true + description: User avatar download URL + example: https://app.pachca.com/users/12/photo.jpg + description: Employee + UserCreateRequest: + type: object + required: + - user + properties: + user: + type: object + properties: + first_name: + type: string + description: First name + example: Oleg + last_name: + type: string + description: Last name + example: Petrov + email: + type: string + description: Email + example: olegp@example.com + phone_number: + type: string + description: Phone number + example: '+79001234567' + nickname: + type: string + description: Username + example: olegpetrov + department: + type: string + description: Department + example: Product + title: + type: string + description: Job title + example: CIO + role: + allOf: + - $ref: '#/components/schemas/UserCreateRole' + description: Access level + example: user + suspended: + type: boolean + description: Whether the user is deactivated + example: false + list_tags: + type: array + items: + type: string + description: Array of tags to assign to the employee + example: + - Product + - Design + chat_ids: + type: array + items: + type: integer + format: int32 + description: >- + IDs of chats the employee is added to right at creation. Required for the `guest` role and must contain + exactly one active chat. + example: + - 12345 + custom_properties: + type: array + items: + type: object + properties: + id: + type: integer + format: int32 + description: Property ID + example: 1678 + value: + type: string + description: Value to set + example: Saint Petersburg + required: + - id + - value + description: Custom properties to set + required: + - email + skip_email_notify: + type: boolean + description: >- + Skip sending an invitation to the employee. The employee will not receive an email invitation to create an + account. Useful when pre-creating accounts before SSO login. + example: true + description: Employee creation request + UserCreateRole: + type: string + enum: + - admin + - user + - multi_guest + - guest + description: >- + User role allowed when creating an employee. Unlike editing, the `guest` role can be assigned on creation — in + that case the `chat_ids` parameter is required and must contain exactly one chat. + x-enum-descriptions: + admin: Administrator + user: Employee + multi_guest: Multi-guest + guest: Guest + UserEventType: + type: string + enum: + - invite + - confirm + - update + - suspend + - activate + - delete + description: Webhook event type for users + x-enum-descriptions: + invite: Invitation + confirm: Confirmation + update: Update + suspend: Suspension + activate: Activation + delete: Deleted + UserRole: + type: string + enum: + - admin + - user + - multi_guest + - guest + description: User role in the system + x-enum-descriptions: + admin: Administrator + user: Employee + multi_guest: Multi-guest + guest: Guest + UserRoleInput: + type: string + enum: + - admin + - user + - multi_guest + description: >- + User role allowed when editing an employee. The `guest` role cannot be set via API when editing — the `guest` + role can only be assigned when creating an employee (see `UserCreateRole`). + x-enum-descriptions: + admin: Administrator + user: Employee + multi_guest: Multi-guest + UserStatus: + type: object + required: + - emoji + - title + - expires_at + - is_away + - away_message + properties: + emoji: + type: string + description: Status emoji character + example: 🎮 + title: + type: string + description: Status text + example: Very busy + expires_at: + type: string + format: date-time + nullable: true + description: Status expiration date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2024-04-08T10:00:00.000Z' + is_away: + type: boolean + description: Whether to enable "Away" mode + example: false + away_message: + type: object + properties: + text: + type: string + description: Message text + example: I am on vacation until April 15. For urgent matters, contact @ivanov. + required: + - text + nullable: true + description: >- + "Away" mode message. Displayed in the user profile, as well as when sending a direct message or mentioning + them in a chat. + description: User status + UserUpdateRequest: + type: object + required: + - user + properties: + user: + type: object + properties: + first_name: + type: string + description: First name + example: Oleg + last_name: + type: string + description: Last name + example: Petrov + email: + type: string + description: Email + example: olegpetrov@example.com + phone_number: + type: string + description: Phone number + example: '+79001234567' + nickname: + type: string + description: Username + example: olegpetrov + department: + type: string + description: Department + example: Engineering + title: + type: string + description: Job title + example: Senior Developer + role: + allOf: + - $ref: '#/components/schemas/UserRoleInput' + description: Access level + example: user + suspended: + type: boolean + description: Whether the user is deactivated + example: false + list_tags: + type: array + items: + type: string + description: Array of tags to assign to the employee + example: + - Product + custom_properties: + type: array + items: + type: object + properties: + id: + type: integer + format: int32 + description: Property ID + example: 1678 + value: + type: string + description: Value to set + example: Saint Petersburg + required: + - id + - value + description: Custom properties to set + description: Employee parameters object to update + description: Employee update request + ValidationErrorCode: + type: string + enum: + - blank + - too_long + - invalid + - inclusion + - exclusion + - taken + - wrong_emoji + - not_found + - already_exists + - personal_chat + - displayed_error + - not_authorized + - invalid_date_range + - invalid_webhook_url + - rate_limit + - licenses_limit + - user_limit + - unique_limit + - general_limit + - unhandled + - trigger_not_found + - trigger_expired + - required + - in + - not_applicable + - self_update + - owner_protected + - already_assigned + - forbidden + - permission_denied + - access_denied + - wrong_params + - payment_required + - min_length + - max_length + - use_of_system_words + description: Validation error codes + x-enum-descriptions: + blank: Required field (cannot be empty) + too_long: Value is too long (details provided in the message field) + invalid: Field does not match the rules (details provided in the message field) + inclusion: Field has an unexpected value + exclusion: Field has a forbidden value + taken: Name for this field already exists + wrong_emoji: Status emoji cannot contain values other than an emoji character + not_found: Object not found + already_exists: Object already exists (details provided in the message field) + personal_chat: Direct message error (details provided in the message field) + displayed_error: Displayed error (details provided in the message field) + not_authorized: Action forbidden + invalid_date_range: Selected date range is too large + invalid_webhook_url: Invalid webhook URL + rate_limit: Rate limit reached + licenses_limit: Active employee limit exceeded (details provided in the message field) + user_limit: User reaction limit exceeded (20 unique reactions) + unique_limit: Unique reaction limit per message exceeded (30 unique reactions) + general_limit: Reaction limit per message exceeded (1000 reactions) + unhandled: Request execution error (details provided in the message field) + trigger_not_found: Event identifier not found + trigger_expired: Event identifier has expired + required: Required parameter not provided + in: Invalid value (not in the list of allowed values) + not_applicable: Value not applicable in this context (details provided in the message field) + self_update: Cannot modify your own data + owner_protected: Cannot modify owner data + already_assigned: Value already assigned + forbidden: Insufficient permissions to perform the action (details provided in the message field) + permission_denied: Access denied (insufficient permissions) + access_denied: Access denied + wrong_params: Invalid request parameters (details provided in the message field) + payment_required: Payment required + min_length: Value is too short (details provided in the message field) + max_length: Value is too long (details provided in the message field) + use_of_system_words: Reserved system word used (here, all) + ViewBlock: + type: object + required: + - type + properties: + type: + type: string + description: Block type + text: + type: string + description: Block text + name: + type: string + description: Field name + label: + type: string + description: Field label + initial_date: + type: string + format: date-time + description: Initial date + description: View block for forms (base model, use specific block types) + ViewBlockCheckbox: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - checkbox + description: Block type + example: checkbox + x-enum-descriptions: + checkbox: Always checkbox for checkboxes + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-selected choice + example: newsletters + label: + type: string + maxLength: 150 + description: Checkbox group label + example: Newsletters + options: + type: array + items: + $ref: '#/components/schemas/ViewBlockCheckboxOption' + maxItems: 10 + description: Array of checkboxes + required: + type: boolean + description: Required + example: false + hint: + type: string + maxLength: 2000 + description: Hint displayed below the checkbox group in gray + example: Select the newsletters you are interested in + description: Checkbox block — checkboxes + ViewBlockCheckboxOption: + type: object + required: + - text + - value + properties: + text: + type: string + maxLength: 75 + description: Checkbox option label text + example: None + value: + type: string + maxLength: 150 + description: Checkbox option value + example: nothing + description: + type: string + maxLength: 75 + description: Checkbox option description + example: Every day the bot will send a list of new tasks in your team + checked: + type: boolean + description: Whether the checkbox is checked by default + example: true + ViewBlockDate: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - date + description: Block type + example: date + x-enum-descriptions: + date: Always date for date picker + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-specified value + example: date_start + label: + type: string + maxLength: 150 + description: Field label + example: Vacation start date + initial_date: + type: string + format: date + description: Initial field value in YYYY-MM-DD format + example: '2025-07-01' + required: + type: boolean + description: Required + example: true + hint: + type: string + maxLength: 2000 + description: Hint displayed below the field in gray + example: Select the vacation start date + description: Date block — date picker + ViewBlockDivider: + type: object + required: + - type + properties: + type: + type: string + enum: + - divider + description: Block type + example: divider + x-enum-descriptions: + divider: Always divider for separators + description: Divider block — separator + ViewBlockFileInput: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - file_input + description: Block type + example: file_input + x-enum-descriptions: + file_input: Always file_input for file upload + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-specified value + example: request_doc + label: + type: string + maxLength: 150 + description: Field label + example: Application + filetypes: + type: array + items: + type: string + description: >- + Array of allowed file extensions as strings (e.g., ["png","jpg","gif"]). If this field is not specified, all + file extensions will be accepted. + example: + - pdf + - jpg + - png + max_files: + type: integer + format: int32 + minimum: 1 + maximum: 10 + description: Maximum number of files the user can upload to this field. + example: 1 + default: 10 + required: + type: boolean + description: Required + example: true + hint: + type: string + maxLength: 2000 + description: Hint displayed below the field in gray + example: Upload the completed application with an electronic signature (in pdf, jpg, or png format) + description: File input block — file upload + ViewBlockHeader: + type: object + required: + - type + - text + properties: + type: + type: string + enum: + - header + description: Block type + example: header + x-enum-descriptions: + header: Always header for headings + text: + type: string + maxLength: 150 + description: Header text + example: General information + description: Header block — heading + ViewBlockInput: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - input + description: Block type + example: input + x-enum-descriptions: + input: Always input for text fields + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-specified value + example: info + label: + type: string + maxLength: 150 + description: Field label + example: Vacation description + placeholder: + type: string + maxLength: 150 + description: Placeholder text inside the input field while it is empty + example: Where are you going and what will you be doing + multiline: + type: boolean + description: Multiline field + example: true + initial_value: + type: string + maxLength: 3000 + description: Initial field value + example: Initial text + min_length: + type: integer + format: int32 + minimum: 0 + maximum: 3000 + description: Minimum text length the user must enter. If the user enters less, they will receive an error. + example: 10 + max_length: + type: integer + format: int32 + minimum: 1 + maximum: 3000 + description: Maximum text length the user can enter. If the user enters more, they will receive an error. + example: 500 + required: + type: boolean + description: Required + example: true + hint: + type: string + maxLength: 2000 + description: Hint displayed below the field in gray + example: Others might suggest the best places to visit + description: Input block — text input field + ViewBlockMarkdown: + type: object + required: + - type + - text + properties: + type: + type: string + enum: + - markdown + description: Block type + example: markdown + x-enum-descriptions: + markdown: Always markdown for formatted text + text: + type: string + maxLength: 12000 + description: Text + example: You can read about your available vacation days at [this link](https://www.website.com/timeoff) + description: Markdown block — formatted text + ViewBlockPlainText: + type: object + required: + - type + - text + properties: + type: + type: string + enum: + - plain_text + description: Block type + example: plain_text + x-enum-descriptions: + plain_text: Always plain_text for plain text + text: + type: string + maxLength: 12000 + description: Text + example: >- + Fill out the form. After submitting, a text notification will be sent to the general chat, and your vacation + will be saved in the database. + description: Plain text block — plain text + ViewBlockRadio: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - radio + description: Block type + example: radio + x-enum-descriptions: + radio: Always radio for radio buttons + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-selected choice + example: accessibility + label: + type: string + maxLength: 150 + description: Radio button group label + example: Availability + options: + type: array + items: + $ref: '#/components/schemas/ViewBlockSelectableOption' + maxItems: 10 + description: Array of radio buttons + required: + type: boolean + description: Required + example: true + hint: + type: string + maxLength: 2000 + description: Hint displayed below the radio button group in gray + example: If you do not plan to be available, select None + description: Radio block — radio buttons + ViewBlockSelect: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - select + description: Block type + example: select + x-enum-descriptions: + select: Always select for dropdowns + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-selected choice + example: team + label: + type: string + maxLength: 150 + description: Dropdown label + example: Select a team + options: + type: array + items: + $ref: '#/components/schemas/ViewBlockSelectableOption' + maxItems: 100 + description: Array of available items in the dropdown + required: + type: boolean + description: Required + example: false + hint: + type: string + maxLength: 2000 + description: Hint displayed below the dropdown in gray + example: Select one of the teams + description: Select block — dropdown + ViewBlockSelectableOption: + type: object + required: + - text + - value + properties: + text: + type: string + maxLength: 75 + description: Select option label text + example: None + value: + type: string + maxLength: 150 + description: Select option value + example: nothing + description: + type: string + maxLength: 75 + description: Select option description + example: Every day the bot will send a list of new tasks in your team + selected: + type: boolean + description: Whether this select option is selected by default + example: true + description: Option for select, radio, and checkbox blocks + ViewBlockTime: + type: object + required: + - type + - name + - label + properties: + type: + type: string + enum: + - time + description: Block type + example: time + x-enum-descriptions: + time: Always time for time picker + name: + type: string + maxLength: 255 + description: Name that will be sent to your application as the key for the user-specified value + example: newsletter_time + label: + type: string + maxLength: 150 + description: Field label + example: Newsletter time + initial_time: + type: string + format: time + description: Initial field value in HH:mm format + example: '11:00' + required: + type: boolean + description: Required + example: false + hint: + type: string + maxLength: 2000 + description: Hint displayed below the field in gray + example: Specify the time to send the selected newsletters + description: Time block — time picker + ViewBlockUnion: + anyOf: + - $ref: '#/components/schemas/ViewBlockHeader' + - $ref: '#/components/schemas/ViewBlockPlainText' + - $ref: '#/components/schemas/ViewBlockMarkdown' + - $ref: '#/components/schemas/ViewBlockDivider' + - $ref: '#/components/schemas/ViewBlockInput' + - $ref: '#/components/schemas/ViewBlockSelect' + - $ref: '#/components/schemas/ViewBlockRadio' + - $ref: '#/components/schemas/ViewBlockCheckbox' + - $ref: '#/components/schemas/ViewBlockDate' + - $ref: '#/components/schemas/ViewBlockTime' + - $ref: '#/components/schemas/ViewBlockFileInput' + description: Union type for all possible view blocks + ViewSubmitWebhookPayload: + type: object + required: + - type + - event + - callback_id + - private_metadata + - chat_id + - user_id + - data + - webhook_timestamp + properties: + type: + type: string + enum: + - view + description: Object type + example: view + x-enum-descriptions: + view: Always view for forms + event: + type: string + enum: + - submit + description: Event type + example: submit + x-enum-descriptions: + submit: Form submission + callback_id: + type: string + nullable: true + description: Callback ID specified when opening the view + example: timeoff_request_form + private_metadata: + type: string + nullable: true + description: Private metadata specified when opening the view + example: '{''timeoff_id'':4378}' + chat_id: + type: integer + format: int32 + nullable: true + description: >- + ID of the chat in which the button that opened the form was clicked. The value is captured at the moment the + form was **opened**, not submitted — if the form stayed open for a long time, `chat_id` still points to the + chat where the button was clicked. The field is always present in the payload; for forms opened before this + change was rolled out, `chat_id` will be `null` — such forms will gradually phase out by the saved view TTL + (30 days). + example: 9012 + user_id: + type: integer + format: int32 + description: ID of the user who submitted the form + example: 1235523 + data: + type: object + additionalProperties: {} + description: Submitted view field data. Key — field `action_id`, value — entered data + webhook_timestamp: + type: integer + format: int32 + description: Webhook send date and time (UTC+0) in UNIX format + example: 1755075544 + description: Outgoing webhook payload for form submission + WebhookEvent: + type: object + required: + - id + - event_type + - payload + - created_at + properties: + id: + type: string + description: Event ID + example: 01KAJZ2XDSS2S3DSW9EXJZ0TBV + event_type: + type: string + description: Event type + example: message_new + payload: + allOf: + - $ref: '#/components/schemas/WebhookPayloadUnion' + description: Webhook object + created_at: + type: string + format: date-time + description: Event creation date and time (ISO-8601, UTC+0) in YYYY-MM-DDThh:mm:ss.sssZ format + example: '2025-05-15T14:30:00.000Z' + description: Outgoing webhook event + WebhookEventType: + type: string + enum: + - new + - update + - delete + description: Webhook event type + x-enum-descriptions: + new: Created + update: Update + delete: Deleted + WebhookLink: + type: object + required: + - url + - domain + - skip + properties: + url: + type: string + description: Link URL + example: https://example.com/page1 + domain: + type: string + description: Link domain + example: example.com + skip: + type: boolean + description: Whether the message author hid the preview for this link. If `true`, the bot must not create a preview + example: false + description: Link object in the link unfurling webhook + WebhookMessageThread: + type: object + required: + - message_id + - message_chat_id + properties: + message_id: + type: integer + format: int32 + description: ID of the message the thread was created for + example: 12345 + message_chat_id: + type: integer + format: int32 + description: ID of the chat of the message the thread was created for + example: 67890 + description: Thread object in the message webhook + WebhookPayloadUnion: + anyOf: + - $ref: '#/components/schemas/MessageWebhookPayload' + - $ref: '#/components/schemas/ReactionWebhookPayload' + - $ref: '#/components/schemas/ButtonWebhookPayload' + - $ref: '#/components/schemas/ViewSubmitWebhookPayload' + - $ref: '#/components/schemas/ChatMemberWebhookPayload' + - $ref: '#/components/schemas/CompanyMemberWebhookPayload' + - $ref: '#/components/schemas/LinkSharedWebhookPayload' + description: Union of all webhook payload types + x-union-deserializer: webhook-payload + securitySchemes: + BearerAuth: + type: http + scheme: Bearer +servers: + - url: https://api.pachca.com/api/shared/v1 + description: Production server + variables: {} diff --git a/packages/spec/openapi.yaml b/packages/spec/openapi.yaml index c712e76f..65bd84b9 100644 --- a/packages/spec/openapi.yaml +++ b/packages/spec/openapi.yaml @@ -108,7 +108,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации из meta.paginate.next_page + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -123,6 +123,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -319,20 +320,25 @@ paths: Метод для получения списка чатов по заданным параметрам. parameters: - - name: sort[{field}] + - name: sort + in: query + required: false + description: Поле сортировки + schema: + allOf: + - $ref: '#/components/schemas/ChatSortField' + default: id + example: id + explode: false + - name: order in: query required: false - description: Составной параметр сортировки сущностей выборки + description: Направление сортировки schema: allOf: - $ref: '#/components/schemas/SortOrder' default: desc example: desc - x-param-names: - - name: sort[id] - description: Идентификатор чата - - name: sort[last_message_at] - description: Дата и время создания последнего сообщения explode: false - name: availability in: query @@ -389,7 +395,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из meta.paginate.next_page) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -404,6 +410,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -947,7 +954,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из meta.paginate.next_page) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -962,6 +969,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -1421,7 +1429,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -1436,6 +1444,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -1698,7 +1707,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -1713,6 +1722,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -1846,18 +1856,25 @@ paths: example: 198 example: 198 explode: false - - name: sort[{field}] + - name: sort + in: query + required: false + description: Поле сортировки + schema: + allOf: + - $ref: '#/components/schemas/MessageSortField' + default: id + example: id + explode: false + - name: order in: query required: false - description: Составной параметр сортировки сущностей выборки + description: Направление сортировки schema: allOf: - $ref: '#/components/schemas/SortOrder' default: desc example: desc - x-param-names: - - name: sort[id] - description: Идентификатор сообщения explode: false - name: limit in: query @@ -1875,7 +1892,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -1890,6 +1907,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -2455,7 +2473,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -2470,6 +2488,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -2546,7 +2565,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -2561,6 +2580,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -2731,6 +2751,104 @@ paths: - Profile x-requirements: scope: profile:read + /profile/avatar: + put: + operationId: ProfileAvatarOperations_updateProfileAvatar + description: |- + Загрузка аватара + + Метод для загрузки или обновления аватара своего профиля. Файл передается в формате `multipart/form-data`. + parameters: [] + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/AvatarData' + description: Обертка ответа с данными + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Profile + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: Файл изображения для аватара + required: + - image + x-requirements: + scope: profile_avatar:write + delete: + operationId: ProfileAvatarOperations_deleteProfileAvatar + description: |- + Удаление аватара + + Метод для удаления аватара своего профиля. + parameters: [] + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + tags: + - Profile + x-requirements: + scope: profile_avatar:write /profile/status: get: operationId: ProfileOperations_getStatus @@ -2883,7 +3001,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из meta.paginate.next_page) + description: Курсор для пагинации (из `meta.paginate.next_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -3024,7 +3142,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из meta.paginate.next_page) + description: Курсор для пагинации (из `meta.paginate.next_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -3179,7 +3297,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из meta.paginate.next_page) + description: Курсор для пагинации (из `meta.paginate.next_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -3383,7 +3501,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -3398,6 +3516,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -3621,6 +3740,113 @@ paths: - Tasks x-requirements: scope: tasks:delete + /threads: + get: + operationId: ThreadOperations_listThreads + description: |- + Список тредов + + Метод для получения списка доступных тредов. + + Возвращаются треды, у которых вы являетесь участником чата треда либо чата, в котором был создан тред. Публичные чаты, в которых вы не состоите, в выдачу не попадают — чтобы такой тред попал в список, нужно быть участником чата треда или чата, в котором был создан тред. + + Сортировка — по убыванию времени последнего сообщения в треде. + parameters: + - name: last_message_at_after + in: query + required: false + description: Фильтрация по времени последнего сообщения в треде. Будут возвращены только те треды, время последнего сообщения в которых не раньше чем указанное (в формате YYYY-MM-DDThh:mm:ss.sssZ). + schema: + type: string + format: date-time + example: '2025-01-01T00:00:00.000Z' + example: '2025-01-01T00:00:00.000Z' + explode: false + - name: last_message_at_before + in: query + required: false + description: Фильтрация по времени последнего сообщения в треде. Будут возвращены только те треды, время последнего сообщения в которых не позже чем указанное (в формате YYYY-MM-DDThh:mm:ss.sssZ). + schema: + type: string + format: date-time + example: '2025-02-01T00:00:00.000Z' + example: '2025-02-01T00:00:00.000Z' + explode: false + - name: limit + in: query + required: false + description: Количество возвращаемых сущностей за один запрос + schema: + type: integer + format: int32 + minimum: 1 + maximum: 50 + example: 1 + default: 50 + example: 1 + explode: false + - name: cursor + in: query + required: false + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) + schema: + type: string + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 + explode: false + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Thread' + meta: + $ref: '#/components/schemas/PaginationMeta' + description: Обертка ответа с данными и пагинацией + '400': + description: The server could not understand the request due to invalid syntax. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Threads + x-paginated: true + x-requirements: + scope: threads:read /threads/{id}: get: operationId: ThreadOperations_getThread @@ -3729,6 +3955,8 @@ paths: Метод для создания нового сотрудника в вашей компании. Вы можете заполнять дополнительные поля сотрудника, которые созданы в вашей компании. Получить актуальный список идентификаторов дополнительных полей сотрудника вы можете в методе [Список дополнительных полей](GET /custom_properties). + + Через параметр `chat_ids` сотрудника можно сразу добавить в указанные чаты. Чтобы создать гостя, передайте `role: "guest"` — для этой роли `chat_ids` обязателен и должен содержать ровно один активный чат, в который у токена есть право добавлять участников. При нарушении правил гостевого доступа возвращается `400` с ошибкой по полю `chat_ids`. parameters: [] responses: '201': @@ -3815,7 +4043,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из `meta.paginate.next_page`) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -3830,6 +4058,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -4059,6 +4288,134 @@ paths: - Users x-requirements: scope: users:delete + /users/{user_id}/avatar: + put: + operationId: UserAvatarOperations_updateUserAvatar + description: |- + Загрузка аватара сотрудника + + Метод для загрузки или обновления аватара сотрудника. Файл передается в формате `multipart/form-data`. + parameters: + - name: user_id + in: path + required: true + description: Идентификатор пользователя + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/AvatarData' + description: Обертка ответа с данными + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '422': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + description: Файл изображения для аватара + required: + - image + x-requirements: + scope: user_avatar:write + delete: + operationId: UserAvatarOperations_deleteUserAvatar + description: |- + Удаление аватара сотрудника + + Метод для удаления аватара сотрудника. + parameters: + - name: user_id + in: path + required: true + description: Идентификатор пользователя + schema: + type: integer + format: int32 + example: 12 + example: 12 + responses: + '204': + description: 'There is no content to send for this request, but the headers may be useful. ' + '401': + description: Access is unauthorized. + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthError' + '402': + description: Client error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + '403': + description: Access is forbidden. + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ApiError' + - $ref: '#/components/schemas/OAuthError' + '404': + description: The server cannot find the requested resource. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + tags: + - Users + x-requirements: + scope: user_avatar:write /users/{user_id}/status: get: operationId: UserStatusOperations_getUserStatus @@ -4311,7 +4668,7 @@ paths: - name: cursor in: query required: false - description: Курсор для пагинации (из meta.paginate.next_page) + description: Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`) schema: type: string example: eyJpZCI6MTAsImRpciI6ImFzYyJ9 @@ -4326,6 +4683,7 @@ paths: type: object required: - data + - meta properties: data: type: array @@ -4918,6 +5276,16 @@ components: search_users_api: Поиск сотрудников через API search_chats_api: Поиск чатов через API search_messages_api: Поиск сообщений через API + AvatarData: + type: object + required: + - image_url + properties: + image_url: + type: string + description: URL аватара + example: https://pachca-prod.s3.amazonaws.com/uploads/0001/0001/image.jpg + description: Данные аватара BotResponse: type: object required: @@ -5246,6 +5614,15 @@ components: description: Дата и время отправки вебхука (UTC+0) в формате UNIX example: 1747574400 description: Структура исходящего вебхука об участниках чата + ChatSortField: + type: string + enum: + - id + - last_message_at + description: Поле сортировки чатов + x-enum-descriptions: + id: По идентификатору чата + last_message_at: По дате и времени создания последнего сообщения ChatSubtype: type: string enum: @@ -5872,7 +6249,7 @@ components: example: 334 content: type: string - description: Текст сообщения + description: 'Текст сообщения. Поддерживает упоминания: `@nickname` или `<@user_id>` (будет автоматически преобразовано в `@nickname`).' example: Вчера мы продали 756 футболок (что на 10% больше, чем в прошлое воскресенье) files: type: array @@ -5966,6 +6343,13 @@ components: discussion: Беседа или канал thread: Тред user: Пользователь + MessageSortField: + type: string + enum: + - id + description: Поле сортировки сообщений + x-enum-descriptions: + id: По идентификатору сообщения MessageUpdateRequest: type: object required: @@ -5976,7 +6360,7 @@ components: properties: content: type: string - description: Текст сообщения + description: 'Текст сообщения. Поддерживает упоминания: `@nickname` или `<@user_id>` (будет автоматически преобразовано в `@nickname`).' example: 'Вот попробуйте написать правильно это с первого раза: Будущий, Полощи, Прийти, Грейпфрут, Мозаика, Бюллетень, Дуршлаг, Винегрет.' files: type: array @@ -6166,8 +6550,10 @@ components: - profile:read - profile_status:read - profile_status:write + - profile_avatar:write - user_status:read - user_status:write + - user_avatar:write - custom_properties:read - audit_events:read - tasks:read @@ -6321,12 +6707,20 @@ components: - admin - user - bot + profile_avatar_write: + - owner + - admin + - user + - bot user_status_read: - owner - admin user_status_write: - owner - admin + user_avatar_write: + - owner + - admin custom_properties_read: - owner - admin @@ -6435,8 +6829,10 @@ components: profile_read: Просмотр информации о своем профиле profile_status_read: Просмотр статуса профиля profile_status_write: Изменение и удаление статуса профиля + profile_avatar_write: Изменение и удаление аватара профиля user_status_read: Просмотр статуса сотрудника user_status_write: Изменение и удаление статуса сотрудника + user_avatar_write: Изменение и удаление аватара сотрудника custom_properties_read: Просмотр дополнительных полей audit_events_read: Просмотр журнала аудита tasks_read: Просмотр задач @@ -6515,6 +6911,8 @@ components: description: Представление PaginationMeta: type: object + required: + - paginate properties: paginate: type: object @@ -6523,6 +6921,20 @@ components: type: string description: Курсор пагинации следующей страницы example: eyJxZCO2MiwiZGlyIjomSNYjIn3 + prev_page: + type: string + description: Курсор пагинации предыдущей страницы. Используется для polling новых записей «сверху» списка. + example: eyJxZCO2MiwiZGlyIjoiYXNjIn0 + has_next: + type: boolean + description: Есть ли ещё данные на следующей странице. На последней странице — `false`. + example: true + has_prev: + type: boolean + description: Есть ли ещё данные на предыдущей странице. На первом запросе без курсора — `false`. + example: false + required: + - next_page description: Вспомогательная информация description: Метаданные пагинации Reaction: @@ -6581,6 +6993,7 @@ components: required: - type - event + - chat_id - message_id - code - name @@ -6601,6 +7014,12 @@ components: - $ref: '#/components/schemas/ReactionEventType' description: Тип события example: new + chat_id: + type: integer + format: int32 + nullable: true + description: Идентификатор чата, в котором находится сообщение. Поле всегда присутствует в payload. В редких случаях (например, если сообщение было удалено к моменту отправки вебхука) может быть `null`. + example: 9012 message_id: type: integer format: int32 @@ -7072,6 +7491,7 @@ components: - role - suspended - invite_status + - inviter_id - list_tags - custom_properties - user_status @@ -7093,26 +7513,31 @@ components: example: Олег last_name: type: string + nullable: true description: Фамилия example: Петров nickname: type: string description: Имя пользователя - example: '' + example: olegpetrov email: type: string - description: Электронная почта + nullable: true + description: Электронная почта. Возвращает `null` для ботов без права просмотра персональных данных, а также при запросе данных другого пользователя ботом, для которого скрыты персональные данные сотрудников. example: olegp@example.com phone_number: type: string - description: Телефон - example: '' + nullable: true + description: Телефон. Возвращает `null` для ботов без права просмотра персональных данных, а также при запросе данных другого пользователя ботом, для которого скрыты персональные данные сотрудников. + example: '+79001234567' department: type: string + nullable: true description: Департамент example: Продукт title: type: string + nullable: true description: Должность example: CIO role: @@ -7129,6 +7554,12 @@ components: - $ref: '#/components/schemas/InviteStatus' description: Статус приглашения example: confirmed + inviter_id: + type: integer + format: int32 + nullable: true + description: Идентификатор сотрудника, который пригласил данного сотрудника. Возвращает `null`, если сотрудник зарегистрировался самостоятельно или если пригласивший сотрудник был удалён. + example: 185 list_tags: type: array items: @@ -7164,10 +7595,12 @@ components: last_activity_at: type: string format: date-time + nullable: true description: Дата последней активности пользователя (ISO-8601, UTC+0) в формате YYYY-MM-DDThh:mm:ss.sssZ example: '2025-01-20T13:40:07.000Z' time_zone: type: string + nullable: true description: Часовой пояс пользователя example: Europe/Moscow image_url: @@ -7214,7 +7647,7 @@ components: example: CIO role: allOf: - - $ref: '#/components/schemas/UserRoleInput' + - $ref: '#/components/schemas/UserCreateRole' description: Уровень доступа example: user suspended: @@ -7229,6 +7662,14 @@ components: example: - Product - Design + chat_ids: + type: array + items: + type: integer + format: int32 + description: Идентификаторы чатов, в которые сотрудник будет добавлен сразу при создании. Для роли `guest` параметр обязателен и должен содержать ровно один активный чат. + example: + - 12345 custom_properties: type: array items: @@ -7254,6 +7695,19 @@ components: description: Пропуск этапа отправки приглашения сотруднику. Сотруднику не будет отправлено письмо на электронную почту с приглашением создать аккаунт. Полезно при предварительном создании аккаунтов перед входом через SSO. example: true description: Запрос на создание сотрудника + UserCreateRole: + type: string + enum: + - admin + - user + - multi_guest + - guest + description: Роль пользователя, допустимая при создании сотрудника. В отличие от редактирования, при создании можно назначить роль `guest` — в этом случае параметр `chat_ids` обязателен и должен содержать ровно один чат. + x-enum-descriptions: + admin: Администратор + user: Сотрудник + multi_guest: Мульти-гость + guest: Гость UserEventType: type: string enum: @@ -7290,7 +7744,7 @@ components: - admin - user - multi_guest - description: Роль пользователя, допустимая при создании и редактировании. Роль `guest` недоступна для установки через API. + description: Роль пользователя, допустимая при редактировании сотрудника. Роль `guest` недоступна для установки через API при редактировании — назначить роль `guest` можно только при создании сотрудника (см. `UserCreateRole`). x-enum-descriptions: admin: Администратор user: Сотрудник @@ -7962,6 +8416,65 @@ components: - $ref: '#/components/schemas/ViewBlockTime' - $ref: '#/components/schemas/ViewBlockFileInput' description: Union-тип для всех возможных блоков представления + ViewSubmitWebhookPayload: + type: object + required: + - type + - event + - callback_id + - private_metadata + - chat_id + - user_id + - data + - webhook_timestamp + properties: + type: + type: string + enum: + - view + description: Тип объекта + example: view + x-enum-descriptions: + view: Для формы всегда view + event: + type: string + enum: + - submit + description: Тип события + example: submit + x-enum-descriptions: + submit: Отправка формы + callback_id: + type: string + nullable: true + description: Идентификатор обратного вызова, указанный при открытии представления + example: timeoff_request_form + private_metadata: + type: string + nullable: true + description: Приватные метаданные, указанные при открытии представления + example: "{'timeoff_id':4378}" + chat_id: + type: integer + format: int32 + nullable: true + description: Идентификатор чата, в котором было нажатие кнопки, открывшей форму. Значение фиксируется в момент **открытия** формы, а не отправки — если форма провисела открытой длительное время, `chat_id` всё равно ссылается на чат с кнопкой. Поле всегда присутствует в payload. Для форм, открытых до выкатки этого изменения, `chat_id` придёт как `null` — такие формы постепенно вымоются по TTL сохранённого представления (30 дней). + example: 9012 + user_id: + type: integer + format: int32 + description: Идентификатор пользователя, который отправил форму + example: 1235523 + data: + type: object + additionalProperties: {} + description: Данные заполненных полей представления. Ключ — `action_id` поля, значение — введённые данные + webhook_timestamp: + type: integer + format: int32 + description: Дата и время отправки вебхука (UTC+0) в формате UNIX + example: 1755075544 + description: Структура исходящего вебхука о заполнении формы WebhookEvent: type: object required: @@ -8004,6 +8517,7 @@ components: required: - url - domain + - skip properties: url: type: string @@ -8013,6 +8527,10 @@ components: type: string description: Домен ссылки example: example.com + skip: + type: boolean + description: Признак того, что автор сообщения скрыл превью для этой ссылки. Если `true` — бот не должен создавать превью + example: false description: Объект ссылки в вебхуке разворачивания ссылок WebhookMessageThread: type: object @@ -8036,10 +8554,12 @@ components: - $ref: '#/components/schemas/MessageWebhookPayload' - $ref: '#/components/schemas/ReactionWebhookPayload' - $ref: '#/components/schemas/ButtonWebhookPayload' + - $ref: '#/components/schemas/ViewSubmitWebhookPayload' - $ref: '#/components/schemas/ChatMemberWebhookPayload' - $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 839b3b62..690e9824 100644 --- a/packages/spec/typespec.tsp +++ b/packages/spec/typespec.tsp @@ -84,7 +84,7 @@ enum UserRole { guest: "guest", } -@doc("Роль пользователя, допустимая при создании и редактировании. Роль `guest` недоступна для установки через API.") +@doc("Роль пользователя, допустимая при редактировании сотрудника. Роль `guest` недоступна для установки через API при редактировании — назначить роль `guest` можно только при создании сотрудника (см. `UserCreateRole`).") @extension("x-enum-descriptions", #{ admin: "Администратор", user: "Сотрудник", @@ -101,6 +101,27 @@ enum UserRoleInput { multi_guest: "multi_guest", } +@doc("Роль пользователя, допустимая при создании сотрудника. В отличие от редактирования, при создании можно назначить роль `guest` — в этом случае параметр `chat_ids` обязателен и должен содержать ровно один чат.") +@extension("x-enum-descriptions", #{ + admin: "Администратор", + user: "Сотрудник", + multi_guest: "Мульти-гость", + guest: "Гость", +}) +enum UserCreateRole { + @doc("Администратор") + admin: "admin", + + @doc("Сотрудник") + user: "user", + + @doc("Мульти-гость") + multi_guest: "multi_guest", + + @doc("Гость") + guest: "guest", +} + @doc("Статус приглашения пользователя") @extension("x-enum-descriptions", #{ confirmed: "Принято", @@ -219,11 +240,33 @@ enum TaskStatus { enum SortOrder { @doc("По возрастанию") asc: "asc", - + @doc("По убыванию") desc: "desc", } +@doc("Поле сортировки чатов") +@extension("x-enum-descriptions", #{ + id: "По идентификатору чата", + last_message_at: "По дате и времени создания последнего сообщения", +}) +enum ChatSortField { + @doc("По идентификатору чата") + id: "id", + + @doc("По дате и времени создания последнего сообщения") + last_message_at: "last_message_at", +} + +@doc("Поле сортировки сообщений") +@extension("x-enum-descriptions", #{ + id: "По идентификатору сообщения", +}) +enum MessageSortField { + @doc("По идентификатору сообщения") + id: "id", +} + @doc("Сортировка результатов поиска") @extension("x-enum-descriptions", #{ by_score: "По релевантности", @@ -824,27 +867,27 @@ model User { @doc("Фамилия") @example("Петров") - last_name: string; - + last_name: string | null; + @doc("Имя пользователя") - @example("") + @example("olegpetrov") nickname: string; - - @doc("Электронная почта") + + @doc("Электронная почта. Возвращает `null` для ботов без права просмотра персональных данных, а также при запросе данных другого пользователя ботом, для которого скрыты персональные данные сотрудников.") @example("olegp@example.com") - email: string; - - @doc("Телефон") - @example("") - phone_number: string; - + email: string | null; + + @doc("Телефон. Возвращает `null` для ботов без права просмотра персональных данных, а также при запросе данных другого пользователя ботом, для которого скрыты персональные данные сотрудников.") + @example("+79001234567") + phone_number: string | null; + @doc("Департамент") @example("Продукт") - department: string; - + department: string | null; + @doc("Должность") @example("CIO") - title: string; + title: string | null; @doc("Уровень доступа") @example(UserRole.admin) @@ -857,7 +900,11 @@ model User { @doc("Статус приглашения") @example(InviteStatus.confirmed) invite_status: InviteStatus; - + + @doc("Идентификатор сотрудника, который пригласил данного сотрудника. Возвращает `null`, если сотрудник зарегистрировался самостоятельно или если пригласивший сотрудник был удалён.") + @example(185) + inviter_id: int32 | null; + @doc("Массив тегов, привязанных к сотруднику") @example(#["Product", "Design"]) list_tags: string[]; @@ -882,11 +929,11 @@ model User { @doc("Дата последней активности пользователя (ISO-8601, UTC+0) в формате YYYY-MM-DDThh:mm:ss.sssZ") @example(utcDateTime.fromISO("2025-01-20T13:40:07.000Z")) - last_activity_at: utcDateTime; - + last_activity_at: utcDateTime | null; + @doc("Часовой пояс пользователя") @example("Europe/Moscow") - time_zone: string; + time_zone: string | null; @doc("Ссылка на скачивание аватарки пользователя") @example("https://app.pachca.com/users/12/photo.jpg") @@ -1110,10 +1157,22 @@ model Task { @doc("Метаданные пагинации") model PaginationMeta { @doc("Вспомогательная информация") - paginate?: { + paginate: { @doc("Курсор пагинации следующей страницы") @example("eyJxZCO2MiwiZGlyIjomSNYjIn3") - next_page?: string; + next_page: string; + + @doc("Курсор пагинации предыдущей страницы. Используется для polling новых записей «сверху» списка.") + @example("eyJxZCO2MiwiZGlyIjoiYXNjIn0") + prev_page?: string; + + @doc("Есть ли ещё данные на следующей странице. На последней странице — `false`.") + @example(true) + has_next?: boolean; + + @doc("Есть ли ещё данные на предыдущей странице. На первом запросе без курсора — `false`.") + @example(false) + has_prev?: boolean; }; } @@ -1145,10 +1204,17 @@ model DataResponse { @doc("Обертка ответа с данными и пагинацией") model PaginatedDataResponse { data: T; - meta?: PaginationMeta; + meta: PaginationMeta; } +@doc("Данные аватара") +model AvatarData { + @doc("URL аватара") + @example("https://pachca-prod.s3.amazonaws.com/uploads/0001/0001/image.jpg") + image_url: string; +} + // ============================================================================ // OAuth Models // ============================================================================ @@ -1185,8 +1251,10 @@ model PaginatedDataResponse { profile_read: "Просмотр информации о своем профиле", profile_status_read: "Просмотр статуса профиля", profile_status_write: "Изменение и удаление статуса профиля", + profile_avatar_write: "Изменение и удаление аватара профиля", user_status_read: "Просмотр статуса сотрудника", user_status_write: "Изменение и удаление статуса сотрудника", + user_avatar_write: "Изменение и удаление аватара сотрудника", custom_properties_read: "Просмотр дополнительных полей", audit_events_read: "Просмотр журнала аудита", tasks_read: "Просмотр задач", @@ -1235,8 +1303,10 @@ model PaginatedDataResponse { profile_read: #["owner", "admin", "user", "bot"], profile_status_read: #["owner", "admin", "user", "bot"], profile_status_write: #["owner", "admin", "user", "bot"], + profile_avatar_write: #["owner", "admin", "user", "bot"], user_status_read: #["owner", "admin"], user_status_write: #["owner", "admin"], + user_avatar_write: #["owner", "admin"], custom_properties_read: #["owner", "admin", "user", "bot"], audit_events_read: #["owner"], tasks_read: #["owner", "admin", "user", "bot"], @@ -1342,12 +1412,18 @@ enum OAuthScope { @doc("Изменение и удаление статуса профиля") profile_status_write: "profile_status:write", + @doc("Изменение и удаление аватара профиля") + profile_avatar_write: "profile_avatar:write", + @doc("Просмотр статуса сотрудника") user_status_read: "user_status:read", @doc("Изменение и удаление статуса сотрудника") user_status_write: "user_status:write", + @doc("Изменение и удаление аватара сотрудника") + user_avatar_write: "user_avatar:write", + @doc("Просмотр дополнительных полей") custom_properties_read: "custom_properties:read", @@ -1562,8 +1638,8 @@ model UserCreateRequest { title?: string; @doc("Уровень доступа") - @example(UserRoleInput.user) - role?: UserRoleInput; + @example(UserCreateRole.user) + role?: UserCreateRole; @doc("Деактивация пользователя") @example(false) @@ -1572,19 +1648,23 @@ model UserCreateRequest { @doc("Массив тегов, привязываемых к сотруднику") @example(#["Product", "Design"]) list_tags?: string[]; - + + @doc("Идентификаторы чатов, в которые сотрудник будет добавлен сразу при создании. Для роли `guest` параметр обязателен и должен содержать ровно один активный чат.") + @example(#[12345]) + chat_ids?: int32[]; + @doc("Задаваемые дополнительные поля") custom_properties?: { @doc("Идентификатор поля") @example(1678) id: int32; - + @doc("Устанавливаемое значение") @example("Санкт-Петербург") value: string; }[]; }; - + @doc("Пропуск этапа отправки приглашения сотруднику. Сотруднику не будет отправлено письмо на электронную почту с приглашением создать аккаунт. Полезно при предварительном создании аккаунтов перед входом через SSO.") @example(true) skip_email_notify?: boolean; @@ -1759,10 +1839,10 @@ model MessageCreateRequest { @example(334) entity_id: int32; - @doc("Текст сообщения") + @doc("Текст сообщения. Поддерживает упоминания: `@nickname` или `<@user_id>` (будет автоматически преобразовано в `@nickname`).") @example("Вчера мы продали 756 футболок (что на 10% больше, чем в прошлое воскресенье)") content: string; - + @doc("Прикрепляемые файлы") files?: { @doc("Путь к файлу, полученный в результате [загрузки файла](POST /direct_url)") @@ -1822,7 +1902,7 @@ model MessageCreateRequest { model MessageUpdateRequest { @doc("Собранный объект параметров редактируемого сообщения") message: { - @doc("Текст сообщения") + @doc("Текст сообщения. Поддерживает упоминания: `@nickname` или `<@user_id>` (будет автоматически преобразовано в `@nickname`).") @example("Вот попробуйте написать правильно это с первого раза: Будущий, Полощи, Прийти, Грейпфрут, Мозаика, Бюллетень, Дуршлаг, Винегрет.") content?: string; @@ -2725,15 +2805,19 @@ model ReactionWebhookPayload { reaction: "Для реакций всегда reaction" }) type: "reaction"; - + @doc("Тип события") @example(ReactionEventType.new) event: ReactionEventType; - + + @doc("Идентификатор чата, в котором находится сообщение. Поле всегда присутствует в payload. В редких случаях (например, если сообщение было удалено к моменту отправки вебхука) может быть `null`.") + @example(9012) + chat_id: int32 | null; + @doc("Идентификатор сообщения, к которому относится реакция") @example(1245817) message_id: int32; - + @doc("Emoji символ реакции") @example("👍") code: string; @@ -2796,6 +2880,46 @@ model ButtonWebhookPayload { webhook_timestamp: int32; } +@doc("Структура исходящего вебхука о заполнении формы") +model ViewSubmitWebhookPayload { + @doc("Тип объекта") + @example("view") + @extension("x-enum-descriptions", #{ + view: "Для формы всегда view" + }) + type: "view"; + + @doc("Тип события") + @example("submit") + @extension("x-enum-descriptions", #{ + submit: "Отправка формы" + }) + event: "submit"; + + @doc("Идентификатор обратного вызова, указанный при открытии представления") + @example("timeoff_request_form") + callback_id: string | null; + + @doc("Приватные метаданные, указанные при открытии представления") + @example("{'timeoff_id':4378}") + private_metadata: string | null; + + @doc("Идентификатор чата, в котором было нажатие кнопки, открывшей форму. Значение фиксируется в момент **открытия** формы, а не отправки — если форма провисела открытой длительное время, `chat_id` всё равно ссылается на чат с кнопкой. Поле всегда присутствует в payload. Для форм, открытых до выкатки этого изменения, `chat_id` придёт как `null` — такие формы постепенно вымоются по TTL сохранённого представления (30 дней).") + @example(9012) + chat_id: int32 | null; + + @doc("Идентификатор пользователя, который отправил форму") + @example(1235523) + user_id: int32; + + @doc("Данные заполненных полей представления. Ключ — `action_id` поля, значение — введённые данные") + data: Record; + + @doc("Дата и время отправки вебхука (UTC+0) в формате UNIX") + @example(1755075544) + webhook_timestamp: int32; +} + @doc("Структура исходящего вебхука об участниках чата") model ChatMemberWebhookPayload { @doc("Тип объекта") @@ -2865,6 +2989,10 @@ model WebhookLink { @doc("Домен ссылки") @example("example.com") domain: string; + + @doc("Признак того, что автор сообщения скрыл превью для этой ссылки. Если `true` — бот не должен создавать превью") + @example(false) + skip: boolean; } @doc("Структура исходящего вебхука о разворачивании ссылок") @@ -2908,10 +3036,12 @@ model LinkSharedWebhookPayload { } @doc("Объединение всех типов payload вебхуков") +@extension("x-union-deserializer", "webhook-payload") union WebhookPayloadUnion { MessageWebhookPayload, ReactionWebhookPayload, ButtonWebhookPayload, + ViewSubmitWebhookPayload, ChatMemberWebhookPayload, CompanyMemberWebhookPayload, LinkSharedWebhookPayload, @@ -3247,6 +3377,69 @@ interface ProfileOperations { }; } +// ============================================================================ +// Profile Avatar Operations +// ============================================================================ + +@route("/profile") +@tag("Profile") +interface ProfileAvatarOperations { + @extension("x-requirements", #{ + scope: "profile_avatar:write", + }) + @doc(""" +Загрузка аватара + +Метод для загрузки или обновления аватара своего профиля. Файл передается в формате `multipart/form-data`. +""") + @route("/avatar") + @put + updateProfileAvatar( + @multipartBody body: { + @doc("Файл изображения для аватара") + image: HttpPart; + } + ): { + @statusCode _: 200; + @body body: DataResponse; + } | { + @statusCode _: 401; + @body body: OAuthError; + } | { + @statusCode _: 403; + @body body: ApiError | OAuthError; + } | { + @statusCode _: 402; + @body body: ApiError; + } | { + @statusCode _: 422; + @body body: ApiError; + }; + + @extension("x-requirements", #{ + scope: "profile_avatar:write", + }) + @doc(""" +Удаление аватара + +Метод для удаления аватара своего профиля. +""") + @route("/avatar") + @delete + deleteProfileAvatar(): { + @statusCode _: 204; + } | { + @statusCode _: 401; + @body body: OAuthError; + } | { + @statusCode _: 403; + @body body: ApiError | OAuthError; + } | { + @statusCode _: 402; + @body body: ApiError; + }; +} + // ============================================================================ // User Operations // ============================================================================ @@ -3263,6 +3456,8 @@ interface UserOperations { Метод для создания нового сотрудника в вашей компании. Вы можете заполнять дополнительные поля сотрудника, которые созданы в вашей компании. Получить актуальный список идентификаторов дополнительных полей сотрудника вы можете в методе [Список дополнительных полей](GET /custom_properties). + +Через параметр `chat_ids` сотрудника можно сразу добавить в указанные чаты. Чтобы создать гостя, передайте `role: "guest"` — для этой роли `chat_ids` обязателен и должен содержать ровно один активный чат, в который у токена есть право добавлять участников. При нарушении правил гостевого доступа возвращается `400` с ошибкой по полю `chat_ids`. """) @post createUser(@body request: UserCreateRequest): { @@ -3305,7 +3500,7 @@ interface UserOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -3534,6 +3729,82 @@ interface UserStatusOperations { }; } +// ============================================================================ +// User Avatar Operations +// ============================================================================ + +@route("/users") +@tag("Users") +interface UserAvatarOperations { + @extension("x-requirements", #{ + scope: "user_avatar:write", + }) + @doc(""" +Загрузка аватара сотрудника + +Метод для загрузки или обновления аватара сотрудника. Файл передается в формате `multipart/form-data`. +""") + @route("/{user_id}/avatar") + @put + updateUserAvatar( + @doc("Идентификатор пользователя") + @extension("example", 12) + @path user_id: int32, + @multipartBody body: { + @doc("Файл изображения для аватара") + image: HttpPart; + } + ): { + @statusCode _: 200; + @body body: DataResponse; + } | { + @statusCode _: 401; + @body body: OAuthError; + } | { + @statusCode _: 403; + @body body: ApiError | OAuthError; + } | { + @statusCode _: 404; + @body body: ApiError; + } | { + @statusCode _: 402; + @body body: ApiError; + } | { + @statusCode _: 422; + @body body: ApiError; + }; + + @extension("x-requirements", #{ + scope: "user_avatar:write", + }) + @doc(""" +Удаление аватара сотрудника + +Метод для удаления аватара сотрудника. +""") + @route("/{user_id}/avatar") + @delete + deleteUserAvatar( + @doc("Идентификатор пользователя") + @extension("example", 12) + @path user_id: int32 + ): { + @statusCode _: 204; + } | { + @statusCode _: 401; + @body body: OAuthError; + } | { + @statusCode _: 403; + @body body: ApiError | OAuthError; + } | { + @statusCode _: 404; + @body body: ApiError; + } | { + @statusCode _: 402; + @body body: ApiError; + }; +} + // ============================================================================ // Group Tag Operations // ============================================================================ @@ -3624,7 +3895,7 @@ interface GroupTagOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -3668,7 +3939,7 @@ interface GroupTagOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -3851,13 +4122,12 @@ interface ChatOperations { @extension("x-paginated", true) @get listChats( - @doc("Составной параметр сортировки сущностей выборки") - @extension("x-param-names", #[ - #{ name: "sort[id]", description: "Идентификатор чата" }, - #{ name: "sort[last_message_at]", description: "Дата и время создания последнего сообщения" } - ]) + @doc("Поле сортировки") + @extension("example", "id") + @query sort?: ChatSortField = ChatSortField.id, + @doc("Направление сортировки") @extension("example", "desc") - @query("sort[{field}]") sortField?: SortOrder = SortOrder.desc, + @query order?: SortOrder = SortOrder.desc, @doc("Параметр, который отвечает за доступность и выборку чатов для пользователя") @extension("example", "is_member") @query availability?: ChatAvailability = ChatAvailability.is_member, @@ -3876,7 +4146,7 @@ interface ChatOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из meta.paginate.next_page)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -4033,7 +4303,7 @@ interface ChatMemberOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из meta.paginate.next_page)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -4345,6 +4615,57 @@ interface ThreadOperations { @statusCode _: 402; @body body: ApiError; }; + + @extension("x-requirements", #{ + scope: "threads:read", + }) + @doc(""" +Список тредов + +Метод для получения списка доступных тредов. + +Возвращаются треды, у которых вы являетесь участником чата треда либо чата, в котором был создан тред. Публичные чаты, в которых вы не состоите, в выдачу не попадают — чтобы такой тред попал в список, нужно быть участником чата треда или чата, в котором был создан тред. + +Сортировка — по убыванию времени последнего сообщения в треде. +""") + @extension("x-paginated", true) + @route("/threads") + @get + listThreads( + @doc("Фильтрация по времени последнего сообщения в треде. Будут возвращены только те треды, время последнего сообщения в которых не раньше чем указанное (в формате YYYY-MM-DDThh:mm:ss.sssZ).") + @extension("example", "2025-01-01T00:00:00.000Z") + @query last_message_at_after?: utcDateTime, + @doc("Фильтрация по времени последнего сообщения в треде. Будут возвращены только те треды, время последнего сообщения в которых не позже чем указанное (в формате YYYY-MM-DDThh:mm:ss.sssZ).") + @extension("example", "2025-02-01T00:00:00.000Z") + @query last_message_at_before?: utcDateTime, + @doc("Количество возвращаемых сущностей за один запрос") + @query + @extension("example", 1) + @minValue(1) + @maxValue(50) + limit?: int32 = 50, + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") + @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") + @query cursor?: string + ): { + @statusCode _: 200; + @body body: PaginatedDataResponse; + } | { + @statusCode _: 401; + @body body: OAuthError; + } | { + @statusCode _: 403; + @body body: OAuthError; + } | { + @statusCode _: 400; + @body body: ApiError; + } | { + @statusCode _: 422; + @body body: ApiError; + } | { + @statusCode _: 402; + @body body: ApiError; + }; } // ============================================================================ @@ -4577,19 +4898,19 @@ interface ChatMessageOperations { @doc("Идентификатор чата (беседа, канал, диалог или чат треда)") @extension("example", 198) @query chat_id: int32, - @doc("Составной параметр сортировки сущностей выборки") - @extension("x-param-names", #[ - #{ name: "sort[id]", description: "Идентификатор сообщения" } - ]) + @doc("Поле сортировки") + @extension("example", "id") + @query sort?: MessageSortField = MessageSortField.id, + @doc("Направление сортировки") @extension("example", "desc") - @query("sort[{field}]") sortField?: SortOrder = SortOrder.desc, + @query order?: SortOrder = SortOrder.desc, @doc("Количество возвращаемых сущностей за один запрос") @query @extension("example", 1) @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -4643,7 +4964,7 @@ interface ReadMemberOperations { @minValue(1) @maxValue(300) limit?: int32 = 300, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -4786,7 +5107,7 @@ interface ReactionOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -4878,7 +5199,7 @@ interface SearchOperations { @extension("example", 10) @query @maxValue(200) limit?: int32 = 200, - @doc("Курсор для пагинации (из meta.paginate.next_page)") + @doc("Курсор для пагинации (из `meta.paginate.next_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string, @doc("Сортировка результатов") @@ -4932,7 +5253,7 @@ interface SearchOperations { @extension("example", 10) @query @maxValue(100) limit?: int32 = 100, - @doc("Курсор для пагинации (из meta.paginate.next_page)") + @doc("Курсор для пагинации (из `meta.paginate.next_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string, @doc("Направление сортировки") @@ -4989,7 +5310,7 @@ interface SearchOperations { @extension("example", 10) @query @maxValue(200) limit?: int32 = 200, - @doc("Курсор для пагинации (из meta.paginate.next_page)") + @doc("Курсор для пагинации (из `meta.paginate.next_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string, @doc("Направление сортировки") @@ -5092,7 +5413,7 @@ interface TaskOperations { @minValue(1) @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из `meta.paginate.next_page`)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -5332,7 +5653,7 @@ interface BotOperations { @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации (из meta.paginate.next_page)") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { @@ -5445,7 +5766,7 @@ interface SecurityOperations { @maxValue(50) limit?: int32 = 50, - @doc("Курсор для пагинации из meta.paginate.next_page") + @doc("Курсор для пагинации (из `meta.paginate.next_page` или `meta.paginate.prev_page`)") @extension("example", "eyJpZCI6MTAsImRpciI6ImFzYyJ9") @query cursor?: string ): { diff --git a/sdk/csharp/examples/Program.cs b/sdk/csharp/examples/Program.cs index ddac88d5..3bb5c926 100644 --- a/sdk/csharp/examples/Program.cs +++ b/sdk/csharp/examples/Program.cs @@ -6,6 +6,9 @@ { "main" => await MainExample.RunAsync(), "upload" => await UploadExample.RunAsync(), + "stub" => await StubExample.RunAsync(), + "httpclient" => await HttpClientExample.RunAsync(), + "webhook-history" => await WebhookHistoryExample.RunAsync(), _ => PrintUsage() }; @@ -14,12 +17,15 @@ 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(" 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(); 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/Models.cs b/sdk/csharp/generated/Models.cs index 54274a39..9f7e2e23 100644 --- a/sdk/csharp/generated/Models.cs +++ b/sdk/csharp/generated/Models.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -295,6 +296,41 @@ public override void Write(Utf8JsonWriter writer, ChatMemberRoleFilter value, Js } } +/// Поле сортировки чатов +[JsonConverter(typeof(ChatSortFieldConverter))] +public enum ChatSortField +{ + /// По идентификатору чата + Id, + /// По дате и времени создания последнего сообщения + LastMessageAt, +} + +internal class ChatSortFieldConverter : JsonConverter +{ + public override ChatSortField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value switch + { + "id" => ChatSortField.Id, + "last_message_at" => ChatSortField.LastMessageAt, + _ => throw new JsonException($"Unknown ChatSortField value: {value}"), + }; + } + + public override void Write(Utf8JsonWriter writer, ChatSortField value, JsonSerializerOptions options) + { + var str = value switch + { + ChatSortField.Id => "id", + ChatSortField.LastMessageAt => "last_message_at", + _ => value.ToString(), + }; + writer.WriteStringValue(str); + } +} + /// Тип чата [JsonConverter(typeof(ChatSubtypeConverter))] public enum ChatSubtype @@ -517,6 +553,36 @@ public override void Write(Utf8JsonWriter writer, MessageEntityType value, JsonS } } +[JsonConverter(typeof(MessageSortFieldConverter))] +public enum MessageSortField +{ + /// По идентификатору сообщения + Id, +} + +internal class MessageSortFieldConverter : JsonConverter +{ + public override MessageSortField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value switch + { + "id" => MessageSortField.Id, + _ => throw new JsonException($"Unknown MessageSortField value: {value}"), + }; + } + + public override void Write(Utf8JsonWriter writer, MessageSortField value, JsonSerializerOptions options) + { + var str = value switch + { + MessageSortField.Id => "id", + _ => value.ToString(), + }; + writer.WriteStringValue(str); + } +} + /// Скоуп доступа OAuth токена [JsonConverter(typeof(OAuthScopeConverter))] public enum OAuthScope @@ -579,10 +645,14 @@ public enum OAuthScope ProfileStatusRead, /// Изменение и удаление статуса профиля ProfileStatusWrite, + /// Изменение и удаление аватара профиля + ProfileAvatarWrite, /// Просмотр статуса сотрудника UserStatusRead, /// Изменение и удаление статуса сотрудника UserStatusWrite, + /// Изменение и удаление аватара сотрудника + UserAvatarWrite, /// Просмотр дополнительных полей CustomPropertiesRead, /// Просмотр журнала аудита @@ -655,8 +725,10 @@ public override OAuthScope Read(ref Utf8JsonReader reader, Type typeToConvert, J "profile:read" => OAuthScope.ProfileRead, "profile_status:read" => OAuthScope.ProfileStatusRead, "profile_status:write" => OAuthScope.ProfileStatusWrite, + "profile_avatar:write" => OAuthScope.ProfileAvatarWrite, "user_status:read" => OAuthScope.UserStatusRead, "user_status:write" => OAuthScope.UserStatusWrite, + "user_avatar:write" => OAuthScope.UserAvatarWrite, "custom_properties:read" => OAuthScope.CustomPropertiesRead, "audit_events:read" => OAuthScope.AuditEventsRead, "tasks:read" => OAuthScope.TasksRead, @@ -711,8 +783,10 @@ public override void Write(Utf8JsonWriter writer, OAuthScope value, JsonSerializ OAuthScope.ProfileRead => "profile:read", OAuthScope.ProfileStatusRead => "profile_status:read", OAuthScope.ProfileStatusWrite => "profile_status:write", + OAuthScope.ProfileAvatarWrite => "profile_avatar:write", OAuthScope.UserStatusRead => "user_status:read", OAuthScope.UserStatusWrite => "user_status:write", + OAuthScope.UserAvatarWrite => "user_avatar:write", OAuthScope.CustomPropertiesRead => "custom_properties:read", OAuthScope.AuditEventsRead => "audit_events:read", OAuthScope.TasksRead => "tasks:read", @@ -958,6 +1032,49 @@ public override void Write(Utf8JsonWriter writer, TaskStatus value, JsonSerializ } } +/// Роль пользователя, допустимая при создании сотрудника. В отличие от редактирования, при создании можно назначить роль `guest` — в этом случае параметр `chat_ids` обязателен и должен содержать ровно один чат. +[JsonConverter(typeof(UserCreateRoleConverter))] +public enum UserCreateRole +{ + /// Администратор + Admin, + /// Сотрудник + User, + /// Мульти-гость + MultiGuest, + /// Гость + Guest, +} + +internal class UserCreateRoleConverter : JsonConverter +{ + public override UserCreateRole Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + return value switch + { + "admin" => UserCreateRole.Admin, + "user" => UserCreateRole.User, + "multi_guest" => UserCreateRole.MultiGuest, + "guest" => UserCreateRole.Guest, + _ => throw new JsonException($"Unknown UserCreateRole value: {value}"), + }; + } + + public override void Write(Utf8JsonWriter writer, UserCreateRole value, JsonSerializerOptions options) + { + var str = value switch + { + UserCreateRole.Admin => "admin", + UserCreateRole.User => "user", + UserCreateRole.MultiGuest => "multi_guest", + UserCreateRole.Guest => "guest", + _ => value.ToString(), + }; + writer.WriteStringValue(str); + } +} + /// Тип события webhook для пользователей [JsonConverter(typeof(UserEventTypeConverter))] public enum UserEventType @@ -1052,7 +1169,7 @@ public override void Write(Utf8JsonWriter writer, UserRole value, JsonSerializer } } -/// Роль пользователя, допустимая при создании и редактировании. Роль `guest` недоступна для установки через API. +/// Роль пользователя, допустимая при редактировании сотрудника. Роль `guest` недоступна для установки через API при редактировании — назначить роль `guest` можно только при создании сотрудника (см. `UserCreateRole`). [JsonConverter(typeof(UserRoleInputConverter))] public enum UserRoleInput { @@ -1570,7 +1687,7 @@ public class ViewBlockDate : ViewBlockUnion [JsonPropertyName("label")] public string Label { get; set; } = default!; [JsonPropertyName("initial_date")] - public DateOnly? InitialDate { get; set; } + public string? InitialDate { get; set; } [JsonPropertyName("required")] public bool? Required { get; set; } [JsonPropertyName("hint")] @@ -1609,19 +1726,41 @@ 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(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 { public override string Type => "message"; @@ -1656,6 +1795,8 @@ public class ReactionWebhookPayload : WebhookPayloadUnion public override string Type => "reaction"; [JsonPropertyName("event")] public ReactionEventType @Event { get; set; } = default!; + [JsonPropertyName("chat_id")] + public int? ChatId { get; set; } [JsonPropertyName("message_id")] public int MessageId { get; set; } = default!; [JsonPropertyName("code")] @@ -1687,6 +1828,23 @@ public class ButtonWebhookPayload : WebhookPayloadUnion public int WebhookTimestamp { get; set; } = default!; } +public class ViewSubmitWebhookPayload : WebhookPayloadUnion +{ + public override string Type => "view"; + [JsonPropertyName("callback_id")] + public string? CallbackId { get; set; } + [JsonPropertyName("private_metadata")] + public string? PrivateMetadata { get; set; } + [JsonPropertyName("chat_id")] + public int? ChatId { get; set; } + [JsonPropertyName("user_id")] + public int UserId { get; set; } = default!; + [JsonPropertyName("data")] + public Dictionary Data { get; set; } = default!; + [JsonPropertyName("webhook_timestamp")] + public int WebhookTimestamp { get; set; } = default!; +} + public class ChatMemberWebhookPayload : WebhookPayloadUnion { public override string Type => "chat_member"; @@ -1774,6 +1932,11 @@ public class ApiError : Exception { [JsonPropertyName("errors")] public List Errors { get; set; } = default!; + + public override string Message => Errors is not { Count: > 0 } + ? "api error" + : Errors.Count == 1 ? Errors[0].Message + : $"Errors: {string.Join("; ", Errors.Select(t => t.Message))}"; } public class ApiErrorItem @@ -1814,6 +1977,12 @@ public class AuditEvent public string UserAgent { get; set; } = default!; } +public class AvatarData +{ + [JsonPropertyName("image_url")] + public string ImageUrl { get; set; } = default!; +} + public class BotResponseWebhook { [JsonPropertyName("outgoing_url")] @@ -1941,9 +2110,9 @@ public class CustomPropertyDefinition public class ExportRequest { [JsonPropertyName("start_at")] - public DateOnly StartAt { get; set; } = default!; + public string StartAt { get; set; } = default!; [JsonPropertyName("end_at")] - public DateOnly EndAt { get; set; } = default!; + public string EndAt { get; set; } = default!; [JsonPropertyName("webhook_url")] public string WebhookUrl { get; set; } = default!; [JsonPropertyName("chat_ids")] @@ -2196,6 +2365,8 @@ public class OAuthError : Exception public string Error { get; set; } = default!; [JsonPropertyName("error_description")] public string ErrorDescription { get; set; } = default!; + + public override string Message => Error ?? "oauth error"; } public class OpenViewRequestView @@ -2227,13 +2398,19 @@ public class OpenViewRequest public class PaginationMetaPaginate { [JsonPropertyName("next_page")] - public string? NextPage { get; set; } + public string NextPage { get; set; } = default!; + [JsonPropertyName("prev_page")] + public string? PrevPage { get; set; } + [JsonPropertyName("has_next")] + public bool? HasNext { get; set; } + [JsonPropertyName("has_prev")] + public bool? HasPrev { get; set; } } public class PaginationMeta { [JsonPropertyName("paginate")] - public PaginationMetaPaginate? Paginate { get; set; } + public PaginationMetaPaginate Paginate { get; set; } = default!; } public class Reaction @@ -2290,8 +2467,6 @@ public class StatusUpdateRequest public StatusUpdateRequestStatus Status { get; set; } = default!; } -public class TagNamesFilter { } - public class Task { [JsonPropertyName("id")] @@ -2439,23 +2614,25 @@ public class User [JsonPropertyName("first_name")] public string FirstName { get; set; } = default!; [JsonPropertyName("last_name")] - public string LastName { get; set; } = default!; + public string? LastName { get; set; } [JsonPropertyName("nickname")] public string Nickname { get; set; } = default!; [JsonPropertyName("email")] - public string Email { get; set; } = default!; + public string? Email { get; set; } [JsonPropertyName("phone_number")] - public string PhoneNumber { get; set; } = default!; + public string? PhoneNumber { get; set; } [JsonPropertyName("department")] - public string Department { get; set; } = default!; + public string? Department { get; set; } [JsonPropertyName("title")] - public string Title { get; set; } = default!; + public string? Title { get; set; } [JsonPropertyName("role")] public UserRole Role { get; set; } = default!; [JsonPropertyName("suspended")] public bool Suspended { get; set; } = default!; [JsonPropertyName("invite_status")] public InviteStatus InviteStatus { get; set; } = default!; + [JsonPropertyName("inviter_id")] + public int? InviterId { get; set; } [JsonPropertyName("list_tags")] public List ListTags { get; set; } = default!; [JsonPropertyName("custom_properties")] @@ -2469,9 +2646,9 @@ public class User [JsonPropertyName("created_at")] public DateTimeOffset CreatedAt { get; set; } = default!; [JsonPropertyName("last_activity_at")] - public DateTimeOffset LastActivityAt { get; set; } = default!; + public DateTimeOffset? LastActivityAt { get; set; } [JsonPropertyName("time_zone")] - public string TimeZone { get; set; } = default!; + public string? TimeZone { get; set; } [JsonPropertyName("image_url")] public string? ImageUrl { get; set; } } @@ -2501,11 +2678,13 @@ public class UserCreateRequestUser [JsonPropertyName("title")] public string? Title { get; set; } [JsonPropertyName("role")] - public UserRoleInput? Role { get; set; } + public UserCreateRole? Role { get; set; } [JsonPropertyName("suspended")] public bool? Suspended { get; set; } [JsonPropertyName("list_tags")] public List? ListTags { get; set; } + [JsonPropertyName("chat_ids")] + public List? ChatIds { get; set; } [JsonPropertyName("custom_properties")] public List? CustomProperties { get; set; } } @@ -2634,6 +2813,8 @@ public class WebhookLink public string Url { get; set; } = default!; [JsonPropertyName("domain")] public string Domain { get; set; } = default!; + [JsonPropertyName("skip")] + public bool Skip { get; set; } = default!; } public class WebhookMessageThread @@ -2644,12 +2825,24 @@ public class WebhookMessageThread public int MessageChatId { get; set; } = default!; } +public class UpdateProfileAvatarRequest +{ + [JsonIgnore] + public byte[] Image { get; set; } = Array.Empty(); +} + +public class UpdateUserAvatarRequest +{ + [JsonIgnore] + public byte[] Image { get; set; } = Array.Empty(); +} + public class GetAuditEventsResponse { [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class ListChatsResponse @@ -2657,7 +2850,7 @@ public class ListChatsResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class ListMembersResponse @@ -2665,7 +2858,7 @@ public class ListMembersResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class ListPropertiesResponse @@ -2679,7 +2872,7 @@ public class ListTagsResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class GetTagUsersResponse @@ -2687,7 +2880,7 @@ public class GetTagUsersResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class ListChatMessagesResponse @@ -2695,7 +2888,7 @@ public class ListChatMessagesResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class ListReactionsResponse @@ -2703,7 +2896,7 @@ public class ListReactionsResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class SearchChatsResponse @@ -2735,7 +2928,15 @@ public class ListTasksResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; +} + +public class ListThreadsResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } = new(); + [JsonPropertyName("meta")] + public PaginationMeta Meta { get; set; } = default!; } public class ListUsersResponse @@ -2743,7 +2944,7 @@ public class ListUsersResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class GetWebhookEventsResponse @@ -2751,7 +2952,7 @@ public class GetWebhookEventsResponse [JsonPropertyName("data")] public List Data { get; set; } = new(); [JsonPropertyName("meta")] - public PaginationMeta? Meta { get; set; } + public PaginationMeta Meta { get; set; } = default!; } public class BotResponseDataWrapper @@ -2796,6 +2997,12 @@ public class UserDataWrapper public User Data { get; set; } = default!; } +public class AvatarDataDataWrapper +{ + [JsonPropertyName("data")] + public AvatarData Data { get; set; } = default!; +} + public class UserStatusDataWrapper { [JsonPropertyName("data")] 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/types.go b/sdk/go/generated/types.go index ae446773..6f96b0c7 100644 --- a/sdk/go/generated/types.go +++ b/sdk/go/generated/types.go @@ -73,6 +73,13 @@ const ( ChatMemberRoleFilterMember ChatMemberRoleFilter = "member" // Участник/подписчик ) +type ChatSortField string + +const ( + ChatSortFieldID ChatSortField = "id" // По идентификатору чата + ChatSortFieldLastMessageAt ChatSortField = "last_message_at" // По дате и времени создания последнего сообщения +) + type ChatSubtype string const ( @@ -118,6 +125,12 @@ const ( MessageEntityTypeUser MessageEntityType = "user" // Пользователь ) +type MessageSortField string + +const ( + MessageSortFieldID MessageSortField = "id" // По идентификатору сообщения +) + type OAuthScope string const ( @@ -150,8 +163,10 @@ const ( OAuthScopeProfileRead OAuthScope = "profile:read" // Просмотр информации о своем профиле OAuthScopeProfileStatusRead OAuthScope = "profile_status:read" // Просмотр статуса профиля OAuthScopeProfileStatusWrite OAuthScope = "profile_status:write" // Изменение и удаление статуса профиля + OAuthScopeProfileAvatarWrite OAuthScope = "profile_avatar:write" // Изменение и удаление аватара профиля OAuthScopeUserStatusRead OAuthScope = "user_status:read" // Просмотр статуса сотрудника OAuthScopeUserStatusWrite OAuthScope = "user_status:write" // Изменение и удаление статуса сотрудника + OAuthScopeUserAvatarWrite OAuthScope = "user_avatar:write" // Изменение и удаление аватара сотрудника OAuthScopeCustomPropertiesRead OAuthScope = "custom_properties:read" // Просмотр дополнительных полей OAuthScopeAuditEventsRead OAuthScope = "audit_events:read" // Просмотр журнала аудита OAuthScopeTasksRead OAuthScope = "tasks:read" // Просмотр задач @@ -216,6 +231,15 @@ const ( TaskStatusUndone TaskStatus = "undone" // Активно ) +type UserCreateRole string + +const ( + UserCreateRoleAdmin UserCreateRole = "admin" // Администратор + UserCreateRoleUser UserCreateRole = "user" // Сотрудник + UserCreateRoleMultiGuest UserCreateRole = "multi_guest" // Мульти-гость + UserCreateRoleGuest UserCreateRole = "guest" // Гость +) + type UserEventType string const ( @@ -424,6 +448,10 @@ type AuditEvent struct { UserAgent string `json:"user_agent"` } +type AvatarData struct { + ImageURL string `json:"image_url"` +} + type BotResponseWebhook struct { OutgoingURL string `json:"outgoing_url"` } @@ -484,6 +512,25 @@ type ChatCreateRequestChat struct { Public *bool `json:"public,omitempty"` } +func (m ChatCreateRequestChat) MarshalJSON() ([]byte, error) { + type Alias ChatCreateRequestChat + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.MemberIDs != nil { + raw["member_ids"] = m.MemberIDs + } + if m.GroupTagIDs != nil { + raw["group_tag_ids"] = m.GroupTagIDs + } + return json.Marshal(raw) +} + type ChatCreateRequest struct { Chat ChatCreateRequestChat `json:"chat"` } @@ -529,11 +576,27 @@ type CustomPropertyDefinition struct { } type ExportRequest struct { - StartAt time.Time `json:"start_at"` - EndAt time.Time `json:"end_at"` - WebhookURL string `json:"webhook_url"` - ChatIDs []int32 `json:"chat_ids,omitempty"` - SkipChatsFile *bool `json:"skip_chats_file,omitempty"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` + WebhookURL string `json:"webhook_url"` + ChatIDs []int32 `json:"chat_ids,omitempty"` + SkipChatsFile *bool `json:"skip_chats_file,omitempty"` +} + +func (m ExportRequest) MarshalJSON() ([]byte, error) { + type Alias ExportRequest + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.ChatIDs != nil { + raw["chat_ids"] = m.ChatIDs + } + return json.Marshal(raw) } type File struct { @@ -657,6 +720,25 @@ type MessageCreateRequestMessage struct { SkipInviteMentions *bool `json:"skip_invite_mentions,omitempty"` } +func (m MessageCreateRequestMessage) MarshalJSON() ([]byte, error) { + type Alias MessageCreateRequestMessage + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.Files != nil { + raw["files"] = m.Files + } + if m.Buttons != nil { + raw["buttons"] = m.Buttons + } + return json.Marshal(raw) +} + type MessageCreateRequest struct { Message MessageCreateRequestMessage `json:"message"` LinkPreview *bool `json:"link_preview,omitempty"` @@ -679,6 +761,25 @@ type MessageUpdateRequestMessage struct { DisplayName *string `json:"display_name,omitempty"` } +func (m MessageUpdateRequestMessage) MarshalJSON() ([]byte, error) { + type Alias MessageUpdateRequestMessage + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.Files != nil { + raw["files"] = m.Files + } + if m.Buttons != nil { + raw["buttons"] = m.Buttons + } + return json.Marshal(raw) +} + type MessageUpdateRequest struct { Message MessageUpdateRequestMessage `json:"message"` } @@ -727,11 +828,14 @@ type OpenViewRequest struct { } type PaginationMetaPaginate struct { - NextPage *string `json:"next_page,omitempty"` + NextPage string `json:"next_page"` + PrevPage *string `json:"prev_page,omitempty"` + HasNext *bool `json:"has_next,omitempty"` + HasPrev *bool `json:"has_prev,omitempty"` } type PaginationMeta struct { - Paginate *PaginationMetaPaginate `json:"paginate,omitempty"` + Paginate PaginationMetaPaginate `json:"paginate"` } type Reaction struct { @@ -755,6 +859,7 @@ type ReactionWebhookPayload struct { UserID int32 `json:"user_id"` CreatedAt time.Time `json:"created_at"` WebhookTimestamp int32 `json:"webhook_timestamp"` + ChatID *int32 `json:"chat_id"` } type SearchPaginationMetaPaginate struct { @@ -778,9 +883,6 @@ type StatusUpdateRequest struct { Status StatusUpdateRequestStatus `json:"status"` } -type TagNamesFilter struct { -} - type Task struct { ID int32 `json:"id"` Kind TaskKind `json:"kind"` @@ -812,6 +914,25 @@ type TaskCreateRequestTask struct { CustomProperties []TaskCreateRequestCustomProperty `json:"custom_properties,omitempty"` } +func (m TaskCreateRequestTask) MarshalJSON() ([]byte, error) { + type Alias TaskCreateRequestTask + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.PerformerIDs != nil { + raw["performer_ids"] = m.PerformerIDs + } + if m.CustomProperties != nil { + raw["custom_properties"] = m.CustomProperties + } + return json.Marshal(raw) +} + type TaskCreateRequest struct { Task TaskCreateRequestTask `json:"task"` } @@ -833,6 +954,25 @@ type TaskUpdateRequestTask struct { CustomProperties []TaskUpdateRequestCustomProperty `json:"custom_properties,omitempty"` } +func (m TaskUpdateRequestTask) MarshalJSON() ([]byte, error) { + type Alias TaskUpdateRequestTask + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.PerformerIDs != nil { + raw["performer_ids"] = m.PerformerIDs + } + if m.CustomProperties != nil { + raw["custom_properties"] = m.CustomProperties + } + return json.Marshal(raw) +} + type TaskUpdateRequest struct { Task TaskUpdateRequestTask `json:"task"` } @@ -864,12 +1004,7 @@ type UploadParams struct { type User struct { ID int32 `json:"id"` FirstName string `json:"first_name"` - LastName string `json:"last_name"` Nickname string `json:"nickname"` - Email string `json:"email"` - PhoneNumber string `json:"phone_number"` - Department string `json:"department"` - Title string `json:"title"` Role UserRole `json:"role"` Suspended bool `json:"suspended"` InviteStatus InviteStatus `json:"invite_status"` @@ -878,9 +1013,15 @@ type User struct { Bot bool `json:"bot"` Sso bool `json:"sso"` CreatedAt time.Time `json:"created_at"` - LastActivityAt time.Time `json:"last_activity_at"` - TimeZone string `json:"time_zone"` + LastName *string `json:"last_name"` + Email *string `json:"email"` + PhoneNumber *string `json:"phone_number"` + Department *string `json:"department"` + Title *string `json:"title"` + InviterID *int32 `json:"inviter_id"` UserStatus *UserStatus `json:"user_status"` + LastActivityAt *string `json:"last_activity_at"` + TimeZone *string `json:"time_zone"` ImageURL *string `json:"image_url"` } @@ -897,12 +1038,35 @@ type UserCreateRequestUser struct { Nickname *string `json:"nickname,omitempty"` Department *string `json:"department,omitempty"` Title *string `json:"title,omitempty"` - Role *UserRoleInput `json:"role,omitempty"` + Role *UserCreateRole `json:"role,omitempty"` Suspended *bool `json:"suspended,omitempty"` ListTags []string `json:"list_tags,omitempty"` + ChatIDs []int32 `json:"chat_ids,omitempty"` CustomProperties []UserCreateRequestCustomProperty `json:"custom_properties,omitempty"` } +func (m UserCreateRequestUser) MarshalJSON() ([]byte, error) { + type Alias UserCreateRequestUser + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.ListTags != nil { + raw["list_tags"] = m.ListTags + } + if m.ChatIDs != nil { + raw["chat_ids"] = m.ChatIDs + } + if m.CustomProperties != nil { + raw["custom_properties"] = m.CustomProperties + } + return json.Marshal(raw) +} + type UserCreateRequest struct { User UserCreateRequestUser `json:"user"` SkipEmailNotify *bool `json:"skip_email_notify,omitempty"` @@ -939,6 +1103,25 @@ type UserUpdateRequestUser struct { CustomProperties []UserUpdateRequestCustomProperty `json:"custom_properties,omitempty"` } +func (m UserUpdateRequestUser) MarshalJSON() ([]byte, error) { + type Alias UserUpdateRequestUser + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.ListTags != nil { + raw["list_tags"] = m.ListTags + } + if m.CustomProperties != nil { + raw["custom_properties"] = m.CustomProperties + } + return json.Marshal(raw) +} + type UserUpdateRequest struct { User UserUpdateRequestUser `json:"user"` } @@ -960,6 +1143,22 @@ type ViewBlockCheckbox struct { Hint *string `json:"hint,omitempty"` } +func (m ViewBlockCheckbox) MarshalJSON() ([]byte, error) { + type Alias ViewBlockCheckbox + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.Options != nil { + raw["options"] = m.Options + } + return json.Marshal(raw) +} + type ViewBlockCheckboxOption struct { Text string `json:"text"` Value string `json:"value"` @@ -968,12 +1167,12 @@ type ViewBlockCheckboxOption struct { } type ViewBlockDate struct { - Type string `json:"type"` // always "date" - Name string `json:"name"` - Label string `json:"label"` - InitialDate *time.Time `json:"initial_date,omitempty"` - Required *bool `json:"required,omitempty"` - Hint *string `json:"hint,omitempty"` + Type string `json:"type"` // always "date" + Name string `json:"name"` + Label string `json:"label"` + InitialDate *string `json:"initial_date,omitempty"` + Required *bool `json:"required,omitempty"` + Hint *string `json:"hint,omitempty"` } type ViewBlockDivider struct { @@ -990,6 +1189,22 @@ type ViewBlockFileInput struct { Hint *string `json:"hint,omitempty"` } +func (m ViewBlockFileInput) MarshalJSON() ([]byte, error) { + type Alias ViewBlockFileInput + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.Filetypes != nil { + raw["filetypes"] = m.Filetypes + } + return json.Marshal(raw) +} + type ViewBlockHeader struct { Type string `json:"type"` // always "header" Text string `json:"text"` @@ -1027,6 +1242,22 @@ type ViewBlockRadio struct { Hint *string `json:"hint,omitempty"` } +func (m ViewBlockRadio) MarshalJSON() ([]byte, error) { + type Alias ViewBlockRadio + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.Options != nil { + raw["options"] = m.Options + } + return json.Marshal(raw) +} + type ViewBlockSelect struct { Type string `json:"type"` // always "select" Name string `json:"name"` @@ -1036,6 +1267,22 @@ type ViewBlockSelect struct { Hint *string `json:"hint,omitempty"` } +func (m ViewBlockSelect) MarshalJSON() ([]byte, error) { + type Alias ViewBlockSelect + data, err := json.Marshal(Alias(m)) + if err != nil { + return nil, err + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if m.Options != nil { + raw["options"] = m.Options + } + return json.Marshal(raw) +} + type ViewBlockSelectableOption struct { Text string `json:"text"` Value string `json:"value"` @@ -1052,6 +1299,17 @@ type ViewBlockTime struct { Hint *string `json:"hint,omitempty"` } +type ViewSubmitWebhookPayload struct { + Type string `json:"type"` // always "view" + Event string `json:"event"` // always "submit" + UserID int32 `json:"user_id"` + Data map[string]string `json:"data"` + WebhookTimestamp int32 `json:"webhook_timestamp"` + CallbackID *string `json:"callback_id"` + PrivateMetadata *string `json:"private_metadata"` + ChatID *int32 `json:"chat_id"` +} + type WebhookEvent struct { ID string `json:"id"` EventType string `json:"event_type"` @@ -1062,6 +1320,7 @@ type WebhookEvent struct { type WebhookLink struct { URL string `json:"url"` Domain string `json:"domain"` + Skip bool `json:"skip"` } type WebhookMessageThread struct { @@ -1069,6 +1328,14 @@ type WebhookMessageThread struct { MessageChatID int32 `json:"message_chat_id"` } +type UpdateProfileAvatarRequest struct { + Image io.Reader `json:"image"` +} + +type UpdateUserAvatarRequest struct { + Image io.Reader `json:"image"` +} + type AuditEventDetailsUnion struct { AuditDetailsEmpty *AuditDetailsEmpty AuditDetailsUserUpdated *AuditDetailsUserUpdated @@ -1288,6 +1555,7 @@ type WebhookPayloadUnion struct { MessageWebhookPayload *MessageWebhookPayload ReactionWebhookPayload *ReactionWebhookPayload ButtonWebhookPayload *ButtonWebhookPayload + ViewSubmitWebhookPayload *ViewSubmitWebhookPayload ChatMemberWebhookPayload *ChatMemberWebhookPayload CompanyMemberWebhookPayload *CompanyMemberWebhookPayload LinkSharedWebhookPayload *LinkSharedWebhookPayload @@ -1295,25 +1563,32 @@ type WebhookPayloadUnion struct { func (u *WebhookPayloadUnion) UnmarshalJSON(data []byte) error { var disc struct { - Type string `json:"type"` + 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 "chat_member": + case disc.Type == "view": + u.ViewSubmitWebhookPayload = &ViewSubmitWebhookPayload{} + return json.Unmarshal(data, u.ViewSubmitWebhookPayload) + 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: @@ -1331,6 +1606,9 @@ func (u WebhookPayloadUnion) MarshalJSON() ([]byte, error) { if u.ButtonWebhookPayload != nil { return json.Marshal(u.ButtonWebhookPayload) } + if u.ViewSubmitWebhookPayload != nil { + return json.Marshal(u.ViewSubmitWebhookPayload) + } if u.ChatMemberWebhookPayload != nil { return json.Marshal(u.ChatMemberWebhookPayload) } @@ -1356,7 +1634,8 @@ type GetAuditEventsParams struct { } type ListChatsParams struct { - SortID *SortOrder + Sort *ChatSortField + Order *SortOrder Availability *ChatAvailability LastMessageAtAfter *time.Time LastMessageAtBefore *time.Time @@ -1376,7 +1655,7 @@ type ListPropertiesParams struct { } type ListTagsParams struct { - Names *TagNamesFilter + Names []string Limit *int32 Cursor *string } @@ -1388,7 +1667,8 @@ type GetTagUsersParams struct { type ListChatMessagesParams struct { ChatID int32 - SortID *SortOrder + Sort *MessageSortField + Order *SortOrder Limit *int32 Cursor *string } @@ -1448,6 +1728,13 @@ type ListTasksParams struct { Cursor *string } +type ListThreadsParams struct { + LastMessageAtAfter *time.Time + LastMessageAtBefore *time.Time + Limit *int32 + Cursor *string +} + type ListUsersParams struct { Query *string Limit *int32 @@ -1460,18 +1747,18 @@ type GetWebhookEventsParams struct { } type GetAuditEventsResponse struct { - Data []AuditEvent `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []AuditEvent `json:"data"` + Meta PaginationMeta `json:"meta"` } type ListChatsResponse struct { - Data []Chat `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []Chat `json:"data"` + Meta PaginationMeta `json:"meta"` } type ListMembersResponse struct { - Data []User `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []User `json:"data"` + Meta PaginationMeta `json:"meta"` } type ListPropertiesResponse struct { @@ -1479,23 +1766,23 @@ type ListPropertiesResponse struct { } type ListTagsResponse struct { - Data []GroupTag `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []GroupTag `json:"data"` + Meta PaginationMeta `json:"meta"` } type GetTagUsersResponse struct { - Data []User `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []User `json:"data"` + Meta PaginationMeta `json:"meta"` } type ListChatMessagesResponse struct { - Data []Message `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []Message `json:"data"` + Meta PaginationMeta `json:"meta"` } type ListReactionsResponse struct { - Data []Reaction `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []Reaction `json:"data"` + Meta PaginationMeta `json:"meta"` } type SearchChatsResponse struct { @@ -1514,16 +1801,21 @@ type SearchUsersResponse struct { } type ListTasksResponse struct { - Data []Task `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []Task `json:"data"` + Meta PaginationMeta `json:"meta"` +} + +type ListThreadsResponse struct { + Data []Thread `json:"data"` + Meta PaginationMeta `json:"meta"` } type ListUsersResponse struct { - Data []User `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []User `json:"data"` + Meta PaginationMeta `json:"meta"` } type GetWebhookEventsResponse struct { - Data []WebhookEvent `json:"data"` - Meta *PaginationMeta `json:"meta,omitempty"` + Data []WebhookEvent `json:"data"` + Meta PaginationMeta `json:"meta"` } 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/src/main/kotlin/com/pachca/Models.kt b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt index 2080b559..a838580d 100644 --- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt +++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt @@ -1,8 +1,28 @@ 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.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) + 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 @@ -114,6 +134,15 @@ enum class ChatMemberRoleFilter(val value: String) { @SerialName("member") MEMBER("member"), } +/** Поле сортировки чатов */ +@Serializable +enum class ChatSortField(val value: String) { + /** По идентификатору чата */ + @SerialName("id") ID("id"), + /** По дате и времени создания последнего сообщения */ + @SerialName("last_message_at") LAST_MESSAGE_AT("last_message_at"), +} + /** Тип чата */ @Serializable enum class ChatSubtype(val value: String) { @@ -174,6 +203,12 @@ enum class MessageEntityType(val value: String) { @SerialName("user") USER("user"), } +@Serializable +enum class MessageSortField(val value: String) { + /** По идентификатору сообщения */ + @SerialName("id") ID("id"), +} + /** Скоуп доступа OAuth токена */ @Serializable enum class OAuthScope(val value: String) { @@ -235,10 +270,14 @@ enum class OAuthScope(val value: String) { @SerialName("profile_status:read") PROFILE_STATUS_READ("profile_status:read"), /** Изменение и удаление статуса профиля */ @SerialName("profile_status:write") PROFILE_STATUS_WRITE("profile_status:write"), + /** Изменение и удаление аватара профиля */ + @SerialName("profile_avatar:write") PROFILE_AVATAR_WRITE("profile_avatar:write"), /** Просмотр статуса сотрудника */ @SerialName("user_status:read") USER_STATUS_READ("user_status:read"), /** Изменение и удаление статуса сотрудника */ @SerialName("user_status:write") USER_STATUS_WRITE("user_status:write"), + /** Изменение и удаление аватара сотрудника */ + @SerialName("user_avatar:write") USER_AVATAR_WRITE("user_avatar:write"), /** Просмотр дополнительных полей */ @SerialName("custom_properties:read") CUSTOM_PROPERTIES_READ("custom_properties:read"), /** Просмотр журнала аудита */ @@ -335,6 +374,19 @@ enum class TaskStatus(val value: String) { @SerialName("undone") UNDONE("undone"), } +/** Роль пользователя, допустимая при создании сотрудника. В отличие от редактирования, при создании можно назначить роль `guest` — в этом случае параметр `chat_ids` обязателен и должен содержать ровно один чат. */ +@Serializable +enum class UserCreateRole(val value: String) { + /** Администратор */ + @SerialName("admin") ADMIN("admin"), + /** Сотрудник */ + @SerialName("user") USER("user"), + /** Мульти-гость */ + @SerialName("multi_guest") MULTI_GUEST("multi_guest"), + /** Гость */ + @SerialName("guest") GUEST("guest"), +} + /** Тип события webhook для пользователей */ @Serializable enum class UserEventType(val value: String) { @@ -365,7 +417,7 @@ enum class UserRole(val value: String) { @SerialName("guest") GUEST("guest"), } -/** Роль пользователя, допустимая при создании и редактировании. Роль `guest` недоступна для установки через API. */ +/** Роль пользователя, допустимая при редактировании сотрудника. Роль `guest` недоступна для установки через API при редактировании — назначить роль `guest` можно только при создании сотрудника (см. `UserCreateRole`). */ @Serializable enum class UserRoleInput(val value: String) { /** Администратор */ @@ -696,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( @@ -711,7 +797,7 @@ data class MessageWebhookPayload( @SerialName("entity_id") val entityId: Int, val content: String, @SerialName("user_id") val userId: Int, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, val url: String, @SerialName("chat_id") val chatId: Int, @SerialName("parent_message_id") val parentMessageId: Int? = null, @@ -724,11 +810,12 @@ data class MessageWebhookPayload( data class ReactionWebhookPayload( override val type: String = "reaction", val event: ReactionEventType, + @SerialName("chat_id") val chatId: Int, @SerialName("message_id") val messageId: Int, val code: String, val name: String, @SerialName("user_id") val userId: Int, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("webhook_timestamp") val webhookTimestamp: Int, ) : WebhookPayloadUnion @@ -744,6 +831,18 @@ data class ButtonWebhookPayload( @SerialName("webhook_timestamp") val webhookTimestamp: Int, ) : WebhookPayloadUnion +@Serializable +@SerialName("view") +data class ViewSubmitWebhookPayload( + override val type: String = "view", + @SerialName("callback_id") val callbackId: String, + @SerialName("private_metadata") val privateMetadata: String, + @SerialName("chat_id") val chatId: Int, + @SerialName("user_id") val userId: Int, + val data: Map, + @SerialName("webhook_timestamp") val webhookTimestamp: Int, +) : WebhookPayloadUnion + @Serializable @SerialName("chat_member") data class ChatMemberWebhookPayload( @@ -752,7 +851,7 @@ data class ChatMemberWebhookPayload( @SerialName("chat_id") val chatId: Int, @SerialName("thread_id") val threadId: Int? = null, @SerialName("user_ids") val userIds: List, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("webhook_timestamp") val webhookTimestamp: Int, ) : WebhookPayloadUnion @@ -762,7 +861,7 @@ data class CompanyMemberWebhookPayload( override val type: String = "company_member", val event: UserEventType, @SerialName("user_ids") val userIds: List, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("webhook_timestamp") val webhookTimestamp: Int, ) : WebhookPayloadUnion @@ -774,7 +873,7 @@ data class LinkSharedWebhookPayload( @SerialName("message_id") val messageId: Int, val links: List, @SerialName("user_id") val userId: Int, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("webhook_timestamp") val webhookTimestamp: Int, ) : WebhookPayloadUnion @@ -785,10 +884,10 @@ data class AccessTokenInfo( val name: String? = null, @SerialName("user_id") val userId: Long, val scopes: List, - @SerialName("created_at") val createdAt: String, - @SerialName("revoked_at") val revokedAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("revoked_at") val revokedAt: OffsetDateTime? = null, @SerialName("expires_in") val expiresIn: Int? = null, - @SerialName("last_used_at") val lastUsedAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("last_used_at") val lastUsedAt: OffsetDateTime? = null, ) @Serializable @@ -806,6 +905,14 @@ data class AddTagsRequest( data class ApiError( val errors: List, ) : Exception() + { + override val message: String + get() = when { + errors.isEmpty() -> "api error" + errors.size == 1 -> errors[0].message + else -> "Errors: " + errors.joinToString("; ") { it.message } + } +} @Serializable data class ApiErrorItem( @@ -819,7 +926,7 @@ data class ApiErrorItem( @Serializable data class AuditEvent( val id: String, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("event_key") val eventKey: AuditEventKey, @SerialName("entity_id") val entityId: String, @SerialName("entity_type") val entityType: String, @@ -830,6 +937,11 @@ data class AuditEvent( @SerialName("user_agent") val userAgent: String, ) +@Serializable +data class AvatarData( + @SerialName("image_url") val imageUrl: String, +) + @Serializable data class BotResponseWebhook( @SerialName("outgoing_url") val outgoingUrl: String, @@ -867,14 +979,14 @@ data class Button( data class Chat( val id: Int, val name: String, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("owner_id") val ownerId: Int, @SerialName("member_ids") val memberIds: List, @SerialName("group_tag_ids") val groupTagIds: List, val channel: Boolean, val personal: Boolean, val public: Boolean, - @SerialName("last_message_at") val lastMessageAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("last_message_at") val lastMessageAt: OffsetDateTime, @SerialName("meet_room_url") val meetRoomUrl: String, ) @@ -956,7 +1068,7 @@ data class Forwarding( @SerialName("original_message_id") val originalMessageId: Int, @SerialName("original_chat_id") val originalChatId: Int, @SerialName("author_id") val authorId: Int, - @SerialName("original_created_at") val originalCreatedAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("original_created_at") val originalCreatedAt: OffsetDateTime, @SerialName("original_thread_id") val originalThreadId: Int? = null, @SerialName("original_thread_message_id") val originalThreadMessageId: Int? = null, @SerialName("original_thread_parent_chat_id") val originalThreadParentChatId: Int? = null, @@ -1014,7 +1126,7 @@ data class Message( @SerialName("root_chat_id") val rootChatId: Int, val content: String, @SerialName("user_id") val userId: Int, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, val url: String, val files: List, val buttons: List>? = null, @@ -1023,8 +1135,8 @@ data class Message( @SerialName("parent_message_id") val parentMessageId: Int? = null, @SerialName("display_avatar_url") val displayAvatarUrl: String? = null, @SerialName("display_name") val displayName: String? = null, - @SerialName("changed_at") val changedAt: String? = null, - @SerialName("deleted_at") val deletedAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("changed_at") val changedAt: OffsetDateTime? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("deleted_at") val deletedAt: OffsetDateTime? = null, ) @Serializable @@ -1085,6 +1197,9 @@ data class OAuthError( val error: String, @SerialName("error_description") val errorDescription: String, ) : Exception() + { + override val message: String get() = error +} @Serializable data class OpenViewRequestView( @@ -1105,18 +1220,21 @@ data class OpenViewRequest( @Serializable data class PaginationMetaPaginate( - @SerialName("next_page") val nextPage: String? = null, + @SerialName("next_page") val nextPage: String, + @SerialName("prev_page") val prevPage: String? = null, + @SerialName("has_next") val hasNext: Boolean? = null, + @SerialName("has_prev") val hasPrev: Boolean? = null, ) @Serializable data class PaginationMeta( - val paginate: PaginationMetaPaginate? = null, + val paginate: PaginationMetaPaginate, ) @Serializable data class Reaction( @SerialName("user_id") val userId: Int, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, val code: String, val name: String? = null, ) @@ -1142,7 +1260,7 @@ data class SearchPaginationMeta( data class StatusUpdateRequestStatus( val emoji: String, val title: String, - @SerialName("expires_at") val expiresAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("expires_at") val expiresAt: OffsetDateTime? = null, @SerialName("is_away") val isAway: Boolean? = null, @SerialName("away_message") val awayMessage: String? = null, ) @@ -1152,20 +1270,17 @@ data class StatusUpdateRequest( val status: StatusUpdateRequestStatus, ) -@Serializable -class TagNamesFilter - @Serializable data class Task( val id: Int, val kind: TaskKind, val content: String, - @SerialName("due_at") val dueAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("due_at") val dueAt: OffsetDateTime? = null, val priority: Int, @SerialName("user_id") val userId: Int, @SerialName("chat_id") val chatId: Int? = null, val status: TaskStatus, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, @SerialName("performer_ids") val performerIds: List, @SerialName("all_day") val allDay: Boolean, @SerialName("custom_properties") val customProperties: List, @@ -1181,7 +1296,7 @@ data class TaskCreateRequestCustomProperty( data class TaskCreateRequestTask( val kind: TaskKind, val content: String? = null, - @SerialName("due_at") val dueAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("due_at") val dueAt: OffsetDateTime? = null, val priority: Int? = 1, @SerialName("performer_ids") val performerIds: List? = null, @SerialName("chat_id") val chatId: Int? = null, @@ -1204,12 +1319,12 @@ data class TaskUpdateRequestCustomProperty( data class TaskUpdateRequestTask( val kind: TaskKind? = null, val content: String? = null, - @SerialName("due_at") val dueAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("due_at") val dueAt: OffsetDateTime? = null, val priority: Int? = null, @SerialName("performer_ids") val performerIds: List? = null, val status: TaskStatus? = null, @SerialName("all_day") val allDay: Boolean? = null, - @SerialName("done_at") val doneAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("done_at") val doneAt: OffsetDateTime? = null, @SerialName("custom_properties") val customProperties: List? = null, ) @@ -1224,7 +1339,7 @@ data class Thread( @SerialName("chat_id") val chatId: Long, @SerialName("message_id") val messageId: Long, @SerialName("message_chat_id") val messageChatId: Long, - @SerialName("updated_at") val updatedAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("updated_at") val updatedAt: OffsetDateTime, ) @Serializable @@ -1249,23 +1364,24 @@ data class UploadParams( data class User( val id: Int, @SerialName("first_name") val firstName: String, - @SerialName("last_name") val lastName: String, + @SerialName("last_name") val lastName: String? = null, val nickname: String, - val email: String, - @SerialName("phone_number") val phoneNumber: String, - val department: String, - val title: String, + val email: String? = null, + @SerialName("phone_number") val phoneNumber: String? = null, + val department: String? = null, + val title: String? = null, val role: UserRole, val suspended: Boolean, @SerialName("invite_status") val inviteStatus: InviteStatus, + @SerialName("inviter_id") val inviterId: Int? = null, @SerialName("list_tags") val listTags: List, @SerialName("custom_properties") val customProperties: List, @SerialName("user_status") val userStatus: UserStatus? = null, val bot: Boolean, val sso: Boolean, - @SerialName("created_at") val createdAt: String, - @SerialName("last_activity_at") val lastActivityAt: String, - @SerialName("time_zone") val timeZone: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("last_activity_at") val lastActivityAt: OffsetDateTime? = null, + @SerialName("time_zone") val timeZone: String? = null, @SerialName("image_url") val imageUrl: String? = null, ) @@ -1284,9 +1400,10 @@ data class UserCreateRequestUser( val nickname: String? = null, val department: String? = null, val title: String? = null, - val role: UserRoleInput? = null, + val role: UserCreateRole? = null, val suspended: Boolean? = null, @SerialName("list_tags") val listTags: List? = null, + @SerialName("chat_ids") val chatIds: List? = null, @SerialName("custom_properties") val customProperties: List? = null, ) @@ -1305,7 +1422,7 @@ data class UserStatusAwayMessage( data class UserStatus( val emoji: String, val title: String, - @SerialName("expires_at") val expiresAt: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("expires_at") val expiresAt: OffsetDateTime? = null, @SerialName("is_away") val isAway: Boolean, @SerialName("away_message") val awayMessage: UserStatusAwayMessage? = null, ) @@ -1342,7 +1459,7 @@ data class ViewBlock( val text: String? = null, val name: String? = null, val label: String? = null, - @SerialName("initial_date") val initialDate: String? = null, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("initial_date") val initialDate: OffsetDateTime? = null, ) @Serializable @@ -1366,13 +1483,14 @@ data class WebhookEvent( val id: String, @SerialName("event_type") val eventType: String, val payload: WebhookPayloadUnion, - @SerialName("created_at") val createdAt: String, + @Serializable(with = OffsetDateTimeSerializer::class) @SerialName("created_at") val createdAt: OffsetDateTime, ) @Serializable data class WebhookLink( val url: String, val domain: String, + val skip: Boolean, ) @Serializable @@ -1381,22 +1499,32 @@ data class WebhookMessageThread( @SerialName("message_chat_id") val messageChatId: Int, ) +@Serializable +data class UpdateProfileAvatarRequest( + @Transient val image: ByteArray = ByteArray(0), +) + +@Serializable +data class UpdateUserAvatarRequest( + @Transient val image: ByteArray = ByteArray(0), +) + @Serializable data class GetAuditEventsResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable data class ListChatsResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable data class ListMembersResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable @@ -1407,25 +1535,25 @@ data class ListPropertiesResponse( @Serializable data class ListTagsResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable data class GetTagUsersResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable data class ListChatMessagesResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable data class ListReactionsResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable @@ -1449,19 +1577,25 @@ data class SearchUsersResponse( @Serializable data class ListTasksResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, +) + +@Serializable +data class ListThreadsResponse( + val data: List, + val meta: PaginationMeta, ) @Serializable data class ListUsersResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable data class GetWebhookEventsResponse( val data: List, - val meta: PaginationMeta? = null, + val meta: PaginationMeta, ) @Serializable @@ -1485,6 +1619,9 @@ data class AccessTokenInfoDataWrapper(val data: AccessTokenInfo) @Serializable data class UserDataWrapper(val data: User) +@Serializable +data class AvatarDataDataWrapper(val data: AvatarData) + @Serializable data class UserStatusDataWrapper(val data: UserStatus) 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/utils.py b/sdk/python/generated/pachca/utils.py index d1494873..b337dc8a 100644 --- a/sdk/python/generated/pachca/utils.py +++ b/sdk/python/generated/pachca/utils.py @@ -3,18 +3,30 @@ import dataclasses import keyword from dataclasses import asdict, fields -from typing import Type, TypeVar, get_args, get_origin, get_type_hints +from datetime import datetime +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: @@ -28,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: @@ -38,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()} @@ -50,18 +88,40 @@ 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] - 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): return { @@ -70,6 +130,8 @@ def _strip_nones(val: object) -> object: } if isinstance(val, list): return [_strip_nones(v) for v in val] + if isinstance(val, datetime): + return val.isoformat() return val @@ -101,11 +163,11 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: 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(delay) + await asyncio.sleep(_add_jitter(delay)) continue if response.status_code in _RETRYABLE_5XX and attempt < self._max_retries: - delay = _jitter(10 * (2 ** attempt)) - await asyncio.sleep(delay) + delay = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/sdk/swift/examples/Package.swift b/sdk/swift/examples/Package.swift index 2eaba2bb..1c02bc02 100644 --- a/sdk/swift/examples/Package.swift +++ b/sdk/swift/examples/Package.swift @@ -22,5 +22,26 @@ let package = Package( ], path: "Sources/Upload" ), + .executableTarget( + name: "Stub", + dependencies: [ + .product(name: "PachcaSDK", package: "swift"), + ], + path: "Sources/Stub" + ), + .executableTarget( + name: "HttpClient", + dependencies: [ + .product(name: "PachcaSDK", package: "swift"), + ], + path: "Sources/HttpClient" + ), + .executableTarget( + name: "WebhookHistory", + dependencies: [ + .product(name: "PachcaSDK", package: "swift"), + ], + path: "Sources/WebhookHistory" + ), ] ) 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/Models.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift index 87c3e94a..c42d809c 100644 --- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift +++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Models.swift @@ -105,6 +105,13 @@ public enum ChatMemberRoleFilter: String, Codable, CaseIterable { case member } +public enum ChatSortField: String, Codable, CaseIterable { + /// По идентификатору чата + case id + /// По дате и времени создания последнего сообщения + case lastMessageAt = "last_message_at" +} + public enum ChatSubtype: String, Codable, CaseIterable { /// Канал или беседа case discussion @@ -153,6 +160,11 @@ public enum MessageEntityType: String, Codable, CaseIterable { case user } +public enum MessageSortField: String, Codable, CaseIterable { + /// По идентификатору сообщения + case id +} + public enum OAuthScope: String, Codable, CaseIterable { /// Просмотр чатов и списка чатов case chatsRead = "chats:read" @@ -212,10 +224,14 @@ public enum OAuthScope: String, Codable, CaseIterable { case profileStatusRead = "profile_status:read" /// Изменение и удаление статуса профиля case profileStatusWrite = "profile_status:write" + /// Изменение и удаление аватара профиля + case profileAvatarWrite = "profile_avatar:write" /// Просмотр статуса сотрудника case userStatusRead = "user_status:read" /// Изменение и удаление статуса сотрудника case userStatusWrite = "user_status:write" + /// Изменение и удаление аватара сотрудника + case userAvatarWrite = "user_avatar:write" /// Просмотр дополнительных полей case customPropertiesRead = "custom_properties:read" /// Просмотр журнала аудита @@ -300,6 +316,17 @@ public enum TaskStatus: String, Codable, CaseIterable { case undone } +public enum UserCreateRole: String, Codable, CaseIterable { + /// Администратор + case admin + /// Сотрудник + case user + /// Мульти-гость + case multiGuest = "multi_guest" + /// Гость + case guest +} + public enum UserEventType: String, Codable, CaseIterable { /// Приглашение case invite @@ -425,12 +452,12 @@ public struct AccessTokenInfo: Codable { public let name: String? public let userId: Int64 public let scopes: [OAuthScope] - public let createdAt: Date + public let createdAt: String public let revokedAt: String? public let expiresIn: Int? public let lastUsedAt: String? - public init(id: Int64, token: String, name: String? = nil, userId: Int64, scopes: [OAuthScope], createdAt: Date, revokedAt: String? = nil, expiresIn: Int? = nil, lastUsedAt: String? = nil) { + public init(id: Int64, token: String, name: String? = nil, userId: Int64, scopes: [OAuthScope], createdAt: String, revokedAt: String? = nil, expiresIn: Int? = nil, lastUsedAt: String? = nil) { self.id = id self.token = token self.name = name @@ -488,6 +515,12 @@ public struct ApiError: Codable, Error { public init(errors: [ApiErrorItem]) { self.errors = errors } + + public var localizedDescription: String { + guard !errors.isEmpty else { return "api error" } + if errors.count == 1 { return errors[0].message } + return "Errors: " + errors.map(\.message).joined(separator: "; ") + } } public struct ApiErrorItem: Codable { @@ -707,7 +740,7 @@ public struct AuditDetailsUserUpdated: Codable { public struct AuditEvent: Codable { public let id: String - public let createdAt: Date + public let createdAt: String public let eventKey: AuditEventKey public let entityId: String public let entityType: String @@ -717,7 +750,7 @@ public struct AuditEvent: Codable { public let ipAddress: String public let userAgent: String - public init(id: String, createdAt: Date, eventKey: AuditEventKey, entityId: String, entityType: String, actorId: String, actorType: String, details: AuditEventDetailsUnion, ipAddress: String, userAgent: String) { + public init(id: String, createdAt: String, eventKey: AuditEventKey, entityId: String, entityType: String, actorId: String, actorType: String, details: AuditEventDetailsUnion, ipAddress: String, userAgent: String) { self.id = id self.createdAt = createdAt self.eventKey = eventKey @@ -744,6 +777,18 @@ public struct AuditEvent: Codable { } } +public struct AvatarData: Codable { + public let imageUrl: String + + public init(imageUrl: String) { + self.imageUrl = imageUrl + } + + enum CodingKeys: String, CodingKey { + case imageUrl = "image_url" + } +} + public struct BotResponseWebhook: Codable { public let outgoingUrl: String @@ -842,17 +887,17 @@ public struct ButtonWebhookPayload: Codable { public struct Chat: Codable { public let id: Int public let name: String - public let createdAt: Date + public let createdAt: String public let ownerId: Int public let memberIds: [Int] public let groupTagIds: [Int] public let channel: Bool public let personal: Bool public let `public`: Bool - public let lastMessageAt: Date + public let lastMessageAt: String public let meetRoomUrl: String - public init(id: Int, name: String, createdAt: Date, ownerId: Int, memberIds: [Int], groupTagIds: [Int], channel: Bool, personal: Bool, `public`: Bool, lastMessageAt: Date, meetRoomUrl: String) { + public init(id: Int, name: String, createdAt: String, ownerId: Int, memberIds: [Int], groupTagIds: [Int], channel: Bool, personal: Bool, `public`: Bool, lastMessageAt: String, meetRoomUrl: String) { self.id = id self.name = name self.createdAt = createdAt @@ -919,10 +964,10 @@ public struct ChatMemberWebhookPayload: Codable { public let chatId: Int public let threadId: Int? public let userIds: [Int] - public let createdAt: Date + public let createdAt: String public let webhookTimestamp: Int - public init(type: String, event: MemberEventType, chatId: Int, threadId: Int? = nil, userIds: [Int], createdAt: Date, webhookTimestamp: Int) { + public init(type: String, event: MemberEventType, chatId: Int, threadId: Int? = nil, userIds: [Int], createdAt: String, webhookTimestamp: Int) { self.type = type self.event = event self.chatId = chatId @@ -970,10 +1015,10 @@ public struct CompanyMemberWebhookPayload: Codable { public let type: String public let event: UserEventType public let userIds: [Int] - public let createdAt: Date + public let createdAt: String public let webhookTimestamp: Int - public init(type: String, event: UserEventType, userIds: [Int], createdAt: Date, webhookTimestamp: Int) { + public init(type: String, event: UserEventType, userIds: [Int], createdAt: String, webhookTimestamp: Int) { self.type = type self.event = event self.userIds = userIds @@ -1030,13 +1075,13 @@ public struct CustomPropertyDefinition: Codable { } public struct ExportRequest: Codable { - public let startAt: Date - public let endAt: Date + public let startAt: String + public let endAt: String public let webhookUrl: String public let chatIds: [Int]? public let skipChatsFile: Bool? - public init(startAt: Date, endAt: Date, webhookUrl: String, chatIds: [Int]? = nil, skipChatsFile: Bool? = nil) { + public init(startAt: String, endAt: String, webhookUrl: String, chatIds: [Int]? = nil, skipChatsFile: Bool? = nil) { self.startAt = startAt self.endAt = endAt self.webhookUrl = webhookUrl @@ -1123,12 +1168,12 @@ public struct Forwarding: Codable { public let originalMessageId: Int public let originalChatId: Int public let authorId: Int - public let originalCreatedAt: Date + public let originalCreatedAt: String public let originalThreadId: Int? public let originalThreadMessageId: Int? public let originalThreadParentChatId: Int? - public init(originalMessageId: Int, originalChatId: Int, authorId: Int, originalCreatedAt: Date, originalThreadId: Int? = nil, originalThreadMessageId: Int? = nil, originalThreadParentChatId: Int? = nil) { + public init(originalMessageId: Int, originalChatId: Int, authorId: Int, originalCreatedAt: String, originalThreadId: Int? = nil, originalThreadMessageId: Int? = nil, originalThreadParentChatId: Int? = nil) { self.originalMessageId = originalMessageId self.originalChatId = originalChatId self.authorId = authorId @@ -1239,10 +1284,10 @@ public struct LinkSharedWebhookPayload: Codable { public let messageId: Int public let links: [WebhookLink] public let userId: Int - public let createdAt: Date + public let createdAt: String public let webhookTimestamp: Int - public init(type: String, event: String, chatId: Int, messageId: Int, links: [WebhookLink], userId: Int, createdAt: Date, webhookTimestamp: Int) { + public init(type: String, event: String, chatId: Int, messageId: Int, links: [WebhookLink], userId: Int, createdAt: String, webhookTimestamp: Int) { self.type = type self.event = event self.chatId = chatId @@ -1288,7 +1333,7 @@ public struct Message: Codable { public let rootChatId: Int public let content: String public let userId: Int - public let createdAt: Date + public let createdAt: String public let url: String public let files: [File] public let buttons: [[Button]]? @@ -1300,7 +1345,7 @@ public struct Message: Codable { public let changedAt: String? public let deletedAt: String? - public init(id: Int, entityType: MessageEntityType, entityId: Int, chatId: Int, rootChatId: Int, content: String, userId: Int, createdAt: Date, url: String, files: [File], buttons: [[Button]]? = nil, thread: MessageThread? = nil, forwarding: Forwarding? = nil, parentMessageId: Int? = nil, displayAvatarUrl: String? = nil, displayName: String? = nil, changedAt: String? = nil, deletedAt: String? = nil) { + public init(id: Int, entityType: MessageEntityType, entityId: Int, chatId: Int, rootChatId: Int, content: String, userId: Int, createdAt: String, url: String, files: [File], buttons: [[Button]]? = nil, thread: MessageThread? = nil, forwarding: Forwarding? = nil, parentMessageId: Int? = nil, displayAvatarUrl: String? = nil, displayName: String? = nil, changedAt: String? = nil, deletedAt: String? = nil) { self.id = id self.entityType = entityType self.entityId = entityId @@ -1488,14 +1533,14 @@ public struct MessageWebhookPayload: Codable { public let entityId: Int public let content: String public let userId: Int - public let createdAt: Date + public let createdAt: String public let url: String public let chatId: Int public let parentMessageId: Int? public let thread: WebhookMessageThread? public let webhookTimestamp: Int - public init(type: String, id: Int, event: WebhookEventType, entityType: MessageEntityType, entityId: Int, content: String, userId: Int, createdAt: Date, url: String, chatId: Int, parentMessageId: Int? = nil, thread: WebhookMessageThread? = nil, webhookTimestamp: Int) { + public init(type: String, id: Int, event: WebhookEventType, entityType: MessageEntityType, entityId: Int, content: String, userId: Int, createdAt: String, url: String, chatId: Int, parentMessageId: Int? = nil, thread: WebhookMessageThread? = nil, webhookTimestamp: Int) { self.type = type self.id = id self.event = event @@ -1537,6 +1582,8 @@ public struct OAuthError: Codable, Error { self.errorDescription = errorDescription } + public var localizedDescription: String { error } + enum CodingKeys: String, CodingKey { case error case errorDescription = "error_description" @@ -1589,32 +1636,41 @@ public struct OpenViewRequest: Codable { } public struct PaginationMetaPaginate: Codable { - public let nextPage: String? + public let nextPage: String + public let prevPage: String? + public let hasNext: Bool? + public let hasPrev: Bool? - public init(nextPage: String? = nil) { + public init(nextPage: String, prevPage: String? = nil, hasNext: Bool? = nil, hasPrev: Bool? = nil) { self.nextPage = nextPage + self.prevPage = prevPage + self.hasNext = hasNext + self.hasPrev = hasPrev } enum CodingKeys: String, CodingKey { case nextPage = "next_page" + case prevPage = "prev_page" + case hasNext = "has_next" + case hasPrev = "has_prev" } } public struct PaginationMeta: Codable { - public let paginate: PaginationMetaPaginate? + public let paginate: PaginationMetaPaginate - public init(paginate: PaginationMetaPaginate? = nil) { + public init(paginate: PaginationMetaPaginate) { self.paginate = paginate } } public struct Reaction: Codable { public let userId: Int - public let createdAt: Date + public let createdAt: String public let code: String public let name: String? - public init(userId: Int, createdAt: Date, code: String, name: String? = nil) { + public init(userId: Int, createdAt: String, code: String, name: String? = nil) { self.userId = userId self.createdAt = createdAt self.code = code @@ -1642,16 +1698,18 @@ public struct ReactionRequest: Codable { public struct ReactionWebhookPayload: Codable { public let type: String public let event: ReactionEventType + public let chatId: Int? public let messageId: Int public let code: String public let name: String public let userId: Int - public let createdAt: Date + public let createdAt: String public let webhookTimestamp: Int - public init(type: String, event: ReactionEventType, messageId: Int, code: String, name: String, userId: Int, createdAt: Date, webhookTimestamp: Int) { + public init(type: String, event: ReactionEventType, chatId: Int? = nil, messageId: Int, code: String, name: String, userId: Int, createdAt: String, webhookTimestamp: Int) { self.type = type self.event = event + self.chatId = chatId self.messageId = messageId self.code = code self.name = name @@ -1663,6 +1721,7 @@ public struct ReactionWebhookPayload: Codable { enum CodingKeys: String, CodingKey { case type case event + case chatId = "chat_id" case messageId = "message_id" case code case name @@ -1726,9 +1785,6 @@ public struct StatusUpdateRequest: Codable { } } -public struct TagNamesFilter: Codable { -} - public struct Task: Codable { public let id: Int public let kind: TaskKind @@ -1738,12 +1794,12 @@ public struct Task: Codable { public let userId: Int public let chatId: Int? public let status: TaskStatus - public let createdAt: Date + public let createdAt: String public let performerIds: [Int] public let allDay: Bool public let customProperties: [CustomProperty] - public init(id: Int, kind: TaskKind, content: String, dueAt: String? = nil, priority: Int, userId: Int, chatId: Int? = nil, status: TaskStatus, createdAt: Date, performerIds: [Int], allDay: Bool, customProperties: [CustomProperty]) { + public init(id: Int, kind: TaskKind, content: String, dueAt: String? = nil, priority: Int, userId: Int, chatId: Int? = nil, status: TaskStatus, createdAt: String, performerIds: [Int], allDay: Bool, customProperties: [CustomProperty]) { self.id = id self.kind = kind self.content = content @@ -1884,9 +1940,9 @@ public struct Thread: Codable { public let chatId: Int64 public let messageId: Int64 public let messageChatId: Int64 - public let updatedAt: Date + public let updatedAt: String - public init(id: Int64, chatId: Int64, messageId: Int64, messageChatId: Int64, updatedAt: Date) { + public init(id: Int64, chatId: Int64, messageId: Int64, messageChatId: Int64, updatedAt: String) { self.id = id self.chatId = chatId self.messageId = messageId @@ -1950,26 +2006,27 @@ public struct UploadParams: Codable { public struct User: Codable { public let id: Int public let firstName: String - public let lastName: String + public let lastName: String? public let nickname: String - public let email: String - public let phoneNumber: String - public let department: String - public let title: String + public let email: String? + public let phoneNumber: String? + public let department: String? + public let title: String? public let role: UserRole public let suspended: Bool public let inviteStatus: InviteStatus + public let inviterId: Int? public let listTags: [String] public let customProperties: [CustomProperty] public let userStatus: UserStatus? public let bot: Bool public let sso: Bool - public let createdAt: Date - public let lastActivityAt: Date - public let timeZone: String + public let createdAt: String + public let lastActivityAt: String? + public let timeZone: String? public let imageUrl: String? - public init(id: Int, firstName: String, lastName: String, nickname: String, email: String, phoneNumber: String, department: String, title: String, role: UserRole, suspended: Bool, inviteStatus: InviteStatus, listTags: [String], customProperties: [CustomProperty], userStatus: UserStatus? = nil, bot: Bool, sso: Bool, createdAt: Date, lastActivityAt: Date, timeZone: String, imageUrl: String? = nil) { + public init(id: Int, firstName: String, lastName: String? = nil, nickname: String, email: String? = nil, phoneNumber: String? = nil, department: String? = nil, title: String? = nil, role: UserRole, suspended: Bool, inviteStatus: InviteStatus, inviterId: Int? = nil, listTags: [String], customProperties: [CustomProperty], userStatus: UserStatus? = nil, bot: Bool, sso: Bool, createdAt: String, lastActivityAt: String? = nil, timeZone: String? = nil, imageUrl: String? = nil) { self.id = id self.firstName = firstName self.lastName = lastName @@ -1981,6 +2038,7 @@ public struct User: Codable { self.role = role self.suspended = suspended self.inviteStatus = inviteStatus + self.inviterId = inviterId self.listTags = listTags self.customProperties = customProperties self.userStatus = userStatus @@ -2004,6 +2062,7 @@ public struct User: Codable { case role case suspended case inviteStatus = "invite_status" + case inviterId = "inviter_id" case listTags = "list_tags" case customProperties = "custom_properties" case userStatus = "user_status" @@ -2034,12 +2093,13 @@ public struct UserCreateRequestUser: Codable { public let nickname: String? public let department: String? public let title: String? - public let role: UserRoleInput? + public let role: UserCreateRole? public let suspended: Bool? public let listTags: [String]? + public let chatIds: [Int]? public let customProperties: [UserCreateRequestCustomProperty]? - public init(firstName: String? = nil, lastName: String? = nil, email: String, phoneNumber: String? = nil, nickname: String? = nil, department: String? = nil, title: String? = nil, role: UserRoleInput? = nil, suspended: Bool? = nil, listTags: [String]? = nil, customProperties: [UserCreateRequestCustomProperty]? = nil) { + public init(firstName: String? = nil, lastName: String? = nil, email: String, phoneNumber: String? = nil, nickname: String? = nil, department: String? = nil, title: String? = nil, role: UserCreateRole? = nil, suspended: Bool? = nil, listTags: [String]? = nil, chatIds: [Int]? = nil, customProperties: [UserCreateRequestCustomProperty]? = nil) { self.firstName = firstName self.lastName = lastName self.email = email @@ -2050,6 +2110,7 @@ public struct UserCreateRequestUser: Codable { self.role = role self.suspended = suspended self.listTags = listTags + self.chatIds = chatIds self.customProperties = customProperties } @@ -2064,6 +2125,7 @@ public struct UserCreateRequestUser: Codable { case role case suspended case listTags = "list_tags" + case chatIds = "chat_ids" case customProperties = "custom_properties" } } @@ -2442,13 +2504,46 @@ public struct ViewBlockTime: Codable { } } +public struct ViewSubmitWebhookPayload: Codable { + public let type: String + public let event: String + public let callbackId: String? + public let privateMetadata: String? + public let chatId: Int? + public let userId: Int + public let data: [String: String] + public let webhookTimestamp: Int + + public init(type: String, event: String, callbackId: String? = nil, privateMetadata: String? = nil, chatId: Int? = nil, userId: Int, data: [String: String], webhookTimestamp: Int) { + self.type = type + self.event = event + self.callbackId = callbackId + self.privateMetadata = privateMetadata + self.chatId = chatId + self.userId = userId + self.data = data + self.webhookTimestamp = webhookTimestamp + } + + enum CodingKeys: String, CodingKey { + case type + case event + case callbackId = "callback_id" + case privateMetadata = "private_metadata" + case chatId = "chat_id" + case userId = "user_id" + case data + case webhookTimestamp = "webhook_timestamp" + } +} + public struct WebhookEvent: Codable { public let id: String public let eventType: String public let payload: WebhookPayloadUnion - public let createdAt: Date + public let createdAt: String - public init(id: String, eventType: String, payload: WebhookPayloadUnion, createdAt: Date) { + public init(id: String, eventType: String, payload: WebhookPayloadUnion, createdAt: String) { self.id = id self.eventType = eventType self.payload = payload @@ -2466,10 +2561,12 @@ public struct WebhookEvent: Codable { public struct WebhookLink: Codable { public let url: String public let domain: String + public let skip: Bool - public init(url: String, domain: String) { + public init(url: String, domain: String, skip: Bool) { self.url = url self.domain = domain + self.skip = skip } } @@ -2488,6 +2585,22 @@ public struct WebhookMessageThread: Codable { } } +public struct UpdateProfileAvatarRequest: Codable { + public var image: Data + + public init(image: Data) { + self.image = image + } +} + +public struct UpdateUserAvatarRequest: Codable { + public var image: Data + + public init(image: Data) { + self.image = image + } +} + public enum AuditEventDetailsUnion: Codable { case auditDetailsEmpty(AuditDetailsEmpty) case auditDetailsUserUpdated(AuditDetailsUserUpdated) @@ -2663,27 +2776,34 @@ public enum WebhookPayloadUnion: Codable { case messageWebhookPayload(MessageWebhookPayload) case reactionWebhookPayload(ReactionWebhookPayload) case buttonWebhookPayload(ButtonWebhookPayload) + case viewSubmitWebhookPayload(ViewSubmitWebhookPayload) case chatMemberWebhookPayload(ChatMemberWebhookPayload) case companyMemberWebhookPayload(CompanyMemberWebhookPayload) case linkSharedWebhookPayload(LinkSharedWebhookPayload) 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 "chat_member": + case ("view", _): + self = .viewSubmitWebhookPayload(try ViewSubmitWebhookPayload(from: decoder)) + 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( @@ -2700,6 +2820,8 @@ public enum WebhookPayloadUnion: Codable { try value.encode(to: encoder) case .buttonWebhookPayload(let value): try value.encode(to: encoder) + case .viewSubmitWebhookPayload(let value): + try value.encode(to: encoder) case .chatMemberWebhookPayload(let value): try value.encode(to: encoder) case .companyMemberWebhookPayload(let value): @@ -2712,17 +2834,17 @@ public enum WebhookPayloadUnion: Codable { public struct GetAuditEventsResponse: Codable { public let data: [AuditEvent] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct ListChatsResponse: Codable { public let data: [Chat] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct ListMembersResponse: Codable { public let data: [User] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct ListPropertiesResponse: Codable { @@ -2731,22 +2853,22 @@ public struct ListPropertiesResponse: Codable { public struct ListTagsResponse: Codable { public let data: [GroupTag] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct GetTagUsersResponse: Codable { public let data: [User] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct ListChatMessagesResponse: Codable { public let data: [Message] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct ListReactionsResponse: Codable { public let data: [Reaction] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct SearchChatsResponse: Codable { @@ -2766,17 +2888,22 @@ public struct SearchUsersResponse: Codable { public struct ListTasksResponse: Codable { public let data: [Task] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta +} + +public struct ListThreadsResponse: Codable { + public let data: [Thread] + public let meta: PaginationMeta } public struct ListUsersResponse: Codable { public let data: [User] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } public struct GetWebhookEventsResponse: Codable { public let data: [WebhookEvent] - public let meta: PaginationMeta? = nil + public let meta: PaginationMeta } struct BotResponseDataWrapper: Codable { @@ -2807,6 +2934,10 @@ struct UserDataWrapper: Codable { let data: User } +struct AvatarDataDataWrapper: Codable { + let data: AvatarData +} + struct UserStatusDataWrapper: Codable { let data: UserStatus } 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 466a0ad8..b3e8367f 100644 --- a/sdk/typescript/src/generated/client.ts +++ b/sdk/typescript/src/generated/client.ts @@ -28,6 +28,7 @@ import { ListTagsResponse, GroupTag, GetTagUsersParams, + GetTagUsersResponse, GroupTagRequest, ListChatMessagesParams, ListChatMessagesResponse, @@ -41,30 +42,49 @@ import { ReactionRequest, RemoveReactionParams, ListReadMembersParams, + ListThreadsParams, + ListThreadsResponse, Thread, AccessTokenInfo, + AvatarData, StatusUpdateRequest, UserStatus, SearchChatsParams, + SearchChatsResponse, SearchMessagesParams, + SearchMessagesResponse, SearchUsersParams, + SearchUsersResponse, ListTasksParams, ListTasksResponse, Task, TaskCreateRequest, TaskUpdateRequest, ListUsersParams, + ListUsersResponse, UserCreateRequest, UserUpdateRequest, OpenViewRequest, -} from "./types"; -import { deserialize, serialize, fetchWithRetry } from "./utils"; +} from "./types.js"; +import { deserialize, deserializeType, serializeType, fetchWithRetry } from "./utils.js"; -class SecurityService { +export class SecurityService { + async getAuditEvents(params?: GetAuditEventsParams): Promise { + throw new Error("Security.getAuditEvents is not implemented"); + } + + async getAuditEventsAll(params?: Omit): Promise { + throw new Error("Security.getAuditEventsAll is not implemented"); + } +} + +export class SecurityServiceImpl extends SecurityService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async getAuditEvents(params?: GetAuditEventsParams): Promise { const query = new URLSearchParams(); @@ -95,20 +115,43 @@ class SecurityService { async getAuditEventsAll(params?: Omit): Promise { const items: AuditEvent[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.getAuditEvents({ ...params, cursor } as GetAuditEventsParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } } -class BotsService { +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 updateBot(id: number, request: BotUpdateRequest): Promise { + throw new Error("Bots.updateBot is not implemented"); + } + + async deleteWebhookEvent(id: string): Promise { + throw new Error("Bots.deleteWebhookEvent is not implemented"); + } +} + +export class BotsServiceImpl extends BotsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async getWebhookEvents(params?: GetWebhookEventsParams): Promise { const query = new URLSearchParams(); @@ -121,7 +164,7 @@ class 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: @@ -132,11 +175,14 @@ class BotsService { async getWebhookEventsAll(params?: Omit): Promise { const items: WebhookEvent[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.getWebhookEvents({ ...params, cursor } as GetWebhookEventsParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -144,12 +190,12 @@ class 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: @@ -173,15 +219,48 @@ class BotsService { } } -class ChatsService { +export class ChatsService { + async listChats(params?: ListChatsParams): Promise { + throw new Error("Chats.listChats is not implemented"); + } + + async listChatsAll(params?: Omit): Promise { + throw new Error("Chats.listChatsAll is not implemented"); + } + + async getChat(id: number): Promise { + throw new Error("Chats.getChat is not implemented"); + } + + async createChat(request: ChatCreateRequest): Promise { + throw new Error("Chats.createChat is not implemented"); + } + + async updateChat(id: number, request: ChatUpdateRequest): Promise { + throw new Error("Chats.updateChat is not implemented"); + } + + async archiveChat(id: number): Promise { + throw new Error("Chats.archiveChat is not implemented"); + } + + async unarchiveChat(id: number): Promise { + throw new Error("Chats.unarchiveChat is not implemented"); + } +} + +export class ChatsServiceImpl extends ChatsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listChats(params?: ListChatsParams): Promise { const query = new URLSearchParams(); - if (params?.sortId !== undefined) query.set("sort[{field}]", params.sortId); + if (params?.sort !== undefined) query.set("sort", params.sort); + if (params?.order !== undefined) query.set("order", params.order); if (params?.availability !== undefined) query.set("availability", params.availability); if (params?.lastMessageAtAfter !== undefined) query.set("last_message_at_after", params.lastMessageAtAfter); if (params?.lastMessageAtBefore !== undefined) query.set("last_message_at_before", params.lastMessageAtBefore); @@ -206,11 +285,14 @@ class ChatsService { async listChatsAll(params?: Omit): Promise { const items: Chat[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listChats({ ...params, cursor } as ListChatsParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -221,7 +303,7 @@ class 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: @@ -233,12 +315,12 @@ class 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: @@ -250,12 +332,12 @@ class 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: @@ -294,11 +376,35 @@ class ChatsService { } } -class CommonService { +export class CommonService { + async downloadExport(id: number): Promise { + throw new Error("Common.downloadExport is not implemented"); + } + + async listProperties(params: ListPropertiesParams): Promise { + throw new Error("Common.listProperties is not implemented"); + } + + async requestExport(request: ExportRequest): Promise { + throw new Error("Common.requestExport is not implemented"); + } + + async uploadFile(directUrl: string, request: FileUploadRequest): Promise { + throw new Error("Common.uploadFile is not implemented"); + } + + async getUploadParams(): Promise { + throw new Error("Common.getUploadParams is not implemented"); + } +} + +export class CommonServiceImpl extends CommonService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async downloadExport(id: number): Promise { const response = await fetchWithRetry(`${this.baseUrl}/chats/exports/${id}`, { @@ -341,7 +447,7 @@ class 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: @@ -384,7 +490,7 @@ class 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: @@ -393,11 +499,47 @@ class CommonService { } } -class MembersService { +export class MembersService { + async listMembers(id: number, params?: ListMembersParams): Promise { + throw new Error("Members.listMembers is not implemented"); + } + + async listMembersAll(id: number, params?: Omit): Promise { + throw new Error("Members.listMembersAll is not implemented"); + } + + async addTags(id: number, groupTagIds: number[]): Promise { + throw new Error("Members.addTags is not implemented"); + } + + async addMembers(id: number, request: AddMembersRequest): Promise { + throw new Error("Members.addMembers is not implemented"); + } + + async updateMemberRole(id: number, userId: number, role: ChatMemberRole): Promise { + throw new Error("Members.updateMemberRole is not implemented"); + } + + async removeTag(id: number, tagId: number): Promise { + throw new Error("Members.removeTag is not implemented"); + } + + async leaveChat(id: number): Promise { + throw new Error("Members.leaveChat is not implemented"); + } + + async removeMember(id: number, userId: number): Promise { + throw new Error("Members.removeMember is not implemented"); + } +} + +export class MembersServiceImpl extends MembersService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listMembers(id: number, params?: ListMembersParams): Promise { const query = new URLSearchParams(); @@ -422,11 +564,14 @@ class MembersService { async listMembersAll(id: number, params?: Omit): Promise { const items: User[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listMembers(id, { ...params, cursor } as ListMembersParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -450,7 +595,7 @@ class 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: @@ -524,15 +669,53 @@ class MembersService { } } -class GroupTagsService { +export class GroupTagsService { + async listTags(params?: ListTagsParams): Promise { + throw new Error("Group tags.listTags is not implemented"); + } + + async listTagsAll(params?: Omit): Promise { + throw new Error("Group tags.listTagsAll is not implemented"); + } + + async getTag(id: number): Promise { + throw new Error("Group tags.getTag is not implemented"); + } + + async getTagUsers(id: number, params?: GetTagUsersParams): Promise { + throw new Error("Group tags.getTagUsers is not implemented"); + } + + async getTagUsersAll(id: number, params?: Omit): Promise { + throw new Error("Group tags.getTagUsersAll is not implemented"); + } + + async createTag(request: GroupTagRequest): Promise { + throw new Error("Group tags.createTag is not implemented"); + } + + async updateTag(id: number, request: GroupTagRequest): Promise { + throw new Error("Group tags.updateTag is not implemented"); + } + + async deleteTag(id: number): Promise { + throw new Error("Group tags.deleteTag is not implemented"); + } +} + +export class GroupTagsServiceImpl extends GroupTagsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listTags(params?: ListTagsParams): Promise { const query = new URLSearchParams(); - if (params?.names !== undefined) query.set("names", String(params.names)); + if (params?.names !== undefined) { + params.names.forEach((v) => query.append("names[]", String(v))); + } if (params?.limit !== undefined) query.set("limit", String(params.limit)); if (params?.cursor !== undefined) query.set("cursor", params.cursor); const url = `${this.baseUrl}/group_tags${query.toString() ? `?${query}` : ""}`; @@ -553,11 +736,14 @@ class GroupTagsService { async listTagsAll(params?: Omit): Promise { const items: GroupTag[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listTags({ ...params, cursor } as ListTagsParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -568,7 +754,7 @@ class 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: @@ -576,7 +762,7 @@ class GroupTagsService { } } - async getTagUsers(id: number, params?: GetTagUsersParams): Promise { + async getTagUsers(id: number, params?: GetTagUsersParams): Promise { const query = new URLSearchParams(); if (params?.limit !== undefined) query.set("limit", String(params.limit)); if (params?.cursor !== undefined) query.set("cursor", params.cursor); @@ -587,7 +773,7 @@ class GroupTagsService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as ListMembersResponse; + return deserialize(body) as GetTagUsersResponse; case 401: throw new OAuthError(body.error); default: @@ -598,11 +784,14 @@ class GroupTagsService { async getTagUsersAll(id: number, params?: Omit): Promise { const items: User[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.getTagUsers(id, { ...params, cursor } as GetTagUsersParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -610,12 +799,12 @@ class 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: @@ -627,12 +816,12 @@ class 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: @@ -656,16 +845,53 @@ class GroupTagsService { } } -class MessagesService { +export class MessagesService { + async listChatMessages(params: ListChatMessagesParams): Promise { + throw new Error("Messages.listChatMessages is not implemented"); + } + + async listChatMessagesAll(params: Omit): Promise { + throw new Error("Messages.listChatMessagesAll is not implemented"); + } + + async getMessage(id: number): Promise { + throw new Error("Messages.getMessage is not implemented"); + } + + async createMessage(request: MessageCreateRequest): Promise { + throw new Error("Messages.createMessage is not implemented"); + } + + async pinMessage(id: number): Promise { + throw new Error("Messages.pinMessage is not implemented"); + } + + async updateMessage(id: number, request: MessageUpdateRequest): Promise { + throw new Error("Messages.updateMessage is not implemented"); + } + + async deleteMessage(id: number): Promise { + throw new Error("Messages.deleteMessage is not implemented"); + } + + async unpinMessage(id: number): Promise { + throw new Error("Messages.unpinMessage is not implemented"); + } +} + +export class MessagesServiceImpl extends MessagesService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listChatMessages(params: ListChatMessagesParams): Promise { const query = new URLSearchParams(); query.set("chat_id", String(params.chatId)); - if (params?.sortId !== undefined) query.set("sort[{field}]", params.sortId); + if (params?.sort !== undefined) query.set("sort", params.sort); + if (params?.order !== undefined) query.set("order", params.order); if (params?.limit !== undefined) query.set("limit", String(params.limit)); if (params?.cursor !== undefined) query.set("cursor", params.cursor); const response = await fetchWithRetry(`${this.baseUrl}/messages?${query}`, { @@ -685,11 +911,14 @@ class MessagesService { async listChatMessagesAll(params: Omit): Promise { const items: Message[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listChatMessages({ ...params, cursor } as ListChatMessagesParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -700,7 +929,7 @@ class 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: @@ -712,12 +941,12 @@ class 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: @@ -744,12 +973,12 @@ class 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: @@ -788,17 +1017,25 @@ class MessagesService { } } -class LinkPreviewsService { +export class LinkPreviewsService { + async createLinkPreviews(id: number, request: LinkPreviewsRequest): Promise { + throw new Error("Link Previews.createLinkPreviews is not implemented"); + } +} + +export class LinkPreviewsServiceImpl extends LinkPreviewsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async createLinkPreviews(id: number, request: LinkPreviewsRequest): Promise { 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: @@ -811,11 +1048,31 @@ class LinkPreviewsService { } } -class ReactionsService { +export class ReactionsService { + async listReactions(id: number, params?: ListReactionsParams): Promise { + throw new Error("Reactions.listReactions is not implemented"); + } + + async listReactionsAll(id: number, params?: Omit): Promise { + throw new Error("Reactions.listReactionsAll is not implemented"); + } + + async addReaction(id: number, request: ReactionRequest): Promise { + throw new Error("Reactions.addReaction is not implemented"); + } + + async removeReaction(id: number, params: RemoveReactionParams): Promise { + throw new Error("Reactions.removeReaction is not implemented"); + } +} + +export class ReactionsServiceImpl extends ReactionsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listReactions(id: number, params?: ListReactionsParams): Promise { const query = new URLSearchParams(); @@ -839,11 +1096,14 @@ class ReactionsService { async listReactionsAll(id: number, params?: Omit): Promise { const items: Reaction[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listReactions(id, { ...params, cursor } as ListReactionsParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -851,12 +1111,12 @@ class 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: @@ -883,11 +1143,19 @@ class ReactionsService { } } -class ReadMembersService { +export class ReadMembersService { + async listReadMembers(id: number, params?: ListReadMembersParams): Promise { + throw new Error("Read members.listReadMembers is not implemented"); + } +} + +export class ReadMembersServiceImpl extends ReadMembersService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listReadMembers(id: number, params?: ListReadMembersParams): Promise { const query = new URLSearchParams(); @@ -909,11 +1177,66 @@ class ReadMembersService { } } -class ThreadsService { +export class ThreadsService { + async listThreads(params?: ListThreadsParams): Promise { + throw new Error("Threads.listThreads is not implemented"); + } + + async listThreadsAll(params?: Omit): Promise { + throw new Error("Threads.listThreadsAll is not implemented"); + } + + async getThread(id: number): Promise { + throw new Error("Threads.getThread is not implemented"); + } + + async createThread(id: number): Promise { + throw new Error("Threads.createThread is not implemented"); + } +} + +export class ThreadsServiceImpl extends ThreadsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } + + async listThreads(params?: ListThreadsParams): Promise { + const query = new URLSearchParams(); + if (params?.lastMessageAtAfter !== undefined) query.set("last_message_at_after", params.lastMessageAtAfter); + if (params?.lastMessageAtBefore !== undefined) query.set("last_message_at_before", params.lastMessageAtBefore); + if (params?.limit !== undefined) query.set("limit", String(params.limit)); + if (params?.cursor !== undefined) query.set("cursor", params.cursor); + const url = `${this.baseUrl}/threads${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 ListThreadsResponse; + case 401: + throw new OAuthError(body.error); + default: + throw new ApiError(body.errors); + } + } + + async listThreadsAll(params?: Omit): Promise { + const items: Thread[] = []; + let cursor: string | undefined; + let hasNext = true; + while (hasNext) { + const response = await this.listThreads({ ...params, cursor } as ListThreadsParams); + items.push(...response.data); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } + return items; + } async getThread(id: number): Promise { const response = await fetchWithRetry(`${this.baseUrl}/threads/${id}`, { @@ -922,7 +1245,7 @@ class 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: @@ -938,7 +1261,7 @@ class 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: @@ -947,11 +1270,43 @@ class ThreadsService { } } -class ProfileService { +export class ProfileService { + async getTokenInfo(): Promise { + throw new Error("Profile.getTokenInfo is not implemented"); + } + + async getProfile(): Promise { + throw new Error("Profile.getProfile is not implemented"); + } + + async getStatus(): Promise { + throw new Error("Profile.getStatus is not implemented"); + } + + async updateProfileAvatar(image: Blob): Promise { + throw new Error("Profile.updateProfileAvatar is not implemented"); + } + + async updateStatus(request: StatusUpdateRequest): Promise { + throw new Error("Profile.updateStatus is not implemented"); + } + + async deleteProfileAvatar(): Promise { + throw new Error("Profile.deleteProfileAvatar is not implemented"); + } + + async deleteStatus(): Promise { + throw new Error("Profile.deleteStatus is not implemented"); + } +} + +export class ProfileServiceImpl extends ProfileService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async getTokenInfo(): Promise { const response = await fetchWithRetry(`${this.baseUrl}/oauth/token/info`, { @@ -960,7 +1315,7 @@ class 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: @@ -975,7 +1330,7 @@ class 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: @@ -990,7 +1345,26 @@ class 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: + throw new ApiError(body.errors); + } + } + + async updateProfileAvatar(image: Blob): Promise { + const form = new FormData(); + form.set("image", image, "upload"); + const response = await fetchWithRetry(`${this.baseUrl}/profile/avatar`, { + method: "PUT", + headers: this.headers, + body: form, + }); + const body = await response.json(); + switch (response.status) { + case 200: + return deserializeType("AvatarData", body.data) as AvatarData; case 401: throw new OAuthError(body.error); default: @@ -1002,12 +1376,12 @@ class 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: @@ -1015,6 +1389,21 @@ class ProfileService { } } + async deleteProfileAvatar(): Promise { + const response = await fetchWithRetry(`${this.baseUrl}/profile/avatar`, { + method: "DELETE", + headers: this.headers, + }); + switch (response.status) { + case 204: + return; + case 401: + throw new OAuthError(((await response.json()) as any).error); + default: + throw new ApiError(((await response.json()) as any).errors); + } + } + async deleteStatus(): Promise { const response = await fetchWithRetry(`${this.baseUrl}/profile/status`, { method: "DELETE", @@ -1031,13 +1420,41 @@ class ProfileService { } } -class SearchService { +export class SearchService { + async searchChats(params?: SearchChatsParams): Promise { + throw new Error("Search.searchChats is not implemented"); + } + + async searchChatsAll(params?: Omit): Promise { + throw new Error("Search.searchChatsAll is not implemented"); + } + + async searchMessages(params?: SearchMessagesParams): Promise { + throw new Error("Search.searchMessages is not implemented"); + } + + async searchMessagesAll(params?: Omit): Promise { + throw new Error("Search.searchMessagesAll is not implemented"); + } + + async searchUsers(params?: SearchUsersParams): Promise { + throw new Error("Search.searchUsers is not implemented"); + } + + async searchUsersAll(params?: Omit): Promise { + throw new Error("Search.searchUsersAll is not implemented"); + } +} + +export class SearchServiceImpl extends SearchService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } - async searchChats(params?: SearchChatsParams): Promise { + async searchChats(params?: SearchChatsParams): Promise { const query = new URLSearchParams(); if (params?.query !== undefined) query.set("query", params.query); if (params?.limit !== undefined) query.set("limit", String(params.limit)); @@ -1055,7 +1472,7 @@ class SearchService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as ListChatsResponse; + return deserialize(body) as SearchChatsResponse; case 401: throw new OAuthError(body.error); default: @@ -1069,12 +1486,13 @@ class SearchService { do { const response = await this.searchChats({ ...params, cursor } as SearchChatsParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + } while (true); return items; } - async searchMessages(params?: SearchMessagesParams): Promise { + async searchMessages(params?: SearchMessagesParams): Promise { const query = new URLSearchParams(); if (params?.query !== undefined) query.set("query", params.query); if (params?.limit !== undefined) query.set("limit", String(params.limit)); @@ -1082,8 +1500,12 @@ class SearchService { if (params?.order !== undefined) query.set("order", params.order); if (params?.createdFrom !== undefined) query.set("created_from", params.createdFrom); if (params?.createdTo !== undefined) query.set("created_to", params.createdTo); - if (params?.chatIds !== undefined) query.set("chat_ids", String(params.chatIds)); - if (params?.userIds !== undefined) query.set("user_ids", String(params.userIds)); + if (params?.chatIds !== undefined) { + params.chatIds.forEach((v) => query.append("chat_ids[]", String(v))); + } + if (params?.userIds !== undefined) { + params.userIds.forEach((v) => query.append("user_ids[]", String(v))); + } if (params?.active !== undefined) query.set("active", String(params.active)); const url = `${this.baseUrl}/search/messages${query.toString() ? `?${query}` : ""}`; const response = await fetchWithRetry(url, { @@ -1092,7 +1514,7 @@ class SearchService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as ListChatMessagesResponse; + return deserialize(body) as SearchMessagesResponse; case 401: throw new OAuthError(body.error); default: @@ -1106,12 +1528,13 @@ class SearchService { do { const response = await this.searchMessages({ ...params, cursor } as SearchMessagesParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + } while (true); return items; } - async searchUsers(params?: SearchUsersParams): Promise { + async searchUsers(params?: SearchUsersParams): Promise { const query = new URLSearchParams(); if (params?.query !== undefined) query.set("query", params.query); if (params?.limit !== undefined) query.set("limit", String(params.limit)); @@ -1120,7 +1543,9 @@ class SearchService { if (params?.order !== undefined) query.set("order", params.order); if (params?.createdFrom !== undefined) query.set("created_from", params.createdFrom); if (params?.createdTo !== undefined) query.set("created_to", params.createdTo); - if (params?.companyRoles !== undefined) query.set("company_roles", String(params.companyRoles)); + if (params?.companyRoles !== undefined) { + params.companyRoles.forEach((v) => query.append("company_roles[]", String(v))); + } const url = `${this.baseUrl}/search/users${query.toString() ? `?${query}` : ""}`; const response = await fetchWithRetry(url, { headers: this.headers, @@ -1128,7 +1553,7 @@ class SearchService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as ListMembersResponse; + return deserialize(body) as SearchUsersResponse; case 401: throw new OAuthError(body.error); default: @@ -1142,17 +1567,46 @@ class SearchService { do { const response = await this.searchUsers({ ...params, cursor } as SearchUsersParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + } while (true); return items; } } -class TasksService { +export class TasksService { + async listTasks(params?: ListTasksParams): Promise { + throw new Error("Tasks.listTasks is not implemented"); + } + + async listTasksAll(params?: Omit): Promise { + throw new Error("Tasks.listTasksAll is not implemented"); + } + + async getTask(id: number): Promise { + throw new Error("Tasks.getTask is not implemented"); + } + + async createTask(request: TaskCreateRequest): Promise { + throw new Error("Tasks.createTask is not implemented"); + } + + async updateTask(id: number, request: TaskUpdateRequest): Promise { + throw new Error("Tasks.updateTask is not implemented"); + } + + async deleteTask(id: number): Promise { + throw new Error("Tasks.deleteTask is not implemented"); + } +} + +export class TasksServiceImpl extends TasksService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listTasks(params?: ListTasksParams): Promise { const query = new URLSearchParams(); @@ -1176,11 +1630,14 @@ class TasksService { async listTasksAll(params?: Omit): Promise { const items: Task[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listTasks({ ...params, cursor } as ListTasksParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -1191,7 +1648,7 @@ class 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: @@ -1203,12 +1660,12 @@ class 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: @@ -1220,12 +1677,12 @@ class 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: @@ -1249,13 +1706,61 @@ class TasksService { } } -class UsersService { +export class UsersService { + async listUsers(params?: ListUsersParams): Promise { + throw new Error("Users.listUsers is not implemented"); + } + + async listUsersAll(params?: Omit): Promise { + throw new Error("Users.listUsersAll is not implemented"); + } + + async getUser(id: number): Promise { + throw new Error("Users.getUser is not implemented"); + } + + async getUserStatus(userId: number): Promise { + throw new Error("Users.getUserStatus is not implemented"); + } + + async createUser(request: UserCreateRequest): Promise { + throw new Error("Users.createUser is not implemented"); + } + + async updateUser(id: number, request: UserUpdateRequest): Promise { + throw new Error("Users.updateUser is not implemented"); + } + + async updateUserAvatar(userId: number, image: Blob): Promise { + throw new Error("Users.updateUserAvatar is not implemented"); + } + + async updateUserStatus(userId: number, request: StatusUpdateRequest): Promise { + throw new Error("Users.updateUserStatus is not implemented"); + } + + async deleteUser(id: number): Promise { + throw new Error("Users.deleteUser is not implemented"); + } + + async deleteUserAvatar(userId: number): Promise { + throw new Error("Users.deleteUserAvatar is not implemented"); + } + + async deleteUserStatus(userId: number): Promise { + throw new Error("Users.deleteUserStatus is not implemented"); + } +} + +export class UsersServiceImpl extends UsersService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } - async listUsers(params?: ListUsersParams): Promise { + async listUsers(params?: ListUsersParams): Promise { const query = new URLSearchParams(); if (params?.query !== undefined) query.set("query", params.query); if (params?.limit !== undefined) query.set("limit", String(params.limit)); @@ -1267,7 +1772,7 @@ class UsersService { const body = await response.json(); switch (response.status) { case 200: - return deserialize(body) as ListMembersResponse; + return deserialize(body) as ListUsersResponse; case 401: throw new OAuthError(body.error); default: @@ -1278,11 +1783,14 @@ class UsersService { async listUsersAll(params?: Omit): Promise { const items: User[] = []; let cursor: string | undefined; - do { + let hasNext = true; + while (hasNext) { const response = await this.listUsers({ ...params, cursor } as ListUsersParams); items.push(...response.data); - cursor = response.meta?.paginate?.nextPage; - } while (cursor); + if (response.data.length === 0) break; + cursor = response.meta.paginate.nextPage; + hasNext = response.meta.paginate.hasNext ?? true; + } return items; } @@ -1293,7 +1801,7 @@ class 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: @@ -1308,7 +1816,7 @@ class 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: @@ -1320,12 +1828,12 @@ class 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: @@ -1337,12 +1845,31 @@ class 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 deserializeType("User", body.data) as User; + case 401: + throw new OAuthError(body.error); + default: + throw new ApiError(body.errors); + } + } + + async updateUserAvatar(userId: number, image: Blob): Promise { + const form = new FormData(); + form.set("image", image, "upload"); + const response = await fetchWithRetry(`${this.baseUrl}/users/${userId}/avatar`, { + method: "PUT", + headers: this.headers, + body: form, }); const body = await response.json(); switch (response.status) { case 200: - return deserialize(body.data) as User; + return deserializeType("AvatarData", body.data) as AvatarData; case 401: throw new OAuthError(body.error); default: @@ -1354,12 +1881,12 @@ class 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: @@ -1382,6 +1909,21 @@ class UsersService { } } + async deleteUserAvatar(userId: number): Promise { + const response = await fetchWithRetry(`${this.baseUrl}/users/${userId}/avatar`, { + method: "DELETE", + headers: this.headers, + }); + switch (response.status) { + case 204: + return; + case 401: + throw new OAuthError(((await response.json()) as any).error); + default: + throw new ApiError(((await response.json()) as any).errors); + } + } + async deleteUserStatus(userId: number): Promise { const response = await fetchWithRetry(`${this.baseUrl}/users/${userId}/status`, { method: "DELETE", @@ -1398,17 +1940,25 @@ class UsersService { } } -class ViewsService { +export class ViewsService { + async openView(request: OpenViewRequest): Promise { + throw new Error("Views.openView is not implemented"); + } +} + +export class ViewsServiceImpl extends ViewsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async openView(request: OpenViewRequest): Promise { 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: @@ -1421,6 +1971,8 @@ class ViewsService { } } +export const PACHCA_API_URL = "https://api.pachca.com/api/shared/v1"; + export class PachcaClient { readonly bots: BotsService; readonly chats: ChatsService; @@ -1439,23 +1991,70 @@ export class PachcaClient { readonly users: UsersService; readonly views: ViewsService; - constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { - const headers = { Authorization: `Bearer ${token}` }; - this.bots = new BotsService(baseUrl, headers); - this.chats = new ChatsService(baseUrl, headers); - this.common = new CommonService(baseUrl, headers); - this.groupTags = new GroupTagsService(baseUrl, headers); - this.linkPreviews = new LinkPreviewsService(baseUrl, headers); - this.members = new MembersService(baseUrl, headers); - this.messages = new MessagesService(baseUrl, headers); - this.profile = new ProfileService(baseUrl, headers); - this.reactions = new ReactionsService(baseUrl, headers); - this.readMembers = new ReadMembersService(baseUrl, headers); - this.search = new SearchService(baseUrl, headers); - this.security = new SecurityService(baseUrl, headers); - this.tasks = new TasksService(baseUrl, headers); - this.threads = new ThreadsService(baseUrl, headers); - this.users = new UsersService(baseUrl, headers); - this.views = new ViewsService(baseUrl, headers); + constructor(token: string, baseUrl?: string); + constructor(config: { headers: Record; baseUrl?: string; bots?: BotsService; chats?: ChatsService; common?: CommonService; groupTags?: GroupTagsService; linkPreviews?: LinkPreviewsService; members?: MembersService; messages?: MessagesService; profile?: ProfileService; reactions?: ReactionsService; readMembers?: ReadMembersService; search?: SearchService; security?: SecurityService; tasks?: TasksService; threads?: ThreadsService; users?: UsersService; views?: ViewsService }); + constructor(tokenOrConfig: string | { headers: Record; baseUrl?: string; bots?: BotsService; chats?: ChatsService; common?: CommonService; groupTags?: GroupTagsService; linkPreviews?: LinkPreviewsService; members?: MembersService; messages?: MessagesService; profile?: ProfileService; reactions?: ReactionsService; readMembers?: ReadMembersService; search?: SearchService; security?: SecurityService; tasks?: TasksService; threads?: ThreadsService; users?: UsersService; views?: ViewsService }, 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); + this.chats = new ChatsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.common = new CommonServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.groupTags = new GroupTagsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.linkPreviews = new LinkPreviewsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.members = new MembersServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.messages = new MessagesServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.profile = new ProfileServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.reactions = new ReactionsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.readMembers = new ReadMembersServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.search = new SearchServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.security = new SecurityServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.tasks = new TasksServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.threads = new ThreadsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.users = new UsersServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.views = new ViewsServiceImpl(resolvedBaseUrl, resolvedHeaders); + } else { + resolvedHeaders = tokenOrConfig.headers; + resolvedBaseUrl = tokenOrConfig.baseUrl ?? PACHCA_API_URL; + this.bots = tokenOrConfig.bots ?? new BotsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.chats = tokenOrConfig.chats ?? new ChatsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.common = tokenOrConfig.common ?? new CommonServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.groupTags = tokenOrConfig.groupTags ?? new GroupTagsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.linkPreviews = tokenOrConfig.linkPreviews ?? new LinkPreviewsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.members = tokenOrConfig.members ?? new MembersServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.messages = tokenOrConfig.messages ?? new MessagesServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.profile = tokenOrConfig.profile ?? new ProfileServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.reactions = tokenOrConfig.reactions ?? new ReactionsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.readMembers = tokenOrConfig.readMembers ?? new ReadMembersServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.search = tokenOrConfig.search ?? new SearchServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.security = tokenOrConfig.security ?? new SecurityServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.tasks = tokenOrConfig.tasks ?? new TasksServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.threads = tokenOrConfig.threads ?? new ThreadsServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.users = tokenOrConfig.users ?? new UsersServiceImpl(resolvedBaseUrl, resolvedHeaders); + this.views = tokenOrConfig.views ?? new ViewsServiceImpl(resolvedBaseUrl, resolvedHeaders); + } + } + + static stub(overrides: { bots?: BotsService; chats?: ChatsService; common?: CommonService; groupTags?: GroupTagsService; linkPreviews?: LinkPreviewsService; members?: MembersService; messages?: MessagesService; profile?: ProfileService; reactions?: ReactionsService; readMembers?: ReadMembersService; search?: SearchService; security?: SecurityService; tasks?: TasksService; threads?: ThreadsService; users?: UsersService; views?: ViewsService } = {}): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.bots = overrides.bots ?? new BotsService(); + client.chats = overrides.chats ?? new ChatsService(); + client.common = overrides.common ?? new CommonService(); + client.groupTags = overrides.groupTags ?? new GroupTagsService(); + client.linkPreviews = overrides.linkPreviews ?? new LinkPreviewsService(); + client.members = overrides.members ?? new MembersService(); + client.messages = overrides.messages ?? new MessagesService(); + client.profile = overrides.profile ?? new ProfileService(); + client.reactions = overrides.reactions ?? new ReactionsService(); + client.readMembers = overrides.readMembers ?? new ReadMembersService(); + client.search = overrides.search ?? new SearchService(); + client.security = overrides.security ?? new SecurityService(); + client.tasks = overrides.tasks ?? new TasksService(); + client.threads = overrides.threads ?? new ThreadsService(); + client.users = overrides.users ?? new UsersService(); + client.views = overrides.views ?? new ViewsService(); + return client; } } diff --git a/sdk/typescript/src/generated/utils.ts b/sdk/typescript/src/generated/utils.ts index e265ad6f..7aa40cec 100644 --- a/sdk/typescript/src/generated/utils.ts +++ b/sdk/typescript/src/generated/utils.ts @@ -10,23 +10,29 @@ function camelToSnake(str: string): string { .toLowerCase(); } -const RECORD_KEYS = new Set(["payload", "filters", "link_previews", "linkPreviews"]); +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]); @@ -72,12 +110,12 @@ export async function fetchWithRetry(input: RequestInfo | URL, init?: RequestIni 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, delay)); + await new Promise((r) => setTimeout(r, jitter(delay))); continue; } if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { - const delay = jitter(10000 * Math.pow(2, attempt)); - await new Promise((r) => setTimeout(r, delay)); + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, jitter(delay))); continue; } return response; From a49a50b45dca6c87428392fb3e3a9a1ae0213ba6 Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Fri, 5 Jun 2026 18:11:15 +0200 Subject: [PATCH 2/7] Preserve literal discriminators in SDK generation Keep literal discriminator fields on generated union member models instead of dropping them during Kotlin and C# union emission. C# now serializes concrete override properties with the JSON field name, while Kotlin keeps secondary literal fields as body properties to avoid changing positional constructor APIs. Also prefer the configured discriminator field when routing union decoding in Go, Python, Swift, and C# so secondary literals like event do not accidentally become the primary discriminator. Regenerated SDK outputs, generator snapshots, and release metadata. Validation: bun --cwd packages/generator test; cd packages/generator && bun run typecheck; SDK compile/build checks for TypeScript, Python, Go, Kotlin, C#, and Swift; real main and webhook-history examples across all six SDKs. Co-authored-by: openai-codex/gpt-5.5 --- apps/docs/data/releases.json | 22 +++++++ apps/docs/public/updates.md | 10 +++ apps/docs/public/updates/2026-06-05.md | 13 ++++ .../docs/public/updates/season/summer-2026.md | 10 +++ packages/generator/src/lang/csharp.ts | 31 +++++---- packages/generator/src/lang/go.ts | 8 ++- packages/generator/src/lang/kotlin.ts | 40 +++++++---- packages/generator/src/lang/python.ts | 4 +- packages/generator/src/lang/swift.ts | 8 ++- .../array-no-brackets/snapshots/py/utils.py | 66 ++++++++++++------- .../array-no-brackets/snapshots/ts/client.ts | 2 +- .../array-no-brackets/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/crud/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/crud/snapshots/ts/client.ts | 12 ++-- .../tests/crud/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/date-format/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/date-format/snapshots/ts/client.ts | 6 +- .../tests/date-format/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/edge-cases/snapshots/cs/Models.cs | 2 + .../tests/edge-cases/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/edge-cases/snapshots/ts/client.ts | 4 +- .../tests/edge-cases/snapshots/ts/utils.ts | 37 +++++++++++ .../multi-path-params/snapshots/py/utils.py | 66 ++++++++++++------- .../multi-path-params/snapshots/ts/client.ts | 8 +-- .../multi-path-params/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/oneof/snapshots/cs/Models.cs | 3 + .../tests/patch/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/patch/snapshots/ts/client.ts | 6 +- .../tests/patch/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/record/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/record/snapshots/ts/client.ts | 4 +- .../tests/record/snapshots/ts/utils.ts | 35 +++++++--- .../tests/redirect/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/redirect/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/search/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/search/snapshots/ts/client.ts | 2 +- .../tests/search/snapshots/ts/utils.ts | 37 +++++++++++ packages/generator/tests/unions/fixture.yaml | 6 ++ .../tests/unions/snapshots/cs/Models.cs | 5 ++ .../tests/unions/snapshots/go/types.go | 7 +- .../tests/unions/snapshots/kt/Models.kt | 4 +- .../tests/unions/snapshots/py/models.py | 1 + .../tests/unions/snapshots/swift/Models.swift | 4 +- .../tests/unions/snapshots/ts/types.ts | 1 + .../tests/unwrap/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/unwrap/snapshots/ts/client.ts | 6 +- .../tests/unwrap/snapshots/ts/utils.ts | 37 +++++++++++ .../tests/upload/snapshots/py/utils.py | 66 ++++++++++++------- .../tests/upload/snapshots/ts/client.ts | 4 +- .../tests/upload/snapshots/ts/utils.ts | 37 +++++++++++ sdk/csharp/generated/Models.cs | 24 +++++++ sdk/go/generated/types.go | 2 +- .../src/main/kotlin/com/pachca/Models.kt | 12 +++- sdk/python/generated/pachca/utils.py | 2 - 54 files changed, 1052 insertions(+), 352 deletions(-) create mode 100644 apps/docs/public/updates/2026-06-05.md diff --git a/apps/docs/data/releases.json b/apps/docs/data/releases.json index 5a2175d7..23e6bcd5 100644 --- a/apps/docs/data/releases.json +++ b/apps/docs/data/releases.json @@ -1,4 +1,26 @@ [ + { + "product": "sdk", + "version": "1.0.21", + "date": "2026-06-05", + "changes": [ + { + "type": "~", + "description": "SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`" + } + ] + }, + { + "product": "generator", + "version": "1.1.6", + "date": "2026-06-05", + "changes": [ + { + "type": "~", + "description": "Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`" + } + ] + }, { "product": "sdk", "version": "1.0.20", diff --git a/apps/docs/public/updates.md b/apps/docs/public/updates.md index 929b17c9..8ed0104d 100644 --- a/apps/docs/public/updates.md +++ b/apps/docs/public/updates.md @@ -6,6 +6,16 @@ ## ☀️ Лето 2026 +### 05 июня 2026 + +### SDK v1.0.21 + +- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event` + +### Generator v1.1.6 + +- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event` + ### 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..f63e5fad --- /dev/null +++ b/apps/docs/public/updates/2026-06-05.md @@ -0,0 +1,13 @@ +> Это Markdown-версия конкретной страницы. Для контекста за её пределами (правила API, полный перечень методов, авторизация) ОБЯЗАТЕЛЬНО открой [llms.txt](https://dev.pachca.com/llms.txt) перед ответом — это сэкономит токены и предотвратит неполный ответ. + +# 05 июня 2026 + +_05 июня 2026_ + +### SDK v1.0.21 + +- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event` + +### Generator v1.1.6 + +- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event` diff --git a/apps/docs/public/updates/season/summer-2026.md b/apps/docs/public/updates/season/summer-2026.md index 98c35e96..e4ebf4a2 100644 --- a/apps/docs/public/updates/season/summer-2026.md +++ b/apps/docs/public/updates/season/summer-2026.md @@ -2,6 +2,16 @@ # ☀️ Лето 2026 +### 05 июня 2026 + +### SDK v1.0.21 + +- SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event` + +### Generator v1.1.6 + +- Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event` + ### 02 июня 2026 ### SDK v1.0.20 diff --git a/packages/generator/src/lang/csharp.ts b/packages/generator/src/lang/csharp.ts index c2805944..d3d51bd3 100644 --- a/packages/generator/src/lang/csharp.ts +++ b/packages/generator/src/lang/csharp.ts @@ -294,7 +294,9 @@ function emitUnion( } else { lines.push(`[JsonPolymorphic(TypeDiscriminatorPropertyName = "${discriminatorField}")]`); 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 ?? ''; lines.push(`[JsonDerivedType(typeof(${memberModel.name}), "${litValue}")]`); } @@ -321,7 +323,9 @@ function emitUnion( 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.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 ?? ''; lines.push(` ${JSON.stringify(litValue)} => JsonSerializer.Deserialize<${memberModel.name}>(raw, options)!,`); } @@ -337,26 +341,27 @@ function emitUnion( } 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', - ); - 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('}'); diff --git a/packages/generator/src/lang/go.ts b/packages/generator/src/lang/go.ts index d0923799..404a10f5 100644 --- a/packages/generator/src/lang/go.ts +++ b/packages/generator/src/lang/go.ts @@ -255,7 +255,9 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void { 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.type.kind === 'literal'); + 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}{}`); @@ -266,7 +268,9 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void { 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 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)); diff --git a/packages/generator/src/lang/kotlin.ts b/packages/generator/src/lang/kotlin.ts index 2125335b..323dc378 100644 --- a/packages/generator/src/lang/kotlin.ts +++ b/packages/generator/src/lang/kotlin.ts @@ -302,32 +302,50 @@ function emitUnion( } 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}`); } } diff --git a/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts index 9f381c9c..f7cf0a45 100644 --- a/packages/generator/src/lang/python.ts +++ b/packages/generator/src/lang/python.ts @@ -997,7 +997,9 @@ function generateUtils(ir: IR): string { 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.type.kind === 'literal'); + 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)}, _):`); diff --git a/packages/generator/src/lang/swift.ts b/packages/generator/src/lang/swift.ts index eabd3787..b3cc1298 100644 --- a/packages/generator/src/lang/swift.ts +++ b/packages/generator/src/lang/swift.ts @@ -199,7 +199,9 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void { 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.type.kind === 'literal'); + 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))`); @@ -210,7 +212,9 @@ function emitUnion(lines: string[], u: IRUnion, models: IRModel[]): void { 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 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)); 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/sdk/csharp/generated/Models.cs b/sdk/csharp/generated/Models.cs index 9f7e2e23..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!; @@ -1763,6 +1774,7 @@ public override void Write(Utf8JsonWriter writer, WebhookPayloadUnion value, Jso public class MessageWebhookPayload : WebhookPayloadUnion { + [JsonPropertyName("type")] public override string Type => "message"; [JsonPropertyName("id")] public int Id { get; set; } = default!; @@ -1792,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!; @@ -1813,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")] @@ -1830,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")] @@ -1847,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!; @@ -1864,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!; @@ -1877,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/go/generated/types.go b/sdk/go/generated/types.go index 6f96b0c7..4f2d8cc0 100644 --- a/sdk/go/generated/types.go +++ b/sdk/go/generated/types.go @@ -1563,7 +1563,7 @@ type WebhookPayloadUnion struct { func (u *WebhookPayloadUnion) UnmarshalJSON(data []byte) error { var disc struct { - Type string `json:"type"` + Type string `json:"type"` Event string `json:"event"` } if err := json.Unmarshal(data, &disc); err != nil { 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 a838580d..d57a67a6 100644 --- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt +++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Models.kt @@ -829,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") @@ -841,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") @@ -875,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/python/generated/pachca/utils.py b/sdk/python/generated/pachca/utils.py index b337dc8a..14f1ada6 100644 --- a/sdk/python/generated/pachca/utils.py +++ b/sdk/python/generated/pachca/utils.py @@ -91,7 +91,6 @@ def _deserialize_dataclass(cls: Type[T], data: dict) -> T: 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"): @@ -111,7 +110,6 @@ def _webhook_payload_union_deserialize(data: dict) -> WebhookPayloadUnion: case _: raise ValueError(f"Unknown WebhookPayloadUnion discriminator: {data.get('type')}") - _CUSTOM_UNION_DESERIALIZERS: dict[object, CustomUnionDeserializer] = { WebhookPayloadUnion: _webhook_payload_union_deserialize, } From cd27fd02cffb26e006ebed72e8878ca91af774c7 Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Fri, 5 Jun 2026 23:00:50 +0200 Subject: [PATCH 3/7] Add webhook polling helpers across SDKs Add webhook event and payload polling helpers for the generated SDK clients across TypeScript, Go, Python, Kotlin, C#, and Swift. The helpers default to polling from startup time, dedupe delivery IDs in memory, and stop paging once they reach only-old webhook pages. Add polling examples for every SDK. Validation run: TypeScript typecheck, Go generated tests, Python compileall, C# build, Kotlin compileKotlin, and Swift Polling target build. Co-authored-by: openai-codex/gpt-5.5 --- sdk/csharp/examples/PollingExample.cs | 68 +++++++++++ sdk/csharp/examples/Program.cs | 5 + sdk/csharp/generated/Client.cs | 69 +++++++++++ sdk/go/examples/polling.go | 64 +++++++++++ sdk/go/generated/client.go | 108 ++++++++++++++++++ sdk/kotlin/examples/polling.kt | 55 +++++++++ sdk/kotlin/generated/build.gradle.kts | 1 + .../src/main/kotlin/com/pachca/Client.kt | 61 +++++++++- sdk/python/examples/polling.py | 54 +++++++++ sdk/python/generated/pachca/client.py | 69 +++++++++++ sdk/swift/examples/Package.swift | 7 ++ sdk/swift/examples/Sources/Polling/main.swift | 46 ++++++++ .../Pachca/GeneratedSources/Client.swift | 91 +++++++++++++++ sdk/typescript/examples/polling.ts | 43 +++++++ sdk/typescript/src/generated/client.ts | 66 +++++++++++ 15 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 sdk/csharp/examples/PollingExample.cs create mode 100644 sdk/go/examples/polling.go create mode 100644 sdk/kotlin/examples/polling.kt create mode 100644 sdk/python/examples/polling.py create mode 100644 sdk/swift/examples/Sources/Polling/main.swift create mode 100644 sdk/typescript/examples/polling.ts 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 3bb5c926..3ba5d10b 100644 --- a/sdk/csharp/examples/Program.cs +++ b/sdk/csharp/examples/Program.cs @@ -9,6 +9,7 @@ "stub" => await StubExample.RunAsync(), "httpclient" => await HttpClientExample.RunAsync(), "webhook-history" => await WebhookHistoryExample.RunAsync(), + "polling" => await PollingExample.RunAsync(args.Skip(1).ToArray()), _ => PrintUsage() }; @@ -22,6 +23,10 @@ static int PrintUsage() 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)"); 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/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/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/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/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/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/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/swift/examples/Package.swift b/sdk/swift/examples/Package.swift index 1c02bc02..15c45583 100644 --- a/sdk/swift/examples/Package.swift +++ b/sdk/swift/examples/Package.swift @@ -43,5 +43,12 @@ let package = Package( ], 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/generated/Sources/Pachca/GeneratedSources/Client.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift index 4e58822e..73d082a5 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 = Swift.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 !Swift.Task.isCancelled { + var cursor: String? = nil + var hasNext = true + while hasNext && !Swift.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 Swift.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 = Swift.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/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/src/generated/client.ts b/sdk/typescript/src/generated/client.ts index b3e8367f..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, @@ -68,6 +69,24 @@ import { } from "./types.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 { throw new Error("Security.getAuditEvents is not implemented"); @@ -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"); } From ddbf7f8225b0c2a56ac597593a09bf6cee92530c Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Fri, 5 Jun 2026 23:27:24 +0200 Subject: [PATCH 4/7] Generate webhook polling helpers Move the SDK webhook polling helpers into the multi-language generator so future SDK regeneration keeps the polling API in sync across TypeScript, Go, Python, Kotlin, C#, and Swift. The generated helpers default to polling from startup time, dedupe delivery IDs in memory, expose payload polling shortcuts, and stop paging once a page contains no recent webhook events. Validation: bun run test and bun run typecheck in packages/generator; SDK compile/typecheck checks were run earlier after regeneration. Co-authored-by: openai-codex/gpt-5.5 --- packages/generator/src/lang/csharp.ts | 84 ++++++ packages/generator/src/lang/go.ts | 132 +++++++- packages/generator/src/lang/kotlin.ts | 81 ++++- packages/generator/src/lang/python.ts | 107 +++++++ packages/generator/src/lang/swift.ts | 103 +++++++ packages/generator/src/lang/typescript.ts | 97 ++++++ .../tests/webhook-polling/fixture.yaml | 94 ++++++ .../webhook-polling/snapshots/cs/Client.cs | 197 ++++++++++++ .../webhook-polling/snapshots/cs/Models.cs | 68 +++++ .../webhook-polling/snapshots/cs/Utils.cs | 103 +++++++ .../snapshots/cs/examples.json | 12 + .../webhook-polling/snapshots/go/client.go | 285 ++++++++++++++++++ .../snapshots/go/examples.json | 15 + .../webhook-polling/snapshots/go/types.go | 77 +++++ .../webhook-polling/snapshots/go/utils.go | 60 ++++ .../webhook-polling/snapshots/kt/Client.kt | 177 +++++++++++ .../webhook-polling/snapshots/kt/Models.kt | 61 ++++ .../snapshots/kt/examples.json | 12 + .../webhook-polling/snapshots/py/__init__.py | 0 .../webhook-polling/snapshots/py/client.py | 176 +++++++++++ .../snapshots/py/examples.json | 15 + .../webhook-polling/snapshots/py/models.py | 50 +++ .../webhook-polling/snapshots/py/utils.py | 140 +++++++++ .../snapshots/swift/Client.swift | 185 ++++++++++++ .../snapshots/swift/Models.swift | 111 +++++++ .../snapshots/swift/Utils.swift | 69 +++++ .../snapshots/swift/examples.json | 12 + .../webhook-polling/snapshots/ts/client.ts | 150 +++++++++ .../snapshots/ts/examples.json | 12 + .../webhook-polling/snapshots/ts/types.ts | 35 +++ .../webhook-polling/snapshots/ts/utils.ts | 95 ++++++ .../src/main/kotlin/com/pachca/Client.kt | 4 +- 32 files changed, 2814 insertions(+), 5 deletions(-) create mode 100644 packages/generator/tests/webhook-polling/fixture.yaml create mode 100644 packages/generator/tests/webhook-polling/snapshots/cs/Client.cs create mode 100644 packages/generator/tests/webhook-polling/snapshots/cs/Models.cs create mode 100644 packages/generator/tests/webhook-polling/snapshots/cs/Utils.cs create mode 100644 packages/generator/tests/webhook-polling/snapshots/cs/examples.json create mode 100644 packages/generator/tests/webhook-polling/snapshots/go/client.go create mode 100644 packages/generator/tests/webhook-polling/snapshots/go/examples.json create mode 100644 packages/generator/tests/webhook-polling/snapshots/go/types.go create mode 100644 packages/generator/tests/webhook-polling/snapshots/go/utils.go create mode 100644 packages/generator/tests/webhook-polling/snapshots/kt/Client.kt create mode 100644 packages/generator/tests/webhook-polling/snapshots/kt/Models.kt create mode 100644 packages/generator/tests/webhook-polling/snapshots/kt/examples.json create mode 100644 packages/generator/tests/webhook-polling/snapshots/py/__init__.py create mode 100644 packages/generator/tests/webhook-polling/snapshots/py/client.py create mode 100644 packages/generator/tests/webhook-polling/snapshots/py/examples.json create mode 100644 packages/generator/tests/webhook-polling/snapshots/py/models.py create mode 100644 packages/generator/tests/webhook-polling/snapshots/py/utils.py create mode 100644 packages/generator/tests/webhook-polling/snapshots/swift/Client.swift create mode 100644 packages/generator/tests/webhook-polling/snapshots/swift/Models.swift create mode 100644 packages/generator/tests/webhook-polling/snapshots/swift/Utils.swift create mode 100644 packages/generator/tests/webhook-polling/snapshots/swift/examples.json create mode 100644 packages/generator/tests/webhook-polling/snapshots/ts/client.ts create mode 100644 packages/generator/tests/webhook-polling/snapshots/ts/examples.json create mode 100644 packages/generator/tests/webhook-polling/snapshots/ts/types.ts create mode 100644 packages/generator/tests/webhook-polling/snapshots/ts/utils.ts diff --git a/packages/generator/src/lang/csharp.ts b/packages/generator/src/lang/csharp.ts index d3d51bd3..5c662d8f 100644 --- a/packages/generator/src/lang/csharp.ts +++ b/packages/generator/src/lang/csharp.ts @@ -603,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(''); @@ -622,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, @@ -640,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(''); @@ -666,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 = ' '; diff --git a/packages/generator/src/lang/go.ts b/packages/generator/src/lang/go.ts index 404a10f5..dbc23234 100644 --- a/packages/generator/src/lang/go.ts +++ b/packages/generator/src/lang/go.ts @@ -725,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); @@ -752,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(''); @@ -764,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(''); + } } } @@ -802,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'); @@ -814,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"'); @@ -857,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(''); + } } } diff --git a/packages/generator/src/lang/kotlin.ts b/packages/generator/src/lang/kotlin.ts index 323dc378..0c33e6f4 100644 --- a/packages/generator/src/lang/kotlin.ts +++ b/packages/generator/src/lang/kotlin.ts @@ -495,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) { @@ -516,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, @@ -551,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 { @@ -1066,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 ->'); diff --git a/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts index f7cf0a45..619ea17b 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(''); diff --git a/packages/generator/src/lang/swift.ts b/packages/generator/src/lang/swift.ts index b3cc1298..361b1d9e 100644 --- a/packages/generator/src/lang/swift.ts +++ b/packages/generator/src/lang/swift.ts @@ -537,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`); @@ -571,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 = Swift.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 !Swift.Task.isCancelled {'); + lines.push(' var cursor: String? = nil'); + lines.push(' var hasNext = true'); + lines.push(' while hasNext && !Swift.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 Swift.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 = Swift.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); @@ -582,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); @@ -594,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('}'); diff --git a/packages/generator/src/lang/typescript.ts b/packages/generator/src/lang/typescript.ts index 10df71f8..09f5f6fa 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'); } @@ -455,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) { @@ -524,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('}'); @@ -573,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); 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..ab81efe3 --- /dev/null +++ b/packages/generator/tests/webhook-polling/snapshots/cs/examples.json @@ -0,0 +1,12 @@ +{ + "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)" + } +} 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..dcf4d1d4 --- /dev/null +++ b/packages/generator/tests/webhook-polling/snapshots/go/examples.json @@ -0,0 +1,15 @@ +{ + "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" + ] + } +} 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..3a05d8fd --- /dev/null +++ b/packages/generator/tests/webhook-polling/snapshots/kt/examples.json @@ -0,0 +1,12 @@ +{ + "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)" + } +} 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..54d299af --- /dev/null +++ b/packages/generator/tests/webhook-polling/snapshots/py/examples.json @@ -0,0 +1,15 @@ +{ + "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" + ] + } +} 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..253b0f74 --- /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 = Swift.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 !Swift.Task.isCancelled { + var cursor: String? = nil + var hasNext = true + while hasNext && !Swift.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 Swift.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 = Swift.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..4bf79764 --- /dev/null +++ b/packages/generator/tests/webhook-polling/snapshots/swift/examples.json @@ -0,0 +1,12 @@ +{ + "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)" + } +} 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..dfd079a3 --- /dev/null +++ b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json @@ -0,0 +1,12 @@ +{ + "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 })" + } +} 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/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt index 500f727a..dc8682a5 100644 --- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt +++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt @@ -11,14 +11,14 @@ import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import java.io.Closeable 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 From c5333ce7f83d86ceec18a5295bf7ee45ff94de20 Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Sat, 6 Jun 2026 11:19:55 +0200 Subject: [PATCH 5/7] Add webhook polling release notes Document the pending SDK and generator releases for webhook payload discriminators and native polling helpers so the changelog sync gate sees package-visible changes. Add the 2026-06-05 product update source entry and regenerate the public update markdown and llms index from the docs generator. Validation: bun run check:changelog. Co-authored-by: openai-codex/gpt-5.5 --- apps/docs/content/updates/2026-06-05.md | 10 ++++++++++ apps/docs/data/releases.json | 8 ++++++++ apps/docs/public/llms.txt | 5 +++-- apps/docs/public/updates.md | 12 +++++++++++- apps/docs/public/updates/2026-06-05.md | 10 +++++++++- apps/docs/public/updates/season/summer-2026.md | 12 +++++++++++- 6 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 apps/docs/content/updates/2026-06-05.md 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 23e6bcd5..6f932b52 100644 --- a/apps/docs/data/releases.json +++ b/apps/docs/data/releases.json @@ -7,6 +7,10 @@ { "type": "~", "description": "SDK сохраняют литеральные поля webhook-моделей, включая `type` и дополнительный дискриминатор `event`" + }, + { + "type": "+", + "description": "SDK получили helpers для polling истории вебхуков и примеры запуска без публичного webhook URL" } ] }, @@ -18,6 +22,10 @@ { "type": "~", "description": "Генераторы сохраняют литеральные поля моделей, включая основной дискриминатор `type` и дополнительные литералы вроде `event`" + }, + { + "type": "+", + "description": "Генераторы выпускают SDK helpers для polling истории вебхуков во всех поддерживаемых языках" } ] }, diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index fa127d53..1ad7f8b0 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -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 @@ -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/updates.md b/apps/docs/public/updates.md index 8ed0104d..e85116f8 100644 --- a/apps/docs/public/updates.md +++ b/apps/docs/public/updates.md @@ -6,15 +6,25 @@ ## ☀️ Лето 2026 -### 05 июня 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 diff --git a/apps/docs/public/updates/2026-06-05.md b/apps/docs/public/updates/2026-06-05.md index f63e5fad..4b35435a 100644 --- a/apps/docs/public/updates/2026-06-05.md +++ b/apps/docs/public/updates/2026-06-05.md @@ -1,13 +1,21 @@ > Это Markdown-версия конкретной страницы. Для контекста за её пределами (правила API, полный перечень методов, авторизация) ОБЯЗАТЕЛЬНО открой [llms.txt](https://dev.pachca.com/llms.txt) перед ответом — это сэкономит токены и предотвратит неполный ответ. -# 05 июня 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 истории вебхуков во всех поддерживаемых языках diff --git a/apps/docs/public/updates/season/summer-2026.md b/apps/docs/public/updates/season/summer-2026.md index e4ebf4a2..8435e40e 100644 --- a/apps/docs/public/updates/season/summer-2026.md +++ b/apps/docs/public/updates/season/summer-2026.md @@ -2,15 +2,25 @@ # ☀️ Лето 2026 -### 05 июня 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 From 609be8a8689599d2cd20c1633e6cc74f0382c4d5 Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Sat, 6 Jun 2026 11:25:35 +0200 Subject: [PATCH 6/7] Document SDK webhook polling helpers Replace the webhook polling guide's manual interval loop with the generated SDK polling helpers and regenerate public markdown artifacts. Expose the polling helper methods in generated TypeScript and Python examples.json so docs code validation recognizes guide snippets using pollWebhookEvents and poll_webhook_events. Validation: apps/docs bun run generate-llms; packages/generator bun run test and bun run typecheck; sdk/typescript bun run typecheck; sdk/python compileall; bun run check:changelog. Co-authored-by: openai-codex/gpt-5.5 --- apps/docs/content/guides/webhook/polling.mdx | 43 +++-- apps/docs/public/guides/llms.txt | 2 +- apps/docs/public/guides/webhook/polling.md | 43 +++-- apps/docs/public/llms-full.txt | 165 +++++++++--------- apps/docs/public/llms.txt | 2 +- apps/docs/public/skill.md | 2 +- packages/generator/src/lang/python.ts | 10 ++ packages/generator/src/lang/typescript.ts | 10 ++ .../snapshots/py/examples.json | 12 ++ .../snapshots/ts/examples.json | 12 ++ sdk/python/generated/pachca/examples.json | 12 ++ sdk/typescript/src/generated/examples.json | 12 ++ 12 files changed, 210 insertions(+), 115 deletions(-) diff --git a/apps/docs/content/guides/webhook/polling.mdx b/apps/docs/content/guides/webhook/polling.mdx index 276a02b0..87103c41 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,40 @@ 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" -const client = new PachcaClient("YOUR_BOT_TOKEN") +const client = new PachcaClient(process.env.PACHCA_TOKEN!) +const startedAt = new Date() -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({ + limit: 50, + intervalMs: 5_000, + createdAfter: startedAt, +})) { + console.log("Событие:", event.eventType, event.payload) + // Обработать событие... } +``` -// Запускать каждые 5 секунд -setInterval(pollEvents, 5000) +Если вам нужен только payload вебхука, используйте `pollWebhookPayloads`: + +```typescript +for await (const payload of client.bots.pollWebhookPayloads({ + intervalMs: 5_000, +})) { + console.log("Payload:", payload) +} ``` + +## Ручная работа через API + +SDK helpers используют метод [История событий](GET /webhooks/events) и курсорную пагинацию. Если вы пишете собственный polling loop без SDK, периодически запрашивайте список событий, обрабатывайте новые delivery ID и храните дедупликацию на своей стороне. Метод [Удаление события](DELETE /webhooks/events/{id}) можно использовать для ручной очистки обработанных записей истории. 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..3ee11e0c 100644 --- a/apps/docs/public/guides/webhook/polling.md +++ b/apps/docs/public/guides/webhook/polling.md @@ -1,18 +1,15 @@ > Расположение: Исходящие вебхуки -> Краткое содержание: Поллинг исходящих вебхуков Пачки через 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) @@ -20,21 +17,33 @@ ```typescript import { PachcaClient } from "@pachca/sdk" -const client = new PachcaClient("YOUR_BOT_TOKEN") +const client = new PachcaClient(process.env.PACHCA_TOKEN!) +const startedAt = new Date() -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({ + limit: 50, + intervalMs: 5_000, + createdAfter: startedAt, +})) { + console.log("Событие:", event.eventType, event.payload) + // Обработать событие... } +``` -// Запускать каждые 5 секунд -setInterval(pollEvents, 5000) +Если вам нужен только payload вебхука, используйте `pollWebhookPayloads`: + +```typescript +for await (const payload of client.bots.pollWebhookPayloads({ + intervalMs: 5_000, +})) { + console.log("Payload:", 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..758702cf 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–11140 | | · Обзор | 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–2353 | +| · Кнопки в сообщениях | 2354–2510 | +| · Формы | 2511–2580 | +| · Блоки представления | 2581–2795 | +| · Обработка форм | 2796–2935 | +| · Разворачивание ссылок | 2936–3048 | +| · Экспорт сообщений | 3049–3190 | +| · DLP-система | 3191–3398 | +| · Пачка Audit Events API | 3399–3473 | +| · Форматирование текста | 3474–3554 | +| · Сценарии | 3555–4043 | +| · CLI | 4044–4137 | +| · Установка | 4138–4230 | +| · Авторизация | 4231–4304 | +| · Вывод | 4305–4408 | +| · Флаги и скрипты | 4409–4635 | +| · Сценарии | 4636–4683 | +| · Файлы | 4684–4718 | +| · Прямые запросы | 4719–4829 | +| · Команды | 4830–4964 | +| · SDK и генератор | 4965–5050 | +| · TypeScript | 5051–5463 | +| · Python | 5464–5870 | +| · Go | 5871–6302 | +| · Kotlin | 6303–6726 | +| · Swift | 6727–7119 | +| · C# | 7120–7573 | +| · n8n | 7574–7649 | +| · Начало работы | 7650–7877 | +| · Ресурсы и операции | 7878–8252 | +| · Триггер | 8253–8502 | +| · Тестирование | 8503–8776 | +| · Примеры workflow | 8777–8992 | +| · Продвинутые функции | 8993–9229 | +| · Устранение ошибок | 9230–9422 | +| · Миграция с v1 | 9423–9544 | +| · Последние обновления | 9545–9554 | +| · Авторизация | 9555–9733 | +| · Запросы и ответы | 9734–9926 | +| · Пагинация | 9927–10163 | +| · Загрузка файлов | 10164–10381 | +| · Ошибки | 10382–10455 | +| · Лимиты | 10456–10573 | +| · Модели | 10574–11140 | +| API-методы — все эндпоинты со схемами и примерами | 11141–26552 | +| · Common | 11143–11937 | +| · Profile | 11938–12939 | +| · Users | 12940–15325 | +| · Group tags | 15326–16673 | +| · Chats | 16674–17941 | +| · Members | 17942–19286 | +| · Threads | 19287–19844 | +| · Messages | 19845–21674 | +| · Read members | 21675–21921 | +| · Reactions | 21922–22568 | +| · Link Previews | 22569–22824 | +| · Search | 22825–23625 | +| · Tasks | 23626–24917 | +| · Views | 24918–25489 | +| · Bots | 25490–26240 | +| · Security | 26241–26552 | # LIBRARY RULES @@ -2300,14 +2300,11 @@ 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) @@ -2315,21 +2312,33 @@ app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { ```typescript import { PachcaClient } from "@pachca/sdk" -const client = new PachcaClient("YOUR_BOT_TOKEN") +const client = new PachcaClient(process.env.PACHCA_TOKEN!) +const startedAt = new Date() -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({ + limit: 50, + intervalMs: 5_000, + createdAfter: startedAt, +})) { + console.log("Событие:", event.eventType, event.payload) + // Обработать событие... } +``` + +Если вам нужен только payload вебхука, используйте `pollWebhookPayloads`: -// Запускать каждые 5 секунд -setInterval(pollEvents, 5000) +```typescript +for await (const payload of client.bots.pollWebhookPayloads({ + intervalMs: 5_000, +})) { + console.log("Payload:", 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 1ad7f8b0..a6cbeb4d 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -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 блоков в одном представлении 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/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts index 619ea17b..5e4854fd 100644 --- a/packages/generator/src/lang/python.ts +++ b/packages/generator/src/lang/python.ts @@ -1463,6 +1463,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}_poll_webhook_events`] = { + usage: `async for event in client.${serviceProp}.poll_webhook_events(interval_seconds=5.0):\n print(event)`, + imports: ['PachcaClient'], + }; + result[`${op.operationId}_poll_webhook_payloads`] = { + usage: `async for payload in client.${serviceProp}.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)`, + imports: ['PachcaClient'], + }; + } } } diff --git a/packages/generator/src/lang/typescript.ts b/packages/generator/src/lang/typescript.ts index 09f5f6fa..b1d78fc3 100644 --- a/packages/generator/src/lang/typescript.ts +++ b/packages/generator/src/lang/typescript.ts @@ -1415,6 +1415,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: `for await (const event of client.${serviceProp}.pollWebhookEvents({ intervalMs: 5_000 })) {\n console.log(event)\n}`, + imports: ['PachcaClient'], + }; + result[`${op.operationId}_pollWebhookPayloads`] = { + usage: `for await (const payload of client.${serviceProp}.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}`, + imports: ['PachcaClient'], + }; + } } } diff --git a/packages/generator/tests/webhook-polling/snapshots/py/examples.json b/packages/generator/tests/webhook-polling/snapshots/py/examples.json index 54d299af..340a7274 100644 --- a/packages/generator/tests/webhook-polling/snapshots/py/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/py/examples.json @@ -11,5 +11,17 @@ "imports": [ "GetWebhookEventsParams" ] + }, + "BotOperations_getWebhookEvents_poll_webhook_events": { + "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)", + "imports": [ + "PachcaClient" + ] + }, + "BotOperations_getWebhookEvents_poll_webhook_payloads": { + "usage": "async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)", + "imports": [ + "PachcaClient" + ] } } diff --git a/packages/generator/tests/webhook-polling/snapshots/ts/examples.json b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json index dfd079a3..5b490d0a 100644 --- a/packages/generator/tests/webhook-polling/snapshots/ts/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json @@ -8,5 +8,17 @@ "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}", + "imports": [ + "PachcaClient" + ] + }, + "BotOperations_getWebhookEvents_pollWebhookPayloads": { + "usage": "for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}", + "imports": [ + "PachcaClient" + ] } } diff --git a/sdk/python/generated/pachca/examples.json b/sdk/python/generated/pachca/examples.json index efd01cc9..062ea19c 100644 --- a/sdk/python/generated/pachca/examples.json +++ b/sdk/python/generated/pachca/examples.json @@ -20,6 +20,18 @@ "GetWebhookEventsParams" ] }, + "BotOperations_getWebhookEvents_poll_webhook_events": { + "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)", + "imports": [ + "PachcaClient" + ] + }, + "BotOperations_getWebhookEvents_poll_webhook_payloads": { + "usage": "async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)", + "imports": [ + "PachcaClient" + ] + }, "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/typescript/src/generated/examples.json b/sdk/typescript/src/generated/examples.json index 804cbac4..6462c77a 100644 --- a/sdk/typescript/src/generated/examples.json +++ b/sdk/typescript/src/generated/examples.json @@ -16,6 +16,18 @@ "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}", + "imports": [ + "PachcaClient" + ] + }, + "BotOperations_getWebhookEvents_pollWebhookPayloads": { + "usage": "for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}", + "imports": [ + "PachcaClient" + ] + }, "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 }) })", From 113bb76c8c3820882f43d0b3e93ed22482dc389b Mon Sep 17 00:00:00 2001 From: aenadgrleey Date: Sat, 6 Jun 2026 12:23:23 +0200 Subject: [PATCH 7/7] Document webhook polling SDK helpers Add SDK example entries for webhook polling helpers across generated languages and use them in the webhook polling guide. Teach ApiCodeExample and the MDX expander to render SDK-only multi-language examples while filtering non-SDK tabs. Commit regenerated markdown and SDK example snapshots so CI generated-sync has no drift. Ignore local .pi/tmp runtime logs so dev-server files are not accidentally staged. Validation: bun turbo check; package generator bun test; SDK language build checks; CI=1 check-generated-sync before commit reproduced only expected uncommitted generated diffs. Co-authored-by: openai-codex/gpt-5.5 --- .gitignore | 1 + apps/docs/components/mdx/mdx-components.tsx | 35 +++ apps/docs/content/guides/webhook/polling.mdx | 38 +-- apps/docs/lib/mdx-expander.ts | 44 ++- apps/docs/public/guides/webhook/polling.md | 161 +++++++++- apps/docs/public/llms-full.txt | 285 +++++++++++++----- apps/docs/public/llms.txt | 2 +- packages/generator/src/lang/csharp.ts | 9 + packages/generator/src/lang/go.ts | 8 + packages/generator/src/lang/kotlin.ts | 10 + packages/generator/src/lang/python.ts | 6 +- packages/generator/src/lang/swift.ts | 18 +- packages/generator/src/lang/typescript.ts | 2 - .../snapshots/cs/examples.json | 9 + .../snapshots/go/examples.json | 6 + .../snapshots/kt/examples.json | 13 + .../snapshots/py/examples.json | 14 +- .../snapshots/swift/Client.swift | 10 +- .../snapshots/swift/examples.json | 6 + .../snapshots/ts/examples.json | 10 +- sdk/csharp/generated/examples.json | 9 + sdk/go/generated/examples.json | 6 + .../src/main/kotlin/com/pachca/Client.kt | 4 +- .../src/main/kotlin/com/pachca/examples.json | 13 + sdk/python/generated/pachca/examples.json | 14 +- .../Pachca/GeneratedSources/Client.swift | 10 +- sdk/swift/generated/examples.json | 6 + sdk/typescript/src/generated/examples.json | 10 +- 28 files changed, 570 insertions(+), 189 deletions(-) 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 87103c41..d07e51a9 100644 --- a/apps/docs/content/guides/webhook/polling.mdx +++ b/apps/docs/content/guides/webhook/polling.mdx @@ -15,33 +15,17 @@ related: SDK helpers по умолчанию начинают читать только события, созданные после запуска воркера, и дедуплицируют уже обработанные delivery ID в памяти. -## Пример поллинга (TypeScript) - -```typescript -import { PachcaClient } from "@pachca/sdk" - -const client = new PachcaClient(process.env.PACHCA_TOKEN!) -const startedAt = new Date() - -for await (const event of client.bots.pollWebhookEvents({ - limit: 50, - intervalMs: 5_000, - createdAfter: startedAt, -})) { - console.log("Событие:", event.eventType, event.payload) - // Обработать событие... -} -``` - -Если вам нужен только payload вебхука, используйте `pollWebhookPayloads`: - -```typescript -for await (const payload of client.bots.pollWebhookPayloads({ - intervalMs: 5_000, -})) { - console.log("Payload:", payload) -} -``` +## Пример поллинга + + + +Если вам нужен только payload вебхука, используйте helper для polling payload'ов: + + ## Ручная работа через API 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/webhook/polling.md b/apps/docs/public/guides/webhook/polling.md index 3ee11e0c..47689207 100644 --- a/apps/docs/public/guides/webhook/polling.md +++ b/apps/docs/public/guides/webhook/polling.md @@ -12,34 +12,165 @@ > SDK helpers по умолчанию начинают читать только события, созданные после запуска воркера, и дедуплицируют уже обработанные delivery ID в памяти. -## Пример поллинга (TypeScript) +## Пример поллинга + +**Polling событий** + +### typescript ```typescript import { PachcaClient } from "@pachca/sdk" -const client = new PachcaClient(process.env.PACHCA_TOKEN!) -const startedAt = new Date() +const client = new PachcaClient("YOUR_TOKEN") -for await (const event of client.bots.pollWebhookEvents({ - limit: 50, - intervalMs: 5_000, - createdAfter: startedAt, -})) { - console.log("Событие:", event.eventType, event.payload) - // Обработать событие... +for await (const event of client.bots.pollWebhookEvents({ intervalMs: 5_000 })) { + console.log(event) } ``` -Если вам нужен только payload вебхука, используйте `pollWebhookPayloads`: +### 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 -for await (const payload of client.bots.pollWebhookPayloads({ - intervalMs: 5_000, -})) { - console.log("Payload:", payload) +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-full.txt b/apps/docs/public/llms-full.txt index 758702cf..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–11140 | +| Руководства — гайды: SDK, вебхуки, боты, формы, n8n, CLI | 883–11271 | | · Обзор | 888–991 | | · Быстрый старт | 992–1082 | | · AI агенты | 1083–1218 | @@ -31,67 +31,67 @@ | · Исходящие вебхуки | 1890–1934 | | · Настройка и типы событий | 1935–2109 | | · Безопасность и обработчик | 2110–2300 | -| · Поллинг | 2301–2353 | -| · Кнопки в сообщениях | 2354–2510 | -| · Формы | 2511–2580 | -| · Блоки представления | 2581–2795 | -| · Обработка форм | 2796–2935 | -| · Разворачивание ссылок | 2936–3048 | -| · Экспорт сообщений | 3049–3190 | -| · DLP-система | 3191–3398 | -| · Пачка Audit Events API | 3399–3473 | -| · Форматирование текста | 3474–3554 | -| · Сценарии | 3555–4043 | -| · CLI | 4044–4137 | -| · Установка | 4138–4230 | -| · Авторизация | 4231–4304 | -| · Вывод | 4305–4408 | -| · Флаги и скрипты | 4409–4635 | -| · Сценарии | 4636–4683 | -| · Файлы | 4684–4718 | -| · Прямые запросы | 4719–4829 | -| · Команды | 4830–4964 | -| · SDK и генератор | 4965–5050 | -| · TypeScript | 5051–5463 | -| · Python | 5464–5870 | -| · Go | 5871–6302 | -| · Kotlin | 6303–6726 | -| · Swift | 6727–7119 | -| · C# | 7120–7573 | -| · n8n | 7574–7649 | -| · Начало работы | 7650–7877 | -| · Ресурсы и операции | 7878–8252 | -| · Триггер | 8253–8502 | -| · Тестирование | 8503–8776 | -| · Примеры workflow | 8777–8992 | -| · Продвинутые функции | 8993–9229 | -| · Устранение ошибок | 9230–9422 | -| · Миграция с v1 | 9423–9544 | -| · Последние обновления | 9545–9554 | -| · Авторизация | 9555–9733 | -| · Запросы и ответы | 9734–9926 | -| · Пагинация | 9927–10163 | -| · Загрузка файлов | 10164–10381 | -| · Ошибки | 10382–10455 | -| · Лимиты | 10456–10573 | -| · Модели | 10574–11140 | -| API-методы — все эндпоинты со схемами и примерами | 11141–26552 | -| · Common | 11143–11937 | -| · Profile | 11938–12939 | -| · Users | 12940–15325 | -| · Group tags | 15326–16673 | -| · Chats | 16674–17941 | -| · Members | 17942–19286 | -| · Threads | 19287–19844 | -| · Messages | 19845–21674 | -| · Read members | 21675–21921 | -| · Reactions | 21922–22568 | -| · Link Previews | 22569–22824 | -| · Search | 22825–23625 | -| · Tasks | 23626–24917 | -| · Views | 24918–25489 | -| · Bots | 25490–26240 | -| · Security | 26241–26552 | +| · Поллинг | 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 @@ -2307,34 +2307,165 @@ app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { > SDK helpers по умолчанию начинают читать только события, созданные после запуска воркера, и дедуплицируют уже обработанные delivery ID в памяти. -## Пример поллинга (TypeScript) +## Пример поллинга + +**Polling событий** + +### typescript ```typescript import { PachcaClient } from "@pachca/sdk" -const client = new PachcaClient(process.env.PACHCA_TOKEN!) -const startedAt = new Date() +const client = new PachcaClient("YOUR_TOKEN") -for await (const event of client.bots.pollWebhookEvents({ - limit: 50, - intervalMs: 5_000, - createdAfter: startedAt, -})) { - console.log("Событие:", event.eventType, event.payload) - // Обработать событие... +for await (const event of client.bots.pollWebhookEvents({ intervalMs: 5_000 })) { + console.log(event) } ``` -Если вам нужен только payload вебхука, используйте `pollWebhookPayloads`: +### 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 -for await (const payload of client.bots.pollWebhookPayloads({ - intervalMs: 5_000, -})) { - console.log("Payload:", payload) +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 a6cbeb4d..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 токенов). diff --git a/packages/generator/src/lang/csharp.ts b/packages/generator/src/lang/csharp.ts index 5c662d8f..4b64594a 100644 --- a/packages/generator/src/lang/csharp.ts +++ b/packages/generator/src/lang/csharp.ts @@ -1538,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 dbc23234..ec94af78 100644 --- a/packages/generator/src/lang/go.ts +++ b/packages/generator/src/lang/go.ts @@ -1475,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 0c33e6f4..a00a5064 100644 --- a/packages/generator/src/lang/kotlin.ts +++ b/packages/generator/src/lang/kotlin.ts @@ -1454,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 5e4854fd..b6b4eb3f 100644 --- a/packages/generator/src/lang/python.ts +++ b/packages/generator/src/lang/python.ts @@ -1464,13 +1464,11 @@ function generateExamples(ir: IR): string { if (ex.imports.length > 0) entry.imports = ex.imports; result[op.operationId] = entry; if (op.methodName === 'getWebhookEvents' && op.successResponse.dataRef === 'WebhookEvent') { - result[`${op.operationId}_poll_webhook_events`] = { + result[`${op.operationId}_pollWebhookEvents`] = { usage: `async for event in client.${serviceProp}.poll_webhook_events(interval_seconds=5.0):\n print(event)`, - imports: ['PachcaClient'], }; - result[`${op.operationId}_poll_webhook_payloads`] = { + result[`${op.operationId}_pollWebhookPayloads`] = { usage: `async for payload in client.${serviceProp}.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)`, - imports: ['PachcaClient'], }; } } diff --git a/packages/generator/src/lang/swift.ts b/packages/generator/src/lang/swift.ts index 361b1d9e..3c298cad 100644 --- a/packages/generator/src/lang/swift.ts +++ b/packages/generator/src/lang/swift.ts @@ -583,7 +583,7 @@ function emitWebhookPollingMethods(lines: string[], prefix: string): void { lines.push(' maxSeenDeliveryIds: Int = 5_000'); lines.push(' ) -> AsyncThrowingStream {'); lines.push(' AsyncThrowingStream { continuation in'); - lines.push(' let task = Swift.Task {'); + 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"])'); @@ -602,10 +602,10 @@ function emitWebhookPollingMethods(lines: string[], prefix: string): void { lines.push(' return true'); lines.push(' }'); lines.push(''); - lines.push(' while !Swift.Task.isCancelled {'); + lines.push(' while !_Concurrency.Task.isCancelled {'); lines.push(' var cursor: String? = nil'); lines.push(' var hasNext = true'); - lines.push(' while hasNext && !Swift.Task.isCancelled {'); + 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() {'); @@ -620,7 +620,7 @@ function emitWebhookPollingMethods(lines: string[], prefix: string): void { lines.push(' hasNext = (response.meta.paginate.hasNext ?? !response.data.isEmpty) && pageHasRecentEvents'); lines.push(' cursor = response.meta.paginate.nextPage'); lines.push(' }'); - lines.push(' try await Swift.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000))'); + 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 {'); @@ -639,7 +639,7 @@ function emitWebhookPollingMethods(lines: string[], prefix: string): void { lines.push(' includePayload: @escaping (WebhookPayloadUnion) -> Bool = { _ in true }'); lines.push(' ) -> AsyncThrowingStream {'); lines.push(' AsyncThrowingStream { continuation in'); - lines.push(' let task = Swift.Task {'); + lines.push(' let task = _Concurrency.Task {'); lines.push(' do {'); lines.push(' for try await event in pollWebhookEvents('); lines.push(' limit: limit,'); @@ -1187,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 b1d78fc3..4bd1f20c 100644 --- a/packages/generator/src/lang/typescript.ts +++ b/packages/generator/src/lang/typescript.ts @@ -1418,11 +1418,9 @@ function generateExamples(ir: IR): string { 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}`, - imports: ['PachcaClient'], }; result[`${op.operationId}_pollWebhookPayloads`] = { usage: `for await (const payload of client.${serviceProp}.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}`, - imports: ['PachcaClient'], }; } } diff --git a/packages/generator/tests/webhook-polling/snapshots/cs/examples.json b/packages/generator/tests/webhook-polling/snapshots/cs/examples.json index ab81efe3..543258d6 100644 --- a/packages/generator/tests/webhook-polling/snapshots/cs/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/cs/examples.json @@ -8,5 +8,14 @@ "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/examples.json b/packages/generator/tests/webhook-polling/snapshots/go/examples.json index dcf4d1d4..33bae7d1 100644 --- a/packages/generator/tests/webhook-polling/snapshots/go/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/go/examples.json @@ -11,5 +11,11 @@ "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/kt/examples.json b/packages/generator/tests/webhook-polling/snapshots/kt/examples.json index 3a05d8fd..c6a7ca9e 100644 --- a/packages/generator/tests/webhook-polling/snapshots/kt/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/kt/examples.json @@ -8,5 +8,18 @@ "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/examples.json b/packages/generator/tests/webhook-polling/snapshots/py/examples.json index 340a7274..de6f45b4 100644 --- a/packages/generator/tests/webhook-polling/snapshots/py/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/py/examples.json @@ -12,16 +12,10 @@ "GetWebhookEventsParams" ] }, - "BotOperations_getWebhookEvents_poll_webhook_events": { - "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)", - "imports": [ - "PachcaClient" - ] + "BotOperations_getWebhookEvents_pollWebhookEvents": { + "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)" }, - "BotOperations_getWebhookEvents_poll_webhook_payloads": { - "usage": "async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)", - "imports": [ - "PachcaClient" - ] + "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/swift/Client.swift b/packages/generator/tests/webhook-polling/snapshots/swift/Client.swift index 253b0f74..6a89320b 100644 --- a/packages/generator/tests/webhook-polling/snapshots/swift/Client.swift +++ b/packages/generator/tests/webhook-polling/snapshots/swift/Client.swift @@ -32,7 +32,7 @@ open class BotsService { maxSeenDeliveryIds: Int = 5_000 ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let task = Swift.Task { + let task = _Concurrency.Task { do { guard maxSeenDeliveryIds > 0 else { throw NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: "maxSeenDeliveryIds must be greater than 0"]) @@ -51,10 +51,10 @@ open class BotsService { return true } - while !Swift.Task.isCancelled { + while !_Concurrency.Task.isCancelled { var cursor: String? = nil var hasNext = true - while hasNext && !Swift.Task.isCancelled { + while hasNext && !_Concurrency.Task.isCancelled { let response = try await getWebhookEvents(limit: limit, cursor: cursor) var pageHasRecentEvents = false for event in response.data.reversed() { @@ -69,7 +69,7 @@ open class BotsService { hasNext = (response.meta.paginate.hasNext ?? !response.data.isEmpty) && pageHasRecentEvents cursor = response.meta.paginate.nextPage } - try await Swift.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000)) + try await _Concurrency.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000)) } continuation.finish() } catch { @@ -88,7 +88,7 @@ open class BotsService { includePayload: @escaping (WebhookPayloadUnion) -> Bool = { _ in true } ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let task = Swift.Task { + let task = _Concurrency.Task { do { for try await event in pollWebhookEvents( limit: limit, diff --git a/packages/generator/tests/webhook-polling/snapshots/swift/examples.json b/packages/generator/tests/webhook-polling/snapshots/swift/examples.json index 4bf79764..5cdffcd1 100644 --- a/packages/generator/tests/webhook-polling/snapshots/swift/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/swift/examples.json @@ -8,5 +8,11 @@ "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/examples.json b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json index 5b490d0a..8349b5a1 100644 --- a/packages/generator/tests/webhook-polling/snapshots/ts/examples.json +++ b/packages/generator/tests/webhook-polling/snapshots/ts/examples.json @@ -10,15 +10,9 @@ "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}", - "imports": [ - "PachcaClient" - ] + "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}", - "imports": [ - "PachcaClient" - ] + "usage": "for await (const payload of client.bots.pollWebhookPayloads({ intervalMs: 5_000 })) {\n console.log(payload)\n}" } } 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/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/kotlin/generated/src/main/kotlin/com/pachca/Client.kt b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt index dc8682a5..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,14 +11,14 @@ import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* -import kotlinx.serialization.json.Json -import java.io.Closeable 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 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/generated/pachca/examples.json b/sdk/python/generated/pachca/examples.json index 062ea19c..4fcce8a7 100644 --- a/sdk/python/generated/pachca/examples.json +++ b/sdk/python/generated/pachca/examples.json @@ -20,17 +20,11 @@ "GetWebhookEventsParams" ] }, - "BotOperations_getWebhookEvents_poll_webhook_events": { - "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)", - "imports": [ - "PachcaClient" - ] + "BotOperations_getWebhookEvents_pollWebhookEvents": { + "usage": "async for event in client.bots.poll_webhook_events(interval_seconds=5.0):\n print(event)" }, - "BotOperations_getWebhookEvents_poll_webhook_payloads": { - "usage": "async for payload in client.bots.poll_webhook_payloads(interval_seconds=5.0):\n print(payload)", - "imports": [ - "PachcaClient" - ] + "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)", diff --git a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift index 73d082a5..6f15dea4 100644 --- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift +++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift @@ -98,7 +98,7 @@ open class BotsService { maxSeenDeliveryIds: Int = 5_000 ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let task = Swift.Task { + let task = _Concurrency.Task { do { guard maxSeenDeliveryIds > 0 else { throw NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: "maxSeenDeliveryIds must be greater than 0"]) @@ -117,10 +117,10 @@ open class BotsService { return true } - while !Swift.Task.isCancelled { + while !_Concurrency.Task.isCancelled { var cursor: String? = nil var hasNext = true - while hasNext && !Swift.Task.isCancelled { + while hasNext && !_Concurrency.Task.isCancelled { let response = try await getWebhookEvents(limit: limit, cursor: cursor) var pageHasRecentEvents = false for event in response.data.reversed() { @@ -135,7 +135,7 @@ open class BotsService { hasNext = (response.meta.paginate.hasNext ?? !response.data.isEmpty) && pageHasRecentEvents cursor = response.meta.paginate.nextPage } - try await Swift.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000)) + try await _Concurrency.Task.sleep(nanoseconds: UInt64(max(interval, 0) * 1_000_000_000)) } continuation.finish() } catch { @@ -154,7 +154,7 @@ open class BotsService { includePayload: @escaping (WebhookPayloadUnion) -> Bool = { _ in true } ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let task = Swift.Task { + let task = _Concurrency.Task { do { for try await event in pollWebhookEvents( limit: limit, 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/src/generated/examples.json b/sdk/typescript/src/generated/examples.json index 6462c77a..4ff09b77 100644 --- a/sdk/typescript/src/generated/examples.json +++ b/sdk/typescript/src/generated/examples.json @@ -17,16 +17,10 @@ "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}", - "imports": [ - "PachcaClient" - ] + "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}", - "imports": [ - "PachcaClient" - ] + "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)",