diff --git a/packages/generator/src/lang/csharp.ts b/packages/generator/src/lang/csharp.ts index 5d76533f..f7d083d7 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([ @@ -402,6 +403,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; @@ -415,6 +417,8 @@ 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() { @@ -422,6 +426,12 @@ internal static class PachcaUtils 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, @@ -445,15 +455,15 @@ 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } @@ -553,13 +563,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;'); @@ -577,7 +600,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'); @@ -599,7 +622,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}`); @@ -648,6 +671,7 @@ function emitOperation( op: IROperation, ir: IR, globalHasApiError: boolean, + modifier = 'public override', ): void { const indent = ' '; const indent2 = ' '; @@ -662,11 +686,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},`); } @@ -679,6 +703,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, @@ -945,23 +1015,36 @@ function emitPachcaClient( 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) { @@ -979,14 +1062,24 @@ 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(' }'); + + // 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('}'); diff --git a/packages/generator/src/lang/go.ts b/packages/generator/src/lang/go.ts index bc380d35..173a2d11 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; @@ -403,7 +403,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 @@ -583,7 +583,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`); @@ -627,6 +627,83 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { 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('}'); +} + function generateClient(ir: IR): string { const lines: string[] = []; lines.push('package pachca'); @@ -640,7 +717,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"', '"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,36 +742,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('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(''); } - 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: DefaultBaseURL}`); } 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) { @@ -738,7 +832,27 @@ function generateClient(ir: IR): string { 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 impl = `&${serviceToImplName(f.cls)}{baseURL: cfg.baseURL, client: client}`; + lines.push(`\t\t${f.f.padEnd(maxField)}: func() ${f.cls} { if ${cfgField} != nil { return ${cfgField} }; return ${impl} }(),`); + } + 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}'); + lines.push('\treturn &PachcaClient{'); + for (const f of fields) { + const cfgField = `cfg.${f.f.charAt(0).toLowerCase() + f.f.slice(1)}`; + const stub = `&${serviceToStubName(f.cls)}{}`; + lines.push(`\t\t${f.f.padEnd(maxField)}: func() ${f.cls} { if ${cfgField} != nil { return ${cfgField} }; return ${stub} }(),`); + } lines.push('\t}'); lines.push('}'); lines.push(''); @@ -749,11 +863,66 @@ 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 addJitter(delay time.Duration) time.Duration {', + '\tfactor := 0.5 + rand.Float64()*0.5', + '\treturn time.Duration(float64(delay) * factor)', + '}', + '', + '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< 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 +428,32 @@ 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`)})`); +} + +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)}`); @@ -431,10 +464,41 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { } if (params.length <= 2) { - lines.push(`${indent}suspend fun ${op.methodName}All(${params.join(', ')}): List<${itemType}> {`); + lines.push(`${indent}suspend fun ${op.methodName}All(${params.join(', ')}): List<${itemType}> =`); } else { lines.push(`${indent}suspend fun ${op.methodName}All(`); 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`)})`); +} + +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}> {`); } @@ -476,21 +540,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},`); } @@ -779,29 +843,6 @@ function emitPachcaClient( 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'); - } - lines.push(' install(ContentNegotiation) {'); - lines.push(' json(Json { explicitNulls = false })'); - lines.push(' }'); - lines.push(' install(HttpRequestRetry) {'); - lines.push(' retryOnServerErrors(maxRetries = 3)'); - lines.push(' retryIf { _, response -> response.status.value == 429 }'); - lines.push(' delayMillis { retry ->'); - lines.push(' val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull()'); - lines.push(' if (retryAfter != null) retryAfter * 1000L else retry * 1000L'); - lines.push(' }'); - lines.push(' }'); - lines.push(' defaultRequest {'); - lines.push(' bearerAuth(token)'); - lines.push(' }'); - lines.push(' }'); - lines.push(''); - const serviceEntries = ir.services .map((svc) => ({ propName: tagToProperty(svc.tag), @@ -809,13 +850,79 @@ 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(' retryOnServerErrors(maxRetries = 3)'); + lines.push(' retryIf { _, response -> response.status.value == 429 }'); + lines.push(' delayMillis { retry ->'); + lines.push(' val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull()'); + lines.push(' if (retryAfter != null) retryAfter * 1000L else retry * 1000L'); + lines.push(' }'); + lines.push(' }'); + lines.push(' defaultRequest { bearerAuth(token) }'); + lines.push(' }'); + lines.push(' }'); lines.push(''); lines.push(' override fun close() {'); - lines.push(' client.close()'); + lines.push(' client?.close()'); lines.push(' }'); lines.push('}'); } diff --git a/packages/generator/src/lang/python.ts b/packages/generator/src/lang/python.ts index 56acf26c..579ee02a 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([ @@ -634,6 +635,59 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { 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); @@ -643,6 +697,8 @@ function generateClient(ir: IR): { content: string; needUtils: boolean } { if (ir.services.length > 0) { lines.push('from __future__ import annotations'); lines.push(''); + lines.push('from dataclasses import dataclass'); + lines.push(''); lines.push('import httpx'); lines.push(''); } @@ -669,7 +725,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,23 +754,41 @@ 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)); 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 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(''); + + // 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(''); @@ -791,10 +878,16 @@ function generateUtils(): string { '', '', '_MAX_RETRIES = 3', + '_RETRYABLE_5XX = {500, 502, 503, 504}', + '', + '', + 'def _add_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."""', + ' """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', @@ -807,7 +900,11 @@ function generateUtils(): string { ' 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 = attempt + 1', + ' await asyncio.sleep(_add_jitter(delay))', ' continue', ' return response', ' return response # unreachable', diff --git a/packages/generator/src/lang/swift.ts b/packages/generator/src/lang/swift.ts index 695c5614..d21e43c8 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', @@ -255,7 +255,7 @@ function opReturn(op: IROperation, ir: IR): 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,7 +276,7 @@ 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}`; lines.push(` var components = URLComponents(string: "${swiftUrlBase}")!`); @@ -418,7 +418,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,7 +431,7 @@ 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 {'); @@ -460,13 +460,68 @@ function emitPaginationMethod(lines: string[], op: IROperation, ir: IR): void { 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 +530,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 +560,48 @@ 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)); + lines.push('public struct PachcaClient {'); for (const s of svcs) lines.push(` public let ${s.prop}: ${s.cls}`); lines.push(''); + + // 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 ? ` = ${JSON.stringify(ir.baseUrl)}` : ''; - lines.push(` public init(token: String, baseURL: String${swiftDefault}) {`); + 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(''); + + // 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(''); @@ -587,19 +675,32 @@ function generateUtils(ir: IR): string { lines.push( 'private let maxRetries = 3', + 'private let retryable5xx: Set = [500, 502, 503, 504]', + '', + 'private func addJitter(_ delay: UInt64) -> UInt64 {', + ' let factor = 0.5 + Double.random(in: 0..<0.5)', + ' return UInt64(Double(delay) * factor)', + '}', '', '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: addJitter(delay))', + ' continue', + ' }', + ' if retryable5xx.contains(http.statusCode), attempt < maxRetries {', + ' let delay = UInt64(attempt + 1) * 1_000_000_000', + ' try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay))', + ' continue', ' }', - ' 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 ace37ca0..191ff061 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 { @@ -465,18 +466,28 @@ 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)); + 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}` };'); for (const s of serviceEntries) { - lines.push(` this.${s.prop} = new ${s.cls}(baseUrl, headers);`); + lines.push(` this.${s.prop} = new ${serviceToImplName(s.cls)}(baseUrl, headers);`); + } + lines.push(' }'); + lines.push(''); + // Static stub() factory method + const stubArgs = serviceEntries.map((s) => `${s.prop}: ${s.cls} = new ${s.cls}()`); + lines.push(` static stub(${stubArgs.join(', ')}): PachcaClient {`); + lines.push(' const client = Object.create(PachcaClient.prototype);'); + for (const s of serviceEntries) { + lines.push(` client.${s.prop} = ${s.prop};`); } + lines.push(' return client;'); lines.push(' }'); lines.push('}'); } @@ -488,11 +499,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 +530,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); @@ -800,6 +851,11 @@ function generateUtils(ir: IR): string { return [...lines, '', 'const MAX_RETRIES = 3;', + 'const RETRYABLE_5XX = new Set([500, 502, 503, 504]);', + '', + 'function addJitter(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++) {', @@ -807,7 +863,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, addJitter(delay)));', + ' continue;', + ' }', + ' if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) {', + ' const delay = 1000 * (attempt + 1);', + ' await new Promise((r) => setTimeout(r, addJitter(delay)));', ' continue;', ' }', ' return response;', diff --git a/packages/generator/src/naming.ts b/packages/generator/src/naming.ts index 322d6b8a..c42934ca 100644 --- a/packages/generator/src/naming.ts +++ b/packages/generator/src/naming.ts @@ -47,6 +47,16 @@ export function tagToServiceName(tag: string): string { return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('') + 'Service'; } +/** Service class name → implementation class name: "ChatsService" → "ChatsServiceImpl" */ +export function serviceToImplName(serviceName: string): string { + return `${serviceName}Impl`; +} + +/** Service class name → stub class name: "ChatsService" → "ChatsServiceStub" */ +export function serviceToStubName(serviceName: string): string { + return `${serviceName}Stub`; +} + /** "ChatOperations_listChats" → "listChats" */ export function operationIdToMethod(operationId: string): string { const parts = operationId.split('_'); diff --git a/packages/generator/tests/additional-props-bool/snapshots/cs/Utils.cs b/packages/generator/tests/additional-props-bool/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/additional-props-bool/snapshots/cs/Utils.cs +++ b/packages/generator/tests/additional-props-bool/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/additional-props-bool/snapshots/go/utils.go b/packages/generator/tests/additional-props-bool/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/additional-props-bool/snapshots/go/utils.go +++ b/packages/generator/tests/additional-props-bool/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/additional-props-bool/snapshots/swift/Utils.swift b/packages/generator/tests/additional-props-bool/snapshots/swift/Utils.swift index 3b0a2240..16901971 100644 --- a/packages/generator/tests/additional-props-bool/snapshots/swift/Utils.swift +++ b/packages/generator/tests/additional-props-bool/snapshots/swift/Utils.swift @@ -58,19 +58,32 @@ public struct AnyCodable: Codable { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/allof-sibling/snapshots/cs/Utils.cs b/packages/generator/tests/allof-sibling/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/allof-sibling/snapshots/cs/Utils.cs +++ b/packages/generator/tests/allof-sibling/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/allof-sibling/snapshots/go/utils.go b/packages/generator/tests/allof-sibling/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/allof-sibling/snapshots/go/utils.go +++ b/packages/generator/tests/allof-sibling/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/allof-sibling/snapshots/swift/Utils.swift b/packages/generator/tests/allof-sibling/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/allof-sibling/snapshots/swift/Utils.swift +++ b/packages/generator/tests/allof-sibling/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/circular-ref/snapshots/cs/Utils.cs b/packages/generator/tests/circular-ref/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/circular-ref/snapshots/cs/Utils.cs +++ b/packages/generator/tests/circular-ref/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/circular-ref/snapshots/go/utils.go b/packages/generator/tests/circular-ref/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/circular-ref/snapshots/go/utils.go +++ b/packages/generator/tests/circular-ref/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/circular-ref/snapshots/swift/Utils.swift b/packages/generator/tests/circular-ref/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/circular-ref/snapshots/swift/Utils.swift +++ b/packages/generator/tests/circular-ref/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/crud/snapshots/cs/Client.cs b/packages/generator/tests/crud/snapshots/cs/Client.cs index 583e8c6c..9a3ce004 100644 --- a/packages/generator/tests/crud/snapshots/cs/Client.cs +++ b/packages/generator/tests/crud/snapshots/cs/Client.cs @@ -11,18 +11,71 @@ namespace Pachca.Sdk; -public sealed class ChatsService +public class ChatsService +{ + + public virtual async System.Threading.Tasks.Task ListChatsAsync( + ChatAvailability? availability = null, + int? limit = null, + string? cursor = null, + string? sortField = null, + SortOrder? sortOrder = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.listChats is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListChatsAllAsync( + ChatAvailability? availability = null, + int? limit = null, + string? sortField = null, + SortOrder? sortOrder = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.listChatsAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.getChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.createChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateChatAsync( + int id, + ChatUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.updateChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.archiveChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.deleteChat is not implemented"); + } +} + +public sealed class ChatsServiceImpl : ChatsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ChatsService(string baseUrl, HttpClient client) + internal ChatsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListChatsAsync( + public override async System.Threading.Tasks.Task ListChatsAsync( ChatAvailability? availability = null, int? limit = null, string? cursor = null, @@ -56,7 +109,7 @@ public async System.Threading.Tasks.Task ListChatsAsync( } } - public async System.Threading.Tasks.Task> ListChatsAllAsync( + public override async System.Threading.Tasks.Task> ListChatsAllAsync( ChatAvailability? availability = null, int? limit = null, string? sortField = null, @@ -74,7 +127,7 @@ public async System.Threading.Tasks.Task> ListChatsAllAsync( return items; } - public async System.Threading.Tasks.Task GetChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -91,7 +144,7 @@ public async System.Threading.Tasks.Task GetChatAsync(int id, Cancellation } } - public async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -109,7 +162,7 @@ public async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest } } - public async System.Threading.Tasks.Task UpdateChatAsync( + public override async System.Threading.Tasks.Task UpdateChatAsync( int id, ChatUpdateRequest request, CancellationToken cancellationToken = default) @@ -130,7 +183,7 @@ public async System.Threading.Tasks.Task UpdateChatAsync( } } - public async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}/archive"; using var request = new HttpRequestMessage(HttpMethod.Put, url); @@ -147,7 +200,7 @@ public async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationTo } } - public async System.Threading.Tasks.Task DeleteChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -167,22 +220,32 @@ public async System.Threading.Tasks.Task DeleteChatAsync(int id, CancellationTok public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public ChatsService Chats { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(ChatsService chats) + { + Chats = chats; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", ChatsService? chats = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Chats = new ChatsService(baseUrl, _client); + Chats = chats ?? new ChatsServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(ChatsService? chats = null) + { + return new PachcaClient(chats ?? new ChatsService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/crud/snapshots/cs/Utils.cs b/packages/generator/tests/crud/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/crud/snapshots/cs/Utils.cs +++ b/packages/generator/tests/crud/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/crud/snapshots/go/client.go b/packages/generator/tests/crud/snapshots/go/client.go index 800bd86f..207c5c31 100644 --- a/packages/generator/tests/crud/snapshots/go/client.go +++ b/packages/generator/tests/crud/snapshots/go/client.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/url" - "strconv" "time" ) @@ -21,38 +20,52 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type ChatsService interface { + ListChats(ctx context.Context, params *ListChatsParams) (*ListChatsResponse, error) + ListChatsAll(ctx context.Context, params *ListChatsParams) ([]Chat, error) + GetChat(ctx context.Context, id int32) (*Chat, error) + CreateChat(ctx context.Context, request ChatCreateRequest) (*Chat, error) + UpdateChat(ctx context.Context, id int32, request ChatUpdateRequest) (*Chat, error) + ArchiveChat(ctx context.Context, id int32) error + DeleteChat(ctx context.Context, id int32) error +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithChats(service ChatsService) ClientOption { + return func(cfg *clientConfig) { cfg.chats = service } +} + +func WithStubChats(service ChatsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.chats = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Chats: &ChatsService{baseURL: url, client: client}, + Chats: func() ChatsService { if cfg.chats != nil { return cfg.chats }; return &ChatsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Chats: func() ChatsService { if cfg.chats != nil { return cfg.chats }; return &ChatsServiceStub{} }(), } } diff --git a/packages/generator/tests/crud/snapshots/go/utils.go b/packages/generator/tests/crud/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/crud/snapshots/go/utils.go +++ b/packages/generator/tests/crud/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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("Chats.listChatsAll is not implemented") + + suspend fun getChat(id: Int): Chat = + throw NotImplementedError("Chats.getChat is not implemented") + + suspend fun createChat(request: ChatCreateRequest): Chat = + throw NotImplementedError("Chats.createChat is not implemented") + + suspend fun updateChat(id: Int, request: ChatUpdateRequest): Chat = + throw NotImplementedError("Chats.updateChat is not implemented") + + suspend fun archiveChat(id: Int) = + throw NotImplementedError("Chats.archiveChat is not implemented") + + suspend fun deleteChat(id: Int) = + throw NotImplementedError("Chats.deleteChat is not implemented") +} + +class ChatsServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : ChatsService { + override suspend fun listChats( + availability: ChatAvailability?, + limit: Int?, + cursor: String?, + sortField: String?, + sortOrder: SortOrder?, ): ListChatsResponse { val response = client.get("$baseUrl/chats") { availability?.let { parameter("availability", it.value) } @@ -38,11 +72,11 @@ class ChatsService internal constructor( } } - suspend fun listChatsAll( - availability: ChatAvailability? = null, - limit: Int? = null, - sortField: String? = null, - sortOrder: SortOrder? = null, + override suspend fun listChatsAll( + availability: ChatAvailability?, + limit: Int?, + sortField: String?, + sortOrder: SortOrder?, ): List { val items = mutableListOf() var cursor: String? = null @@ -60,7 +94,7 @@ class ChatsService internal constructor( return items } - suspend fun getChat(id: Int): Chat { + override suspend fun getChat(id: Int): Chat { val response = client.get("$baseUrl/chats/$id") return when (response.status.value) { 200 -> response.body().data @@ -69,7 +103,7 @@ class ChatsService internal constructor( } } - suspend fun createChat(request: ChatCreateRequest): Chat { + override suspend fun createChat(request: ChatCreateRequest): Chat { val response = client.post("$baseUrl/chats") { contentType(ContentType.Application.Json) setBody(request) @@ -81,7 +115,7 @@ class ChatsService internal constructor( } } - suspend fun updateChat(id: Int, request: ChatUpdateRequest): Chat { + override suspend fun updateChat(id: Int, request: ChatUpdateRequest): Chat { val response = client.put("$baseUrl/chats/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -93,7 +127,7 @@ class ChatsService internal constructor( } } - suspend fun archiveChat(id: Int) { + override suspend fun archiveChat(id: Int) { val response = client.put("$baseUrl/chats/$id/archive") when (response.status.value) { 204 -> return @@ -102,7 +136,7 @@ class ChatsService internal constructor( } } - suspend fun deleteChat(id: Int) { + override suspend fun deleteChat(id: Int) { val response = client.delete("$baseUrl/chats/$id") when (response.status.value) { 204 -> return @@ -112,28 +146,47 @@ class ChatsService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.pachca.com/api/shared/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val chats: ChatsService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.pachca.com/api/shared/v1", + chats: ChatsService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + chats = chats ?: ChatsServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + chats: ChatsService = object : ChatsService {} + ): PachcaClient = PachcaClient( + client = null, + chats = chats + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val chats = ChatsService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/crud/snapshots/py/client.py b/packages/generator/tests/crud/snapshots/py/client.py index e73999ce..066d4f69 100644 --- a/packages/generator/tests/crud/snapshots/py/client.py +++ b/packages/generator/tests/crud/snapshots/py/client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import ( @@ -16,6 +18,51 @@ from .utils import deserialize, serialize, RetryTransport class ChatsService: + async def list_chats( + self, + params: ListChatsParams | None = None, + ) -> ListChatsResponse: + raise NotImplementedError("Chats.listChats is not implemented") + + async def list_chats_all( + self, + params: ListChatsParams | None = None, + ) -> list[Chat]: + raise NotImplementedError("Chats.listChatsAll is not implemented") + + async def get_chat( + self, + id: int, + ) -> Chat: + raise NotImplementedError("Chats.getChat is not implemented") + + async def create_chat( + self, + request: ChatCreateRequest, + ) -> Chat: + raise NotImplementedError("Chats.createChat is not implemented") + + async def update_chat( + self, + id: int, + request: ChatUpdateRequest, + ) -> Chat: + raise NotImplementedError("Chats.updateChat is not implemented") + + async def archive_chat( + self, + id: int, + ) -> None: + raise NotImplementedError("Chats.archiveChat is not implemented") + + async def delete_chat( + self, + id: int, + ) -> None: + raise NotImplementedError("Chats.deleteChat is not implemented") + + +class ChatsServiceImpl(ChatsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -147,13 +194,23 @@ async def delete_chat( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", chats: ChatsService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.chats = ChatsService(self._client) + self.chats: ChatsService = chats or ChatsServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + chats: ChatsService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.chats = chats or ChatsService() + return self diff --git a/packages/generator/tests/crud/snapshots/py/utils.py b/packages/generator/tests/crud/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/crud/snapshots/py/utils.py +++ b/packages/generator/tests/crud/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/crud/snapshots/swift/Client.swift b/packages/generator/tests/crud/snapshots/swift/Client.swift index a08a1a28..bef67a56 100644 --- a/packages/generator/tests/crud/snapshots/swift/Client.swift +++ b/packages/generator/tests/crud/snapshots/swift/Client.swift @@ -3,7 +3,43 @@ import Foundation import FoundationNetworking #endif -public struct ChatsService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class ChatsService { + public init() {} + + open func listChats(availability: ChatAvailability? = nil, limit: Int? = nil, cursor: String? = nil, sortField: String? = nil, sortOrder: SortOrder? = nil) async throws -> ListChatsResponse { + throw pachcaNotImplemented("Chats.listChats") + } + + open func listChatsAll(availability: ChatAvailability? = nil, limit: Int? = nil, sortField: String? = nil, sortOrder: SortOrder? = nil) async throws -> [Chat] { + throw pachcaNotImplemented("Chats.listChatsAll") + } + + open func getChat(id: Int) async throws -> Chat { + throw pachcaNotImplemented("Chats.getChat") + } + + open func createChat(request body: ChatCreateRequest) async throws -> Chat { + throw pachcaNotImplemented("Chats.createChat") + } + + open func updateChat(id: Int, request body: ChatUpdateRequest) async throws -> Chat { + throw pachcaNotImplemented("Chats.updateChat") + } + + open func archiveChat(id: Int) async throws -> Void { + throw pachcaNotImplemented("Chats.archiveChat") + } + + open func deleteChat(id: Int) async throws -> Void { + throw pachcaNotImplemented("Chats.deleteChat") + } +} + +public final class ChatsServiceImpl: ChatsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +48,10 @@ public struct ChatsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listChats(availability: ChatAvailability? = nil, limit: Int? = nil, cursor: String? = nil, sortField: String? = nil, sortOrder: SortOrder? = nil) async throws -> ListChatsResponse { + public override func listChats(availability: ChatAvailability? = nil, limit: Int? = nil, cursor: String? = nil, sortField: String? = nil, sortOrder: SortOrder? = nil) async throws -> ListChatsResponse { var components = URLComponents(string: "\(baseURL)/chats")! var queryItems: [URLQueryItem] = [] if let availability { queryItems.append(URLQueryItem(name: "availability", value: availability.rawValue)) } @@ -37,7 +74,7 @@ public struct ChatsService { } } - public func listChatsAll(availability: ChatAvailability? = nil, limit: Int? = nil, sortField: String? = nil, sortOrder: SortOrder? = nil) async throws -> [Chat] { + public override func listChatsAll(availability: ChatAvailability? = nil, limit: Int? = nil, sortField: String? = nil, sortOrder: SortOrder? = nil) async throws -> [Chat] { var items: [Chat] = [] var cursor: String? = nil repeat { @@ -48,7 +85,7 @@ public struct ChatsService { return items } - public func getChat(id: Int) async throws -> Chat { + public override func getChat(id: Int) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -63,7 +100,7 @@ public struct ChatsService { } } - public func createChat(request body: ChatCreateRequest) async throws -> Chat { + public override func createChat(request body: ChatCreateRequest) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -81,7 +118,7 @@ public struct ChatsService { } } - public func updateChat(id: Int, request body: ChatUpdateRequest) async throws -> Chat { + public override func updateChat(id: Int, request body: ChatUpdateRequest) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -99,7 +136,7 @@ public struct ChatsService { } } - public func archiveChat(id: Int) async throws -> Void { + public override func archiveChat(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/archive")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -115,7 +152,7 @@ public struct ChatsService { } } - public func deleteChat(id: Int) async throws -> Void { + public override func deleteChat(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -135,8 +172,20 @@ public struct ChatsService { public struct PachcaClient { public let chats: ChatsService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(chats: ChatsService) { + self.chats = chats + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", chats: ChatsService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.chats = ChatsService(baseURL: baseURL, headers: headers) + self.init( + chats: chats ?? ChatsServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(chats: ChatsService = ChatsService()) -> PachcaClient { + PachcaClient( + chats: chats + ) } } diff --git a/packages/generator/tests/crud/snapshots/swift/Utils.swift b/packages/generator/tests/crud/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/crud/snapshots/swift/Utils.swift +++ b/packages/generator/tests/crud/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/crud/snapshots/ts/client.ts b/packages/generator/tests/crud/snapshots/ts/client.ts index c00d7c3f..60ea846b 100644 --- a/packages/generator/tests/crud/snapshots/ts/client.ts +++ b/packages/generator/tests/crud/snapshots/ts/client.ts @@ -9,11 +9,43 @@ import { } from "./types"; import { deserialize, serialize, fetchWithRetry } from "./utils"; -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 deleteChat(id: number): Promise { + throw new Error("Chats.deleteChat 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(); @@ -133,6 +165,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.chats = new ChatsService(baseUrl, headers); + this.chats = new ChatsServiceImpl(baseUrl, headers); + } + + static stub(chats: ChatsService = new ChatsService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.chats = chats; + return client; } } diff --git a/packages/generator/tests/crud/snapshots/ts/utils.ts b/packages/generator/tests/crud/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/crud/snapshots/ts/utils.ts +++ b/packages/generator/tests/crud/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/deep-nesting/snapshots/cs/Utils.cs b/packages/generator/tests/deep-nesting/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/deep-nesting/snapshots/cs/Utils.cs +++ b/packages/generator/tests/deep-nesting/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/deep-nesting/snapshots/go/utils.go b/packages/generator/tests/deep-nesting/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/deep-nesting/snapshots/go/utils.go +++ b/packages/generator/tests/deep-nesting/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/deep-nesting/snapshots/swift/Utils.swift b/packages/generator/tests/deep-nesting/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/deep-nesting/snapshots/swift/Utils.swift +++ b/packages/generator/tests/deep-nesting/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/edge-cases/snapshots/cs/Client.cs b/packages/generator/tests/edge-cases/snapshots/cs/Client.cs index bc4a882d..7361ef39 100644 --- a/packages/generator/tests/edge-cases/snapshots/cs/Client.cs +++ b/packages/generator/tests/edge-cases/snapshots/cs/Client.cs @@ -12,18 +12,39 @@ namespace Pachca.Sdk; -public sealed class EventsService +public class EventsService +{ + + public virtual async System.Threading.Tasks.Task ListEventsAsync( + bool? isActive = null, + List? scopes = null, + EventFilter? filter = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Events.listEvents is not implemented"); + } + + public virtual async System.Threading.Tasks.Task PublishEventAsync( + int id, + OAuthScope scope, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Events.publishEvent is not implemented"); + } +} + +public sealed class EventsServiceImpl : EventsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal EventsService(string baseUrl, HttpClient client) + internal EventsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListEventsAsync( + public override async System.Threading.Tasks.Task ListEventsAsync( bool? isActive = null, List? scopes = null, EventFilter? filter = null, @@ -49,7 +70,7 @@ public async System.Threading.Tasks.Task ListEventsAsync( } } - public async System.Threading.Tasks.Task PublishEventAsync( + public override async System.Threading.Tasks.Task PublishEventAsync( int id, OAuthScope scope, CancellationToken cancellationToken = default) @@ -70,18 +91,27 @@ public async System.Threading.Tasks.Task PublishEventAsync( } } -public sealed class UploadsService +public class UploadsService +{ + + public virtual async System.Threading.Tasks.Task CreateUploadAsync(UploadRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Uploads.createUpload is not implemented"); + } +} + +public sealed class UploadsServiceImpl : UploadsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal UploadsService(string baseUrl, HttpClient client) + internal UploadsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task CreateUploadAsync(UploadRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateUploadAsync(UploadRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/uploads"; using var content = new MultipartFormDataContent(); @@ -103,24 +133,35 @@ public async System.Threading.Tasks.Task CreateUploadAsync(UploadRequest request public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public EventsService Events { get; } public UploadsService Uploads { get; } - public PachcaClient(string token, string baseUrl) + private PachcaClient(EventsService events, UploadsService uploads) + { + Events = events; + Uploads = uploads; + } + + public PachcaClient(string token, string baseUrl, EventsService? events = null, UploadsService? uploads = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Events = new EventsService(baseUrl, _client); - Uploads = new UploadsService(baseUrl, _client); + Events = events ?? new EventsServiceImpl(baseUrl, _client); + Uploads = uploads ?? new UploadsServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(EventsService? events = null, UploadsService? uploads = null) + { + return new PachcaClient(events ?? new EventsService(), uploads ?? new UploadsService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/edge-cases/snapshots/cs/Utils.cs b/packages/generator/tests/edge-cases/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/edge-cases/snapshots/cs/Utils.cs +++ b/packages/generator/tests/edge-cases/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/edge-cases/snapshots/go/client.go b/packages/generator/tests/edge-cases/snapshots/go/client.go index 13f517d8..7921995f 100644 --- a/packages/generator/tests/edge-cases/snapshots/go/client.go +++ b/packages/generator/tests/edge-cases/snapshots/go/client.go @@ -9,7 +9,6 @@ import ( "mime/multipart" "net/http" "net/url" - "strconv" "time" ) @@ -23,38 +22,27 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type EventsService interface { + ListEvents(ctx context.Context, params *ListEventsParams) (*ListEventsResponse, error) + PublishEvent(ctx context.Context, id int32, scope OAuthScope) (*Event, error) +} -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< 0 { url = baseURL[0] } +func WithEvents(service EventsService) ClientOption { + return func(cfg *clientConfig) { cfg.events = service } +} + +func WithUploads(service UploadsService) ClientOption { + return func(cfg *clientConfig) { cfg.uploads = service } +} + +func WithStubEvents(service EventsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.events = service } +} + +func WithStubUploads(service UploadsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.uploads = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Events : &EventsService{baseURL: url, client: client}, - Uploads: &UploadsService{baseURL: url, client: client}, + Events : func() EventsService { if cfg.events != nil { return cfg.events }; return &EventsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Uploads: func() UploadsService { if cfg.uploads != nil { return cfg.uploads }; return &UploadsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Events : func() EventsService { if cfg.events != nil { return cfg.events }; return &EventsServiceStub{} }(), + Uploads: func() UploadsService { if cfg.uploads != nil { return cfg.uploads }; return &UploadsServiceStub{} }(), } } diff --git a/packages/generator/tests/edge-cases/snapshots/go/utils.go b/packages/generator/tests/edge-cases/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/edge-cases/snapshots/go/utils.go +++ b/packages/generator/tests/edge-cases/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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<? = null, filter: EventFilter? = null, + ): ListEventsResponse = + throw NotImplementedError("Events.listEvents is not implemented") + + suspend fun publishEvent(id: Int, scope: OAuthScope): Event = + throw NotImplementedError("Events.publishEvent is not implemented") +} + +class EventsServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : EventsService { + override suspend fun listEvents( + isActive: Boolean?, + scopes: List?, + filter: EventFilter?, ): ListEventsResponse { val response = client.get("$baseUrl/events") { isActive?.let { parameter("is_active", it) } @@ -34,7 +46,7 @@ class EventsService internal constructor( } } - suspend fun publishEvent(id: Int, scope: OAuthScope): Event { + override suspend fun publishEvent(id: Int, scope: OAuthScope): Event { val response = client.put("$baseUrl/events/$id/publish") { contentType(ContentType.Application.Json) setBody(PublishEventRequest(scope = scope)) @@ -46,11 +58,16 @@ class EventsService internal constructor( } } -class UploadsService internal constructor( +interface UploadsService { + suspend fun createUpload(request: UploadRequest) = + throw NotImplementedError("Uploads.createUpload is not implemented") +} + +class UploadsServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun createUpload(request: UploadRequest) { +) : UploadsService { + override suspend fun createUpload(request: UploadRequest) { val response = client.submitFormWithBinaryData( "$baseUrl/uploads", formData { @@ -67,29 +84,52 @@ class UploadsService internal constructor( } } -class PachcaClient(token: String, baseUrl: String) : Closeable { - private val client = HttpClient { - expectSuccess = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val events: EventsService, + val uploads: UploadsService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String, + events: EventsService? = null, + uploads: UploadsService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + events = events ?: EventsServiceImpl(baseUrl, client), + uploads = uploads ?: UploadsServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + events: EventsService = object : EventsService {}, + uploads: UploadsService = object : UploadsService {} + ): PachcaClient = PachcaClient( + client = null, + events = events, + uploads = uploads + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val events = EventsService(baseUrl, client) - val uploads = UploadsService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/edge-cases/snapshots/py/client.py b/packages/generator/tests/edge-cases/snapshots/py/client.py index 687f9eb9..2cf82ac2 100644 --- a/packages/generator/tests/edge-cases/snapshots/py/client.py +++ b/packages/generator/tests/edge-cases/snapshots/py/client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import ( @@ -13,6 +15,21 @@ from .utils import deserialize, RetryTransport class EventsService: + async def list_events( + self, + params: ListEventsParams | None = None, + ) -> ListEventsResponse: + raise NotImplementedError("Events.listEvents is not implemented") + + async def publish_event( + self, + id: int, + scope: OAuthScope, + ) -> Event: + raise NotImplementedError("Events.publishEvent is not implemented") + + +class EventsServiceImpl(EventsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -60,6 +77,14 @@ async def publish_event( class UploadsService: + async def create_upload( + self, + request: UploadRequest, + ) -> None: + raise NotImplementedError("Uploads.createUpload is not implemented") + + +class UploadsServiceImpl(UploadsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -84,14 +109,26 @@ async def create_upload( class PachcaClient: - def __init__(self, token: str, base_url: str) -> None: + def __init__(self, token: str, base_url: str, events: EventsService | None = None, uploads: UploadsService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.events = EventsService(self._client) - self.uploads = UploadsService(self._client) + self.events: EventsService = events or EventsServiceImpl(self._client) + self.uploads: UploadsService = uploads or UploadsServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + events: EventsService | None = None, + uploads: UploadsService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.events = events or EventsService() + self.uploads = uploads or UploadsService() + return self diff --git a/packages/generator/tests/edge-cases/snapshots/py/utils.py b/packages/generator/tests/edge-cases/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/edge-cases/snapshots/py/utils.py +++ b/packages/generator/tests/edge-cases/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/edge-cases/snapshots/swift/Client.swift b/packages/generator/tests/edge-cases/snapshots/swift/Client.swift index 0cb2aa07..8d5ec703 100644 --- a/packages/generator/tests/edge-cases/snapshots/swift/Client.swift +++ b/packages/generator/tests/edge-cases/snapshots/swift/Client.swift @@ -3,7 +3,23 @@ import Foundation import FoundationNetworking #endif -public struct EventsService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class EventsService { + public init() {} + + open func listEvents(isActive: Bool? = nil, scopes: [OAuthScope]? = nil, filter: EventFilter? = nil) async throws -> ListEventsResponse { + throw pachcaNotImplemented("Events.listEvents") + } + + open func publishEvent(id: Int, scope: OAuthScope) async throws -> Event { + throw pachcaNotImplemented("Events.publishEvent") + } +} + +public final class EventsServiceImpl: EventsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +28,10 @@ public struct EventsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listEvents(isActive: Bool? = nil, scopes: [OAuthScope]? = nil, filter: EventFilter? = nil) async throws -> ListEventsResponse { + public override func listEvents(isActive: Bool? = nil, scopes: [OAuthScope]? = nil, filter: EventFilter? = nil) async throws -> ListEventsResponse { var components = URLComponents(string: "\(baseURL)/events")! var queryItems: [URLQueryItem] = [] if let isActive { queryItems.append(URLQueryItem(name: "is_active", value: String(isActive))) } @@ -33,7 +50,7 @@ public struct EventsService { } } - public func publishEvent(id: Int, scope: OAuthScope) async throws -> Event { + public override func publishEvent(id: Int, scope: OAuthScope) async throws -> Event { var request = URLRequest(url: URL(string: "\(baseURL)/events/\(id)/publish")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -50,7 +67,15 @@ public struct EventsService { } } -public struct UploadsService { +open class UploadsService { + public init() {} + + open func createUpload(request body: UploadRequest) async throws -> Void { + throw pachcaNotImplemented("Uploads.createUpload") + } +} + +public final class UploadsServiceImpl: UploadsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -59,9 +84,10 @@ public struct UploadsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func createUpload(request body: UploadRequest) async throws -> Void { + public override func createUpload(request body: UploadRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/uploads")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -96,9 +122,23 @@ public struct PachcaClient { public let events: EventsService public let uploads: UploadsService - public init(token: String, baseURL: String) { + private init(events: EventsService, uploads: UploadsService) { + self.events = events + self.uploads = uploads + } + + public init(token: String, baseURL: String, events: EventsService? = nil, uploads: UploadsService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.events = EventsService(baseURL: baseURL, headers: headers) - self.uploads = UploadsService(baseURL: baseURL, headers: headers) + self.init( + events: events ?? EventsServiceImpl(baseURL: baseURL, headers: headers), + uploads: uploads ?? UploadsServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(events: EventsService = EventsService(), uploads: UploadsService = UploadsService()) -> PachcaClient { + PachcaClient( + events: events, + uploads: uploads + ) } } diff --git a/packages/generator/tests/edge-cases/snapshots/swift/Utils.swift b/packages/generator/tests/edge-cases/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/edge-cases/snapshots/swift/Utils.swift +++ b/packages/generator/tests/edge-cases/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/edge-cases/snapshots/ts/client.ts b/packages/generator/tests/edge-cases/snapshots/ts/client.ts index 769675da..314526ee 100644 --- a/packages/generator/tests/edge-cases/snapshots/ts/client.ts +++ b/packages/generator/tests/edge-cases/snapshots/ts/client.ts @@ -7,11 +7,23 @@ import { } from "./types"; import { deserialize, fetchWithRetry } from "./utils"; -class EventsService { +export class EventsService { + async listEvents(params?: ListEventsParams): Promise { + throw new Error("Events.listEvents is not implemented"); + } + + async publishEvent(id: number, scope: OAuthScope): Promise { + throw new Error("Events.publishEvent is not implemented"); + } +} + +export class EventsServiceImpl extends EventsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async listEvents(params?: ListEventsParams): Promise { const query = new URLSearchParams(); @@ -47,11 +59,19 @@ class EventsService { } } -class UploadsService { +export class UploadsService { + async createUpload(request: UploadRequest): Promise { + throw new Error("Uploads.createUpload is not implemented"); + } +} + +export class UploadsServiceImpl extends UploadsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async createUpload(request: UploadRequest): Promise { const form = new FormData(); @@ -77,7 +97,14 @@ export class PachcaClient { constructor(token: string, baseUrl: string) { const headers = { Authorization: `Bearer ${token}` }; - this.events = new EventsService(baseUrl, headers); - this.uploads = new UploadsService(baseUrl, headers); + this.events = new EventsServiceImpl(baseUrl, headers); + this.uploads = new UploadsServiceImpl(baseUrl, headers); + } + + static stub(events: EventsService = new EventsService(), uploads: UploadsService = new UploadsService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.events = events; + client.uploads = uploads; + return client; } } diff --git a/packages/generator/tests/edge-cases/snapshots/ts/utils.ts b/packages/generator/tests/edge-cases/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/edge-cases/snapshots/ts/utils.ts +++ b/packages/generator/tests/edge-cases/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/enums/snapshots/cs/Utils.cs b/packages/generator/tests/enums/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/enums/snapshots/cs/Utils.cs +++ b/packages/generator/tests/enums/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/enums/snapshots/go/utils.go b/packages/generator/tests/enums/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/enums/snapshots/go/utils.go +++ b/packages/generator/tests/enums/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/enums/snapshots/swift/Utils.swift b/packages/generator/tests/enums/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/enums/snapshots/swift/Utils.swift +++ b/packages/generator/tests/enums/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/models/snapshots/cs/Utils.cs b/packages/generator/tests/models/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/models/snapshots/cs/Utils.cs +++ b/packages/generator/tests/models/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/models/snapshots/go/utils.go b/packages/generator/tests/models/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/models/snapshots/go/utils.go +++ b/packages/generator/tests/models/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/models/snapshots/swift/Utils.swift b/packages/generator/tests/models/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/models/snapshots/swift/Utils.swift +++ b/packages/generator/tests/models/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/multi-path-params/snapshots/cs/Client.cs b/packages/generator/tests/multi-path-params/snapshots/cs/Client.cs index 77819166..a93506a9 100644 --- a/packages/generator/tests/multi-path-params/snapshots/cs/Client.cs +++ b/packages/generator/tests/multi-path-params/snapshots/cs/Client.cs @@ -11,18 +11,48 @@ namespace Pachca.Sdk; -public sealed class TasksService +public class TasksService +{ + + public virtual async System.Threading.Tasks.Task GetTaskAsync( + int projectId, + int taskId, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.getTask is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateTaskAsync( + int projectId, + int taskId, + TaskUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.updateTask is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteCommentAsync( + int projectId, + int taskId, + int commentId, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.deleteComment is not implemented"); + } +} + +public sealed class TasksServiceImpl : TasksService { private readonly string _baseUrl; private readonly HttpClient _client; - internal TasksService(string baseUrl, HttpClient client) + internal TasksServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task GetTaskAsync( + public override async System.Threading.Tasks.Task GetTaskAsync( int projectId, int taskId, CancellationToken cancellationToken = default) @@ -40,7 +70,7 @@ internal TasksService(string baseUrl, HttpClient client) } } - public async System.Threading.Tasks.Task UpdateTaskAsync( + public override async System.Threading.Tasks.Task UpdateTaskAsync( int projectId, int taskId, TaskUpdateRequest request, @@ -60,7 +90,7 @@ internal TasksService(string baseUrl, HttpClient client) } } - public async System.Threading.Tasks.Task DeleteCommentAsync( + public override async System.Threading.Tasks.Task DeleteCommentAsync( int projectId, int taskId, int commentId, @@ -82,22 +112,32 @@ public async System.Threading.Tasks.Task DeleteCommentAsync( public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public TasksService Tasks { get; } - public PachcaClient(string token, string baseUrl = "https://api.example.com/v1") + private PachcaClient(TasksService tasks) + { + Tasks = tasks; + } + + public PachcaClient(string token, string baseUrl = "https://api.example.com/v1", TasksService? tasks = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Tasks = new TasksService(baseUrl, _client); + Tasks = tasks ?? new TasksServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(TasksService? tasks = null) + { + return new PachcaClient(tasks ?? new TasksService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/multi-path-params/snapshots/cs/Utils.cs b/packages/generator/tests/multi-path-params/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/multi-path-params/snapshots/cs/Utils.cs +++ b/packages/generator/tests/multi-path-params/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/multi-path-params/snapshots/go/client.go b/packages/generator/tests/multi-path-params/snapshots/go/client.go index 7c39db2e..0e12e4ff 100644 --- a/packages/generator/tests/multi-path-params/snapshots/go/client.go +++ b/packages/generator/tests/multi-path-params/snapshots/go/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "time" ) @@ -20,38 +19,32 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type TasksService interface { + GetTask(ctx context.Context, projectId int32, taskId int32) (*Task, error) + UpdateTask(ctx context.Context, projectId int32, taskId int32, request TaskUpdateRequest) (*Task, error) + DeleteComment(ctx context.Context, projectId int32, taskId int32, commentId int32) error +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithTasks(service TasksService) ClientOption { + return func(cfg *clientConfig) { cfg.tasks = service } +} + +func WithStubTasks(service TasksService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.tasks = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Tasks: &TasksService{baseURL: url, client: client}, + Tasks: func() TasksService { if cfg.tasks != nil { return cfg.tasks }; return &TasksServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Tasks: func() TasksService { if cfg.tasks != nil { return cfg.tasks }; return &TasksServiceStub{} }(), } } diff --git a/packages/generator/tests/multi-path-params/snapshots/go/utils.go b/packages/generator/tests/multi-path-params/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/multi-path-params/snapshots/go/utils.go +++ b/packages/generator/tests/multi-path-params/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< response.body().data @@ -25,7 +44,7 @@ class TasksService internal constructor( } } - suspend fun updateTask( + override suspend fun updateTask( projectId: Int, taskId: Int, request: TaskUpdateRequest, @@ -40,7 +59,7 @@ class TasksService internal constructor( } } - suspend fun deleteComment( + override suspend fun deleteComment( projectId: Int, taskId: Int, commentId: Int, @@ -53,28 +72,47 @@ class TasksService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.example.com/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val tasks: TasksService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.example.com/v1", + tasks: TasksService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + tasks = tasks ?: TasksServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + tasks: TasksService = object : TasksService {} + ): PachcaClient = PachcaClient( + client = null, + tasks = tasks + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val tasks = TasksService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/multi-path-params/snapshots/py/client.py b/packages/generator/tests/multi-path-params/snapshots/py/client.py index 92696119..0608f2ac 100644 --- a/packages/generator/tests/multi-path-params/snapshots/py/client.py +++ b/packages/generator/tests/multi-path-params/snapshots/py/client.py @@ -1,11 +1,38 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import Task, TaskUpdateRequest from .utils import deserialize, serialize, RetryTransport class TasksService: + async def get_task( + self, + project_id: int, + task_id: int, + ) -> Task: + raise NotImplementedError("Tasks.getTask is not implemented") + + async def update_task( + self, + project_id: int, + task_id: int, + request: TaskUpdateRequest, + ) -> Task: + raise NotImplementedError("Tasks.updateTask is not implemented") + + async def delete_comment( + self, + project_id: int, + task_id: int, + comment_id: int, + ) -> None: + raise NotImplementedError("Tasks.deleteComment is not implemented") + + +class TasksServiceImpl(TasksService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -64,13 +91,23 @@ async def delete_comment( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.example.com/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.example.com/v1", tasks: TasksService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.tasks = TasksService(self._client) + self.tasks: TasksService = tasks or TasksServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + tasks: TasksService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.tasks = tasks or TasksService() + return self 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 44d19034..950682b4 100644 --- a/packages/generator/tests/multi-path-params/snapshots/py/utils.py +++ b/packages/generator/tests/multi-path-params/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/multi-path-params/snapshots/swift/Client.swift b/packages/generator/tests/multi-path-params/snapshots/swift/Client.swift index 2d780992..353d0d64 100644 --- a/packages/generator/tests/multi-path-params/snapshots/swift/Client.swift +++ b/packages/generator/tests/multi-path-params/snapshots/swift/Client.swift @@ -3,7 +3,27 @@ import Foundation import FoundationNetworking #endif -public struct TasksService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class TasksService { + public init() {} + + open func getTask(projectId: Int, taskId: Int) async throws -> Task { + throw pachcaNotImplemented("Tasks.getTask") + } + + open func updateTask(projectId: Int, taskId: Int, request body: TaskUpdateRequest) async throws -> Task { + throw pachcaNotImplemented("Tasks.updateTask") + } + + open func deleteComment(projectId: Int, taskId: Int, commentId: Int) async throws -> Void { + throw pachcaNotImplemented("Tasks.deleteComment") + } +} + +public final class TasksServiceImpl: TasksService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +32,10 @@ public struct TasksService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func getTask(projectId: Int, taskId: Int) async throws -> Task { + public override func getTask(projectId: Int, taskId: Int) async throws -> Task { var request = URLRequest(url: URL(string: "\(baseURL)/projects/\(projectId)/tasks/\(taskId)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -27,7 +48,7 @@ public struct TasksService { } } - public func updateTask(projectId: Int, taskId: Int, request body: TaskUpdateRequest) async throws -> Task { + public override func updateTask(projectId: Int, taskId: Int, request body: TaskUpdateRequest) async throws -> Task { var request = URLRequest(url: URL(string: "\(baseURL)/projects/\(projectId)/tasks/\(taskId)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -43,7 +64,7 @@ public struct TasksService { } } - public func deleteComment(projectId: Int, taskId: Int, commentId: Int) async throws -> Void { + public override func deleteComment(projectId: Int, taskId: Int, commentId: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/projects/\(projectId)/tasks/\(taskId)/comments/\(commentId)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -61,8 +82,20 @@ public struct TasksService { public struct PachcaClient { public let tasks: TasksService - public init(token: String, baseURL: String = "https://api.example.com/v1") { + private init(tasks: TasksService) { + self.tasks = tasks + } + + public init(token: String, baseURL: String = "https://api.example.com/v1", tasks: TasksService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.tasks = TasksService(baseURL: baseURL, headers: headers) + self.init( + tasks: tasks ?? TasksServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(tasks: TasksService = TasksService()) -> PachcaClient { + PachcaClient( + tasks: tasks + ) } } diff --git a/packages/generator/tests/multi-path-params/snapshots/swift/Utils.swift b/packages/generator/tests/multi-path-params/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/multi-path-params/snapshots/swift/Utils.swift +++ b/packages/generator/tests/multi-path-params/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } 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 bc1ac931..ae1bff04 100644 --- a/packages/generator/tests/multi-path-params/snapshots/ts/client.ts +++ b/packages/generator/tests/multi-path-params/snapshots/ts/client.ts @@ -1,11 +1,27 @@ import { Task, TaskUpdateRequest } from "./types"; import { deserialize, serialize, fetchWithRetry } from "./utils"; -class TasksService { +export class TasksService { + async getTask(projectId: number, taskId: number): Promise { + throw new Error("Tasks.getTask is not implemented"); + } + + async updateTask(projectId: number, taskId: number, request: TaskUpdateRequest): Promise { + throw new Error("Tasks.updateTask is not implemented"); + } + + async deleteComment(projectId: number, taskId: number, commentId: number): Promise { + throw new Error("Tasks.deleteComment is not implemented"); + } +} + +export class TasksServiceImpl extends TasksService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async getTask(projectId: number, taskId: number): Promise { const response = await fetchWithRetry(`${this.baseUrl}/projects/${projectId}/tasks/${taskId}`, { @@ -54,6 +70,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.example.com/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.tasks = new TasksService(baseUrl, headers); + this.tasks = new TasksServiceImpl(baseUrl, headers); + } + + static stub(tasks: TasksService = new TasksService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.tasks = tasks; + return client; } } 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 4c979225..ac5d6549 100644 --- a/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts +++ b/packages/generator/tests/multi-path-params/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/nullable-ref/snapshots/cs/Utils.cs b/packages/generator/tests/nullable-ref/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/nullable-ref/snapshots/cs/Utils.cs +++ b/packages/generator/tests/nullable-ref/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/nullable-ref/snapshots/go/utils.go b/packages/generator/tests/nullable-ref/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/nullable-ref/snapshots/go/utils.go +++ b/packages/generator/tests/nullable-ref/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/nullable-ref/snapshots/swift/Utils.swift b/packages/generator/tests/nullable-ref/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/nullable-ref/snapshots/swift/Utils.swift +++ b/packages/generator/tests/nullable-ref/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/oneof/snapshots/cs/Utils.cs b/packages/generator/tests/oneof/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/oneof/snapshots/cs/Utils.cs +++ b/packages/generator/tests/oneof/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/oneof/snapshots/go/utils.go b/packages/generator/tests/oneof/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/oneof/snapshots/go/utils.go +++ b/packages/generator/tests/oneof/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/oneof/snapshots/swift/Utils.swift b/packages/generator/tests/oneof/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/oneof/snapshots/swift/Utils.swift +++ b/packages/generator/tests/oneof/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/patch/snapshots/cs/Client.cs b/packages/generator/tests/patch/snapshots/cs/Client.cs index 7d167bda..ee892bd1 100644 --- a/packages/generator/tests/patch/snapshots/cs/Client.cs +++ b/packages/generator/tests/patch/snapshots/cs/Client.cs @@ -11,18 +11,30 @@ namespace Pachca.Sdk; -public sealed class ItemsService +public class ItemsService +{ + + public virtual async System.Threading.Tasks.Task PatchItemAsync( + int id, + ItemPatchRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Items.patchItem is not implemented"); + } +} + +public sealed class ItemsServiceImpl : ItemsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ItemsService(string baseUrl, HttpClient client) + internal ItemsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task PatchItemAsync( + public override async System.Threading.Tasks.Task PatchItemAsync( int id, ItemPatchRequest request, CancellationToken cancellationToken = default) @@ -44,22 +56,32 @@ public async System.Threading.Tasks.Task PatchItemAsync( public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public ItemsService Items { get; } - public PachcaClient(string token, string baseUrl = "https://api.example.com/v1") + private PachcaClient(ItemsService items) + { + Items = items; + } + + public PachcaClient(string token, string baseUrl = "https://api.example.com/v1", ItemsService? items = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Items = new ItemsService(baseUrl, _client); + Items = items ?? new ItemsServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(ItemsService? items = null) + { + return new PachcaClient(items ?? new ItemsService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/patch/snapshots/cs/Utils.cs b/packages/generator/tests/patch/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/patch/snapshots/cs/Utils.cs +++ b/packages/generator/tests/patch/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/patch/snapshots/go/client.go b/packages/generator/tests/patch/snapshots/go/client.go index 38f212e7..2d3cf23d 100644 --- a/packages/generator/tests/patch/snapshots/go/client.go +++ b/packages/generator/tests/patch/snapshots/go/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "time" ) @@ -20,38 +19,22 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type ItemsService interface { + PatchItem(ctx context.Context, id int32, request ItemPatchRequest) (*Item, error) +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithItems(service ItemsService) ClientOption { + return func(cfg *clientConfig) { cfg.items = service } +} + +func WithStubItems(service ItemsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.items = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Items: &ItemsService{baseURL: url, client: client}, + Items: func() ItemsService { if cfg.items != nil { return cfg.items }; return &ItemsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Items: func() ItemsService { if cfg.items != nil { return cfg.items }; return &ItemsServiceStub{} }(), } } diff --git a/packages/generator/tests/patch/snapshots/go/utils.go b/packages/generator/tests/patch/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/patch/snapshots/go/utils.go +++ b/packages/generator/tests/patch/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + items: ItemsService = object : ItemsService {} + ): PachcaClient = PachcaClient( + client = null, + items = items + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val items = ItemsService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/patch/snapshots/py/client.py b/packages/generator/tests/patch/snapshots/py/client.py index bde4cefb..793e14d4 100644 --- a/packages/generator/tests/patch/snapshots/py/client.py +++ b/packages/generator/tests/patch/snapshots/py/client.py @@ -1,11 +1,22 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import ItemPatchRequest, Item, ApiError from .utils import deserialize, serialize, RetryTransport class ItemsService: + async def patch_item( + self, + id: int, + request: ItemPatchRequest, + ) -> Item: + raise NotImplementedError("Items.patchItem is not implemented") + + +class ItemsServiceImpl(ItemsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -27,13 +38,23 @@ async def patch_item( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.example.com/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.example.com/v1", items: ItemsService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.items = ItemsService(self._client) + self.items: ItemsService = items or ItemsServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + items: ItemsService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.items = items or ItemsService() + return self diff --git a/packages/generator/tests/patch/snapshots/py/utils.py b/packages/generator/tests/patch/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/patch/snapshots/py/utils.py +++ b/packages/generator/tests/patch/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/patch/snapshots/swift/Client.swift b/packages/generator/tests/patch/snapshots/swift/Client.swift index 08813432..400cda4d 100644 --- a/packages/generator/tests/patch/snapshots/swift/Client.swift +++ b/packages/generator/tests/patch/snapshots/swift/Client.swift @@ -3,7 +3,19 @@ import Foundation import FoundationNetworking #endif -public struct ItemsService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class ItemsService { + public init() {} + + open func patchItem(id: Int, request body: ItemPatchRequest) async throws -> Item { + throw pachcaNotImplemented("Items.patchItem") + } +} + +public final class ItemsServiceImpl: ItemsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +24,10 @@ public struct ItemsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func patchItem(id: Int, request body: ItemPatchRequest) async throws -> Item { + public override func patchItem(id: Int, request body: ItemPatchRequest) async throws -> Item { var request = URLRequest(url: URL(string: "\(baseURL)/items/\(id)")!) request.httpMethod = "PATCH" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -34,8 +47,20 @@ public struct ItemsService { public struct PachcaClient { public let items: ItemsService - public init(token: String, baseURL: String = "https://api.example.com/v1") { + private init(items: ItemsService) { + self.items = items + } + + public init(token: String, baseURL: String = "https://api.example.com/v1", items: ItemsService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.items = ItemsService(baseURL: baseURL, headers: headers) + self.init( + items: items ?? ItemsServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(items: ItemsService = ItemsService()) -> PachcaClient { + PachcaClient( + items: items + ) } } diff --git a/packages/generator/tests/patch/snapshots/swift/Utils.swift b/packages/generator/tests/patch/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/patch/snapshots/swift/Utils.swift +++ b/packages/generator/tests/patch/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/patch/snapshots/ts/client.ts b/packages/generator/tests/patch/snapshots/ts/client.ts index c4340151..125781af 100644 --- a/packages/generator/tests/patch/snapshots/ts/client.ts +++ b/packages/generator/tests/patch/snapshots/ts/client.ts @@ -1,11 +1,19 @@ import { ItemPatchRequest, Item, ApiError } from "./types"; import { deserialize, serialize, fetchWithRetry } from "./utils"; -class ItemsService { +export class ItemsService { + async patchItem(id: number, request: ItemPatchRequest): Promise { + throw new Error("Items.patchItem is not implemented"); + } +} + +export class ItemsServiceImpl extends ItemsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async patchItem(id: number, request: ItemPatchRequest): Promise { const response = await fetchWithRetry(`${this.baseUrl}/items/${id}`, { @@ -28,6 +36,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.example.com/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.items = new ItemsService(baseUrl, headers); + this.items = new ItemsServiceImpl(baseUrl, headers); + } + + static stub(items: ItemsService = new ItemsService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.items = items; + return client; } } diff --git a/packages/generator/tests/patch/snapshots/ts/utils.ts b/packages/generator/tests/patch/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/patch/snapshots/ts/utils.ts +++ b/packages/generator/tests/patch/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/record/snapshots/cs/Client.cs b/packages/generator/tests/record/snapshots/cs/Client.cs index 6ffdfeb3..dfbbe2d8 100644 --- a/packages/generator/tests/record/snapshots/cs/Client.cs +++ b/packages/generator/tests/record/snapshots/cs/Client.cs @@ -11,18 +11,30 @@ namespace Pachca.Sdk; -public sealed class LinkPreviewsService +public class LinkPreviewsService +{ + + public virtual async System.Threading.Tasks.Task CreateLinkPreviewsAsync( + int id, + LinkPreviewsRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Link Previews.createLinkPreviews is not implemented"); + } +} + +public sealed class LinkPreviewsServiceImpl : LinkPreviewsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal LinkPreviewsService(string baseUrl, HttpClient client) + internal LinkPreviewsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task CreateLinkPreviewsAsync( + public override async System.Threading.Tasks.Task CreateLinkPreviewsAsync( int id, LinkPreviewsRequest request, CancellationToken cancellationToken = default) @@ -46,22 +58,32 @@ public async System.Threading.Tasks.Task CreateLinkPreviewsAsync( public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public LinkPreviewsService LinkPreviews { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(LinkPreviewsService linkPreviews) + { + LinkPreviews = linkPreviews; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", LinkPreviewsService? linkPreviews = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - LinkPreviews = new LinkPreviewsService(baseUrl, _client); + LinkPreviews = linkPreviews ?? new LinkPreviewsServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(LinkPreviewsService? linkPreviews = null) + { + return new PachcaClient(linkPreviews ?? new LinkPreviewsService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/record/snapshots/cs/Utils.cs b/packages/generator/tests/record/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/record/snapshots/cs/Utils.cs +++ b/packages/generator/tests/record/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/record/snapshots/go/client.go b/packages/generator/tests/record/snapshots/go/client.go index 91e9c6ff..fc6f1ecb 100644 --- a/packages/generator/tests/record/snapshots/go/client.go +++ b/packages/generator/tests/record/snapshots/go/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "time" ) @@ -20,38 +19,22 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 - -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithLinkPreviews(service LinkPreviewsService) ClientOption { + return func(cfg *clientConfig) { cfg.linkPreviews = service } +} + +func WithStubLinkPreviews(service LinkPreviewsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.linkPreviews = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - LinkPreviews: &LinkPreviewsService{baseURL: url, client: client}, + LinkPreviews: func() LinkPreviewsService { if cfg.linkPreviews != nil { return cfg.linkPreviews }; return &LinkPreviewsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + LinkPreviews: func() LinkPreviewsService { if cfg.linkPreviews != nil { return cfg.linkPreviews }; return &LinkPreviewsServiceStub{} }(), } } diff --git a/packages/generator/tests/record/snapshots/go/utils.go b/packages/generator/tests/record/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/record/snapshots/go/utils.go +++ b/packages/generator/tests/record/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + linkPreviews: LinkPreviewsService = object : LinkPreviewsService {} + ): PachcaClient = PachcaClient( + client = null, + linkPreviews = linkPreviews + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val linkPreviews = LinkPreviewsService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/record/snapshots/py/client.py b/packages/generator/tests/record/snapshots/py/client.py index e52317a7..c8df541f 100644 --- a/packages/generator/tests/record/snapshots/py/client.py +++ b/packages/generator/tests/record/snapshots/py/client.py @@ -1,11 +1,22 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import LinkPreviewsRequest, OAuthError, ApiError from .utils import deserialize, serialize, RetryTransport class LinkPreviewsService: + async def create_link_previews( + self, + id: int, + request: LinkPreviewsRequest, + ) -> None: + raise NotImplementedError("Link Previews.createLinkPreviews is not implemented") + + +class LinkPreviewsServiceImpl(LinkPreviewsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -28,13 +39,23 @@ async def create_link_previews( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", link_previews: LinkPreviewsService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.link_previews = LinkPreviewsService(self._client) + self.link_previews: LinkPreviewsService = link_previews or LinkPreviewsServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + link_previews: LinkPreviewsService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.link_previews = link_previews or LinkPreviewsService() + return self diff --git a/packages/generator/tests/record/snapshots/py/utils.py b/packages/generator/tests/record/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/record/snapshots/py/utils.py +++ b/packages/generator/tests/record/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/record/snapshots/swift/Client.swift b/packages/generator/tests/record/snapshots/swift/Client.swift index d9907a27..ca1eae0f 100644 --- a/packages/generator/tests/record/snapshots/swift/Client.swift +++ b/packages/generator/tests/record/snapshots/swift/Client.swift @@ -3,7 +3,19 @@ import Foundation import FoundationNetworking #endif -public struct LinkPreviewsService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class LinkPreviewsService { + public init() {} + + open func createLinkPreviews(id: Int, request body: LinkPreviewsRequest) async throws -> Void { + throw pachcaNotImplemented("Link Previews.createLinkPreviews") + } +} + +public final class LinkPreviewsServiceImpl: LinkPreviewsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +24,10 @@ public struct LinkPreviewsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func createLinkPreviews(id: Int, request body: LinkPreviewsRequest) async throws -> Void { + public override func createLinkPreviews(id: Int, request body: LinkPreviewsRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)/link_previews")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -36,8 +49,20 @@ public struct LinkPreviewsService { public struct PachcaClient { public let linkPreviews: LinkPreviewsService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(linkPreviews: LinkPreviewsService) { + self.linkPreviews = linkPreviews + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", linkPreviews: LinkPreviewsService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.linkPreviews = LinkPreviewsService(baseURL: baseURL, headers: headers) + self.init( + linkPreviews: linkPreviews ?? LinkPreviewsServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(linkPreviews: LinkPreviewsService = LinkPreviewsService()) -> PachcaClient { + PachcaClient( + linkPreviews: linkPreviews + ) } } diff --git a/packages/generator/tests/record/snapshots/swift/Utils.swift b/packages/generator/tests/record/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/record/snapshots/swift/Utils.swift +++ b/packages/generator/tests/record/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/record/snapshots/ts/client.ts b/packages/generator/tests/record/snapshots/ts/client.ts index 3486be90..500513f8 100644 --- a/packages/generator/tests/record/snapshots/ts/client.ts +++ b/packages/generator/tests/record/snapshots/ts/client.ts @@ -1,11 +1,19 @@ import { LinkPreviewsRequest, OAuthError, ApiError } from "./types"; import { serialize, fetchWithRetry } from "./utils"; -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`, { @@ -29,6 +37,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.linkPreviews = new LinkPreviewsService(baseUrl, headers); + this.linkPreviews = new LinkPreviewsServiceImpl(baseUrl, headers); + } + + static stub(linkPreviews: LinkPreviewsService = new LinkPreviewsService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.linkPreviews = linkPreviews; + return client; } } diff --git a/packages/generator/tests/record/snapshots/ts/utils.ts b/packages/generator/tests/record/snapshots/ts/utils.ts index 873cc4e4..9e39032c 100644 --- a/packages/generator/tests/record/snapshots/ts/utils.ts +++ b/packages/generator/tests/record/snapshots/ts/utils.ts @@ -60,6 +60,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -67,7 +72,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/redirect/snapshots/cs/Client.cs b/packages/generator/tests/redirect/snapshots/cs/Client.cs index c6ee8d27..69fda314 100644 --- a/packages/generator/tests/redirect/snapshots/cs/Client.cs +++ b/packages/generator/tests/redirect/snapshots/cs/Client.cs @@ -11,18 +11,27 @@ namespace Pachca.Sdk; -public sealed class CommonService +public class CommonService +{ + + public virtual async System.Threading.Tasks.Task DownloadExportAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.downloadExport is not implemented"); + } +} + +public sealed class CommonServiceImpl : CommonService { private readonly string _baseUrl; private readonly HttpClient _client; - internal CommonService(string baseUrl, HttpClient client) + internal CommonServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task DownloadExportAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DownloadExportAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/exports/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -43,11 +52,16 @@ public async System.Threading.Tasks.Task DownloadExportAsync(int id, Can public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public CommonService Common { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(CommonService common) + { + Common = common; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", CommonService? common = null) { var handler = new SocketsHttpHandler { @@ -57,12 +71,17 @@ public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/s _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Common = new CommonService(baseUrl, _client); + Common = common ?? new CommonServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(CommonService? common = null) + { + return new PachcaClient(common ?? new CommonService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/redirect/snapshots/cs/Utils.cs b/packages/generator/tests/redirect/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/redirect/snapshots/cs/Utils.cs +++ b/packages/generator/tests/redirect/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/redirect/snapshots/go/client.go b/packages/generator/tests/redirect/snapshots/go/client.go index ae944414..197ae761 100644 --- a/packages/generator/tests/redirect/snapshots/go/client.go +++ b/packages/generator/tests/redirect/snapshots/go/client.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "time" ) @@ -20,38 +19,22 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type CommonService interface { + DownloadExport(ctx context.Context, id int32) (string, error) +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithCommon(service CommonService) ClientOption { + return func(cfg *clientConfig) { cfg.common = service } +} + +func WithStubCommon(service CommonService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.common = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -95,6 +105,16 @@ func NewPachcaClient(token string, baseURL ...string) *PachcaClient { }, } return &PachcaClient{ - Common: &CommonService{baseURL: url, client: client}, + Common: func() CommonService { if cfg.common != nil { return cfg.common }; return &CommonServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Common: func() CommonService { if cfg.common != nil { return cfg.common }; return &CommonServiceStub{} }(), } } diff --git a/packages/generator/tests/redirect/snapshots/go/utils.go b/packages/generator/tests/redirect/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/redirect/snapshots/go/utils.go +++ b/packages/generator/tests/redirect/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< response.headers[HttpHeaders.Location] @@ -28,29 +33,48 @@ class CommonService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.pachca.com/api/shared/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - followRedirects = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val common: CommonService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.pachca.com/api/shared/v1", + common: CommonService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + common = common ?: CommonServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + common: CommonService = object : CommonService {} + ): PachcaClient = PachcaClient( + client = null, + common = common + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + followRedirects = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val common = CommonService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/redirect/snapshots/py/client.py b/packages/generator/tests/redirect/snapshots/py/client.py index 9a2fbe6e..8b389731 100644 --- a/packages/generator/tests/redirect/snapshots/py/client.py +++ b/packages/generator/tests/redirect/snapshots/py/client.py @@ -1,11 +1,21 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import OAuthError, ApiError from .utils import deserialize, RetryTransport class CommonService: + async def download_export( + self, + id: int, + ) -> str: + raise NotImplementedError("Common.downloadExport is not implemented") + + +class CommonServiceImpl(CommonService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -32,13 +42,23 @@ async def download_export( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", common: CommonService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.common = CommonService(self._client) + self.common: CommonService = common or CommonServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + common: CommonService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.common = common or CommonService() + return self diff --git a/packages/generator/tests/redirect/snapshots/py/utils.py b/packages/generator/tests/redirect/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/redirect/snapshots/py/utils.py +++ b/packages/generator/tests/redirect/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/redirect/snapshots/swift/Client.swift b/packages/generator/tests/redirect/snapshots/swift/Client.swift index f6a80bdd..731a3b21 100644 --- a/packages/generator/tests/redirect/snapshots/swift/Client.swift +++ b/packages/generator/tests/redirect/snapshots/swift/Client.swift @@ -3,7 +3,19 @@ import Foundation import FoundationNetworking #endif -public struct CommonService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class CommonService { + public init() {} + + open func downloadExport(id: Int) async throws -> String { + throw pachcaNotImplemented("Common.downloadExport") + } +} + +public final class CommonServiceImpl: CommonService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +24,10 @@ public struct CommonService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func downloadExport(id: Int) async throws -> String { + public override func downloadExport(id: Int) async throws -> String { var request = URLRequest(url: URL(string: "\(baseURL)/exports/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let delegate = RedirectPreventer() @@ -49,8 +62,20 @@ private final class RedirectPreventer: NSObject, URLSessionTaskDelegate { public struct PachcaClient { public let common: CommonService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(common: CommonService) { + self.common = common + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", common: CommonService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.common = CommonService(baseURL: baseURL, headers: headers) + self.init( + common: common ?? CommonServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(common: CommonService = CommonService()) -> PachcaClient { + PachcaClient( + common: common + ) } } diff --git a/packages/generator/tests/redirect/snapshots/swift/Utils.swift b/packages/generator/tests/redirect/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/redirect/snapshots/swift/Utils.swift +++ b/packages/generator/tests/redirect/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/redirect/snapshots/ts/client.ts b/packages/generator/tests/redirect/snapshots/ts/client.ts index 62e389e7..83ca68a7 100644 --- a/packages/generator/tests/redirect/snapshots/ts/client.ts +++ b/packages/generator/tests/redirect/snapshots/ts/client.ts @@ -1,11 +1,19 @@ import { OAuthError, ApiError } from "./types"; import { fetchWithRetry } from "./utils"; -class CommonService { +export class CommonService { + async downloadExport(id: number): Promise { + throw new Error("Common.downloadExport 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}/exports/${id}`, { @@ -33,6 +41,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.common = new CommonService(baseUrl, headers); + this.common = new CommonServiceImpl(baseUrl, headers); + } + + static stub(common: CommonService = new CommonService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.common = common; + return client; } } diff --git a/packages/generator/tests/redirect/snapshots/ts/utils.ts b/packages/generator/tests/redirect/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/redirect/snapshots/ts/utils.ts +++ b/packages/generator/tests/redirect/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/reserved-keywords/snapshots/cs/Utils.cs b/packages/generator/tests/reserved-keywords/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/reserved-keywords/snapshots/cs/Utils.cs +++ b/packages/generator/tests/reserved-keywords/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/reserved-keywords/snapshots/go/utils.go b/packages/generator/tests/reserved-keywords/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/reserved-keywords/snapshots/go/utils.go +++ b/packages/generator/tests/reserved-keywords/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/reserved-keywords/snapshots/swift/Utils.swift b/packages/generator/tests/reserved-keywords/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/reserved-keywords/snapshots/swift/Utils.swift +++ b/packages/generator/tests/reserved-keywords/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/search/snapshots/cs/Client.cs b/packages/generator/tests/search/snapshots/cs/Client.cs index d098afc2..b2de9ecc 100644 --- a/packages/generator/tests/search/snapshots/cs/Client.cs +++ b/packages/generator/tests/search/snapshots/cs/Client.cs @@ -11,18 +11,49 @@ namespace Pachca.Sdk; -public sealed class SearchService +public class SearchService +{ + + public virtual async System.Threading.Tasks.Task SearchMessagesAsync( + string query, + List? chatIds = null, + List? userIds = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + SearchSort? sort = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchMessages is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> SearchMessagesAllAsync( + string query, + List? chatIds = null, + List? userIds = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + SearchSort? sort = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchMessagesAll is not implemented"); + } +} + +public sealed class SearchServiceImpl : SearchService { private readonly string _baseUrl; private readonly HttpClient _client; - internal SearchService(string baseUrl, HttpClient client) + internal SearchServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task SearchMessagesAsync( + public override async System.Threading.Tasks.Task SearchMessagesAsync( string query, List? chatIds = null, List? userIds = null, @@ -66,7 +97,7 @@ public async System.Threading.Tasks.Task SearchMessagesA } } - public async System.Threading.Tasks.Task> SearchMessagesAllAsync( + public override async System.Threading.Tasks.Task> SearchMessagesAllAsync( string query, List? chatIds = null, List? userIds = null, @@ -90,22 +121,32 @@ public async System.Threading.Tasks.Task> SearchMessag public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public SearchService Search { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(SearchService search) + { + Search = search; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", SearchService? search = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Search = new SearchService(baseUrl, _client); + Search = search ?? new SearchServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(SearchService? search = null) + { + return new PachcaClient(search ?? new SearchService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/search/snapshots/cs/Utils.cs b/packages/generator/tests/search/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/search/snapshots/cs/Utils.cs +++ b/packages/generator/tests/search/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/search/snapshots/go/client.go b/packages/generator/tests/search/snapshots/go/client.go index 5868f5ea..ba21d000 100644 --- a/packages/generator/tests/search/snapshots/go/client.go +++ b/packages/generator/tests/search/snapshots/go/client.go @@ -6,7 +6,6 @@ import ( "fmt" "net/http" "net/url" - "strconv" "time" ) @@ -20,38 +19,27 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type SearchService interface { + SearchMessages(ctx context.Context, params SearchMessagesParams) (*SearchMessagesResponse, error) + SearchMessagesAll(ctx context.Context, params *SearchMessagesParams) ([]MessageSearchResult, error) +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithSearch(service SearchService) ClientOption { + return func(cfg *clientConfig) { cfg.search = service } +} + +func WithStubSearch(service SearchService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.search = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Search: &SearchService{baseURL: url, client: client}, + Search: func() SearchService { if cfg.search != nil { return cfg.search }; return &SearchServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Search: func() SearchService { if cfg.search != nil { return cfg.search }; return &SearchServiceStub{} }(), } } diff --git a/packages/generator/tests/search/snapshots/go/utils.go b/packages/generator/tests/search/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/search/snapshots/go/utils.go +++ b/packages/generator/tests/search/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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<? = null, @@ -26,6 +23,34 @@ class SearchService internal constructor( sort: SearchSort? = null, limit: Int? = null, cursor: String? = null, + ): SearchMessagesResponse = + throw NotImplementedError("Search.searchMessages is not implemented") + + suspend fun searchMessagesAll( + query: String, + chatIds: List? = null, + userIds: List? = null, + createdFrom: String? = null, + createdTo: String? = null, + sort: SearchSort? = null, + limit: Int? = null, + ): List = + throw NotImplementedError("Search.searchMessagesAll is not implemented") +} + +class SearchServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : SearchService { + override suspend fun searchMessages( + query: String, + chatIds: List?, + userIds: List?, + createdFrom: String?, + createdTo: String?, + sort: SearchSort?, + limit: Int?, + cursor: String?, ): SearchMessagesResponse { val response = client.get("$baseUrl/search/messages") { parameter("query", query) @@ -44,14 +69,14 @@ class SearchService internal constructor( } } - suspend fun searchMessagesAll( + override suspend fun searchMessagesAll( query: String, - chatIds: List? = null, - userIds: List? = null, - createdFrom: String? = null, - createdTo: String? = null, - sort: SearchSort? = null, - limit: Int? = null, + chatIds: List?, + userIds: List?, + createdFrom: String?, + createdTo: String?, + sort: SearchSort?, + limit: Int?, ): List { val items = mutableListOf() var cursor: String? = null @@ -73,28 +98,47 @@ class SearchService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.pachca.com/api/shared/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val search: SearchService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.pachca.com/api/shared/v1", + search: SearchService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + search = search ?: SearchServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + search: SearchService = object : SearchService {} + ): PachcaClient = PachcaClient( + client = null, + search = search + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val search = SearchService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/search/snapshots/py/client.py b/packages/generator/tests/search/snapshots/py/client.py index b7f30dd4..50e0a8fd 100644 --- a/packages/generator/tests/search/snapshots/py/client.py +++ b/packages/generator/tests/search/snapshots/py/client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import ( @@ -12,6 +14,20 @@ from .utils import deserialize, RetryTransport class SearchService: + async def search_messages( + self, + params: SearchMessagesParams, + ) -> SearchMessagesResponse: + raise NotImplementedError("Search.searchMessages is not implemented") + + async def search_messages_all( + self, + params: SearchMessagesParams, + ) -> list[MessageSearchResult]: + raise NotImplementedError("Search.searchMessagesAll is not implemented") + + +class SearchServiceImpl(SearchService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -71,13 +87,23 @@ async def search_messages_all( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", search: SearchService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.search = SearchService(self._client) + self.search: SearchService = search or SearchServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + search: SearchService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.search = search or SearchService() + return self diff --git a/packages/generator/tests/search/snapshots/py/utils.py b/packages/generator/tests/search/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/search/snapshots/py/utils.py +++ b/packages/generator/tests/search/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/search/snapshots/swift/Client.swift b/packages/generator/tests/search/snapshots/swift/Client.swift index 98fc752e..4e3c42cc 100644 --- a/packages/generator/tests/search/snapshots/swift/Client.swift +++ b/packages/generator/tests/search/snapshots/swift/Client.swift @@ -3,7 +3,23 @@ import Foundation import FoundationNetworking #endif -public struct SearchService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class SearchService { + public init() {} + + open func searchMessages(query: String, chatIds: [Int]? = nil, userIds: [Int]? = nil, createdFrom: String? = nil, createdTo: String? = nil, sort: SearchSort? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> SearchMessagesResponse { + throw pachcaNotImplemented("Search.searchMessages") + } + + open func searchMessagesAll(query: String, chatIds: [Int]? = nil, userIds: [Int]? = nil, createdFrom: String? = nil, createdTo: String? = nil, sort: SearchSort? = nil, limit: Int? = nil) async throws -> [MessageSearchResult] { + throw pachcaNotImplemented("Search.searchMessagesAll") + } +} + +public final class SearchServiceImpl: SearchService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +28,10 @@ public struct SearchService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func searchMessages(query: String, chatIds: [Int]? = nil, userIds: [Int]? = nil, createdFrom: String? = nil, createdTo: String? = nil, sort: SearchSort? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> SearchMessagesResponse { + public override func searchMessages(query: String, chatIds: [Int]? = nil, userIds: [Int]? = nil, createdFrom: String? = nil, createdTo: String? = nil, sort: SearchSort? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> SearchMessagesResponse { var components = URLComponents(string: "\(baseURL)/search/messages")! var queryItems: [URLQueryItem] = [] queryItems.append(URLQueryItem(name: "query", value: String(query))) @@ -40,7 +57,7 @@ public struct SearchService { } } - public func searchMessagesAll(query: String, chatIds: [Int]? = nil, userIds: [Int]? = nil, createdFrom: String? = nil, createdTo: String? = nil, sort: SearchSort? = nil, limit: Int? = nil) async throws -> [MessageSearchResult] { + public override func searchMessagesAll(query: String, chatIds: [Int]? = nil, userIds: [Int]? = nil, createdFrom: String? = nil, createdTo: String? = nil, sort: SearchSort? = nil, limit: Int? = nil) async throws -> [MessageSearchResult] { var items: [MessageSearchResult] = [] var cursor: String? = nil repeat { @@ -55,8 +72,20 @@ public struct SearchService { public struct PachcaClient { public let search: SearchService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(search: SearchService) { + self.search = search + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", search: SearchService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.search = SearchService(baseURL: baseURL, headers: headers) + self.init( + search: search ?? SearchServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(search: SearchService = SearchService()) -> PachcaClient { + PachcaClient( + search: search + ) } } diff --git a/packages/generator/tests/search/snapshots/swift/Utils.swift b/packages/generator/tests/search/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/search/snapshots/swift/Utils.swift +++ b/packages/generator/tests/search/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/search/snapshots/ts/client.ts b/packages/generator/tests/search/snapshots/ts/client.ts index aa0b0e4a..3a7f4246 100644 --- a/packages/generator/tests/search/snapshots/ts/client.ts +++ b/packages/generator/tests/search/snapshots/ts/client.ts @@ -6,11 +6,23 @@ import { } from "./types"; import { deserialize, fetchWithRetry } from "./utils"; -class SearchService { +export class SearchService { + 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"); + } +} + +export class SearchServiceImpl extends SearchService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async searchMessages(params: SearchMessagesParams): Promise { const query = new URLSearchParams(); @@ -57,6 +69,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.search = new SearchService(baseUrl, headers); + this.search = new SearchServiceImpl(baseUrl, headers); + } + + static stub(search: SearchService = new SearchService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.search = search; + return client; } } diff --git a/packages/generator/tests/search/snapshots/ts/utils.ts b/packages/generator/tests/search/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/search/snapshots/ts/utils.ts +++ b/packages/generator/tests/search/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/unions/snapshots/cs/Utils.cs b/packages/generator/tests/unions/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/unions/snapshots/cs/Utils.cs +++ b/packages/generator/tests/unions/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/unions/snapshots/go/utils.go b/packages/generator/tests/unions/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/unions/snapshots/go/utils.go +++ b/packages/generator/tests/unions/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< PachcaClient { + PachcaClient( + ) } } diff --git a/packages/generator/tests/unions/snapshots/swift/Utils.swift b/packages/generator/tests/unions/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/unions/snapshots/swift/Utils.swift +++ b/packages/generator/tests/unions/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/unwrap/snapshots/cs/Client.cs b/packages/generator/tests/unwrap/snapshots/cs/Client.cs index b3347652..cfbca6d4 100644 --- a/packages/generator/tests/unwrap/snapshots/cs/Client.cs +++ b/packages/generator/tests/unwrap/snapshots/cs/Client.cs @@ -11,18 +11,30 @@ namespace Pachca.Sdk; -public sealed class MembersService +public class MembersService +{ + + public virtual async System.Threading.Tasks.Task AddMembersAsync( + int id, + List memberIds, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.addMembers is not implemented"); + } +} + +public sealed class MembersServiceImpl : MembersService { private readonly string _baseUrl; private readonly HttpClient _client; - internal MembersService(string baseUrl, HttpClient client) + internal MembersServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task AddMembersAsync( + public override async System.Threading.Tasks.Task AddMembersAsync( int id, List memberIds, CancellationToken cancellationToken = default) @@ -45,18 +57,32 @@ public async System.Threading.Tasks.Task AddMembersAsync( } } -public sealed class ChatsService +public class ChatsService +{ + + public virtual async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.createChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.archiveChat is not implemented"); + } +} + +public sealed class ChatsServiceImpl : ChatsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ChatsService(string baseUrl, HttpClient client) + internal ChatsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -74,7 +100,7 @@ public async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest } } - public async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}/archive"; using var request = new HttpRequestMessage(HttpMethod.Put, url); @@ -94,24 +120,35 @@ public async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationTo public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public ChatsService Chats { get; } public MembersService Members { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(ChatsService chats, MembersService members) + { + Chats = chats; + Members = members; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", ChatsService? chats = null, MembersService? members = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Chats = new ChatsService(baseUrl, _client); - Members = new MembersService(baseUrl, _client); + Chats = chats ?? new ChatsServiceImpl(baseUrl, _client); + Members = members ?? new MembersServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(ChatsService? chats = null, MembersService? members = null) + { + return new PachcaClient(chats ?? new ChatsService(), members ?? new MembersService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/unwrap/snapshots/cs/Utils.cs b/packages/generator/tests/unwrap/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/unwrap/snapshots/cs/Utils.cs +++ b/packages/generator/tests/unwrap/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/unwrap/snapshots/go/client.go b/packages/generator/tests/unwrap/snapshots/go/client.go index 91ce3b41..e0c2a55e 100644 --- a/packages/generator/tests/unwrap/snapshots/go/client.go +++ b/packages/generator/tests/unwrap/snapshots/go/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "time" ) @@ -20,38 +19,22 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type MembersService interface { + AddMembers(ctx context.Context, id int32, memberIds []int32) error +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithChats(service ChatsService) ClientOption { + return func(cfg *clientConfig) { cfg.chats = service } +} + +func WithMembers(service MembersService) ClientOption { + return func(cfg *clientConfig) { cfg.members = service } +} + +func WithStubChats(service ChatsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.chats = service } +} + +func WithStubMembers(service MembersService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.members = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Chats : &ChatsService{baseURL: url, client: client}, - Members: &MembersService{baseURL: url, client: client}, + Chats : func() ChatsService { if cfg.chats != nil { return cfg.chats }; return &ChatsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Members: func() MembersService { if cfg.members != nil { return cfg.members }; return &MembersServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Chats : func() ChatsService { if cfg.chats != nil { return cfg.chats }; return &ChatsServiceStub{} }(), + Members: func() MembersService { if cfg.members != nil { return cfg.members }; return &MembersServiceStub{} }(), } } diff --git a/packages/generator/tests/unwrap/snapshots/go/utils.go b/packages/generator/tests/unwrap/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/unwrap/snapshots/go/utils.go +++ b/packages/generator/tests/unwrap/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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("Members.addMembers is not implemented") +} + +class MembersServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun addMembers(id: Int, memberIds: List) { +) : MembersService { + override suspend fun addMembers(id: Int, memberIds: List) { val response = client.post("$baseUrl/chats/$id/members") { contentType(ContentType.Application.Json) setBody(AddMembersRequest(memberIds = memberIds)) @@ -30,11 +35,19 @@ class MembersService internal constructor( } } -class ChatsService internal constructor( +interface ChatsService { + suspend fun createChat(request: ChatCreateRequest): Chat = + throw NotImplementedError("Chats.createChat is not implemented") + + suspend fun archiveChat(id: Int) = + throw NotImplementedError("Chats.archiveChat is not implemented") +} + +class ChatsServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun createChat(request: ChatCreateRequest): Chat { +) : ChatsService { + override suspend fun createChat(request: ChatCreateRequest): Chat { val response = client.post("$baseUrl/chats") { contentType(ContentType.Application.Json) setBody(request) @@ -46,7 +59,7 @@ class ChatsService internal constructor( } } - suspend fun archiveChat(id: Int) { + override suspend fun archiveChat(id: Int) { val response = client.put("$baseUrl/chats/$id/archive") when (response.status.value) { 204 -> return @@ -56,29 +69,52 @@ class ChatsService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.pachca.com/api/shared/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val chats: ChatsService, + val members: MembersService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.pachca.com/api/shared/v1", + chats: ChatsService? = null, + members: MembersService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + chats = chats ?: ChatsServiceImpl(baseUrl, client), + members = members ?: MembersServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + chats: ChatsService = object : ChatsService {}, + members: MembersService = object : MembersService {} + ): PachcaClient = PachcaClient( + client = null, + chats = chats, + members = members + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val chats = ChatsService(baseUrl, client) - val members = MembersService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/unwrap/snapshots/py/client.py b/packages/generator/tests/unwrap/snapshots/py/client.py index 86aa8a30..b9f69b93 100644 --- a/packages/generator/tests/unwrap/snapshots/py/client.py +++ b/packages/generator/tests/unwrap/snapshots/py/client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import ( @@ -11,6 +13,15 @@ from .utils import deserialize, serialize, RetryTransport class MembersService: + async def add_members( + self, + id: int, + member_ids: list[int], + ) -> None: + raise NotImplementedError("Members.addMembers is not implemented") + + +class MembersServiceImpl(MembersService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -33,6 +44,20 @@ async def add_members( class ChatsService: + async def create_chat( + self, + request: ChatCreateRequest, + ) -> Chat: + raise NotImplementedError("Chats.createChat is not implemented") + + async def archive_chat( + self, + id: int, + ) -> None: + raise NotImplementedError("Chats.archiveChat is not implemented") + + +class ChatsServiceImpl(ChatsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -70,14 +95,26 @@ async def archive_chat( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", chats: ChatsService | None = None, members: MembersService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.chats = ChatsService(self._client) - self.members = MembersService(self._client) + self.chats: ChatsService = chats or ChatsServiceImpl(self._client) + self.members: MembersService = members or MembersServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + chats: ChatsService | None = None, + members: MembersService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.chats = chats or ChatsService() + self.members = members or MembersService() + return self diff --git a/packages/generator/tests/unwrap/snapshots/py/utils.py b/packages/generator/tests/unwrap/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/unwrap/snapshots/py/utils.py +++ b/packages/generator/tests/unwrap/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/unwrap/snapshots/swift/Client.swift b/packages/generator/tests/unwrap/snapshots/swift/Client.swift index fb1531e6..f839811a 100644 --- a/packages/generator/tests/unwrap/snapshots/swift/Client.swift +++ b/packages/generator/tests/unwrap/snapshots/swift/Client.swift @@ -3,7 +3,19 @@ import Foundation import FoundationNetworking #endif -public struct MembersService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class MembersService { + public init() {} + + open func addMembers(id: Int, memberIds: [Int]) async throws -> Void { + throw pachcaNotImplemented("Members.addMembers") + } +} + +public final class MembersServiceImpl: MembersService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +24,10 @@ public struct MembersService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func addMembers(id: Int, memberIds: [Int]) async throws -> Void { + public override func addMembers(id: Int, memberIds: [Int]) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/members")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -33,7 +46,19 @@ public struct MembersService { } } -public struct ChatsService { +open class ChatsService { + public init() {} + + open func createChat(request body: ChatCreateRequest) async throws -> Chat { + throw pachcaNotImplemented("Chats.createChat") + } + + open func archiveChat(id: Int) async throws -> Void { + throw pachcaNotImplemented("Chats.archiveChat") + } +} + +public final class ChatsServiceImpl: ChatsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -42,9 +67,10 @@ public struct ChatsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func createChat(request body: ChatCreateRequest) async throws -> Chat { + public override func createChat(request body: ChatCreateRequest) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -62,7 +88,7 @@ public struct ChatsService { } } - public func archiveChat(id: Int) async throws -> Void { + public override func archiveChat(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/archive")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -83,9 +109,23 @@ public struct PachcaClient { public let chats: ChatsService public let members: MembersService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(chats: ChatsService, members: MembersService) { + self.chats = chats + self.members = members + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", chats: ChatsService? = nil, members: MembersService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.chats = ChatsService(baseURL: baseURL, headers: headers) - self.members = MembersService(baseURL: baseURL, headers: headers) + self.init( + chats: chats ?? ChatsServiceImpl(baseURL: baseURL, headers: headers), + members: members ?? MembersServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(chats: ChatsService = ChatsService(), members: MembersService = MembersService()) -> PachcaClient { + PachcaClient( + chats: chats, + members: members + ) } } diff --git a/packages/generator/tests/unwrap/snapshots/swift/Utils.swift b/packages/generator/tests/unwrap/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/unwrap/snapshots/swift/Utils.swift +++ b/packages/generator/tests/unwrap/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/unwrap/snapshots/ts/client.ts b/packages/generator/tests/unwrap/snapshots/ts/client.ts index b81c4050..a1fb992d 100644 --- a/packages/generator/tests/unwrap/snapshots/ts/client.ts +++ b/packages/generator/tests/unwrap/snapshots/ts/client.ts @@ -6,11 +6,19 @@ import { } from "./types"; import { deserialize, serialize, fetchWithRetry } from "./utils"; -class MembersService { +export class MembersService { + async addMembers(id: number, memberIds: number[]): Promise { + throw new Error("Members.addMembers is not implemented"); + } +} + +export class MembersServiceImpl extends MembersService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async addMembers(id: number, memberIds: number[]): Promise { const response = await fetchWithRetry(`${this.baseUrl}/chats/${id}/members`, { @@ -29,11 +37,23 @@ class MembersService { } } -class ChatsService { +export class ChatsService { + async createChat(request: ChatCreateRequest): Promise { + throw new Error("Chats.createChat is not implemented"); + } + + async archiveChat(id: number): Promise { + throw new Error("Chats.archiveChat is not implemented"); + } +} + +export class ChatsServiceImpl extends ChatsService { constructor( private baseUrl: string, private headers: Record, - ) {} + ) { + super(); + } async createChat(request: ChatCreateRequest): Promise { const response = await fetchWithRetry(`${this.baseUrl}/chats`, { @@ -74,7 +94,14 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.chats = new ChatsService(baseUrl, headers); - this.members = new MembersService(baseUrl, headers); + this.chats = new ChatsServiceImpl(baseUrl, headers); + this.members = new MembersServiceImpl(baseUrl, headers); + } + + static stub(chats: ChatsService = new ChatsService(), members: MembersService = new MembersService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.chats = chats; + client.members = members; + return client; } } diff --git a/packages/generator/tests/unwrap/snapshots/ts/utils.ts b/packages/generator/tests/unwrap/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/unwrap/snapshots/ts/utils.ts +++ b/packages/generator/tests/unwrap/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/packages/generator/tests/upload/snapshots/cs/Client.cs b/packages/generator/tests/upload/snapshots/cs/Client.cs index 298e3beb..65fb0771 100644 --- a/packages/generator/tests/upload/snapshots/cs/Client.cs +++ b/packages/generator/tests/upload/snapshots/cs/Client.cs @@ -12,18 +12,35 @@ namespace Pachca.Sdk; -public sealed class CommonService +public class CommonService +{ + + public virtual async System.Threading.Tasks.Task UploadFileAsync( + string directUrl, + FileUploadRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.uploadFile is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetUploadParamsAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.getUploadParams is not implemented"); + } +} + +public sealed class CommonServiceImpl : CommonService { private readonly string _baseUrl; private readonly HttpClient _client; - internal CommonService(string baseUrl, HttpClient client) + internal CommonServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task UploadFileAsync( + public override async System.Threading.Tasks.Task UploadFileAsync( string directUrl, FileUploadRequest request, CancellationToken cancellationToken = default) @@ -55,7 +72,7 @@ public async System.Threading.Tasks.Task UploadFileAsync( } } - public async System.Threading.Tasks.Task GetUploadParamsAsync(CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetUploadParamsAsync(CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/uploads"; using var request = new HttpRequestMessage(HttpMethod.Post, url); @@ -75,22 +92,32 @@ public async System.Threading.Tasks.Task GetUploadParamsAsync(Canc public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public CommonService Common { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(CommonService common) + { + Common = common; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", CommonService? common = null) { _client = new HttpClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Common = new CommonService(baseUrl, _client); + Common = common ?? new CommonServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(CommonService? common = null) + { + return new PachcaClient(common ?? new CommonService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/packages/generator/tests/upload/snapshots/cs/Utils.cs b/packages/generator/tests/upload/snapshots/cs/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/packages/generator/tests/upload/snapshots/cs/Utils.cs +++ b/packages/generator/tests/upload/snapshots/cs/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/packages/generator/tests/upload/snapshots/go/client.go b/packages/generator/tests/upload/snapshots/go/client.go index 954ce86e..cb309394 100644 --- a/packages/generator/tests/upload/snapshots/go/client.go +++ b/packages/generator/tests/upload/snapshots/go/client.go @@ -7,7 +7,6 @@ import ( "io" "mime/multipart" "net/http" - "strconv" "time" ) @@ -21,38 +20,27 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type CommonService interface { + UploadFile(ctx context.Context, directUrl string, request FileUploadRequest) error + GetUploadParams(ctx context.Context) (*UploadParams, error) +} -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< 0 { url = baseURL[0] } +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *clientConfig) { cfg.baseURL = baseURL } +} + +func WithCommon(service CommonService) ClientOption { + return func(cfg *clientConfig) { cfg.common = service } +} + +func WithStubCommon(service CommonService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.common = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, } return &PachcaClient{ - Common: &CommonService{baseURL: url, client: client}, + Common: func() CommonService { if cfg.common != nil { return cfg.common }; return &CommonServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Common: func() CommonService { if cfg.common != nil { return cfg.common }; return &CommonServiceStub{} }(), } } diff --git a/packages/generator/tests/upload/snapshots/go/utils.go b/packages/generator/tests/upload/snapshots/go/utils.go index d0527665..eb9a853a 100644 --- a/packages/generator/tests/upload/snapshots/go/utils.go +++ b/packages/generator/tests/upload/snapshots/go/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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< response.body().data @@ -54,28 +62,47 @@ class CommonService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.pachca.com/api/shared/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) +class PachcaClient private constructor( + private val client: HttpClient?, + val common: CommonService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.pachca.com/api/shared/v1", + common: CommonService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + common = common ?: CommonServiceImpl(baseUrl, client) + ) } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L + + fun stub( + common: CommonService = object : CommonService {} + ): PachcaClient = PachcaClient( + client = null, + common = common + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } } - } - defaultRequest { - bearerAuth(token) + defaultRequest { bearerAuth(token) } } } - val common = CommonService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/packages/generator/tests/upload/snapshots/py/client.py b/packages/generator/tests/upload/snapshots/py/client.py index e9fd7fe6..e6d472a1 100644 --- a/packages/generator/tests/upload/snapshots/py/client.py +++ b/packages/generator/tests/upload/snapshots/py/client.py @@ -1,11 +1,26 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import FileUploadRequest, OAuthError, UploadParams from .utils import deserialize, RetryTransport class CommonService: + async def upload_file( + self, + direct_url: str, + request: FileUploadRequest, + ) -> None: + raise NotImplementedError("Common.uploadFile is not implemented") + + async def get_upload_params( + self) -> UploadParams: + raise NotImplementedError("Common.getUploadParams is not implemented") + + +class CommonServiceImpl(CommonService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -57,13 +72,23 @@ async def get_upload_params( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", common: CommonService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.common = CommonService(self._client) + self.common: CommonService = common or CommonServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + common: CommonService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.common = common or CommonService() + return self diff --git a/packages/generator/tests/upload/snapshots/py/utils.py b/packages/generator/tests/upload/snapshots/py/utils.py index 44d19034..950682b4 100644 --- a/packages/generator/tests/upload/snapshots/py/utils.py +++ b/packages/generator/tests/upload/snapshots/py/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/packages/generator/tests/upload/snapshots/swift/Client.swift b/packages/generator/tests/upload/snapshots/swift/Client.swift index fc08c5cb..9f646c8b 100644 --- a/packages/generator/tests/upload/snapshots/swift/Client.swift +++ b/packages/generator/tests/upload/snapshots/swift/Client.swift @@ -3,7 +3,23 @@ import Foundation import FoundationNetworking #endif -public struct CommonService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class CommonService { + public init() {} + + open func uploadFile(directUrl: String, request body: FileUploadRequest) async throws -> Void { + throw pachcaNotImplemented("Common.uploadFile") + } + + open func getUploadParams() async throws -> UploadParams { + throw pachcaNotImplemented("Common.getUploadParams") + } +} + +public final class CommonServiceImpl: CommonService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +28,10 @@ public struct CommonService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func uploadFile(directUrl: String, request body: FileUploadRequest) async throws -> Void { + public override func uploadFile(directUrl: String, request body: FileUploadRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(directUrl)")!) request.httpMethod = "POST" let boundary = UUID().uuidString @@ -52,7 +69,7 @@ public struct CommonService { } } - public func getUploadParams() async throws -> UploadParams { + public override func getUploadParams() async throws -> UploadParams { var request = URLRequest(url: URL(string: "\(baseURL)/uploads")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -72,8 +89,20 @@ public struct CommonService { public struct PachcaClient { public let common: CommonService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(common: CommonService) { + self.common = common + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", common: CommonService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.common = CommonService(baseURL: baseURL, headers: headers) + self.init( + common: common ?? CommonServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(common: CommonService = CommonService()) -> PachcaClient { + PachcaClient( + common: common + ) } } diff --git a/packages/generator/tests/upload/snapshots/swift/Utils.swift b/packages/generator/tests/upload/snapshots/swift/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/packages/generator/tests/upload/snapshots/swift/Utils.swift +++ b/packages/generator/tests/upload/snapshots/swift/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/packages/generator/tests/upload/snapshots/ts/client.ts b/packages/generator/tests/upload/snapshots/ts/client.ts index db1d5470..6bc4d429 100644 --- a/packages/generator/tests/upload/snapshots/ts/client.ts +++ b/packages/generator/tests/upload/snapshots/ts/client.ts @@ -1,11 +1,23 @@ import { FileUploadRequest, OAuthError, UploadParams } from "./types"; import { deserialize, fetchWithRetry } from "./utils"; -class CommonService { +export class CommonService { + 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 uploadFile(directUrl: string, request: FileUploadRequest): Promise { const form = new FormData(); @@ -54,6 +66,12 @@ export class PachcaClient { constructor(token: string, baseUrl: string = "https://api.pachca.com/api/shared/v1") { const headers = { Authorization: `Bearer ${token}` }; - this.common = new CommonService(baseUrl, headers); + this.common = new CommonServiceImpl(baseUrl, headers); + } + + static stub(common: CommonService = new CommonService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.common = common; + return client; } } diff --git a/packages/generator/tests/upload/snapshots/ts/utils.ts b/packages/generator/tests/upload/snapshots/ts/utils.ts index 4c979225..ac5d6549 100644 --- a/packages/generator/tests/upload/snapshots/ts/utils.ts +++ b/packages/generator/tests/upload/snapshots/ts/utils.ts @@ -33,6 +33,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -40,7 +45,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response; diff --git a/sdk/csharp/generated/Client.cs b/sdk/csharp/generated/Client.cs index 044b2aae..bbd87d93 100644 --- a/sdk/csharp/generated/Client.cs +++ b/sdk/csharp/generated/Client.cs @@ -12,18 +12,51 @@ namespace Pachca.Sdk; -public sealed class SecurityService +public class SecurityService +{ + + public virtual async System.Threading.Tasks.Task GetAuditEventsAsync( + DateTimeOffset? startTime = null, + DateTimeOffset? endTime = null, + AuditEventKey? eventKey = null, + string? actorId = null, + string? actorType = null, + string? entityId = null, + string? entityType = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Security.getAuditEvents is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> GetAuditEventsAllAsync( + DateTimeOffset? startTime = null, + DateTimeOffset? endTime = null, + AuditEventKey? eventKey = null, + string? actorId = null, + string? actorType = null, + string? entityId = null, + string? entityType = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Security.getAuditEventsAll is not implemented"); + } +} + +public sealed class SecurityServiceImpl : SecurityService { private readonly string _baseUrl; private readonly HttpClient _client; - internal SecurityService(string baseUrl, HttpClient client) + internal SecurityServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task GetAuditEventsAsync( + public override async System.Threading.Tasks.Task GetAuditEventsAsync( DateTimeOffset? startTime = null, DateTimeOffset? endTime = null, AuditEventKey? eventKey = null, @@ -69,7 +102,7 @@ public async System.Threading.Tasks.Task GetAuditEventsA } } - public async System.Threading.Tasks.Task> GetAuditEventsAllAsync( + public override async System.Threading.Tasks.Task> GetAuditEventsAllAsync( DateTimeOffset? startTime = null, DateTimeOffset? endTime = null, AuditEventKey? eventKey = null, @@ -92,18 +125,50 @@ public async System.Threading.Tasks.Task> GetAuditEventsAllAsyn } } -public sealed class BotsService +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 System.Threading.Tasks.Task UpdateBotAsync( + int id, + BotUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Bots.updateBot is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteWebhookEventAsync(string id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Bots.deleteWebhookEvent is not implemented"); + } +} + +public sealed class BotsServiceImpl : BotsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal BotsService(string baseUrl, HttpClient client) + internal BotsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task GetWebhookEventsAsync( + public override async System.Threading.Tasks.Task GetWebhookEventsAsync( int? limit = null, string? cursor = null, CancellationToken cancellationToken = default) @@ -128,7 +193,7 @@ public async System.Threading.Tasks.Task GetWebhookEve } } - public async System.Threading.Tasks.Task> GetWebhookEventsAllAsync( + public override async System.Threading.Tasks.Task> GetWebhookEventsAllAsync( int? limit = null, CancellationToken cancellationToken = default) { @@ -143,7 +208,7 @@ public async System.Threading.Tasks.Task> GetWebhookEventsAll return items; } - public async System.Threading.Tasks.Task UpdateBotAsync( + public override async System.Threading.Tasks.Task UpdateBotAsync( int id, BotUpdateRequest request, CancellationToken cancellationToken = default) @@ -164,7 +229,7 @@ public async System.Threading.Tasks.Task UpdateBotAsync( } } - public async System.Threading.Tasks.Task DeleteWebhookEventAsync(string id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteWebhookEventAsync(string id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/webhooks/events/{id}"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -182,18 +247,75 @@ public async System.Threading.Tasks.Task DeleteWebhookEventAsync(string id, Canc } } -public sealed class ChatsService +public class ChatsService +{ + + public virtual async System.Threading.Tasks.Task ListChatsAsync( + SortOrder? sortId = null, + ChatAvailability? availability = null, + DateTimeOffset? lastMessageAtAfter = null, + DateTimeOffset? lastMessageAtBefore = null, + bool? personal = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.listChats is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListChatsAllAsync( + SortOrder? sortId = null, + ChatAvailability? availability = null, + DateTimeOffset? lastMessageAtAfter = null, + DateTimeOffset? lastMessageAtBefore = null, + bool? personal = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.listChatsAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.getChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.createChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateChatAsync( + int id, + ChatUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.updateChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.archiveChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UnarchiveChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Chats.unarchiveChat is not implemented"); + } +} + +public sealed class ChatsServiceImpl : ChatsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ChatsService(string baseUrl, HttpClient client) + internal ChatsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListChatsAsync( + public override async System.Threading.Tasks.Task ListChatsAsync( SortOrder? sortId = null, ChatAvailability? availability = null, DateTimeOffset? lastMessageAtAfter = null, @@ -233,7 +355,7 @@ public async System.Threading.Tasks.Task ListChatsAsync( } } - public async System.Threading.Tasks.Task> ListChatsAllAsync( + public override async System.Threading.Tasks.Task> ListChatsAllAsync( SortOrder? sortId = null, ChatAvailability? availability = null, DateTimeOffset? lastMessageAtAfter = null, @@ -253,7 +375,7 @@ public async System.Threading.Tasks.Task> ListChatsAllAsync( return items; } - public async System.Threading.Tasks.Task GetChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -270,7 +392,7 @@ public async System.Threading.Tasks.Task GetChatAsync(int id, Cancellation } } - public async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -288,7 +410,7 @@ public async System.Threading.Tasks.Task CreateChatAsync(ChatCreateRequest } } - public async System.Threading.Tasks.Task UpdateChatAsync( + public override async System.Threading.Tasks.Task UpdateChatAsync( int id, ChatUpdateRequest request, CancellationToken cancellationToken = default) @@ -309,7 +431,7 @@ public async System.Threading.Tasks.Task UpdateChatAsync( } } - public async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}/archive"; using var request = new HttpRequestMessage(HttpMethod.Put, url); @@ -326,7 +448,7 @@ public async System.Threading.Tasks.Task ArchiveChatAsync(int id, CancellationTo } } - public async System.Threading.Tasks.Task UnarchiveChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task UnarchiveChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}/unarchive"; using var request = new HttpRequestMessage(HttpMethod.Put, url); @@ -344,18 +466,50 @@ public async System.Threading.Tasks.Task UnarchiveChatAsync(int id, Cancellation } } -public sealed class CommonService +public class CommonService +{ + + public virtual async System.Threading.Tasks.Task DownloadExportAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.downloadExport is not implemented"); + } + + public virtual async System.Threading.Tasks.Task ListPropertiesAsync(SearchEntityType entityType, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.listProperties is not implemented"); + } + + public virtual async System.Threading.Tasks.Task RequestExportAsync(ExportRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.requestExport is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UploadFileAsync( + string directUrl, + FileUploadRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.uploadFile is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetUploadParamsAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Common.getUploadParams is not implemented"); + } +} + +public sealed class CommonServiceImpl : CommonService { private readonly string _baseUrl; private readonly HttpClient _client; - internal CommonService(string baseUrl, HttpClient client) + internal CommonServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task DownloadExportAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DownloadExportAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/exports/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -373,7 +527,7 @@ public async System.Threading.Tasks.Task DownloadExportAsync(int id, Can } } - public async System.Threading.Tasks.Task ListPropertiesAsync(SearchEntityType entityType, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task ListPropertiesAsync(SearchEntityType entityType, CancellationToken cancellationToken = default) { var queryParts = new List(); queryParts.Add($"entity_type={Uri.EscapeDataString(PachcaUtils.EnumToApiString(entityType))}"); @@ -392,7 +546,7 @@ public async System.Threading.Tasks.Task ListPropertiesA } } - public async System.Threading.Tasks.Task RequestExportAsync(ExportRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task RequestExportAsync(ExportRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/exports"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -410,7 +564,7 @@ public async System.Threading.Tasks.Task RequestExportAsync(ExportRequest reques } } - public async System.Threading.Tasks.Task UploadFileAsync( + public override async System.Threading.Tasks.Task UploadFileAsync( string directUrl, FileUploadRequest request, CancellationToken cancellationToken = default) @@ -440,7 +594,7 @@ public async System.Threading.Tasks.Task UploadFileAsync( } } - public async System.Threading.Tasks.Task GetUploadParamsAsync(CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetUploadParamsAsync(CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/uploads"; using var request = new HttpRequestMessage(HttpMethod.Post, url); @@ -458,18 +612,87 @@ public async System.Threading.Tasks.Task GetUploadParamsAsync(Canc } } -public sealed class MembersService +public class MembersService +{ + + public virtual async System.Threading.Tasks.Task ListMembersAsync( + int id, + ChatMemberRoleFilter? role = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.listMembers is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListMembersAllAsync( + int id, + ChatMemberRoleFilter? role = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.listMembersAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task AddTagsAsync( + int id, + List groupTagIds, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.addTags is not implemented"); + } + + public virtual async System.Threading.Tasks.Task AddMembersAsync( + int id, + AddMembersRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.addMembers is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateMemberRoleAsync( + int id, + int userId, + ChatMemberRole role, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.updateMemberRole is not implemented"); + } + + public virtual async System.Threading.Tasks.Task RemoveTagAsync( + int id, + int tagId, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.removeTag is not implemented"); + } + + public virtual async System.Threading.Tasks.Task LeaveChatAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.leaveChat is not implemented"); + } + + public virtual async System.Threading.Tasks.Task RemoveMemberAsync( + int id, + int userId, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Members.removeMember is not implemented"); + } +} + +public sealed class MembersServiceImpl : MembersService { private readonly string _baseUrl; private readonly HttpClient _client; - internal MembersService(string baseUrl, HttpClient client) + internal MembersServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListMembersAsync( + public override async System.Threading.Tasks.Task ListMembersAsync( int id, ChatMemberRoleFilter? role = null, int? limit = null, @@ -498,7 +721,7 @@ public async System.Threading.Tasks.Task ListMembersAsync( } } - public async System.Threading.Tasks.Task> ListMembersAllAsync( + public override async System.Threading.Tasks.Task> ListMembersAllAsync( int id, ChatMemberRoleFilter? role = null, int? limit = null, @@ -515,7 +738,7 @@ public async System.Threading.Tasks.Task> ListMembersAllAsync( return items; } - public async System.Threading.Tasks.Task AddTagsAsync( + public override async System.Threading.Tasks.Task AddTagsAsync( int id, List groupTagIds, CancellationToken cancellationToken = default) @@ -537,7 +760,7 @@ public async System.Threading.Tasks.Task AddTagsAsync( } } - public async System.Threading.Tasks.Task AddMembersAsync( + public override async System.Threading.Tasks.Task AddMembersAsync( int id, AddMembersRequest request, CancellationToken cancellationToken = default) @@ -558,7 +781,7 @@ public async System.Threading.Tasks.Task AddMembersAsync( } } - public async System.Threading.Tasks.Task UpdateMemberRoleAsync( + public override async System.Threading.Tasks.Task UpdateMemberRoleAsync( int id, int userId, ChatMemberRole role, @@ -581,7 +804,7 @@ public async System.Threading.Tasks.Task UpdateMemberRoleAsync( } } - public async System.Threading.Tasks.Task RemoveTagAsync( + public override async System.Threading.Tasks.Task RemoveTagAsync( int id, int tagId, CancellationToken cancellationToken = default) @@ -601,7 +824,7 @@ public async System.Threading.Tasks.Task RemoveTagAsync( } } - public async System.Threading.Tasks.Task LeaveChatAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task LeaveChatAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/chats/{id}/leave"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -618,7 +841,7 @@ public async System.Threading.Tasks.Task LeaveChatAsync(int id, CancellationToke } } - public async System.Threading.Tasks.Task RemoveMemberAsync( + public override async System.Threading.Tasks.Task RemoveMemberAsync( int id, int userId, CancellationToken cancellationToken = default) @@ -639,18 +862,79 @@ public async System.Threading.Tasks.Task RemoveMemberAsync( } } -public sealed class GroupTagsService +public class GroupTagsService +{ + + public virtual async System.Threading.Tasks.Task ListTagsAsync( + TagNamesFilter? names = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.listTags is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListTagsAllAsync( + TagNamesFilter? names = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.listTagsAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetTagAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.getTag is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetTagUsersAsync( + int id, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.getTagUsers is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> GetTagUsersAllAsync( + int id, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.getTagUsersAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateTagAsync(GroupTagRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.createTag is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateTagAsync( + int id, + GroupTagRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.updateTag is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteTagAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Group tags.deleteTag is not implemented"); + } +} + +public sealed class GroupTagsServiceImpl : GroupTagsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal GroupTagsService(string baseUrl, HttpClient client) + internal GroupTagsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListTagsAsync( + public override async System.Threading.Tasks.Task ListTagsAsync( TagNamesFilter? names = null, int? limit = null, string? cursor = null, @@ -678,7 +962,7 @@ public async System.Threading.Tasks.Task ListTagsAsync( } } - public async System.Threading.Tasks.Task> ListTagsAllAsync( + public override async System.Threading.Tasks.Task> ListTagsAllAsync( TagNamesFilter? names = null, int? limit = null, CancellationToken cancellationToken = default) @@ -694,7 +978,7 @@ public async System.Threading.Tasks.Task> ListTagsAllAsync( return items; } - public async System.Threading.Tasks.Task GetTagAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetTagAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/group_tags/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -711,7 +995,7 @@ public async System.Threading.Tasks.Task GetTagAsync(int id, Cancellat } } - public async System.Threading.Tasks.Task GetTagUsersAsync( + public override async System.Threading.Tasks.Task GetTagUsersAsync( int id, int? limit = null, string? cursor = null, @@ -737,7 +1021,7 @@ public async System.Threading.Tasks.Task GetTagUsersAsync( } } - public async System.Threading.Tasks.Task> GetTagUsersAllAsync( + public override async System.Threading.Tasks.Task> GetTagUsersAllAsync( int id, int? limit = null, CancellationToken cancellationToken = default) @@ -753,7 +1037,7 @@ public async System.Threading.Tasks.Task> GetTagUsersAllAsync( return items; } - public async System.Threading.Tasks.Task CreateTagAsync(GroupTagRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateTagAsync(GroupTagRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/group_tags"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -771,7 +1055,7 @@ public async System.Threading.Tasks.Task CreateTagAsync(GroupTagReques } } - public async System.Threading.Tasks.Task UpdateTagAsync( + public override async System.Threading.Tasks.Task UpdateTagAsync( int id, GroupTagRequest request, CancellationToken cancellationToken = default) @@ -792,7 +1076,7 @@ public async System.Threading.Tasks.Task UpdateTagAsync( } } - public async System.Threading.Tasks.Task DeleteTagAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteTagAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/group_tags/{id}"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -810,18 +1094,74 @@ public async System.Threading.Tasks.Task DeleteTagAsync(int id, CancellationToke } } -public sealed class MessagesService +public class MessagesService +{ + + public virtual async System.Threading.Tasks.Task ListChatMessagesAsync( + int chatId, + SortOrder? sortId = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.listChatMessages is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListChatMessagesAllAsync( + int chatId, + SortOrder? sortId = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.listChatMessagesAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetMessageAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.getMessage is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateMessageAsync(MessageCreateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.createMessage is not implemented"); + } + + public virtual async System.Threading.Tasks.Task PinMessageAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.pinMessage is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateMessageAsync( + int id, + MessageUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.updateMessage is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteMessageAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.deleteMessage is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UnpinMessageAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Messages.unpinMessage is not implemented"); + } +} + +public sealed class MessagesServiceImpl : MessagesService { private readonly string _baseUrl; private readonly HttpClient _client; - internal MessagesService(string baseUrl, HttpClient client) + internal MessagesServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListChatMessagesAsync( + public override async System.Threading.Tasks.Task ListChatMessagesAsync( int chatId, SortOrder? sortId = null, int? limit = null, @@ -851,7 +1191,7 @@ public async System.Threading.Tasks.Task ListChatMessa } } - public async System.Threading.Tasks.Task> ListChatMessagesAllAsync( + public override async System.Threading.Tasks.Task> ListChatMessagesAllAsync( int chatId, SortOrder? sortId = null, int? limit = null, @@ -868,7 +1208,7 @@ public async System.Threading.Tasks.Task> ListChatMessagesAllAsync return items; } - public async System.Threading.Tasks.Task GetMessageAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetMessageAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/messages/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -885,7 +1225,7 @@ public async System.Threading.Tasks.Task GetMessageAsync(int id, Cancel } } - public async System.Threading.Tasks.Task CreateMessageAsync(MessageCreateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateMessageAsync(MessageCreateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/messages"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -903,7 +1243,7 @@ public async System.Threading.Tasks.Task CreateMessageAsync(MessageCrea } } - public async System.Threading.Tasks.Task PinMessageAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task PinMessageAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/messages/{id}/pin"; using var request = new HttpRequestMessage(HttpMethod.Post, url); @@ -920,7 +1260,7 @@ public async System.Threading.Tasks.Task PinMessageAsync(int id, CancellationTok } } - public async System.Threading.Tasks.Task UpdateMessageAsync( + public override async System.Threading.Tasks.Task UpdateMessageAsync( int id, MessageUpdateRequest request, CancellationToken cancellationToken = default) @@ -941,7 +1281,7 @@ public async System.Threading.Tasks.Task UpdateMessageAsync( } } - public async System.Threading.Tasks.Task DeleteMessageAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteMessageAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/messages/{id}"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -958,7 +1298,7 @@ public async System.Threading.Tasks.Task DeleteMessageAsync(int id, Cancellation } } - public async System.Threading.Tasks.Task UnpinMessageAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task UnpinMessageAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/messages/{id}/pin"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -976,18 +1316,30 @@ public async System.Threading.Tasks.Task UnpinMessageAsync(int id, CancellationT } } -public sealed class LinkPreviewsService +public class LinkPreviewsService +{ + + public virtual async System.Threading.Tasks.Task CreateLinkPreviewsAsync( + int id, + LinkPreviewsRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Link Previews.createLinkPreviews is not implemented"); + } +} + +public sealed class LinkPreviewsServiceImpl : LinkPreviewsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal LinkPreviewsService(string baseUrl, HttpClient client) + internal LinkPreviewsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task CreateLinkPreviewsAsync( + public override async System.Threading.Tasks.Task CreateLinkPreviewsAsync( int id, LinkPreviewsRequest request, CancellationToken cancellationToken = default) @@ -1009,18 +1361,56 @@ public async System.Threading.Tasks.Task CreateLinkPreviewsAsync( } } -public sealed class ReactionsService +public class ReactionsService +{ + + public virtual async System.Threading.Tasks.Task ListReactionsAsync( + int id, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Reactions.listReactions is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListReactionsAllAsync( + int id, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Reactions.listReactionsAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task AddReactionAsync( + int id, + ReactionRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Reactions.addReaction is not implemented"); + } + + public virtual async System.Threading.Tasks.Task RemoveReactionAsync( + int id, + string code, + string? name = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Reactions.removeReaction is not implemented"); + } +} + +public sealed class ReactionsServiceImpl : ReactionsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ReactionsService(string baseUrl, HttpClient client) + internal ReactionsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListReactionsAsync( + public override async System.Threading.Tasks.Task ListReactionsAsync( int id, int? limit = null, string? cursor = null, @@ -1046,7 +1436,7 @@ public async System.Threading.Tasks.Task ListReactionsAsy } } - public async System.Threading.Tasks.Task> ListReactionsAllAsync( + public override async System.Threading.Tasks.Task> ListReactionsAllAsync( int id, int? limit = null, CancellationToken cancellationToken = default) @@ -1062,7 +1452,7 @@ public async System.Threading.Tasks.Task> ListReactionsAllAsync( return items; } - public async System.Threading.Tasks.Task AddReactionAsync( + public override async System.Threading.Tasks.Task AddReactionAsync( int id, ReactionRequest request, CancellationToken cancellationToken = default) @@ -1083,7 +1473,7 @@ public async System.Threading.Tasks.Task AddReactionAsync( } } - public async System.Threading.Tasks.Task RemoveReactionAsync( + public override async System.Threading.Tasks.Task RemoveReactionAsync( int id, string code, string? name = null, @@ -1109,18 +1499,31 @@ public async System.Threading.Tasks.Task RemoveReactionAsync( } } -public sealed class ReadMembersService +public class ReadMembersService +{ + + public virtual async System.Threading.Tasks.Task ListReadMembersAsync( + int id, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Read members.listReadMembers is not implemented"); + } +} + +public sealed class ReadMembersServiceImpl : ReadMembersService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ReadMembersService(string baseUrl, HttpClient client) + internal ReadMembersServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListReadMembersAsync( + public override async System.Threading.Tasks.Task ListReadMembersAsync( int id, int? limit = null, string? cursor = null, @@ -1147,18 +1550,32 @@ public async System.Threading.Tasks.Task ListReadMembersAsync( } } -public sealed class ThreadsService +public class ThreadsService +{ + + public virtual async System.Threading.Tasks.Task GetThreadAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Threads.getThread is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateThreadAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Threads.createThread is not implemented"); + } +} + +public sealed class ThreadsServiceImpl : ThreadsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ThreadsService(string baseUrl, HttpClient client) + internal ThreadsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task GetThreadAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetThreadAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/threads/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1175,7 +1592,7 @@ internal ThreadsService(string baseUrl, HttpClient client) } } - public async System.Threading.Tasks.Task CreateThreadAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateThreadAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/messages/{id}/thread"; using var request = new HttpRequestMessage(HttpMethod.Post, url); @@ -1193,18 +1610,47 @@ internal ThreadsService(string baseUrl, HttpClient client) } } -public sealed class ProfileService +public class ProfileService +{ + + public virtual async System.Threading.Tasks.Task GetTokenInfoAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Profile.getTokenInfo is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetProfileAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Profile.getProfile is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetStatusAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Profile.getStatus is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateStatusAsync(StatusUpdateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Profile.updateStatus is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteStatusAsync(CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Profile.deleteStatus is not implemented"); + } +} + +public sealed class ProfileServiceImpl : ProfileService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ProfileService(string baseUrl, HttpClient client) + internal ProfileServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task GetTokenInfoAsync(CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetTokenInfoAsync(CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/oauth/token/info"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1221,7 +1667,7 @@ public async System.Threading.Tasks.Task GetTokenInfoAsync(Canc } } - public async System.Threading.Tasks.Task GetProfileAsync(CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetProfileAsync(CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/profile"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1238,7 +1684,7 @@ public async System.Threading.Tasks.Task GetProfileAsync(CancellationToken } } - public async System.Threading.Tasks.Task GetStatusAsync(CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetStatusAsync(CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/profile/status"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1255,7 +1701,7 @@ public async System.Threading.Tasks.Task GetStatusAsync(CancellationToke } } - public async System.Threading.Tasks.Task UpdateStatusAsync(StatusUpdateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task UpdateStatusAsync(StatusUpdateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/profile/status"; using var httpRequest = new HttpRequestMessage(HttpMethod.Put, url); @@ -1273,7 +1719,7 @@ public async System.Threading.Tasks.Task UpdateStatusAsync(StatusUpd } } - public async System.Threading.Tasks.Task DeleteStatusAsync(CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteStatusAsync(CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/profile/status"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -1291,18 +1737,107 @@ public async System.Threading.Tasks.Task DeleteStatusAsync(CancellationToken can } } -public sealed class SearchService +public class SearchService +{ + + public virtual async System.Threading.Tasks.Task SearchChatsAsync( + string? query = null, + int? limit = null, + string? cursor = null, + SortOrder? order = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + bool? active = null, + ChatSubtype? chatSubtype = null, + bool? personal = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchChats is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> SearchChatsAllAsync( + string? query = null, + int? limit = null, + SortOrder? order = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + bool? active = null, + ChatSubtype? chatSubtype = null, + bool? personal = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchChatsAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task SearchMessagesAsync( + string? query = null, + int? limit = null, + string? cursor = null, + SortOrder? order = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + List? chatIds = null, + List? userIds = null, + bool? active = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchMessages is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> SearchMessagesAllAsync( + string? query = null, + int? limit = null, + SortOrder? order = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + List? chatIds = null, + List? userIds = null, + bool? active = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchMessagesAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task SearchUsersAsync( + string? query = null, + int? limit = null, + string? cursor = null, + SearchSortOrder? sort = null, + SortOrder? order = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + List? companyRoles = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchUsers is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> SearchUsersAllAsync( + string? query = null, + int? limit = null, + SearchSortOrder? sort = null, + SortOrder? order = null, + DateTimeOffset? createdFrom = null, + DateTimeOffset? createdTo = null, + List? companyRoles = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Search.searchUsersAll is not implemented"); + } +} + +public sealed class SearchServiceImpl : SearchService { private readonly string _baseUrl; private readonly HttpClient _client; - internal SearchService(string baseUrl, HttpClient client) + internal SearchServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task SearchChatsAsync( + public override async System.Threading.Tasks.Task SearchChatsAsync( string? query = null, int? limit = null, string? cursor = null, @@ -1348,7 +1883,7 @@ public async System.Threading.Tasks.Task SearchChatsAsync( } } - public async System.Threading.Tasks.Task> SearchChatsAllAsync( + public override async System.Threading.Tasks.Task> SearchChatsAllAsync( string? query = null, int? limit = null, SortOrder? order = null, @@ -1370,7 +1905,7 @@ public async System.Threading.Tasks.Task> SearchChatsAllAsync( return items; } - public async System.Threading.Tasks.Task SearchMessagesAsync( + public override async System.Threading.Tasks.Task SearchMessagesAsync( string? query = null, int? limit = null, string? cursor = null, @@ -1416,7 +1951,7 @@ public async System.Threading.Tasks.Task SearchMessage } } - public async System.Threading.Tasks.Task> SearchMessagesAllAsync( + public override async System.Threading.Tasks.Task> SearchMessagesAllAsync( string? query = null, int? limit = null, SortOrder? order = null, @@ -1438,7 +1973,7 @@ public async System.Threading.Tasks.Task> SearchMessagesAllAsync( return items; } - public async System.Threading.Tasks.Task SearchUsersAsync( + public override async System.Threading.Tasks.Task SearchUsersAsync( string? query = null, int? limit = null, string? cursor = null, @@ -1481,7 +2016,7 @@ public async System.Threading.Tasks.Task SearchUsersAsync( } } - public async System.Threading.Tasks.Task> SearchUsersAllAsync( + public override async System.Threading.Tasks.Task> SearchUsersAllAsync( string? query = null, int? limit = null, SearchSortOrder? sort = null, @@ -1503,18 +2038,60 @@ public async System.Threading.Tasks.Task> SearchUsersAllAsync( } } -public sealed class TasksService +public class TasksService +{ + + public virtual async System.Threading.Tasks.Task ListTasksAsync( + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.listTasks is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListTasksAllAsync( + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.listTasksAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetTaskAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.getTask is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateTaskAsync(TaskCreateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.createTask is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateTaskAsync( + int id, + TaskUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.updateTask is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteTaskAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Tasks.deleteTask is not implemented"); + } +} + +public sealed class TasksServiceImpl : TasksService { private readonly string _baseUrl; private readonly HttpClient _client; - internal TasksService(string baseUrl, HttpClient client) + internal TasksServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListTasksAsync( + public override async System.Threading.Tasks.Task ListTasksAsync( int? limit = null, string? cursor = null, CancellationToken cancellationToken = default) @@ -1539,7 +2116,7 @@ public async System.Threading.Tasks.Task ListTasksAsync( } } - public async System.Threading.Tasks.Task> ListTasksAllAsync( + public override async System.Threading.Tasks.Task> ListTasksAllAsync( int? limit = null, CancellationToken cancellationToken = default) { @@ -1554,7 +2131,7 @@ public async System.Threading.Tasks.Task ListTasksAsync( return items; } - public async System.Threading.Tasks.Task GetTaskAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetTaskAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/tasks/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1571,7 +2148,7 @@ public async System.Threading.Tasks.Task ListTasksAsync( } } - public async System.Threading.Tasks.Task CreateTaskAsync(TaskCreateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateTaskAsync(TaskCreateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/tasks"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -1589,7 +2166,7 @@ public async System.Threading.Tasks.Task ListTasksAsync( } } - public async System.Threading.Tasks.Task UpdateTaskAsync( + public override async System.Threading.Tasks.Task UpdateTaskAsync( int id, TaskUpdateRequest request, CancellationToken cancellationToken = default) @@ -1610,7 +2187,7 @@ public async System.Threading.Tasks.Task ListTasksAsync( } } - public async System.Threading.Tasks.Task DeleteTaskAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteTaskAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/tasks/{id}"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -1628,18 +2205,80 @@ public async System.Threading.Tasks.Task DeleteTaskAsync(int id, CancellationTok } } -public sealed class UsersService +public class UsersService +{ + + public virtual async System.Threading.Tasks.Task ListUsersAsync( + string? query = null, + int? limit = null, + string? cursor = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.listUsers is not implemented"); + } + + public virtual async System.Threading.Tasks.Task> ListUsersAllAsync( + string? query = null, + int? limit = null, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.listUsersAll is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetUserAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.getUser is not implemented"); + } + + public virtual async System.Threading.Tasks.Task GetUserStatusAsync(int userId, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.getUserStatus is not implemented"); + } + + public virtual async System.Threading.Tasks.Task CreateUserAsync(UserCreateRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.createUser is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateUserAsync( + int id, + UserUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.updateUser is not implemented"); + } + + public virtual async System.Threading.Tasks.Task UpdateUserStatusAsync( + int userId, + StatusUpdateRequest request, + CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.updateUserStatus is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteUserAsync(int id, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.deleteUser is not implemented"); + } + + public virtual async System.Threading.Tasks.Task DeleteUserStatusAsync(int userId, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Users.deleteUserStatus is not implemented"); + } +} + +public sealed class UsersServiceImpl : UsersService { private readonly string _baseUrl; private readonly HttpClient _client; - internal UsersService(string baseUrl, HttpClient client) + internal UsersServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task ListUsersAsync( + public override async System.Threading.Tasks.Task ListUsersAsync( string? query = null, int? limit = null, string? cursor = null, @@ -1667,7 +2306,7 @@ public async System.Threading.Tasks.Task ListUsersAsync( } } - public async System.Threading.Tasks.Task> ListUsersAllAsync( + public override async System.Threading.Tasks.Task> ListUsersAllAsync( string? query = null, int? limit = null, CancellationToken cancellationToken = default) @@ -1683,7 +2322,7 @@ public async System.Threading.Tasks.Task> ListUsersAllAsync( return items; } - public async System.Threading.Tasks.Task GetUserAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetUserAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/users/{id}"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1700,7 +2339,7 @@ public async System.Threading.Tasks.Task GetUserAsync(int id, Cancellation } } - public async System.Threading.Tasks.Task GetUserStatusAsync(int userId, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task GetUserStatusAsync(int userId, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/users/{userId}/status"; using var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -1717,7 +2356,7 @@ public async System.Threading.Tasks.Task GetUserStatusAsync(int userId, } } - public async System.Threading.Tasks.Task CreateUserAsync(UserCreateRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task CreateUserAsync(UserCreateRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/users"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -1735,7 +2374,7 @@ public async System.Threading.Tasks.Task CreateUserAsync(UserCreateRequest } } - public async System.Threading.Tasks.Task UpdateUserAsync( + public override async System.Threading.Tasks.Task UpdateUserAsync( int id, UserUpdateRequest request, CancellationToken cancellationToken = default) @@ -1756,7 +2395,7 @@ public async System.Threading.Tasks.Task UpdateUserAsync( } } - public async System.Threading.Tasks.Task UpdateUserStatusAsync( + public override async System.Threading.Tasks.Task UpdateUserStatusAsync( int userId, StatusUpdateRequest request, CancellationToken cancellationToken = default) @@ -1777,7 +2416,7 @@ public async System.Threading.Tasks.Task UpdateUserStatusAsync( } } - public async System.Threading.Tasks.Task DeleteUserAsync(int id, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteUserAsync(int id, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/users/{id}"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -1794,7 +2433,7 @@ public async System.Threading.Tasks.Task DeleteUserAsync(int id, CancellationTok } } - public async System.Threading.Tasks.Task DeleteUserStatusAsync(int userId, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task DeleteUserStatusAsync(int userId, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/users/{userId}/status"; using var request = new HttpRequestMessage(HttpMethod.Delete, url); @@ -1812,18 +2451,27 @@ public async System.Threading.Tasks.Task DeleteUserStatusAsync(int userId, Cance } } -public sealed class ViewsService +public class ViewsService +{ + + public virtual async System.Threading.Tasks.Task OpenViewAsync(OpenViewRequest request, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("Views.openView is not implemented"); + } +} + +public sealed class ViewsServiceImpl : ViewsService { private readonly string _baseUrl; private readonly HttpClient _client; - internal ViewsService(string baseUrl, HttpClient client) + internal ViewsServiceImpl(string baseUrl, HttpClient client) { _baseUrl = baseUrl; _client = client; } - public async System.Threading.Tasks.Task OpenViewAsync(OpenViewRequest request, CancellationToken cancellationToken = default) + public override async System.Threading.Tasks.Task OpenViewAsync(OpenViewRequest request, CancellationToken cancellationToken = default) { var url = $"{_baseUrl}/views/open"; using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url); @@ -1844,7 +2492,7 @@ public async System.Threading.Tasks.Task OpenViewAsync(OpenViewRequest request, public sealed class PachcaClient : IDisposable { - private readonly HttpClient _client; + private readonly HttpClient? _client; public BotsService Bots { get; } public ChatsService Chats { get; } @@ -1863,7 +2511,27 @@ public sealed class PachcaClient : IDisposable public UsersService Users { get; } public ViewsService Views { get; } - public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1") + private PachcaClient(BotsService bots, ChatsService chats, CommonService common, GroupTagsService groupTags, LinkPreviewsService linkPreviews, MembersService members, MessagesService messages, ProfileService profile, ReactionsService reactions, ReadMembersService readMembers, SearchService search, SecurityService security, TasksService tasks, ThreadsService threads, UsersService users, ViewsService views) + { + Bots = bots; + Chats = chats; + Common = common; + GroupTags = groupTags; + LinkPreviews = linkPreviews; + Members = members; + Messages = messages; + Profile = profile; + Reactions = reactions; + ReadMembers = readMembers; + Search = search; + Security = security; + Tasks = tasks; + Threads = threads; + Users = users; + Views = views; + } + + public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/shared/v1", BotsService? bots = null, ChatsService? chats = null, CommonService? common = null, GroupTagsService? groupTags = null, LinkPreviewsService? linkPreviews = null, MembersService? members = null, MessagesService? messages = null, ProfileService? profile = null, ReactionsService? reactions = null, ReadMembersService? readMembers = null, SearchService? search = null, SecurityService? security = null, TasksService? tasks = null, ThreadsService? threads = null, UsersService? users = null, ViewsService? views = null) { var handler = new SocketsHttpHandler { @@ -1873,27 +2541,32 @@ public PachcaClient(string token, string baseUrl = "https://api.pachca.com/api/s _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - Bots = new BotsService(baseUrl, _client); - Chats = new ChatsService(baseUrl, _client); - Common = new CommonService(baseUrl, _client); - GroupTags = new GroupTagsService(baseUrl, _client); - LinkPreviews = new LinkPreviewsService(baseUrl, _client); - Members = new MembersService(baseUrl, _client); - Messages = new MessagesService(baseUrl, _client); - Profile = new ProfileService(baseUrl, _client); - Reactions = new ReactionsService(baseUrl, _client); - ReadMembers = new ReadMembersService(baseUrl, _client); - Search = new SearchService(baseUrl, _client); - Security = new SecurityService(baseUrl, _client); - Tasks = new TasksService(baseUrl, _client); - Threads = new ThreadsService(baseUrl, _client); - Users = new UsersService(baseUrl, _client); - Views = new ViewsService(baseUrl, _client); + Bots = bots ?? new BotsServiceImpl(baseUrl, _client); + Chats = chats ?? new ChatsServiceImpl(baseUrl, _client); + Common = common ?? new CommonServiceImpl(baseUrl, _client); + GroupTags = groupTags ?? new GroupTagsServiceImpl(baseUrl, _client); + LinkPreviews = linkPreviews ?? new LinkPreviewsServiceImpl(baseUrl, _client); + Members = members ?? new MembersServiceImpl(baseUrl, _client); + Messages = messages ?? new MessagesServiceImpl(baseUrl, _client); + Profile = profile ?? new ProfileServiceImpl(baseUrl, _client); + Reactions = reactions ?? new ReactionsServiceImpl(baseUrl, _client); + ReadMembers = readMembers ?? new ReadMembersServiceImpl(baseUrl, _client); + Search = search ?? new SearchServiceImpl(baseUrl, _client); + Security = security ?? new SecurityServiceImpl(baseUrl, _client); + Tasks = tasks ?? new TasksServiceImpl(baseUrl, _client); + Threads = threads ?? new ThreadsServiceImpl(baseUrl, _client); + Users = users ?? new UsersServiceImpl(baseUrl, _client); + Views = views ?? new ViewsServiceImpl(baseUrl, _client); + } + + public static PachcaClient Stub(BotsService? bots = null, ChatsService? chats = null, CommonService? common = null, GroupTagsService? groupTags = null, LinkPreviewsService? linkPreviews = null, MembersService? members = null, MessagesService? messages = null, ProfileService? profile = null, ReactionsService? reactions = null, ReadMembersService? readMembers = null, SearchService? search = null, SecurityService? security = null, TasksService? tasks = null, ThreadsService? threads = null, UsersService? users = null, ViewsService? views = null) + { + return new PachcaClient(bots ?? new BotsService(), chats ?? new ChatsService(), common ?? new CommonService(), groupTags ?? new GroupTagsService(), linkPreviews ?? new LinkPreviewsService(), members ?? new MembersService(), messages ?? new MessagesService(), profile ?? new ProfileService(), reactions ?? new ReactionsService(), readMembers ?? new ReadMembersService(), search ?? new SearchService(), security ?? new SecurityService(), tasks ?? new TasksService(), threads ?? new ThreadsService(), users ?? new UsersService(), views ?? new ViewsService()); } public void Dispose() { - _client.Dispose(); + _client?.Dispose(); GC.SuppressFinalize(this); } } diff --git a/sdk/csharp/generated/README.md b/sdk/csharp/generated/README.md index 90e201e9..0c99d578 100644 --- a/sdk/csharp/generated/README.md +++ b/sdk/csharp/generated/README.md @@ -121,3 +121,26 @@ catch (ApiError e) using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var users = await client.Users.ListUsersAsync(cancellationToken: cts.Token); ``` + +## Тестирование + +Для unit-тестов используйте `PachcaClient.Stub()` — создаёт клиент без HTTP-подключения. + +Методы без переопределения выбрасывают `NotImplementedException("Service.method is not implemented")`: + +```csharp +using Moq; +using Pachca.Sdk; + +// Мок-сервис +var mockMessages = new Mock(); +mockMessages + .Setup(m => m.GetMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Message { Id = 1, Content = "Test message", EntityId = 123 }); + +// Тест +var client = PachcaClient.Stub(messages: mockMessages.Object); + +var message = await client.Messages.GetMessageAsync(1); +Assert.Equal("Test message", message.Content); +``` diff --git a/sdk/csharp/generated/Utils.cs b/sdk/csharp/generated/Utils.cs index 8b913ac8..482fcaaf 100644 --- a/sdk/csharp/generated/Utils.cs +++ b/sdk/csharp/generated/Utils.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -14,6 +15,8 @@ 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() { @@ -21,6 +24,12 @@ internal static class PachcaUtils 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, @@ -44,15 +53,15 @@ internal static async Task SendWithRetryAsync( { 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; } - if ((int)response.StatusCode >= 500 && attempt < MaxRetries) + if (Retryable5xx.Contains((int)response.StatusCode) && attempt < MaxRetries) { var delay = TimeSpan.FromSeconds(attempt + 1); - await System.Threading.Tasks.Task.Delay(delay, cancellationToken).ConfigureAwait(false); + await System.Threading.Tasks.Task.Delay(AddJitter(delay), cancellationToken).ConfigureAwait(false); response.Dispose(); continue; } diff --git a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.deps.json b/sdk/csharp/generated/bin/Debug/net8.0/Pachca.deps.json deleted file mode 100644 index 2961953f..00000000 --- a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.deps.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "runtimeTarget": { - "name": ".NETCoreApp,Version=v8.0", - "signature": "" - }, - "compilationOptions": {}, - "targets": { - ".NETCoreApp,Version=v8.0": { - "Pachca/0.0.0": { - "runtime": { - "Pachca.dll": {} - } - } - } - }, - "libraries": { - "Pachca/0.0.0": { - "type": "project", - "serviceable": false, - "sha512": "" - } - } -} \ No newline at end of file diff --git a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.dll b/sdk/csharp/generated/bin/Debug/net8.0/Pachca.dll deleted file mode 100644 index a747aca6..00000000 Binary files a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.dll and /dev/null differ diff --git a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.pdb b/sdk/csharp/generated/bin/Debug/net8.0/Pachca.pdb deleted file mode 100644 index 54cd6a6f..00000000 Binary files a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.pdb and /dev/null differ diff --git a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.xml b/sdk/csharp/generated/bin/Debug/net8.0/Pachca.xml deleted file mode 100644 index 5b7e389b..00000000 --- a/sdk/csharp/generated/bin/Debug/net8.0/Pachca.xml +++ /dev/null @@ -1,599 +0,0 @@ - - - - Pachca - - - - Тип аудит-события - - - Пользователь успешно вошел в систему - - - Пользователь вышел из системы - - - Неудачная попытка двухфакторной аутентификации - - - Успешная двухфакторная аутентификация - - - Создана новая учетная запись пользователя - - - Учетная запись пользователя удалена - - - Роль пользователя была изменена - - - Данные пользователя обновлены - - - Создан новый тег - - - Тег удален - - - Пользователь добавлен в тег - - - Пользователь удален из тега - - - Создан новый чат - - - Чат переименован - - - Изменены права доступа к чату - - - Пользователь присоединился к чату - - - Пользователь покинул чат - - - Тег добавлен в чат - - - Тег удален из чата - - - Сообщение отредактировано - - - Сообщение удалено - - - Сообщение создано - - - Реакция добавлена - - - Реакция удалена - - - Тред создан - - - Создан новый токен доступа - - - Токен доступа обновлен - - - Токен доступа удален - - - Данные зашифрованы - - - Данные расшифрованы - - - Доступ к журналам аудита получен - - - Срабатывание правила DLP-системы - - - Поиск сотрудников через API - - - Поиск чатов через API - - - Поиск сообщений через API - - - Доступность чатов для пользователя - - - Чаты, где пользователь является участником - - - Все открытые чаты компании, вне зависимости от участия в них пользователя - - - Роль участника чата - - - Админ - - - Редактор (доступно только для каналов) - - - Участник или подписчик - - - Роль участника чата (с фильтром все) - - - Любая роль - - - Создатель - - - Админ - - - Редактор - - - Участник/подписчик - - - Тип чата - - - Канал или беседа - - - Тред - - - Тип данных дополнительного поля - - - Строковое значение - - - Числовое значение - - - Дата - - - Ссылка - - - Тип файла - - - Обычный файл - - - Изображение - - - Статус приглашения пользователя - - - Принято - - - Отправлено - - - Тип события webhook для участников - - - Добавление - - - Удаление - - - Тип сущности для сообщений - - - Беседа или канал - - - Тред - - - Пользователь - - - Скоуп доступа OAuth токена - - - Просмотр чатов и списка чатов - - - Создание новых чатов - - - Изменение настроек чата - - - Архивация и разархивация чатов - - - Выход из чатов - - - Просмотр участников чата - - - Добавление, изменение и удаление участников чата - - - Скачивание экспортов чата - - - Создание экспортов чата - - - Просмотр сообщений в чатах - - - Отправка сообщений - - - Редактирование сообщений - - - Удаление сообщений - - - Просмотр реакций на сообщения - - - Добавление и удаление реакций - - - Закрепление и открепление сообщений - - - Просмотр тредов (комментариев) - - - Создание тредов (комментариев) - - - Unfurl (разворачивание ссылок) - - - Просмотр информации о сотрудниках и списка сотрудников - - - Создание новых сотрудников - - - Редактирование данных сотрудника - - - Удаление сотрудников - - - Просмотр тегов - - - Создание, редактирование и удаление тегов - - - Изменение настроек бота - - - Просмотр информации о своем профиле - - - Просмотр статуса профиля - - - Изменение и удаление статуса профиля - - - Просмотр статуса сотрудника - - - Изменение и удаление статуса сотрудника - - - Просмотр дополнительных полей - - - Просмотр журнала аудита - - - Просмотр задач - - - Создание задач - - - Изменение задачи - - - Удаление задачи - - - Скачивание файлов - - - Загрузка файлов - - - Получение данных для загрузки файлов - - - Открытие форм (представлений) - - - Просмотр вебхуков - - - Создание и управление вебхуками - - - Просмотр лога вебхуков - - - Удаление записи в логе вебхука - - - Поиск сотрудников - - - Поиск чатов - - - Поиск сообщений - - - Тип события webhook для реакций - - - Создание - - - Удаление - - - Тип сущности для поиска - - - Пользователь - - - Задача - - - Сортировка результатов поиска - - - По релевантности - - - По алфавиту - - - Порядок сортировки - - - По возрастанию - - - По убыванию - - - Тип задачи - - - Позвонить контакту - - - Встреча - - - Простое напоминание - - - Событие - - - Написать письмо - - - Статус напоминания - - - Выполнено - - - Активно - - - Тип события webhook для пользователей - - - Приглашение - - - Подтверждение - - - Обновление - - - Приостановка - - - Активация - - - Удаление - - - Роль пользователя в системе - - - Администратор - - - Сотрудник - - - Мульти-гость - - - Гость - - - Роль пользователя, допустимая при создании и редактировании. Роль `guest` недоступна для установки через API. - - - Администратор - - - Сотрудник - - - Мульти-гость - - - Коды ошибок валидации - - - Обязательное поле (не может быть пустым) - - - Слишком длинное значение (пояснения вы получите в поле message) - - - Поле не соответствует правилам (пояснения вы получите в поле message) - - - Поле имеет непредусмотренное значение - - - Поле имеет недопустимое значение - - - Название для этого поля уже существует - - - Emoji статуса не может содержать значения отличные от Emoji символа - - - Объект не найден - - - Объект уже существует (пояснения вы получите в поле message) - - - Ошибка личного чата (пояснения вы получите в поле message) - - - Отображаемая ошибка (пояснения вы получите в поле message) - - - Действие запрещено - - - Выбран слишком большой диапазон дат - - - Некорректный URL вебхука - - - Достигнут лимит запросов - - - Превышен лимит активных сотрудников (пояснения вы получите в поле message) - - - Превышен лимит количества реакций, которые может добавить пользователь (20 уникальных реакций) - - - Превышен лимит количества уникальных реакций, которые можно добавить на сообщение (30 уникальных реакций) - - - Превышен лимит количества реакций, которые можно добавить на сообщение (1000 реакций) - - - Ошибка выполнения запроса (пояснения вы получите в поле message) - - - Не удалось найти идентификатор события - - - Время жизни идентификатора события истекло - - - Обязательный параметр не передан - - - Недопустимое значение (не входит в список допустимых) - - - Значение неприменимо в данном контексте (пояснения вы получите в поле message) - - - Нельзя изменить свои собственные данные - - - Нельзя изменить данные владельца - - - Значение уже назначено - - - Недостаточно прав для выполнения действия (пояснения вы получите в поле message) - - - Доступ запрещён (недостаточно прав) - - - Доступ запрещён - - - Некорректные параметры запроса (пояснения вы получите в поле message) - - - Требуется оплата - - - Значение слишком короткое (пояснения вы получите в поле message) - - - Значение слишком длинное (пояснения вы получите в поле message) - - - Использовано зарезервированное системное слово (here, all) - - - Тип события webhook - - - Создание - - - Обновление - - - Удаление - - - diff --git a/sdk/csharp/generated/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs b/sdk/csharp/generated/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs deleted file mode 100644 index 2217181c..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/.NETCoreApp,Version=v8.0.AssemblyAttributes.cs +++ /dev/null @@ -1,4 +0,0 @@ -// -using System; -using System.Reflection; -[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")] diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfo.cs b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfo.cs deleted file mode 100644 index b53a36a2..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfo.cs +++ /dev/null @@ -1,24 +0,0 @@ -//------------------------------------------------------------------------------ -// -// This code was generated by a tool. -// -// Changes to this file may cause incorrect behavior and will be lost if -// the code is regenerated. -// -//------------------------------------------------------------------------------ - -using System; -using System.Reflection; - -[assembly: System.Reflection.AssemblyCompanyAttribute("Pachca")] -[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] -[assembly: System.Reflection.AssemblyDescriptionAttribute("Official Pachca API SDK for .NET")] -[assembly: System.Reflection.AssemblyFileVersionAttribute("0.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("0.0.0+63c81f1ef97ffdf701404195e2e23ef10c4df682")] -[assembly: System.Reflection.AssemblyProductAttribute("Pachca")] -[assembly: System.Reflection.AssemblyTitleAttribute("Pachca")] -[assembly: System.Reflection.AssemblyVersionAttribute("0.0.0.0")] -[assembly: System.Reflection.AssemblyMetadataAttribute("RepositoryUrl", "https://github.com/pachca/openapi")] - -// Generated by the MSBuild WriteCodeFragment class. - diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfoInputs.cache b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfoInputs.cache deleted file mode 100644 index ca3bdf21..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfoInputs.cache +++ /dev/null @@ -1 +0,0 @@ -b10e847b2e9024097da0b7fca6158b75db566b67cff2130365fb3427084e48b6 diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.GeneratedMSBuildEditorConfig.editorconfig b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.GeneratedMSBuildEditorConfig.editorconfig deleted file mode 100644 index 97d70010..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.GeneratedMSBuildEditorConfig.editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -is_global = true -build_property.TargetFramework = net8.0 -build_property.TargetFrameworkIdentifier = .NETCoreApp -build_property.TargetFrameworkVersion = v8.0 -build_property.TargetPlatformMinVersion = -build_property.UsingMicrosoftNETSdkWeb = -build_property.ProjectTypeGuids = -build_property.InvariantGlobalization = -build_property.PlatformNeutralAssembly = -build_property.EnforceExtendedAnalyzerRules = -build_property._SupportedPlatformList = Linux,macOS,Windows -build_property.RootNamespace = Pachca.Sdk -build_property.ProjectDir = /home/runner/work/openapi/openapi/sdk/csharp/generated/ -build_property.EnableComHosting = -build_property.EnableGeneratedComInterfaceComImportInterop = -build_property.EffectiveAnalysisLevelStyle = 8.0 -build_property.EnableCodeStyleSeverity = diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.assets.cache b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.assets.cache deleted file mode 100644 index c777a4a1..00000000 Binary files a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.assets.cache and /dev/null differ diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.CoreCompileInputs.cache b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.CoreCompileInputs.cache deleted file mode 100644 index 522c513d..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.CoreCompileInputs.cache +++ /dev/null @@ -1 +0,0 @@ -8b4bedcecccf98bda5b99823825875d64935e1236dfda69b4f1366b3e253b906 diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.FileListAbsolute.txt b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.FileListAbsolute.txt deleted file mode 100644 index 18b478ec..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.FileListAbsolute.txt +++ /dev/null @@ -1,14 +0,0 @@ -/home/runner/work/openapi/openapi/sdk/csharp/generated/bin/Debug/net8.0/Pachca.deps.json -/home/runner/work/openapi/openapi/sdk/csharp/generated/bin/Debug/net8.0/Pachca.dll -/home/runner/work/openapi/openapi/sdk/csharp/generated/bin/Debug/net8.0/Pachca.pdb -/home/runner/work/openapi/openapi/sdk/csharp/generated/bin/Debug/net8.0/Pachca.xml -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.GeneratedMSBuildEditorConfig.editorconfig -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfoInputs.cache -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.AssemblyInfo.cs -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.csproj.CoreCompileInputs.cache -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.sourcelink.json -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.dll -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/refint/Pachca.dll -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.xml -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/Pachca.pdb -/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/Debug/net8.0/ref/Pachca.dll diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.dll b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.dll deleted file mode 100644 index a747aca6..00000000 Binary files a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.dll and /dev/null differ diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.pdb b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.pdb deleted file mode 100644 index 54cd6a6f..00000000 Binary files a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.pdb and /dev/null differ diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.sourcelink.json b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.sourcelink.json deleted file mode 100644 index 6b6705b9..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.sourcelink.json +++ /dev/null @@ -1 +0,0 @@ -{"documents":{"/home/runner/work/openapi/openapi/*":"https://raw.githubusercontent.com/pachca/openapi/63c81f1ef97ffdf701404195e2e23ef10c4df682/*"}} \ No newline at end of file diff --git a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.xml b/sdk/csharp/generated/obj/Debug/net8.0/Pachca.xml deleted file mode 100644 index 5b7e389b..00000000 --- a/sdk/csharp/generated/obj/Debug/net8.0/Pachca.xml +++ /dev/null @@ -1,599 +0,0 @@ - - - - Pachca - - - - Тип аудит-события - - - Пользователь успешно вошел в систему - - - Пользователь вышел из системы - - - Неудачная попытка двухфакторной аутентификации - - - Успешная двухфакторная аутентификация - - - Создана новая учетная запись пользователя - - - Учетная запись пользователя удалена - - - Роль пользователя была изменена - - - Данные пользователя обновлены - - - Создан новый тег - - - Тег удален - - - Пользователь добавлен в тег - - - Пользователь удален из тега - - - Создан новый чат - - - Чат переименован - - - Изменены права доступа к чату - - - Пользователь присоединился к чату - - - Пользователь покинул чат - - - Тег добавлен в чат - - - Тег удален из чата - - - Сообщение отредактировано - - - Сообщение удалено - - - Сообщение создано - - - Реакция добавлена - - - Реакция удалена - - - Тред создан - - - Создан новый токен доступа - - - Токен доступа обновлен - - - Токен доступа удален - - - Данные зашифрованы - - - Данные расшифрованы - - - Доступ к журналам аудита получен - - - Срабатывание правила DLP-системы - - - Поиск сотрудников через API - - - Поиск чатов через API - - - Поиск сообщений через API - - - Доступность чатов для пользователя - - - Чаты, где пользователь является участником - - - Все открытые чаты компании, вне зависимости от участия в них пользователя - - - Роль участника чата - - - Админ - - - Редактор (доступно только для каналов) - - - Участник или подписчик - - - Роль участника чата (с фильтром все) - - - Любая роль - - - Создатель - - - Админ - - - Редактор - - - Участник/подписчик - - - Тип чата - - - Канал или беседа - - - Тред - - - Тип данных дополнительного поля - - - Строковое значение - - - Числовое значение - - - Дата - - - Ссылка - - - Тип файла - - - Обычный файл - - - Изображение - - - Статус приглашения пользователя - - - Принято - - - Отправлено - - - Тип события webhook для участников - - - Добавление - - - Удаление - - - Тип сущности для сообщений - - - Беседа или канал - - - Тред - - - Пользователь - - - Скоуп доступа OAuth токена - - - Просмотр чатов и списка чатов - - - Создание новых чатов - - - Изменение настроек чата - - - Архивация и разархивация чатов - - - Выход из чатов - - - Просмотр участников чата - - - Добавление, изменение и удаление участников чата - - - Скачивание экспортов чата - - - Создание экспортов чата - - - Просмотр сообщений в чатах - - - Отправка сообщений - - - Редактирование сообщений - - - Удаление сообщений - - - Просмотр реакций на сообщения - - - Добавление и удаление реакций - - - Закрепление и открепление сообщений - - - Просмотр тредов (комментариев) - - - Создание тредов (комментариев) - - - Unfurl (разворачивание ссылок) - - - Просмотр информации о сотрудниках и списка сотрудников - - - Создание новых сотрудников - - - Редактирование данных сотрудника - - - Удаление сотрудников - - - Просмотр тегов - - - Создание, редактирование и удаление тегов - - - Изменение настроек бота - - - Просмотр информации о своем профиле - - - Просмотр статуса профиля - - - Изменение и удаление статуса профиля - - - Просмотр статуса сотрудника - - - Изменение и удаление статуса сотрудника - - - Просмотр дополнительных полей - - - Просмотр журнала аудита - - - Просмотр задач - - - Создание задач - - - Изменение задачи - - - Удаление задачи - - - Скачивание файлов - - - Загрузка файлов - - - Получение данных для загрузки файлов - - - Открытие форм (представлений) - - - Просмотр вебхуков - - - Создание и управление вебхуками - - - Просмотр лога вебхуков - - - Удаление записи в логе вебхука - - - Поиск сотрудников - - - Поиск чатов - - - Поиск сообщений - - - Тип события webhook для реакций - - - Создание - - - Удаление - - - Тип сущности для поиска - - - Пользователь - - - Задача - - - Сортировка результатов поиска - - - По релевантности - - - По алфавиту - - - Порядок сортировки - - - По возрастанию - - - По убыванию - - - Тип задачи - - - Позвонить контакту - - - Встреча - - - Простое напоминание - - - Событие - - - Написать письмо - - - Статус напоминания - - - Выполнено - - - Активно - - - Тип события webhook для пользователей - - - Приглашение - - - Подтверждение - - - Обновление - - - Приостановка - - - Активация - - - Удаление - - - Роль пользователя в системе - - - Администратор - - - Сотрудник - - - Мульти-гость - - - Гость - - - Роль пользователя, допустимая при создании и редактировании. Роль `guest` недоступна для установки через API. - - - Администратор - - - Сотрудник - - - Мульти-гость - - - Коды ошибок валидации - - - Обязательное поле (не может быть пустым) - - - Слишком длинное значение (пояснения вы получите в поле message) - - - Поле не соответствует правилам (пояснения вы получите в поле message) - - - Поле имеет непредусмотренное значение - - - Поле имеет недопустимое значение - - - Название для этого поля уже существует - - - Emoji статуса не может содержать значения отличные от Emoji символа - - - Объект не найден - - - Объект уже существует (пояснения вы получите в поле message) - - - Ошибка личного чата (пояснения вы получите в поле message) - - - Отображаемая ошибка (пояснения вы получите в поле message) - - - Действие запрещено - - - Выбран слишком большой диапазон дат - - - Некорректный URL вебхука - - - Достигнут лимит запросов - - - Превышен лимит активных сотрудников (пояснения вы получите в поле message) - - - Превышен лимит количества реакций, которые может добавить пользователь (20 уникальных реакций) - - - Превышен лимит количества уникальных реакций, которые можно добавить на сообщение (30 уникальных реакций) - - - Превышен лимит количества реакций, которые можно добавить на сообщение (1000 реакций) - - - Ошибка выполнения запроса (пояснения вы получите в поле message) - - - Не удалось найти идентификатор события - - - Время жизни идентификатора события истекло - - - Обязательный параметр не передан - - - Недопустимое значение (не входит в список допустимых) - - - Значение неприменимо в данном контексте (пояснения вы получите в поле message) - - - Нельзя изменить свои собственные данные - - - Нельзя изменить данные владельца - - - Значение уже назначено - - - Недостаточно прав для выполнения действия (пояснения вы получите в поле message) - - - Доступ запрещён (недостаточно прав) - - - Доступ запрещён - - - Некорректные параметры запроса (пояснения вы получите в поле message) - - - Требуется оплата - - - Значение слишком короткое (пояснения вы получите в поле message) - - - Значение слишком длинное (пояснения вы получите в поле message) - - - Использовано зарезервированное системное слово (here, all) - - - Тип события webhook - - - Создание - - - Обновление - - - Удаление - - - diff --git a/sdk/csharp/generated/obj/Debug/net8.0/ref/Pachca.dll b/sdk/csharp/generated/obj/Debug/net8.0/ref/Pachca.dll deleted file mode 100644 index 1d7e1776..00000000 Binary files a/sdk/csharp/generated/obj/Debug/net8.0/ref/Pachca.dll and /dev/null differ diff --git a/sdk/csharp/generated/obj/Debug/net8.0/refint/Pachca.dll b/sdk/csharp/generated/obj/Debug/net8.0/refint/Pachca.dll deleted file mode 100644 index 1d7e1776..00000000 Binary files a/sdk/csharp/generated/obj/Debug/net8.0/refint/Pachca.dll and /dev/null differ diff --git a/sdk/csharp/generated/obj/Pachca.csproj.nuget.dgspec.json b/sdk/csharp/generated/obj/Pachca.csproj.nuget.dgspec.json deleted file mode 100644 index 39d648dd..00000000 --- a/sdk/csharp/generated/obj/Pachca.csproj.nuget.dgspec.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "format": 1, - "restore": { - "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj": {} - }, - "projects": { - "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj": { - "version": "0.0.0", - "restore": { - "projectUniqueName": "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj", - "projectName": "Pachca.Sdk", - "projectPath": "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj", - "packagesPath": "/home/runner/.nuget/packages/", - "outputPath": "/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/", - "projectStyle": "PackageReference", - "configFilePaths": [ - "/home/runner/.nuget/NuGet/NuGet.Config" - ], - "originalTargetFrameworks": [ - "net8.0" - ], - "sources": { - "https://api.nuget.org/v3/index.json": {} - }, - "frameworks": { - "net8.0": { - "targetAlias": "net8.0", - "projectReferences": {} - } - }, - "warningProperties": { - "warnAsError": [ - "NU1605" - ] - }, - "restoreAuditProperties": { - "enableAudit": "true", - "auditLevel": "low", - "auditMode": "direct" - }, - "SdkAnalysisLevel": "10.0.100" - }, - "frameworks": { - "net8.0": { - "targetAlias": "net8.0", - "imports": [ - "net461", - "net462", - "net47", - "net471", - "net472", - "net48", - "net481" - ], - "assetTargetFallback": true, - "warn": true, - "downloadDependencies": [ - { - "name": "Microsoft.AspNetCore.App.Ref", - "version": "[8.0.23, 8.0.23]" - }, - { - "name": "Microsoft.NETCore.App.Ref", - "version": "[8.0.23, 8.0.23]" - } - ], - "frameworkReferences": { - "Microsoft.NETCore.App": { - "privateAssets": "all" - } - }, - "runtimeIdentifierGraphPath": "/usr/share/dotnet/sdk/10.0.102/PortableRuntimeIdentifierGraph.json" - } - } - } - } -} \ No newline at end of file diff --git a/sdk/csharp/generated/obj/Pachca.csproj.nuget.g.props b/sdk/csharp/generated/obj/Pachca.csproj.nuget.g.props deleted file mode 100644 index 2098f6f9..00000000 --- a/sdk/csharp/generated/obj/Pachca.csproj.nuget.g.props +++ /dev/null @@ -1,15 +0,0 @@ - - - - True - NuGet - $(MSBuildThisFileDirectory)project.assets.json - /home/runner/.nuget/packages/ - /home/runner/.nuget/packages/ - PackageReference - 7.0.0 - - - - - \ No newline at end of file diff --git a/sdk/csharp/generated/obj/Pachca.csproj.nuget.g.targets b/sdk/csharp/generated/obj/Pachca.csproj.nuget.g.targets deleted file mode 100644 index 3dc06ef3..00000000 --- a/sdk/csharp/generated/obj/Pachca.csproj.nuget.g.targets +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/sdk/csharp/generated/obj/project.assets.json b/sdk/csharp/generated/obj/project.assets.json deleted file mode 100644 index d721d499..00000000 --- a/sdk/csharp/generated/obj/project.assets.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "version": 3, - "targets": { - "net8.0": {} - }, - "libraries": {}, - "projectFileDependencyGroups": { - "net8.0": [] - }, - "packageFolders": { - "/home/runner/.nuget/packages/": {} - }, - "project": { - "version": "0.0.0", - "restore": { - "projectUniqueName": "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj", - "projectName": "Pachca.Sdk", - "projectPath": "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj", - "packagesPath": "/home/runner/.nuget/packages/", - "outputPath": "/home/runner/work/openapi/openapi/sdk/csharp/generated/obj/", - "projectStyle": "PackageReference", - "configFilePaths": [ - "/home/runner/.nuget/NuGet/NuGet.Config" - ], - "originalTargetFrameworks": [ - "net8.0" - ], - "sources": { - "https://api.nuget.org/v3/index.json": {} - }, - "frameworks": { - "net8.0": { - "targetAlias": "net8.0", - "projectReferences": {} - } - }, - "warningProperties": { - "warnAsError": [ - "NU1605" - ] - }, - "restoreAuditProperties": { - "enableAudit": "true", - "auditLevel": "low", - "auditMode": "direct" - }, - "SdkAnalysisLevel": "10.0.100" - }, - "frameworks": { - "net8.0": { - "targetAlias": "net8.0", - "imports": [ - "net461", - "net462", - "net47", - "net471", - "net472", - "net48", - "net481" - ], - "assetTargetFallback": true, - "warn": true, - "downloadDependencies": [ - { - "name": "Microsoft.AspNetCore.App.Ref", - "version": "[8.0.23, 8.0.23]" - }, - { - "name": "Microsoft.NETCore.App.Ref", - "version": "[8.0.23, 8.0.23]" - } - ], - "frameworkReferences": { - "Microsoft.NETCore.App": { - "privateAssets": "all" - } - }, - "runtimeIdentifierGraphPath": "/usr/share/dotnet/sdk/10.0.102/PortableRuntimeIdentifierGraph.json" - } - } - } -} \ No newline at end of file diff --git a/sdk/csharp/generated/obj/project.nuget.cache b/sdk/csharp/generated/obj/project.nuget.cache deleted file mode 100644 index 215873fa..00000000 --- a/sdk/csharp/generated/obj/project.nuget.cache +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": 2, - "dgSpecHash": "21O9MPH9XcE=", - "success": true, - "projectFilePath": "/home/runner/work/openapi/openapi/sdk/csharp/generated/Pachca.csproj", - "expectedPackageFiles": [ - "/home/runner/.nuget/packages/microsoft.netcore.app.ref/8.0.23/microsoft.netcore.app.ref.8.0.23.nupkg.sha512", - "/home/runner/.nuget/packages/microsoft.aspnetcore.app.ref/8.0.23/microsoft.aspnetcore.app.ref.8.0.23.nupkg.sha512" - ], - "logs": [] -} \ No newline at end of file diff --git a/sdk/go/README.md b/sdk/go/README.md index bb0b5c45..a13f934f 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -116,3 +116,42 @@ if err != nil { } } ``` + +## Тестирование + +Для unit-тестов используйте `NewStubPachcaClient()` — создаёт клиент без HTTP-подключения. + +Методы без переопределения возвращают `error` с сообщением `"Service.method is not implemented"`: + +```go +import ( + "context" + "testing" + + pachca "github.com/pachca/openapi/sdk/go/generated" +) + +// Мок-сервис +type mockMessagesService struct { + pachca.MessagesService +} + +func (m *mockMessagesService) GetMessage(ctx context.Context, id int64) (*pachca.Message, error) { + return &pachca.Message{ID: id, Content: "Test message", EntityID: 123}, nil +} + +// Тест +func TestGetMessage(t *testing.T) { + client := pachca.NewStubPachcaClient( + pachca.WithStubMessages(&mockMessagesService{}), + ) + + msg, err := client.Messages.GetMessage(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if msg.Content != "Test message" { + t.Errorf("expected 'Test message', got %q", msg.Content) + } +} +``` diff --git a/sdk/go/generated/client.go b/sdk/go/generated/client.go index 20316165..ca6c928f 100644 --- a/sdk/go/generated/client.go +++ b/sdk/go/generated/client.go @@ -10,7 +10,6 @@ import ( "mime/multipart" "net/http" "net/url" - "strconv" "time" ) @@ -24,38 +23,27 @@ func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { return t.base.RoundTrip(req) } -const maxRetries = 3 +type SecurityService interface { + GetAuditEvents(ctx context.Context, params *GetAuditEventsParams) (*GetAuditEventsResponse, error) + GetAuditEventsAll(ctx context.Context, params *GetAuditEventsParams) ([]AuditEvent, error) +} -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< 0 { url = baseURL[0] } +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 WithChats(service ChatsService) ClientOption { + return func(cfg *clientConfig) { cfg.chats = service } +} + +func WithCommon(service CommonService) ClientOption { + return func(cfg *clientConfig) { cfg.common = service } +} + +func WithGroupTags(service GroupTagsService) ClientOption { + return func(cfg *clientConfig) { cfg.groupTags = service } +} + +func WithLinkPreviews(service LinkPreviewsService) ClientOption { + return func(cfg *clientConfig) { cfg.linkPreviews = service } +} + +func WithMembers(service MembersService) ClientOption { + return func(cfg *clientConfig) { cfg.members = service } +} + +func WithMessages(service MessagesService) ClientOption { + return func(cfg *clientConfig) { cfg.messages = service } +} + +func WithProfile(service ProfileService) ClientOption { + return func(cfg *clientConfig) { cfg.profile = service } +} + +func WithReactions(service ReactionsService) ClientOption { + return func(cfg *clientConfig) { cfg.reactions = service } +} + +func WithReadMembers(service ReadMembersService) ClientOption { + return func(cfg *clientConfig) { cfg.readMembers = service } +} + +func WithSearch(service SearchService) ClientOption { + return func(cfg *clientConfig) { cfg.search = service } +} + +func WithSecurity(service SecurityService) ClientOption { + return func(cfg *clientConfig) { cfg.security = service } +} + +func WithTasks(service TasksService) ClientOption { + return func(cfg *clientConfig) { cfg.tasks = service } +} + +func WithThreads(service ThreadsService) ClientOption { + return func(cfg *clientConfig) { cfg.threads = service } +} + +func WithUsers(service UsersService) ClientOption { + return func(cfg *clientConfig) { cfg.users = service } +} + +func WithViews(service ViewsService) ClientOption { + return func(cfg *clientConfig) { cfg.views = service } +} + +func WithStubBots(service BotsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.bots = service } +} + +func WithStubChats(service ChatsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.chats = service } +} + +func WithStubCommon(service CommonService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.common = service } +} + +func WithStubGroupTags(service GroupTagsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.groupTags = service } +} + +func WithStubLinkPreviews(service LinkPreviewsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.linkPreviews = service } +} + +func WithStubMembers(service MembersService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.members = service } +} + +func WithStubMessages(service MessagesService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.messages = service } +} + +func WithStubProfile(service ProfileService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.profile = service } +} + +func WithStubReactions(service ReactionsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.reactions = service } +} + +func WithStubReadMembers(service ReadMembersService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.readMembers = service } +} + +func WithStubSearch(service SearchService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.search = service } +} + +func WithStubSecurity(service SecurityService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.security = service } +} + +func WithStubTasks(service TasksService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.tasks = service } +} + +func WithStubThreads(service ThreadsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.threads = service } +} + +func WithStubUsers(service UsersService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.users = service } +} + +func WithStubViews(service ViewsService) StubClientOption { + return func(cfg *stubClientConfig) { cfg.views = service } +} + +func NewPachcaClient(token string, opts ...ClientOption) *PachcaClient { + cfg := clientConfig{baseURL: DefaultBaseURL} + for _, opt := range opts { + opt(&cfg) + } client := &http.Client{ Transport: &authTransport{token: token, base: http.DefaultTransport}, CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -2583,21 +3198,46 @@ func NewPachcaClient(token string, baseURL ...string) *PachcaClient { }, } return &PachcaClient{ - Bots : &BotsService{baseURL: url, client: client}, - Chats : &ChatsService{baseURL: url, client: client}, - Common : &CommonService{baseURL: url, client: client}, - GroupTags : &GroupTagsService{baseURL: url, client: client}, - LinkPreviews: &LinkPreviewsService{baseURL: url, client: client}, - Members : &MembersService{baseURL: url, client: client}, - Messages : &MessagesService{baseURL: url, client: client}, - Profile : &ProfileService{baseURL: url, client: client}, - Reactions : &ReactionsService{baseURL: url, client: client}, - ReadMembers : &ReadMembersService{baseURL: url, client: client}, - Search : &SearchService{baseURL: url, client: client}, - Security : &SecurityService{baseURL: url, client: client}, - Tasks : &TasksService{baseURL: url, client: client}, - Threads : &ThreadsService{baseURL: url, client: client}, - Users : &UsersService{baseURL: url, client: client}, - Views : &ViewsService{baseURL: url, client: client}, + Bots : func() BotsService { if cfg.bots != nil { return cfg.bots }; return &BotsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Chats : func() ChatsService { if cfg.chats != nil { return cfg.chats }; return &ChatsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Common : func() CommonService { if cfg.common != nil { return cfg.common }; return &CommonServiceImpl{baseURL: cfg.baseURL, client: client} }(), + GroupTags : func() GroupTagsService { if cfg.groupTags != nil { return cfg.groupTags }; return &GroupTagsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + LinkPreviews: func() LinkPreviewsService { if cfg.linkPreviews != nil { return cfg.linkPreviews }; return &LinkPreviewsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Members : func() MembersService { if cfg.members != nil { return cfg.members }; return &MembersServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Messages : func() MessagesService { if cfg.messages != nil { return cfg.messages }; return &MessagesServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Profile : func() ProfileService { if cfg.profile != nil { return cfg.profile }; return &ProfileServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Reactions : func() ReactionsService { if cfg.reactions != nil { return cfg.reactions }; return &ReactionsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + ReadMembers : func() ReadMembersService { if cfg.readMembers != nil { return cfg.readMembers }; return &ReadMembersServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Search : func() SearchService { if cfg.search != nil { return cfg.search }; return &SearchServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Security : func() SecurityService { if cfg.security != nil { return cfg.security }; return &SecurityServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Tasks : func() TasksService { if cfg.tasks != nil { return cfg.tasks }; return &TasksServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Threads : func() ThreadsService { if cfg.threads != nil { return cfg.threads }; return &ThreadsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Users : func() UsersService { if cfg.users != nil { return cfg.users }; return &UsersServiceImpl{baseURL: cfg.baseURL, client: client} }(), + Views : func() ViewsService { if cfg.views != nil { return cfg.views }; return &ViewsServiceImpl{baseURL: cfg.baseURL, client: client} }(), + } +} + +func NewStubPachcaClient(opts ...StubClientOption) *PachcaClient { + cfg := stubClientConfig{} + for _, opt := range opts { + opt(&cfg) + } + return &PachcaClient{ + Bots : func() BotsService { if cfg.bots != nil { return cfg.bots }; return &BotsServiceStub{} }(), + Chats : func() ChatsService { if cfg.chats != nil { return cfg.chats }; return &ChatsServiceStub{} }(), + Common : func() CommonService { if cfg.common != nil { return cfg.common }; return &CommonServiceStub{} }(), + GroupTags : func() GroupTagsService { if cfg.groupTags != nil { return cfg.groupTags }; return &GroupTagsServiceStub{} }(), + LinkPreviews: func() LinkPreviewsService { if cfg.linkPreviews != nil { return cfg.linkPreviews }; return &LinkPreviewsServiceStub{} }(), + Members : func() MembersService { if cfg.members != nil { return cfg.members }; return &MembersServiceStub{} }(), + Messages : func() MessagesService { if cfg.messages != nil { return cfg.messages }; return &MessagesServiceStub{} }(), + Profile : func() ProfileService { if cfg.profile != nil { return cfg.profile }; return &ProfileServiceStub{} }(), + Reactions : func() ReactionsService { if cfg.reactions != nil { return cfg.reactions }; return &ReactionsServiceStub{} }(), + ReadMembers : func() ReadMembersService { if cfg.readMembers != nil { return cfg.readMembers }; return &ReadMembersServiceStub{} }(), + Search : func() SearchService { if cfg.search != nil { return cfg.search }; return &SearchServiceStub{} }(), + Security : func() SecurityService { if cfg.security != nil { return cfg.security }; return &SecurityServiceStub{} }(), + Tasks : func() TasksService { if cfg.tasks != nil { return cfg.tasks }; return &TasksServiceStub{} }(), + Threads : func() ThreadsService { if cfg.threads != nil { return cfg.threads }; return &ThreadsServiceStub{} }(), + Users : func() UsersService { if cfg.users != nil { return cfg.users }; return &UsersServiceStub{} }(), + Views : func() ViewsService { if cfg.views != nil { return cfg.views }; return &ViewsServiceStub{} }(), } } diff --git a/sdk/go/generated/utils.go b/sdk/go/generated/utils.go index d0527665..eb9a853a 100644 --- a/sdk/go/generated/utils.go +++ b/sdk/go/generated/utils.go @@ -1,6 +1,61 @@ 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 addJitter(delay time.Duration) time.Duration { + factor := 0.5 + rand.Float64()*0.5 + return time.Duration(float64(delay) * factor) +} + +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<() +coEvery { mockMessages.getMessage(any()) } returns Message( + id = 1, + content = "Test message", + entityId = 123 +) + +// Тест +val client = PachcaClient.stub(messages = mockMessages) + +val message = client.messages.getMessage(1) +assertEquals("Test message", message.content) +``` 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 9f968882..3c2b53fd 100644 --- a/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt +++ b/sdk/kotlin/generated/src/main/kotlin/com/pachca/Client.kt @@ -14,10 +14,7 @@ import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json import java.io.Closeable -class SecurityService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface SecurityService { suspend fun getAuditEvents( startTime: String? = null, endTime: String? = null, @@ -28,6 +25,36 @@ class SecurityService internal constructor( entityType: String? = null, limit: Int? = null, cursor: String? = null, + ): GetAuditEventsResponse = + throw NotImplementedError("Security.getAuditEvents is not implemented") + + suspend fun getAuditEventsAll( + startTime: String? = null, + endTime: String? = null, + eventKey: AuditEventKey? = null, + actorId: String? = null, + actorType: String? = null, + entityId: String? = null, + entityType: String? = null, + limit: Int? = null, + ): List = + throw NotImplementedError("Security.getAuditEventsAll is not implemented") +} + +class SecurityServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : SecurityService { + override suspend fun getAuditEvents( + startTime: String?, + endTime: String?, + eventKey: AuditEventKey?, + actorId: String?, + actorType: String?, + entityId: String?, + entityType: String?, + limit: Int?, + cursor: String?, ): GetAuditEventsResponse { val response = client.get("$baseUrl/audit_events") { startTime?.let { parameter("start_time", it) } @@ -47,15 +74,15 @@ class SecurityService internal constructor( } } - suspend fun getAuditEventsAll( - startTime: String? = null, - endTime: String? = null, - eventKey: AuditEventKey? = null, - actorId: String? = null, - actorType: String? = null, - entityId: String? = null, - entityType: String? = null, - limit: Int? = null, + override suspend fun getAuditEventsAll( + startTime: String?, + endTime: String?, + eventKey: AuditEventKey?, + actorId: String?, + actorType: String?, + entityId: String?, + entityType: String?, + limit: Int?, ): List { val items = mutableListOf() var cursor: String? = null @@ -78,11 +105,25 @@ class SecurityService internal constructor( } } -class BotsService internal constructor( +interface BotsService { + suspend fun getWebhookEvents(limit: Int? = null, cursor: String? = null): GetWebhookEventsResponse = + throw NotImplementedError("Bots.getWebhookEvents is not implemented") + + suspend fun getWebhookEventsAll(limit: Int? = null): List = + throw NotImplementedError("Bots.getWebhookEventsAll is not implemented") + + suspend fun updateBot(id: Int, request: BotUpdateRequest): BotResponse = + throw NotImplementedError("Bots.updateBot is not implemented") + + suspend fun deleteWebhookEvent(id: String) = + throw NotImplementedError("Bots.deleteWebhookEvent is not implemented") +} + +class BotsServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun getWebhookEvents(limit: Int? = null, cursor: String? = null): GetWebhookEventsResponse { +) : 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) } @@ -94,7 +135,7 @@ class BotsService internal constructor( } } - suspend fun getWebhookEventsAll(limit: Int? = null): List { + override suspend fun getWebhookEventsAll(limit: Int?): List { val items = mutableListOf() var cursor: String? = null do { @@ -105,7 +146,7 @@ class BotsService internal constructor( return items } - suspend fun updateBot(id: Int, request: BotUpdateRequest): BotResponse { + override suspend fun updateBot(id: Int, request: BotUpdateRequest): BotResponse { val response = client.put("$baseUrl/bots/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -117,7 +158,7 @@ class BotsService internal constructor( } } - suspend fun deleteWebhookEvent(id: String) { + override suspend fun deleteWebhookEvent(id: String) { val response = client.delete("$baseUrl/webhooks/events/$id") when (response.status.value) { 204 -> return @@ -127,10 +168,7 @@ class BotsService internal constructor( } } -class ChatsService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface ChatsService { suspend fun listChats( sortId: SortOrder? = null, availability: ChatAvailability? = null, @@ -139,6 +177,47 @@ class ChatsService internal constructor( personal: Boolean? = null, limit: Int? = null, cursor: String? = null, + ): ListChatsResponse = + throw NotImplementedError("Chats.listChats is not implemented") + + suspend fun listChatsAll( + sortId: SortOrder? = null, + availability: ChatAvailability? = null, + lastMessageAtAfter: String? = null, + lastMessageAtBefore: String? = null, + personal: Boolean? = null, + limit: Int? = null, + ): List = + throw NotImplementedError("Chats.listChatsAll is not implemented") + + suspend fun getChat(id: Int): Chat = + throw NotImplementedError("Chats.getChat is not implemented") + + suspend fun createChat(request: ChatCreateRequest): Chat = + throw NotImplementedError("Chats.createChat is not implemented") + + suspend fun updateChat(id: Int, request: ChatUpdateRequest): Chat = + throw NotImplementedError("Chats.updateChat is not implemented") + + suspend fun archiveChat(id: Int) = + throw NotImplementedError("Chats.archiveChat is not implemented") + + suspend fun unarchiveChat(id: Int) = + throw NotImplementedError("Chats.unarchiveChat is not implemented") +} + +class ChatsServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : ChatsService { + override suspend fun listChats( + sortId: SortOrder?, + availability: ChatAvailability?, + lastMessageAtAfter: String?, + lastMessageAtBefore: String?, + personal: Boolean?, + limit: Int?, + cursor: String?, ): ListChatsResponse { val response = client.get("$baseUrl/chats") { sortId?.let { parameter("sort[{field}]", it.value) } @@ -156,13 +235,13 @@ class ChatsService internal constructor( } } - suspend fun listChatsAll( - sortId: SortOrder? = null, - availability: ChatAvailability? = null, - lastMessageAtAfter: String? = null, - lastMessageAtBefore: String? = null, - personal: Boolean? = null, - limit: Int? = null, + override suspend fun listChatsAll( + sortId: SortOrder?, + availability: ChatAvailability?, + lastMessageAtAfter: String?, + lastMessageAtBefore: String?, + personal: Boolean?, + limit: Int?, ): List { val items = mutableListOf() var cursor: String? = null @@ -182,7 +261,7 @@ class ChatsService internal constructor( return items } - suspend fun getChat(id: Int): Chat { + override suspend fun getChat(id: Int): Chat { val response = client.get("$baseUrl/chats/$id") return when (response.status.value) { 200 -> response.body().data @@ -191,7 +270,7 @@ class ChatsService internal constructor( } } - suspend fun createChat(request: ChatCreateRequest): Chat { + override suspend fun createChat(request: ChatCreateRequest): Chat { val response = client.post("$baseUrl/chats") { contentType(ContentType.Application.Json) setBody(request) @@ -203,7 +282,7 @@ class ChatsService internal constructor( } } - suspend fun updateChat(id: Int, request: ChatUpdateRequest): Chat { + override suspend fun updateChat(id: Int, request: ChatUpdateRequest): Chat { val response = client.put("$baseUrl/chats/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -215,7 +294,7 @@ class ChatsService internal constructor( } } - suspend fun archiveChat(id: Int) { + override suspend fun archiveChat(id: Int) { val response = client.put("$baseUrl/chats/$id/archive") when (response.status.value) { 204 -> return @@ -224,7 +303,7 @@ class ChatsService internal constructor( } } - suspend fun unarchiveChat(id: Int) { + override suspend fun unarchiveChat(id: Int) { val response = client.put("$baseUrl/chats/$id/unarchive") when (response.status.value) { 204 -> return @@ -234,11 +313,28 @@ class ChatsService internal constructor( } } -class CommonService internal constructor( +interface CommonService { + suspend fun downloadExport(id: Int): String = + throw NotImplementedError("Common.downloadExport is not implemented") + + suspend fun listProperties(entityType: SearchEntityType): ListPropertiesResponse = + throw NotImplementedError("Common.listProperties is not implemented") + + suspend fun requestExport(request: ExportRequest) = + throw NotImplementedError("Common.requestExport is not implemented") + + suspend fun uploadFile(directUrl: String, request: FileUploadRequest) = + throw NotImplementedError("Common.uploadFile is not implemented") + + suspend fun getUploadParams(): UploadParams = + throw NotImplementedError("Common.getUploadParams is not implemented") +} + +class CommonServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun downloadExport(id: Int): String { +) : CommonService { + override suspend fun downloadExport(id: Int): String { val response = client.get("$baseUrl/chats/exports/$id") return when (response.status.value) { 302 -> response.headers[HttpHeaders.Location] @@ -248,7 +344,7 @@ class CommonService internal constructor( } } - suspend fun listProperties(entityType: SearchEntityType): ListPropertiesResponse { + override suspend fun listProperties(entityType: SearchEntityType): ListPropertiesResponse { val response = client.get("$baseUrl/custom_properties") { parameter("entity_type", entityType.value) } @@ -259,7 +355,7 @@ class CommonService internal constructor( } } - suspend fun requestExport(request: ExportRequest) { + override suspend fun requestExport(request: ExportRequest) { val response = client.post("$baseUrl/chats/exports") { contentType(ContentType.Application.Json) setBody(request) @@ -271,7 +367,7 @@ class CommonService internal constructor( } } - suspend fun uploadFile(directUrl: String, request: FileUploadRequest) { + override suspend fun uploadFile(directUrl: String, request: FileUploadRequest) { val response = client.submitFormWithBinaryData( directUrl, formData { @@ -296,7 +392,7 @@ class CommonService internal constructor( } } - suspend fun getUploadParams(): UploadParams { + override suspend fun getUploadParams(): UploadParams { val response = client.post("$baseUrl/uploads") return when (response.status.value) { 201 -> response.body() @@ -306,15 +402,54 @@ class CommonService internal constructor( } } -class MembersService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface MembersService { suspend fun listMembers( id: Int, role: ChatMemberRoleFilter? = null, limit: Int? = null, cursor: String? = null, + ): ListMembersResponse = + throw NotImplementedError("Members.listMembers is not implemented") + + suspend fun listMembersAll( + id: Int, + role: ChatMemberRoleFilter? = null, + limit: Int? = null, + ): List = + throw NotImplementedError("Members.listMembersAll is not implemented") + + suspend fun addTags(id: Int, groupTagIds: List) = + throw NotImplementedError("Members.addTags is not implemented") + + suspend fun addMembers(id: Int, request: AddMembersRequest) = + throw NotImplementedError("Members.addMembers is not implemented") + + suspend fun updateMemberRole( + id: Int, + userId: Int, + role: ChatMemberRole, + ) = + throw NotImplementedError("Members.updateMemberRole is not implemented") + + suspend fun removeTag(id: Int, tagId: Int) = + throw NotImplementedError("Members.removeTag is not implemented") + + suspend fun leaveChat(id: Int) = + throw NotImplementedError("Members.leaveChat is not implemented") + + suspend fun removeMember(id: Int, userId: Int) = + throw NotImplementedError("Members.removeMember is not implemented") +} + +class MembersServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : MembersService { + override suspend fun listMembers( + id: Int, + role: ChatMemberRoleFilter?, + limit: Int?, + cursor: String?, ): ListMembersResponse { val response = client.get("$baseUrl/chats/$id/members") { role?.let { parameter("role", it.value) } @@ -328,10 +463,10 @@ class MembersService internal constructor( } } - suspend fun listMembersAll( + override suspend fun listMembersAll( id: Int, - role: ChatMemberRoleFilter? = null, - limit: Int? = null, + role: ChatMemberRoleFilter?, + limit: Int?, ): List { val items = mutableListOf() var cursor: String? = null @@ -348,7 +483,7 @@ class MembersService internal constructor( return items } - suspend fun addTags(id: Int, groupTagIds: List) { + override suspend fun addTags(id: Int, groupTagIds: List) { val response = client.post("$baseUrl/chats/$id/group_tags") { contentType(ContentType.Application.Json) setBody(AddTagsRequest(groupTagIds = groupTagIds)) @@ -360,7 +495,7 @@ class MembersService internal constructor( } } - suspend fun addMembers(id: Int, request: AddMembersRequest) { + override suspend fun addMembers(id: Int, request: AddMembersRequest) { val response = client.post("$baseUrl/chats/$id/members") { contentType(ContentType.Application.Json) setBody(request) @@ -372,7 +507,7 @@ class MembersService internal constructor( } } - suspend fun updateMemberRole( + override suspend fun updateMemberRole( id: Int, userId: Int, role: ChatMemberRole, @@ -388,7 +523,7 @@ class MembersService internal constructor( } } - suspend fun removeTag(id: Int, tagId: Int) { + override suspend fun removeTag(id: Int, tagId: Int) { val response = client.delete("$baseUrl/chats/$id/group_tags/$tagId") when (response.status.value) { 204 -> return @@ -397,7 +532,7 @@ class MembersService internal constructor( } } - suspend fun leaveChat(id: Int) { + override suspend fun leaveChat(id: Int) { val response = client.delete("$baseUrl/chats/$id/leave") when (response.status.value) { 204 -> return @@ -406,7 +541,7 @@ class MembersService internal constructor( } } - suspend fun removeMember(id: Int, userId: Int) { + override suspend fun removeMember(id: Int, userId: Int) { val response = client.delete("$baseUrl/chats/$id/members/$userId") when (response.status.value) { 204 -> return @@ -416,14 +551,48 @@ class MembersService internal constructor( } } -class GroupTagsService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface GroupTagsService { suspend fun listTags( names: TagNamesFilter? = null, limit: Int? = null, cursor: String? = null, + ): ListTagsResponse = + throw NotImplementedError("Group tags.listTags is not implemented") + + suspend fun listTagsAll(names: TagNamesFilter? = null, limit: Int? = null): List = + throw NotImplementedError("Group tags.listTagsAll is not implemented") + + suspend fun getTag(id: Int): GroupTag = + throw NotImplementedError("Group tags.getTag is not implemented") + + suspend fun getTagUsers( + id: Int, + limit: Int? = null, + cursor: String? = null, + ): ListMembersResponse = + throw NotImplementedError("Group tags.getTagUsers is not implemented") + + suspend fun getTagUsersAll(id: Int, limit: Int? = null): List = + throw NotImplementedError("Group tags.getTagUsersAll is not implemented") + + suspend fun createTag(request: GroupTagRequest): GroupTag = + throw NotImplementedError("Group tags.createTag is not implemented") + + suspend fun updateTag(id: Int, request: GroupTagRequest): GroupTag = + throw NotImplementedError("Group tags.updateTag is not implemented") + + suspend fun deleteTag(id: Int) = + throw NotImplementedError("Group tags.deleteTag is not implemented") +} + +class GroupTagsServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : GroupTagsService { + override suspend fun listTags( + names: TagNamesFilter?, + limit: Int?, + cursor: String?, ): ListTagsResponse { val response = client.get("$baseUrl/group_tags") { names?.let { parameter("names", it) } @@ -437,7 +606,7 @@ class GroupTagsService internal constructor( } } - suspend fun listTagsAll(names: TagNamesFilter? = null, limit: Int? = null): List { + override suspend fun listTagsAll(names: TagNamesFilter?, limit: Int?): List { val items = mutableListOf() var cursor: String? = null do { @@ -448,7 +617,7 @@ class GroupTagsService internal constructor( return items } - suspend fun getTag(id: Int): GroupTag { + override suspend fun getTag(id: Int): GroupTag { val response = client.get("$baseUrl/group_tags/$id") return when (response.status.value) { 200 -> response.body().data @@ -457,10 +626,10 @@ class GroupTagsService internal constructor( } } - suspend fun getTagUsers( + override suspend fun getTagUsers( id: Int, - limit: Int? = null, - cursor: String? = null, + limit: Int?, + cursor: String?, ): ListMembersResponse { val response = client.get("$baseUrl/group_tags/$id/users") { limit?.let { parameter("limit", it) } @@ -473,7 +642,7 @@ class GroupTagsService internal constructor( } } - suspend fun getTagUsersAll(id: Int, limit: Int? = null): List { + override suspend fun getTagUsersAll(id: Int, limit: Int?): List { val items = mutableListOf() var cursor: String? = null do { @@ -484,7 +653,7 @@ class GroupTagsService internal constructor( return items } - suspend fun createTag(request: GroupTagRequest): GroupTag { + override suspend fun createTag(request: GroupTagRequest): GroupTag { val response = client.post("$baseUrl/group_tags") { contentType(ContentType.Application.Json) setBody(request) @@ -496,7 +665,7 @@ class GroupTagsService internal constructor( } } - suspend fun updateTag(id: Int, request: GroupTagRequest): GroupTag { + override suspend fun updateTag(id: Int, request: GroupTagRequest): GroupTag { val response = client.put("$baseUrl/group_tags/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -508,7 +677,7 @@ class GroupTagsService internal constructor( } } - suspend fun deleteTag(id: Int) { + override suspend fun deleteTag(id: Int) { val response = client.delete("$baseUrl/group_tags/$id") when (response.status.value) { 204 -> return @@ -518,15 +687,50 @@ class GroupTagsService internal constructor( } } -class MessagesService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface MessagesService { suspend fun listChatMessages( chatId: Int, sortId: SortOrder? = null, limit: Int? = null, cursor: String? = null, + ): ListChatMessagesResponse = + throw NotImplementedError("Messages.listChatMessages is not implemented") + + suspend fun listChatMessagesAll( + chatId: Int, + sortId: SortOrder? = null, + limit: Int? = null, + ): List = + throw NotImplementedError("Messages.listChatMessagesAll is not implemented") + + suspend fun getMessage(id: Int): Message = + throw NotImplementedError("Messages.getMessage is not implemented") + + suspend fun createMessage(request: MessageCreateRequest): Message = + throw NotImplementedError("Messages.createMessage is not implemented") + + suspend fun pinMessage(id: Int) = + throw NotImplementedError("Messages.pinMessage is not implemented") + + suspend fun updateMessage(id: Int, request: MessageUpdateRequest): Message = + throw NotImplementedError("Messages.updateMessage is not implemented") + + suspend fun deleteMessage(id: Int) = + throw NotImplementedError("Messages.deleteMessage is not implemented") + + suspend fun unpinMessage(id: Int) = + throw NotImplementedError("Messages.unpinMessage is not implemented") +} + +class MessagesServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : MessagesService { + override suspend fun listChatMessages( + chatId: Int, + sortId: SortOrder?, + limit: Int?, + cursor: String?, ): ListChatMessagesResponse { val response = client.get("$baseUrl/messages") { parameter("chat_id", chatId) @@ -541,10 +745,10 @@ class MessagesService internal constructor( } } - suspend fun listChatMessagesAll( + override suspend fun listChatMessagesAll( chatId: Int, - sortId: SortOrder? = null, - limit: Int? = null, + sortId: SortOrder?, + limit: Int?, ): List { val items = mutableListOf() var cursor: String? = null @@ -561,7 +765,7 @@ class MessagesService internal constructor( return items } - suspend fun getMessage(id: Int): Message { + override suspend fun getMessage(id: Int): Message { val response = client.get("$baseUrl/messages/$id") return when (response.status.value) { 200 -> response.body().data @@ -570,7 +774,7 @@ class MessagesService internal constructor( } } - suspend fun createMessage(request: MessageCreateRequest): Message { + override suspend fun createMessage(request: MessageCreateRequest): Message { val response = client.post("$baseUrl/messages") { contentType(ContentType.Application.Json) setBody(request) @@ -582,7 +786,7 @@ class MessagesService internal constructor( } } - suspend fun pinMessage(id: Int) { + override suspend fun pinMessage(id: Int) { val response = client.post("$baseUrl/messages/$id/pin") when (response.status.value) { 204 -> return @@ -591,7 +795,7 @@ class MessagesService internal constructor( } } - suspend fun updateMessage(id: Int, request: MessageUpdateRequest): Message { + override suspend fun updateMessage(id: Int, request: MessageUpdateRequest): Message { val response = client.put("$baseUrl/messages/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -603,7 +807,7 @@ class MessagesService internal constructor( } } - suspend fun deleteMessage(id: Int) { + override suspend fun deleteMessage(id: Int) { val response = client.delete("$baseUrl/messages/$id") when (response.status.value) { 204 -> return @@ -612,7 +816,7 @@ class MessagesService internal constructor( } } - suspend fun unpinMessage(id: Int) { + override suspend fun unpinMessage(id: Int) { val response = client.delete("$baseUrl/messages/$id/pin") when (response.status.value) { 204 -> return @@ -622,11 +826,16 @@ class MessagesService internal constructor( } } -class LinkPreviewsService internal constructor( +interface LinkPreviewsService { + suspend fun createLinkPreviews(id: Int, request: LinkPreviewsRequest) = + throw NotImplementedError("Link Previews.createLinkPreviews is not implemented") +} + +class LinkPreviewsServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun createLinkPreviews(id: Int, request: LinkPreviewsRequest) { +) : LinkPreviewsService { + override suspend fun createLinkPreviews(id: Int, request: LinkPreviewsRequest) { val response = client.post("$baseUrl/messages/$id/link_previews") { contentType(ContentType.Application.Json) setBody(request) @@ -639,14 +848,36 @@ class LinkPreviewsService internal constructor( } } -class ReactionsService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface ReactionsService { suspend fun listReactions( id: Int, limit: Int? = null, cursor: String? = null, + ): ListReactionsResponse = + throw NotImplementedError("Reactions.listReactions is not implemented") + + suspend fun listReactionsAll(id: Int, limit: Int? = null): List = + throw NotImplementedError("Reactions.listReactionsAll is not implemented") + + suspend fun addReaction(id: Int, request: ReactionRequest): Reaction = + throw NotImplementedError("Reactions.addReaction is not implemented") + + suspend fun removeReaction( + id: Int, + code: String, + name: String? = null, + ) = + throw NotImplementedError("Reactions.removeReaction is not implemented") +} + +class ReactionsServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : ReactionsService { + override suspend fun listReactions( + id: Int, + limit: Int?, + cursor: String?, ): ListReactionsResponse { val response = client.get("$baseUrl/messages/$id/reactions") { limit?.let { parameter("limit", it) } @@ -659,7 +890,7 @@ class ReactionsService internal constructor( } } - suspend fun listReactionsAll(id: Int, limit: Int? = null): List { + override suspend fun listReactionsAll(id: Int, limit: Int?): List { val items = mutableListOf() var cursor: String? = null do { @@ -670,7 +901,7 @@ class ReactionsService internal constructor( return items } - suspend fun addReaction(id: Int, request: ReactionRequest): Reaction { + override suspend fun addReaction(id: Int, request: ReactionRequest): Reaction { val response = client.post("$baseUrl/messages/$id/reactions") { contentType(ContentType.Application.Json) setBody(request) @@ -682,10 +913,10 @@ class ReactionsService internal constructor( } } - suspend fun removeReaction( + override suspend fun removeReaction( id: Int, code: String, - name: String? = null, + name: String?, ) { val response = client.delete("$baseUrl/messages/$id/reactions") { parameter("code", code) @@ -699,14 +930,23 @@ class ReactionsService internal constructor( } } -class ReadMembersService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface ReadMembersService { suspend fun listReadMembers( id: Int, limit: Int? = null, cursor: String? = null, + ): Any = + throw NotImplementedError("Read members.listReadMembers is not implemented") +} + +class ReadMembersServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : ReadMembersService { + override suspend fun listReadMembers( + id: Int, + limit: Int?, + cursor: String?, ): Any { val response = client.get("$baseUrl/messages/$id/read_member_ids") { limit?.let { parameter("limit", it) } @@ -720,11 +960,19 @@ class ReadMembersService internal constructor( } } -class ThreadsService internal constructor( +interface ThreadsService { + suspend fun getThread(id: Int): Thread = + throw NotImplementedError("Threads.getThread is not implemented") + + suspend fun createThread(id: Int): Thread = + throw NotImplementedError("Threads.createThread is not implemented") +} + +class ThreadsServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun getThread(id: Int): Thread { +) : ThreadsService { + override suspend fun getThread(id: Int): Thread { val response = client.get("$baseUrl/threads/$id") return when (response.status.value) { 200 -> response.body().data @@ -733,7 +981,7 @@ class ThreadsService internal constructor( } } - suspend fun createThread(id: Int): Thread { + override suspend fun createThread(id: Int): Thread { val response = client.post("$baseUrl/messages/$id/thread") return when (response.status.value) { 201 -> response.body().data @@ -743,11 +991,28 @@ class ThreadsService internal constructor( } } -class ProfileService internal constructor( +interface ProfileService { + suspend fun getTokenInfo(): AccessTokenInfo = + throw NotImplementedError("Profile.getTokenInfo is not implemented") + + suspend fun getProfile(): User = + throw NotImplementedError("Profile.getProfile is not implemented") + + suspend fun getStatus(): Any = + throw NotImplementedError("Profile.getStatus is not implemented") + + suspend fun updateStatus(request: StatusUpdateRequest): UserStatus = + throw NotImplementedError("Profile.updateStatus is not implemented") + + suspend fun deleteStatus() = + throw NotImplementedError("Profile.deleteStatus is not implemented") +} + +class ProfileServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun getTokenInfo(): AccessTokenInfo { +) : ProfileService { + override suspend fun getTokenInfo(): AccessTokenInfo { val response = client.get("$baseUrl/oauth/token/info") return when (response.status.value) { 200 -> response.body().data @@ -756,7 +1021,7 @@ class ProfileService internal constructor( } } - suspend fun getProfile(): User { + override suspend fun getProfile(): User { val response = client.get("$baseUrl/profile") return when (response.status.value) { 200 -> response.body().data @@ -765,7 +1030,7 @@ class ProfileService internal constructor( } } - suspend fun getStatus(): Any { + override suspend fun getStatus(): Any { val response = client.get("$baseUrl/profile/status") return when (response.status.value) { 200 -> response.body() @@ -774,7 +1039,7 @@ class ProfileService internal constructor( } } - suspend fun updateStatus(request: StatusUpdateRequest): UserStatus { + override suspend fun updateStatus(request: StatusUpdateRequest): UserStatus { val response = client.put("$baseUrl/profile/status") { contentType(ContentType.Application.Json) setBody(request) @@ -786,7 +1051,7 @@ class ProfileService internal constructor( } } - suspend fun deleteStatus() { + override suspend fun deleteStatus() { val response = client.delete("$baseUrl/profile/status") when (response.status.value) { 204 -> return @@ -796,10 +1061,7 @@ class ProfileService internal constructor( } } -class SearchService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface SearchService { suspend fun searchChats( query: String? = null, limit: Int? = null, @@ -810,6 +1072,84 @@ class SearchService internal constructor( active: Boolean? = null, chatSubtype: ChatSubtype? = null, personal: Boolean? = null, + ): ListChatsResponse = + throw NotImplementedError("Search.searchChats is not implemented") + + suspend fun searchChatsAll( + query: String? = null, + limit: Int? = null, + order: SortOrder? = null, + createdFrom: String? = null, + createdTo: String? = null, + active: Boolean? = null, + chatSubtype: ChatSubtype? = null, + personal: Boolean? = null, + ): List = + throw NotImplementedError("Search.searchChatsAll is not implemented") + + suspend fun searchMessages( + query: String? = null, + limit: Int? = null, + cursor: String? = null, + order: SortOrder? = null, + createdFrom: String? = null, + createdTo: String? = null, + chatIds: List? = null, + userIds: List? = null, + active: Boolean? = null, + ): ListChatMessagesResponse = + throw NotImplementedError("Search.searchMessages is not implemented") + + suspend fun searchMessagesAll( + query: String? = null, + limit: Int? = null, + order: SortOrder? = null, + createdFrom: String? = null, + createdTo: String? = null, + chatIds: List? = null, + userIds: List? = null, + active: Boolean? = null, + ): List = + throw NotImplementedError("Search.searchMessagesAll is not implemented") + + suspend fun searchUsers( + query: String? = null, + limit: Int? = null, + cursor: String? = null, + sort: SearchSortOrder? = null, + order: SortOrder? = null, + createdFrom: String? = null, + createdTo: String? = null, + companyRoles: List? = null, + ): ListMembersResponse = + throw NotImplementedError("Search.searchUsers is not implemented") + + suspend fun searchUsersAll( + query: String? = null, + limit: Int? = null, + sort: SearchSortOrder? = null, + order: SortOrder? = null, + createdFrom: String? = null, + createdTo: String? = null, + companyRoles: List? = null, + ): List = + throw NotImplementedError("Search.searchUsersAll is not implemented") +} + +class SearchServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : SearchService { + override suspend fun searchChats( + query: String?, + limit: Int?, + cursor: String?, + order: SortOrder?, + createdFrom: String?, + createdTo: String?, + active: Boolean?, + chatSubtype: ChatSubtype?, + personal: Boolean?, ): ListChatsResponse { val response = client.get("$baseUrl/search/chats") { query?.let { parameter("query", it) } @@ -829,15 +1169,15 @@ class SearchService internal constructor( } } - suspend fun searchChatsAll( - query: String? = null, - limit: Int? = null, - order: SortOrder? = null, - createdFrom: String? = null, - createdTo: String? = null, - active: Boolean? = null, - chatSubtype: ChatSubtype? = null, - personal: Boolean? = null, + override suspend fun searchChatsAll( + query: String?, + limit: Int?, + order: SortOrder?, + createdFrom: String?, + createdTo: String?, + active: Boolean?, + chatSubtype: ChatSubtype?, + personal: Boolean?, ): List { val items = mutableListOf() var cursor: String? = null @@ -859,16 +1199,16 @@ class SearchService internal constructor( return items } - suspend fun searchMessages( - query: String? = null, - limit: Int? = null, - cursor: String? = null, - order: SortOrder? = null, - createdFrom: String? = null, - createdTo: String? = null, - chatIds: List? = null, - userIds: List? = null, - active: Boolean? = null, + override suspend fun searchMessages( + query: String?, + limit: Int?, + cursor: String?, + order: SortOrder?, + createdFrom: String?, + createdTo: String?, + chatIds: List?, + userIds: List?, + active: Boolean?, ): ListChatMessagesResponse { val response = client.get("$baseUrl/search/messages") { query?.let { parameter("query", it) } @@ -888,15 +1228,15 @@ class SearchService internal constructor( } } - suspend fun searchMessagesAll( - query: String? = null, - limit: Int? = null, - order: SortOrder? = null, - createdFrom: String? = null, - createdTo: String? = null, - chatIds: List? = null, - userIds: List? = null, - active: Boolean? = null, + override suspend fun searchMessagesAll( + query: String?, + limit: Int?, + order: SortOrder?, + createdFrom: String?, + createdTo: String?, + chatIds: List?, + userIds: List?, + active: Boolean?, ): List { val items = mutableListOf() var cursor: String? = null @@ -918,15 +1258,15 @@ class SearchService internal constructor( return items } - suspend fun searchUsers( - query: String? = null, - limit: Int? = null, - cursor: String? = null, - sort: SearchSortOrder? = null, - order: SortOrder? = null, - createdFrom: String? = null, - createdTo: String? = null, - companyRoles: List? = null, + override suspend fun searchUsers( + query: String?, + limit: Int?, + cursor: String?, + sort: SearchSortOrder?, + order: SortOrder?, + createdFrom: String?, + createdTo: String?, + companyRoles: List?, ): ListMembersResponse { val response = client.get("$baseUrl/search/users") { query?.let { parameter("query", it) } @@ -945,14 +1285,14 @@ class SearchService internal constructor( } } - suspend fun searchUsersAll( - query: String? = null, - limit: Int? = null, - sort: SearchSortOrder? = null, - order: SortOrder? = null, - createdFrom: String? = null, - createdTo: String? = null, - companyRoles: List? = null, + override suspend fun searchUsersAll( + query: String?, + limit: Int?, + sort: SearchSortOrder?, + order: SortOrder?, + createdFrom: String?, + createdTo: String?, + companyRoles: List?, ): List { val items = mutableListOf() var cursor: String? = null @@ -974,11 +1314,31 @@ class SearchService internal constructor( } } -class TasksService internal constructor( +interface TasksService { + suspend fun listTasks(limit: Int? = null, cursor: String? = null): ListTasksResponse = + throw NotImplementedError("Tasks.listTasks is not implemented") + + suspend fun listTasksAll(limit: Int? = null): List = + throw NotImplementedError("Tasks.listTasksAll is not implemented") + + suspend fun getTask(id: Int): Task = + throw NotImplementedError("Tasks.getTask is not implemented") + + suspend fun createTask(request: TaskCreateRequest): Task = + throw NotImplementedError("Tasks.createTask is not implemented") + + suspend fun updateTask(id: Int, request: TaskUpdateRequest): Task = + throw NotImplementedError("Tasks.updateTask is not implemented") + + suspend fun deleteTask(id: Int) = + throw NotImplementedError("Tasks.deleteTask is not implemented") +} + +class TasksServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun listTasks(limit: Int? = null, cursor: String? = null): ListTasksResponse { +) : TasksService { + override suspend fun listTasks(limit: Int?, cursor: String?): ListTasksResponse { val response = client.get("$baseUrl/tasks") { limit?.let { parameter("limit", it) } cursor?.let { parameter("cursor", it) } @@ -990,7 +1350,7 @@ class TasksService internal constructor( } } - suspend fun listTasksAll(limit: Int? = null): List { + override suspend fun listTasksAll(limit: Int?): List { val items = mutableListOf() var cursor: String? = null do { @@ -1001,7 +1361,7 @@ class TasksService internal constructor( return items } - suspend fun getTask(id: Int): Task { + override suspend fun getTask(id: Int): Task { val response = client.get("$baseUrl/tasks/$id") return when (response.status.value) { 200 -> response.body().data @@ -1010,7 +1370,7 @@ class TasksService internal constructor( } } - suspend fun createTask(request: TaskCreateRequest): Task { + override suspend fun createTask(request: TaskCreateRequest): Task { val response = client.post("$baseUrl/tasks") { contentType(ContentType.Application.Json) setBody(request) @@ -1022,7 +1382,7 @@ class TasksService internal constructor( } } - suspend fun updateTask(id: Int, request: TaskUpdateRequest): Task { + override suspend fun updateTask(id: Int, request: TaskUpdateRequest): Task { val response = client.put("$baseUrl/tasks/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -1034,7 +1394,7 @@ class TasksService internal constructor( } } - suspend fun deleteTask(id: Int) { + override suspend fun deleteTask(id: Int) { val response = client.delete("$baseUrl/tasks/$id") when (response.status.value) { 204 -> return @@ -1044,14 +1404,47 @@ class TasksService internal constructor( } } -class UsersService internal constructor( - private val baseUrl: String, - private val client: HttpClient, -) { +interface UsersService { suspend fun listUsers( query: String? = null, limit: Int? = null, cursor: String? = null, + ): ListMembersResponse = + throw NotImplementedError("Users.listUsers is not implemented") + + suspend fun listUsersAll(query: String? = null, limit: Int? = null): List = + throw NotImplementedError("Users.listUsersAll is not implemented") + + suspend fun getUser(id: Int): User = + throw NotImplementedError("Users.getUser is not implemented") + + suspend fun getUserStatus(userId: Int): Any = + throw NotImplementedError("Users.getUserStatus is not implemented") + + suspend fun createUser(request: UserCreateRequest): User = + throw NotImplementedError("Users.createUser is not implemented") + + suspend fun updateUser(id: Int, request: UserUpdateRequest): User = + throw NotImplementedError("Users.updateUser is not implemented") + + suspend fun updateUserStatus(userId: Int, request: StatusUpdateRequest): UserStatus = + throw NotImplementedError("Users.updateUserStatus is not implemented") + + suspend fun deleteUser(id: Int) = + throw NotImplementedError("Users.deleteUser is not implemented") + + suspend fun deleteUserStatus(userId: Int) = + throw NotImplementedError("Users.deleteUserStatus is not implemented") +} + +class UsersServiceImpl internal constructor( + private val baseUrl: String, + private val client: HttpClient, +) : UsersService { + override suspend fun listUsers( + query: String?, + limit: Int?, + cursor: String?, ): ListMembersResponse { val response = client.get("$baseUrl/users") { query?.let { parameter("query", it) } @@ -1065,7 +1458,7 @@ class UsersService internal constructor( } } - suspend fun listUsersAll(query: String? = null, limit: Int? = null): List { + override suspend fun listUsersAll(query: String?, limit: Int?): List { val items = mutableListOf() var cursor: String? = null do { @@ -1076,7 +1469,7 @@ class UsersService internal constructor( return items } - suspend fun getUser(id: Int): User { + override suspend fun getUser(id: Int): User { val response = client.get("$baseUrl/users/$id") return when (response.status.value) { 200 -> response.body().data @@ -1085,7 +1478,7 @@ class UsersService internal constructor( } } - suspend fun getUserStatus(userId: Int): Any { + override suspend fun getUserStatus(userId: Int): Any { val response = client.get("$baseUrl/users/$userId/status") return when (response.status.value) { 200 -> response.body() @@ -1094,7 +1487,7 @@ class UsersService internal constructor( } } - suspend fun createUser(request: UserCreateRequest): User { + override suspend fun createUser(request: UserCreateRequest): User { val response = client.post("$baseUrl/users") { contentType(ContentType.Application.Json) setBody(request) @@ -1106,7 +1499,7 @@ class UsersService internal constructor( } } - suspend fun updateUser(id: Int, request: UserUpdateRequest): User { + override suspend fun updateUser(id: Int, request: UserUpdateRequest): User { val response = client.put("$baseUrl/users/$id") { contentType(ContentType.Application.Json) setBody(request) @@ -1118,7 +1511,7 @@ class UsersService internal constructor( } } - suspend fun updateUserStatus(userId: Int, request: StatusUpdateRequest): UserStatus { + override suspend fun updateUserStatus(userId: Int, request: StatusUpdateRequest): UserStatus { val response = client.put("$baseUrl/users/$userId/status") { contentType(ContentType.Application.Json) setBody(request) @@ -1130,7 +1523,7 @@ class UsersService internal constructor( } } - suspend fun deleteUser(id: Int) { + override suspend fun deleteUser(id: Int) { val response = client.delete("$baseUrl/users/$id") when (response.status.value) { 204 -> return @@ -1139,7 +1532,7 @@ class UsersService internal constructor( } } - suspend fun deleteUserStatus(userId: Int) { + override suspend fun deleteUserStatus(userId: Int) { val response = client.delete("$baseUrl/users/$userId/status") when (response.status.value) { 204 -> return @@ -1149,11 +1542,16 @@ class UsersService internal constructor( } } -class ViewsService internal constructor( +interface ViewsService { + suspend fun openView(request: OpenViewRequest) = + throw NotImplementedError("Views.openView is not implemented") +} + +class ViewsServiceImpl internal constructor( private val baseUrl: String, private val client: HttpClient, -) { - suspend fun openView(request: OpenViewRequest) { +) : ViewsService { + override suspend fun openView(request: OpenViewRequest) { val response = client.post("$baseUrl/views/open") { contentType(ContentType.Application.Json) setBody(request) @@ -1166,44 +1564,123 @@ class ViewsService internal constructor( } } -class PachcaClient(token: String, baseUrl: String = "https://api.pachca.com/api/shared/v1") : Closeable { - private val client = HttpClient { - expectSuccess = false - followRedirects = false - install(ContentNegotiation) { - json(Json { explicitNulls = false }) - } - install(HttpRequestRetry) { - retryOnServerErrors(maxRetries = 3) - retryIf { _, response -> response.status.value == 429 } - delayMillis { retry -> - val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() - if (retryAfter != null) retryAfter * 1000L else retry * 1000L - } +class PachcaClient private constructor( + private val client: HttpClient?, + val bots: BotsService, + val chats: ChatsService, + val common: CommonService, + val groupTags: GroupTagsService, + val linkPreviews: LinkPreviewsService, + val members: MembersService, + val messages: MessagesService, + val profile: ProfileService, + val reactions: ReactionsService, + val readMembers: ReadMembersService, + val search: SearchService, + val security: SecurityService, + val tasks: TasksService, + val threads: ThreadsService, + val users: UsersService, + val views: ViewsService +) : Closeable { + + companion object { + operator fun invoke( + token: String, + baseUrl: String = "https://api.pachca.com/api/shared/v1", + bots: BotsService? = null, + chats: ChatsService? = null, + common: CommonService? = null, + groupTags: GroupTagsService? = null, + linkPreviews: LinkPreviewsService? = null, + members: MembersService? = null, + messages: MessagesService? = null, + profile: ProfileService? = null, + reactions: ReactionsService? = null, + readMembers: ReadMembersService? = null, + search: SearchService? = null, + security: SecurityService? = null, + tasks: TasksService? = null, + threads: ThreadsService? = null, + users: UsersService? = null, + views: ViewsService? = null + ): PachcaClient { + val client = createClient(token) + return PachcaClient( + client = client, + bots = bots ?: BotsServiceImpl(baseUrl, client), + chats = chats ?: ChatsServiceImpl(baseUrl, client), + common = common ?: CommonServiceImpl(baseUrl, client), + groupTags = groupTags ?: GroupTagsServiceImpl(baseUrl, client), + linkPreviews = linkPreviews ?: LinkPreviewsServiceImpl(baseUrl, client), + members = members ?: MembersServiceImpl(baseUrl, client), + messages = messages ?: MessagesServiceImpl(baseUrl, client), + profile = profile ?: ProfileServiceImpl(baseUrl, client), + reactions = reactions ?: ReactionsServiceImpl(baseUrl, client), + readMembers = readMembers ?: ReadMembersServiceImpl(baseUrl, client), + search = search ?: SearchServiceImpl(baseUrl, client), + security = security ?: SecurityServiceImpl(baseUrl, client), + tasks = tasks ?: TasksServiceImpl(baseUrl, client), + threads = threads ?: ThreadsServiceImpl(baseUrl, client), + users = users ?: UsersServiceImpl(baseUrl, client), + views = views ?: ViewsServiceImpl(baseUrl, client) + ) } - defaultRequest { - bearerAuth(token) + + fun stub( + bots: BotsService = object : BotsService {}, + chats: ChatsService = object : ChatsService {}, + common: CommonService = object : CommonService {}, + groupTags: GroupTagsService = object : GroupTagsService {}, + linkPreviews: LinkPreviewsService = object : LinkPreviewsService {}, + members: MembersService = object : MembersService {}, + messages: MessagesService = object : MessagesService {}, + profile: ProfileService = object : ProfileService {}, + reactions: ReactionsService = object : ReactionsService {}, + readMembers: ReadMembersService = object : ReadMembersService {}, + search: SearchService = object : SearchService {}, + security: SecurityService = object : SecurityService {}, + tasks: TasksService = object : TasksService {}, + threads: ThreadsService = object : ThreadsService {}, + users: UsersService = object : UsersService {}, + views: ViewsService = object : ViewsService {} + ): PachcaClient = PachcaClient( + client = null, + bots = bots, + chats = chats, + common = common, + groupTags = groupTags, + linkPreviews = linkPreviews, + members = members, + messages = messages, + profile = profile, + reactions = reactions, + readMembers = readMembers, + search = search, + security = security, + tasks = tasks, + threads = threads, + users = users, + views = views + ) + + private fun createClient(token: String): HttpClient = HttpClient { + expectSuccess = false + followRedirects = false + install(ContentNegotiation) { json(Json { explicitNulls = false }) } + install(HttpRequestRetry) { + retryOnServerErrors(maxRetries = 3) + retryIf { _, response -> response.status.value == 429 } + delayMillis { retry -> + val retryAfter = response?.headers?.get("Retry-After")?.toLongOrNull() + if (retryAfter != null) retryAfter * 1000L else retry * 1000L + } + } + defaultRequest { bearerAuth(token) } } } - val bots = BotsService(baseUrl, client) - val chats = ChatsService(baseUrl, client) - val common = CommonService(baseUrl, client) - val groupTags = GroupTagsService(baseUrl, client) - val linkPreviews = LinkPreviewsService(baseUrl, client) - val members = MembersService(baseUrl, client) - val messages = MessagesService(baseUrl, client) - val profile = ProfileService(baseUrl, client) - val reactions = ReactionsService(baseUrl, client) - val readMembers = ReadMembersService(baseUrl, client) - val search = SearchService(baseUrl, client) - val security = SecurityService(baseUrl, client) - val tasks = TasksService(baseUrl, client) - val threads = ThreadsService(baseUrl, client) - val users = UsersService(baseUrl, client) - val views = ViewsService(baseUrl, client) - override fun close() { - client.close() + client?.close() } } diff --git a/sdk/python/generated/README.md b/sdk/python/generated/README.md index 8fbc3ffc..63832e44 100644 --- a/sdk/python/generated/README.md +++ b/sdk/python/generated/README.md @@ -103,3 +103,28 @@ except OAuthError as e: except ApiError as e: print(f"Ошибка API: {e.errors}") ``` + +## Тестирование + +Для unit-тестов используйте `PachcaClient.stub()` — создаёт клиент без HTTP-подключения. + +Методы без переопределения выбрасывают `NotImplementedError("Service.method is not implemented")`: + +```python +from unittest.mock import AsyncMock +from pachca import PachcaClient, MessagesService, Message + +# Мок-сервис +mock_messages = MessagesService() +mock_messages.get_message = AsyncMock(return_value=Message( + id=1, + content="Test message", + entity_id=123 +)) + +# Тест +client = PachcaClient.stub(messages=mock_messages) + +message = await client.messages.get_message(1) +assert message.content == "Test message" +``` diff --git a/sdk/python/generated/pachca/client.py b/sdk/python/generated/pachca/client.py index c767669c..ece8318d 100644 --- a/sdk/python/generated/pachca/client.py +++ b/sdk/python/generated/pachca/client.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import dataclass + import httpx from .models import ( @@ -73,6 +75,20 @@ from .utils import deserialize, serialize, RetryTransport class SecurityService: + async def get_audit_events( + self, + params: GetAuditEventsParams | None = None, + ) -> GetAuditEventsResponse: + raise NotImplementedError("Security.getAuditEvents is not implemented") + + async def get_audit_events_all( + self, + params: GetAuditEventsParams | None = None, + ) -> list[AuditEvent]: + raise NotImplementedError("Security.getAuditEventsAll is not implemented") + + +class SecurityServiceImpl(SecurityService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -131,6 +147,33 @@ async def get_audit_events_all( 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 update_bot( + self, + id: int, + request: BotUpdateRequest, + ) -> BotResponse: + raise NotImplementedError("Bots.updateBot is not implemented") + + async def delete_webhook_event( + self, + id: str, + ) -> None: + raise NotImplementedError("Bots.deleteWebhookEvent is not implemented") + + +class BotsServiceImpl(BotsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -208,6 +251,51 @@ async def delete_webhook_event( class ChatsService: + async def list_chats( + self, + params: ListChatsParams | None = None, + ) -> ListChatsResponse: + raise NotImplementedError("Chats.listChats is not implemented") + + async def list_chats_all( + self, + params: ListChatsParams | None = None, + ) -> list[Chat]: + raise NotImplementedError("Chats.listChatsAll is not implemented") + + async def get_chat( + self, + id: int, + ) -> Chat: + raise NotImplementedError("Chats.getChat is not implemented") + + async def create_chat( + self, + request: ChatCreateRequest, + ) -> Chat: + raise NotImplementedError("Chats.createChat is not implemented") + + async def update_chat( + self, + id: int, + request: ChatUpdateRequest, + ) -> Chat: + raise NotImplementedError("Chats.updateChat is not implemented") + + async def archive_chat( + self, + id: int, + ) -> None: + raise NotImplementedError("Chats.archiveChat is not implemented") + + async def unarchive_chat( + self, + id: int, + ) -> None: + raise NotImplementedError("Chats.unarchiveChat is not implemented") + + +class ChatsServiceImpl(ChatsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -343,6 +431,37 @@ async def unarchive_chat( class CommonService: + async def download_export( + self, + id: int, + ) -> str: + raise NotImplementedError("Common.downloadExport is not implemented") + + async def list_properties( + self, + params: ListPropertiesParams, + ) -> ListPropertiesResponse: + raise NotImplementedError("Common.listProperties is not implemented") + + async def request_export( + self, + request: ExportRequest, + ) -> None: + raise NotImplementedError("Common.requestExport is not implemented") + + async def upload_file( + self, + direct_url: str, + request: FileUploadRequest, + ) -> None: + raise NotImplementedError("Common.uploadFile is not implemented") + + async def get_upload_params( + self) -> UploadParams: + raise NotImplementedError("Common.getUploadParams is not implemented") + + +class CommonServiceImpl(CommonService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -444,6 +563,64 @@ async def get_upload_params( class MembersService: + async def list_members( + self, + id: int, + params: ListMembersParams | None = None, + ) -> ListMembersResponse: + raise NotImplementedError("Members.listMembers is not implemented") + + async def list_members_all( + self, + id: int, + params: ListMembersParams | None = None, + ) -> list[User]: + raise NotImplementedError("Members.listMembersAll is not implemented") + + async def add_tags( + self, + id: int, + group_tag_ids: list[int], + ) -> None: + raise NotImplementedError("Members.addTags is not implemented") + + async def add_members( + self, + id: int, + request: AddMembersRequest, + ) -> None: + raise NotImplementedError("Members.addMembers is not implemented") + + async def update_member_role( + self, + id: int, + user_id: int, + role: ChatMemberRole, + ) -> None: + raise NotImplementedError("Members.updateMemberRole is not implemented") + + async def remove_tag( + self, + id: int, + tag_id: int, + ) -> None: + raise NotImplementedError("Members.removeTag is not implemented") + + async def leave_chat( + self, + id: int, + ) -> None: + raise NotImplementedError("Members.leaveChat is not implemented") + + async def remove_member( + self, + id: int, + user_id: int, + ) -> None: + raise NotImplementedError("Members.removeMember is not implemented") + + +class MembersServiceImpl(MembersService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -591,6 +768,59 @@ async def remove_member( class GroupTagsService: + async def list_tags( + self, + params: ListTagsParams | None = None, + ) -> ListTagsResponse: + raise NotImplementedError("Group tags.listTags is not implemented") + + async def list_tags_all( + self, + params: ListTagsParams | None = None, + ) -> list[GroupTag]: + raise NotImplementedError("Group tags.listTagsAll is not implemented") + + async def get_tag( + self, + id: int, + ) -> GroupTag: + raise NotImplementedError("Group tags.getTag is not implemented") + + async def get_tag_users( + self, + id: int, + params: GetTagUsersParams | None = None, + ) -> ListMembersResponse: + raise NotImplementedError("Group tags.getTagUsers is not implemented") + + async def get_tag_users_all( + self, + id: int, + params: GetTagUsersParams | None = None, + ) -> list[User]: + raise NotImplementedError("Group tags.getTagUsersAll is not implemented") + + async def create_tag( + self, + request: GroupTagRequest, + ) -> GroupTag: + raise NotImplementedError("Group tags.createTag is not implemented") + + async def update_tag( + self, + id: int, + request: GroupTagRequest, + ) -> GroupTag: + raise NotImplementedError("Group tags.updateTag is not implemented") + + async def delete_tag( + self, + id: int, + ) -> None: + raise NotImplementedError("Group tags.deleteTag is not implemented") + + +class GroupTagsServiceImpl(GroupTagsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -744,6 +974,57 @@ async def delete_tag( class MessagesService: + async def list_chat_messages( + self, + params: ListChatMessagesParams, + ) -> ListChatMessagesResponse: + raise NotImplementedError("Messages.listChatMessages is not implemented") + + async def list_chat_messages_all( + self, + params: ListChatMessagesParams, + ) -> list[Message]: + raise NotImplementedError("Messages.listChatMessagesAll is not implemented") + + async def get_message( + self, + id: int, + ) -> Message: + raise NotImplementedError("Messages.getMessage is not implemented") + + async def create_message( + self, + request: MessageCreateRequest, + ) -> Message: + raise NotImplementedError("Messages.createMessage is not implemented") + + async def pin_message( + self, + id: int, + ) -> None: + raise NotImplementedError("Messages.pinMessage is not implemented") + + async def update_message( + self, + id: int, + request: MessageUpdateRequest, + ) -> Message: + raise NotImplementedError("Messages.updateMessage is not implemented") + + async def delete_message( + self, + id: int, + ) -> None: + raise NotImplementedError("Messages.deleteMessage is not implemented") + + async def unpin_message( + self, + id: int, + ) -> None: + raise NotImplementedError("Messages.unpinMessage is not implemented") + + +class MessagesServiceImpl(MessagesService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -887,6 +1168,15 @@ async def unpin_message( class LinkPreviewsService: + async def create_link_previews( + self, + id: int, + request: LinkPreviewsRequest, + ) -> None: + raise NotImplementedError("Link Previews.createLinkPreviews is not implemented") + + +class LinkPreviewsServiceImpl(LinkPreviewsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -909,6 +1199,36 @@ async def create_link_previews( class ReactionsService: + async def list_reactions( + self, + id: int, + params: ListReactionsParams | None = None, + ) -> ListReactionsResponse: + raise NotImplementedError("Reactions.listReactions is not implemented") + + async def list_reactions_all( + self, + id: int, + params: ListReactionsParams | None = None, + ) -> list[Reaction]: + raise NotImplementedError("Reactions.listReactionsAll is not implemented") + + async def add_reaction( + self, + id: int, + request: ReactionRequest, + ) -> Reaction: + raise NotImplementedError("Reactions.addReaction is not implemented") + + async def remove_reaction( + self, + id: int, + params: RemoveReactionParams, + ) -> None: + raise NotImplementedError("Reactions.removeReaction is not implemented") + + +class ReactionsServiceImpl(ReactionsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -994,6 +1314,15 @@ async def remove_reaction( class ReadMembersService: + async def list_read_members( + self, + id: int, + params: ListReadMembersParams | None = None, + ) -> object: + raise NotImplementedError("Read members.listReadMembers is not implemented") + + +class ReadMembersServiceImpl(ReadMembersService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1022,6 +1351,20 @@ async def list_read_members( class ThreadsService: + async def get_thread( + self, + id: int, + ) -> Thread: + raise NotImplementedError("Threads.getThread is not implemented") + + async def create_thread( + self, + id: int, + ) -> Thread: + raise NotImplementedError("Threads.createThread is not implemented") + + +class ThreadsServiceImpl(ThreadsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1059,6 +1402,30 @@ async def create_thread( class ProfileService: + async def get_token_info( + self) -> AccessTokenInfo: + raise NotImplementedError("Profile.getTokenInfo is not implemented") + + async def get_profile( + self) -> User: + raise NotImplementedError("Profile.getProfile is not implemented") + + async def get_status( + self) -> object: + raise NotImplementedError("Profile.getStatus is not implemented") + + async def update_status( + self, + request: StatusUpdateRequest, + ) -> UserStatus: + raise NotImplementedError("Profile.updateStatus is not implemented") + + async def delete_status( + self) -> None: + raise NotImplementedError("Profile.deleteStatus is not implemented") + + +class ProfileServiceImpl(ProfileService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1136,6 +1503,44 @@ async def delete_status( class SearchService: + async def search_chats( + self, + params: SearchChatsParams | None = None, + ) -> ListChatsResponse: + raise NotImplementedError("Search.searchChats is not implemented") + + async def search_chats_all( + self, + params: SearchChatsParams | None = None, + ) -> list[Chat]: + raise NotImplementedError("Search.searchChatsAll is not implemented") + + async def search_messages( + self, + params: SearchMessagesParams | None = None, + ) -> ListChatMessagesResponse: + raise NotImplementedError("Search.searchMessages is not implemented") + + async def search_messages_all( + self, + params: SearchMessagesParams | None = None, + ) -> list[Message]: + raise NotImplementedError("Search.searchMessagesAll is not implemented") + + async def search_users( + self, + params: SearchUsersParams | None = None, + ) -> ListMembersResponse: + raise NotImplementedError("Search.searchUsers is not implemented") + + async def search_users_all( + self, + params: SearchUsersParams | None = None, + ) -> list[User]: + raise NotImplementedError("Search.searchUsersAll is not implemented") + + +class SearchServiceImpl(SearchService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1298,6 +1703,45 @@ async def search_users_all( class TasksService: + async def list_tasks( + self, + params: ListTasksParams | None = None, + ) -> ListTasksResponse: + raise NotImplementedError("Tasks.listTasks is not implemented") + + async def list_tasks_all( + self, + params: ListTasksParams | None = None, + ) -> list[Task]: + raise NotImplementedError("Tasks.listTasksAll is not implemented") + + async def get_task( + self, + id: int, + ) -> Task: + raise NotImplementedError("Tasks.getTask is not implemented") + + async def create_task( + self, + request: TaskCreateRequest, + ) -> Task: + raise NotImplementedError("Tasks.createTask is not implemented") + + async def update_task( + self, + id: int, + request: TaskUpdateRequest, + ) -> Task: + raise NotImplementedError("Tasks.updateTask is not implemented") + + async def delete_task( + self, + id: int, + ) -> None: + raise NotImplementedError("Tasks.deleteTask is not implemented") + + +class TasksServiceImpl(TasksService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1408,6 +1852,64 @@ async def delete_task( class UsersService: + async def list_users( + self, + params: ListUsersParams | None = None, + ) -> ListMembersResponse: + raise NotImplementedError("Users.listUsers is not implemented") + + async def list_users_all( + self, + params: ListUsersParams | None = None, + ) -> list[User]: + raise NotImplementedError("Users.listUsersAll is not implemented") + + async def get_user( + self, + id: int, + ) -> User: + raise NotImplementedError("Users.getUser is not implemented") + + async def get_user_status( + self, + user_id: int, + ) -> object: + raise NotImplementedError("Users.getUserStatus is not implemented") + + async def create_user( + self, + request: UserCreateRequest, + ) -> User: + raise NotImplementedError("Users.createUser is not implemented") + + async def update_user( + self, + id: int, + request: UserUpdateRequest, + ) -> User: + raise NotImplementedError("Users.updateUser is not implemented") + + async def update_user_status( + self, + user_id: int, + request: StatusUpdateRequest, + ) -> UserStatus: + raise NotImplementedError("Users.updateUserStatus is not implemented") + + async def delete_user( + self, + id: int, + ) -> None: + raise NotImplementedError("Users.deleteUser is not implemented") + + async def delete_user_status( + self, + user_id: int, + ) -> None: + raise NotImplementedError("Users.deleteUserStatus is not implemented") + + +class UsersServiceImpl(UsersService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1569,6 +2071,14 @@ async def delete_user_status( class ViewsService: + async def open_view( + self, + request: OpenViewRequest, + ) -> None: + raise NotImplementedError("Views.openView is not implemented") + + +class ViewsServiceImpl(ViewsService): def __init__(self, client: httpx.AsyncClient) -> None: self._client = client @@ -1590,28 +2100,68 @@ async def open_view( class PachcaClient: - def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1") -> None: + def __init__(self, token: str, base_url: str = "https://api.pachca.com/api/shared/v1", bots: BotsService | None = None, chats: ChatsService | None = None, common: CommonService | None = None, group_tags: GroupTagsService | None = None, link_previews: LinkPreviewsService | None = None, members: MembersService | None = None, messages: MessagesService | None = None, profile: ProfileService | None = None, reactions: ReactionsService | None = None, read_members: ReadMembersService | None = None, search: SearchService | None = None, security: SecurityService | None = None, tasks: TasksService | None = None, threads: ThreadsService | None = None, users: UsersService | None = None, views: ViewsService | None = None) -> None: self._client = httpx.AsyncClient( base_url=base_url, headers={"Authorization": f"Bearer {token}"}, transport=RetryTransport(httpx.AsyncHTTPTransport()), ) - self.bots = BotsService(self._client) - self.chats = ChatsService(self._client) - self.common = CommonService(self._client) - self.group_tags = GroupTagsService(self._client) - self.link_previews = LinkPreviewsService(self._client) - self.members = MembersService(self._client) - self.messages = MessagesService(self._client) - self.profile = ProfileService(self._client) - self.reactions = ReactionsService(self._client) - self.read_members = ReadMembersService(self._client) - self.search = SearchService(self._client) - self.security = SecurityService(self._client) - self.tasks = TasksService(self._client) - self.threads = ThreadsService(self._client) - self.users = UsersService(self._client) - self.views = ViewsService(self._client) + self.bots: BotsService = bots or BotsServiceImpl(self._client) + self.chats: ChatsService = chats or ChatsServiceImpl(self._client) + self.common: CommonService = common or CommonServiceImpl(self._client) + self.group_tags: GroupTagsService = group_tags or GroupTagsServiceImpl(self._client) + self.link_previews: LinkPreviewsService = link_previews or LinkPreviewsServiceImpl(self._client) + self.members: MembersService = members or MembersServiceImpl(self._client) + self.messages: MessagesService = messages or MessagesServiceImpl(self._client) + self.profile: ProfileService = profile or ProfileServiceImpl(self._client) + self.reactions: ReactionsService = reactions or ReactionsServiceImpl(self._client) + self.read_members: ReadMembersService = read_members or ReadMembersServiceImpl(self._client) + self.search: SearchService = search or SearchServiceImpl(self._client) + self.security: SecurityService = security or SecurityServiceImpl(self._client) + self.tasks: TasksService = tasks or TasksServiceImpl(self._client) + self.threads: ThreadsService = threads or ThreadsServiceImpl(self._client) + self.users: UsersService = users or UsersServiceImpl(self._client) + self.views: ViewsService = views or ViewsServiceImpl(self._client) async def close(self) -> None: await self._client.aclose() + + @classmethod + def stub( + cls, + bots: BotsService | None = None, + chats: ChatsService | None = None, + common: CommonService | None = None, + group_tags: GroupTagsService | None = None, + link_previews: LinkPreviewsService | None = None, + members: MembersService | None = None, + messages: MessagesService | None = None, + profile: ProfileService | None = None, + reactions: ReactionsService | None = None, + read_members: ReadMembersService | None = None, + search: SearchService | None = None, + security: SecurityService | None = None, + tasks: TasksService | None = None, + threads: ThreadsService | None = None, + users: UsersService | None = None, + views: ViewsService | None = None, + ) -> "PachcaClient": + self = cls.__new__(cls) + self._client = None + self.bots = bots or BotsService() + self.chats = chats or ChatsService() + self.common = common or CommonService() + self.group_tags = group_tags or GroupTagsService() + self.link_previews = link_previews or LinkPreviewsService() + self.members = members or MembersService() + self.messages = messages or MessagesService() + self.profile = profile or ProfileService() + self.reactions = reactions or ReactionsService() + self.read_members = read_members or ReadMembersService() + self.search = search or SearchService() + self.security = security or SecurityService() + self.tasks = tasks or TasksService() + self.threads = threads or ThreadsService() + self.users = users or UsersService() + self.views = views or ViewsService() + return self diff --git a/sdk/python/generated/pachca/utils.py b/sdk/python/generated/pachca/utils.py index 44d19034..950682b4 100644 --- a/sdk/python/generated/pachca/utils.py +++ b/sdk/python/generated/pachca/utils.py @@ -79,10 +79,16 @@ def serialize(obj: object) -> dict: _MAX_RETRIES = 3 +_RETRYABLE_5XX = {500, 502, 503, 504} + + +def _add_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.""" + """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 @@ -95,7 +101,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 = attempt + 1 + await asyncio.sleep(_add_jitter(delay)) continue return response return response # unreachable diff --git a/sdk/swift/README.md b/sdk/swift/README.md index 3296d6fc..536d9a63 100644 --- a/sdk/swift/README.md +++ b/sdk/swift/README.md @@ -110,3 +110,31 @@ do { print("Ошибка авторизации: \(error.message)") } ``` + +## Тестирование + +Для unit-тестов используйте `PachcaClient.stub()` — создаёт клиент без HTTP-подключения. + +Методы без переопределения выбрасывают `NSError` с описанием `"Service.method is not implemented"`: + +```swift +import XCTest +import PachcaSDK + +// Мок-сервис +class MockMessagesService: MessagesService { + override func getMessage(_ id: Int64) async throws -> Message { + return Message(id: id, content: "Test message", entityId: 123) + } +} + +// Тест +final class MessagesTests: XCTestCase { + func testGetMessage() async throws { + let client = PachcaClient.stub(messages: MockMessagesService()) + + let message = try await client.messages.getMessage(1) + XCTAssertEqual(message.content, "Test message") + } +} +``` diff --git a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift index 9f45447f..67e6670f 100644 --- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift +++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Client.swift @@ -3,7 +3,23 @@ import Foundation import FoundationNetworking #endif -public struct SecurityService { +private func pachcaNotImplemented(_ method: String) -> Error { + NSError(domain: "PachcaClient", code: 1, userInfo: [NSLocalizedDescriptionKey: method + " is not implemented"]) +} + +open class SecurityService { + public init() {} + + open func getAuditEvents(startTime: String? = nil, endTime: String? = nil, eventKey: AuditEventKey? = nil, actorId: String? = nil, actorType: String? = nil, entityId: String? = nil, entityType: String? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> GetAuditEventsResponse { + throw pachcaNotImplemented("Security.getAuditEvents") + } + + open func getAuditEventsAll(startTime: String? = nil, endTime: String? = nil, eventKey: AuditEventKey? = nil, actorId: String? = nil, actorType: String? = nil, entityId: String? = nil, entityType: String? = nil, limit: Int? = nil) async throws -> [AuditEvent] { + throw pachcaNotImplemented("Security.getAuditEventsAll") + } +} + +public final class SecurityServiceImpl: SecurityService { let baseURL: String let headers: [String: String] let session: URLSession @@ -12,9 +28,10 @@ public struct SecurityService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func getAuditEvents(startTime: String? = nil, endTime: String? = nil, eventKey: AuditEventKey? = nil, actorId: String? = nil, actorType: String? = nil, entityId: String? = nil, entityType: String? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> GetAuditEventsResponse { + public override func getAuditEvents(startTime: String? = nil, endTime: String? = nil, eventKey: AuditEventKey? = nil, actorId: String? = nil, actorType: String? = nil, entityId: String? = nil, entityType: String? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> GetAuditEventsResponse { var components = URLComponents(string: "\(baseURL)/audit_events")! var queryItems: [URLQueryItem] = [] if let startTime { queryItems.append(URLQueryItem(name: "start_time", value: startTime)) } @@ -41,7 +58,7 @@ public struct SecurityService { } } - public func getAuditEventsAll(startTime: String? = nil, endTime: String? = nil, eventKey: AuditEventKey? = nil, actorId: String? = nil, actorType: String? = nil, entityId: String? = nil, entityType: String? = nil, limit: Int? = nil) async throws -> [AuditEvent] { + public override func getAuditEventsAll(startTime: String? = nil, endTime: String? = nil, eventKey: AuditEventKey? = nil, actorId: String? = nil, actorType: String? = nil, entityId: String? = nil, entityType: String? = nil, limit: Int? = nil) async throws -> [AuditEvent] { var items: [AuditEvent] = [] var cursor: String? = nil repeat { @@ -53,7 +70,27 @@ public struct SecurityService { } } -public struct BotsService { +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 updateBot(id: Int, request body: BotUpdateRequest) async throws -> BotResponse { + throw pachcaNotImplemented("Bots.updateBot") + } + + open func deleteWebhookEvent(id: String) async throws -> Void { + throw pachcaNotImplemented("Bots.deleteWebhookEvent") + } +} + +public final class BotsServiceImpl: BotsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -62,9 +99,10 @@ public struct BotsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func getWebhookEvents(limit: Int? = nil, cursor: String? = nil) async throws -> GetWebhookEventsResponse { + 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))) } @@ -84,7 +122,7 @@ public struct BotsService { } } - public func getWebhookEventsAll(limit: Int? = nil) async throws -> [WebhookEvent] { + public override func getWebhookEventsAll(limit: Int? = nil) async throws -> [WebhookEvent] { var items: [WebhookEvent] = [] var cursor: String? = nil repeat { @@ -95,7 +133,7 @@ public struct BotsService { return items } - public func updateBot(id: Int, request body: BotUpdateRequest) async throws -> BotResponse { + public override func updateBot(id: Int, request body: BotUpdateRequest) async throws -> BotResponse { var request = URLRequest(url: URL(string: "\(baseURL)/bots/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -113,7 +151,7 @@ public struct BotsService { } } - public func deleteWebhookEvent(id: String) async throws -> Void { + public override func deleteWebhookEvent(id: String) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/webhooks/events/\(id)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -130,7 +168,39 @@ public struct BotsService { } } -public struct ChatsService { +open class ChatsService { + public init() {} + + open func listChats(sortId: SortOrder? = nil, availability: ChatAvailability? = nil, lastMessageAtAfter: String? = nil, lastMessageAtBefore: String? = nil, personal: Bool? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListChatsResponse { + throw pachcaNotImplemented("Chats.listChats") + } + + open func listChatsAll(sortId: SortOrder? = nil, availability: ChatAvailability? = nil, lastMessageAtAfter: String? = nil, lastMessageAtBefore: String? = nil, personal: Bool? = nil, limit: Int? = nil) async throws -> [Chat] { + throw pachcaNotImplemented("Chats.listChatsAll") + } + + open func getChat(id: Int) async throws -> Chat { + throw pachcaNotImplemented("Chats.getChat") + } + + open func createChat(request body: ChatCreateRequest) async throws -> Chat { + throw pachcaNotImplemented("Chats.createChat") + } + + open func updateChat(id: Int, request body: ChatUpdateRequest) async throws -> Chat { + throw pachcaNotImplemented("Chats.updateChat") + } + + open func archiveChat(id: Int) async throws -> Void { + throw pachcaNotImplemented("Chats.archiveChat") + } + + open func unarchiveChat(id: Int) async throws -> Void { + throw pachcaNotImplemented("Chats.unarchiveChat") + } +} + +public final class ChatsServiceImpl: ChatsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -139,9 +209,10 @@ public struct ChatsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listChats(sortId: SortOrder? = nil, availability: ChatAvailability? = nil, lastMessageAtAfter: String? = nil, lastMessageAtBefore: String? = nil, personal: Bool? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListChatsResponse { + public override func listChats(sortId: SortOrder? = nil, availability: ChatAvailability? = nil, lastMessageAtAfter: String? = nil, lastMessageAtBefore: String? = nil, personal: Bool? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListChatsResponse { var components = URLComponents(string: "\(baseURL)/chats")! var queryItems: [URLQueryItem] = [] if let sortId { queryItems.append(URLQueryItem(name: "sort[{field}]", value: sortId.rawValue)) } @@ -166,7 +237,7 @@ public struct ChatsService { } } - public func listChatsAll(sortId: SortOrder? = nil, availability: ChatAvailability? = nil, lastMessageAtAfter: String? = nil, lastMessageAtBefore: String? = nil, personal: Bool? = nil, limit: Int? = nil) async throws -> [Chat] { + public override func listChatsAll(sortId: SortOrder? = nil, availability: ChatAvailability? = nil, lastMessageAtAfter: String? = nil, lastMessageAtBefore: String? = nil, personal: Bool? = nil, limit: Int? = nil) async throws -> [Chat] { var items: [Chat] = [] var cursor: String? = nil repeat { @@ -177,7 +248,7 @@ public struct ChatsService { return items } - public func getChat(id: Int) async throws -> Chat { + public override func getChat(id: Int) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -192,7 +263,7 @@ public struct ChatsService { } } - public func createChat(request body: ChatCreateRequest) async throws -> Chat { + public override func createChat(request body: ChatCreateRequest) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -210,7 +281,7 @@ public struct ChatsService { } } - public func updateChat(id: Int, request body: ChatUpdateRequest) async throws -> Chat { + public override func updateChat(id: Int, request body: ChatUpdateRequest) async throws -> Chat { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -228,7 +299,7 @@ public struct ChatsService { } } - public func archiveChat(id: Int) async throws -> Void { + public override func archiveChat(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/archive")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -244,7 +315,7 @@ public struct ChatsService { } } - public func unarchiveChat(id: Int) async throws -> Void { + public override func unarchiveChat(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/unarchive")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -261,7 +332,31 @@ public struct ChatsService { } } -public struct CommonService { +open class CommonService { + public init() {} + + open func downloadExport(id: Int) async throws -> String { + throw pachcaNotImplemented("Common.downloadExport") + } + + open func listProperties(entityType: SearchEntityType) async throws -> ListPropertiesResponse { + throw pachcaNotImplemented("Common.listProperties") + } + + open func requestExport(request body: ExportRequest) async throws -> Void { + throw pachcaNotImplemented("Common.requestExport") + } + + open func uploadFile(directUrl: String, request body: FileUploadRequest) async throws -> Void { + throw pachcaNotImplemented("Common.uploadFile") + } + + open func getUploadParams() async throws -> UploadParams { + throw pachcaNotImplemented("Common.getUploadParams") + } +} + +public final class CommonServiceImpl: CommonService { let baseURL: String let headers: [String: String] let session: URLSession @@ -270,9 +365,10 @@ public struct CommonService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func downloadExport(id: Int) async throws -> String { + public override func downloadExport(id: Int) async throws -> String { var request = URLRequest(url: URL(string: "\(baseURL)/chats/exports/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let delegate = RedirectPreventer() @@ -291,7 +387,7 @@ public struct CommonService { } } - public func listProperties(entityType: SearchEntityType) async throws -> ListPropertiesResponse { + public override func listProperties(entityType: SearchEntityType) async throws -> ListPropertiesResponse { var components = URLComponents(string: "\(baseURL)/custom_properties")! var queryItems: [URLQueryItem] = [] queryItems.append(URLQueryItem(name: "entity_type", value: entityType.rawValue)) @@ -310,7 +406,7 @@ public struct CommonService { } } - public func requestExport(request body: ExportRequest) async throws -> Void { + public override func requestExport(request body: ExportRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/exports")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -328,7 +424,7 @@ public struct CommonService { } } - public func uploadFile(directUrl: String, request body: FileUploadRequest) async throws -> Void { + public override func uploadFile(directUrl: String, request body: FileUploadRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(directUrl)")!) request.httpMethod = "POST" let boundary = UUID().uuidString @@ -364,7 +460,7 @@ public struct CommonService { } } - public func getUploadParams() async throws -> UploadParams { + public override func getUploadParams() async throws -> UploadParams { var request = URLRequest(url: URL(string: "\(baseURL)/uploads")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -381,7 +477,43 @@ public struct CommonService { } } -public struct MembersService { +open class MembersService { + public init() {} + + open func listMembers(id: Int, role: ChatMemberRoleFilter? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { + throw pachcaNotImplemented("Members.listMembers") + } + + open func listMembersAll(id: Int, role: ChatMemberRoleFilter? = nil, limit: Int? = nil) async throws -> [User] { + throw pachcaNotImplemented("Members.listMembersAll") + } + + open func addTags(id: Int, groupTagIds: [Int]) async throws -> Void { + throw pachcaNotImplemented("Members.addTags") + } + + open func addMembers(id: Int, request body: AddMembersRequest) async throws -> Void { + throw pachcaNotImplemented("Members.addMembers") + } + + open func updateMemberRole(id: Int, userId: Int, role: ChatMemberRole) async throws -> Void { + throw pachcaNotImplemented("Members.updateMemberRole") + } + + open func removeTag(id: Int, tagId: Int) async throws -> Void { + throw pachcaNotImplemented("Members.removeTag") + } + + open func leaveChat(id: Int) async throws -> Void { + throw pachcaNotImplemented("Members.leaveChat") + } + + open func removeMember(id: Int, userId: Int) async throws -> Void { + throw pachcaNotImplemented("Members.removeMember") + } +} + +public final class MembersServiceImpl: MembersService { let baseURL: String let headers: [String: String] let session: URLSession @@ -390,9 +522,10 @@ public struct MembersService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listMembers(id: Int, role: ChatMemberRoleFilter? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { + public override func listMembers(id: Int, role: ChatMemberRoleFilter? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { var components = URLComponents(string: "\(baseURL)/chats/{id}/members")! var queryItems: [URLQueryItem] = [] if let role { queryItems.append(URLQueryItem(name: "role", value: role.rawValue)) } @@ -413,7 +546,7 @@ public struct MembersService { } } - public func listMembersAll(id: Int, role: ChatMemberRoleFilter? = nil, limit: Int? = nil) async throws -> [User] { + public override func listMembersAll(id: Int, role: ChatMemberRoleFilter? = nil, limit: Int? = nil) async throws -> [User] { var items: [User] = [] var cursor: String? = nil repeat { @@ -424,7 +557,7 @@ public struct MembersService { return items } - public func addTags(id: Int, groupTagIds: [Int]) async throws -> Void { + public override func addTags(id: Int, groupTagIds: [Int]) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/group_tags")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -442,7 +575,7 @@ public struct MembersService { } } - public func addMembers(id: Int, request body: AddMembersRequest) async throws -> Void { + public override func addMembers(id: Int, request body: AddMembersRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/members")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -460,7 +593,7 @@ public struct MembersService { } } - public func updateMemberRole(id: Int, userId: Int, role: ChatMemberRole) async throws -> Void { + public override func updateMemberRole(id: Int, userId: Int, role: ChatMemberRole) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/members/\(userId)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -478,7 +611,7 @@ public struct MembersService { } } - public func removeTag(id: Int, tagId: Int) async throws -> Void { + public override func removeTag(id: Int, tagId: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/group_tags/\(tagId)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -494,7 +627,7 @@ public struct MembersService { } } - public func leaveChat(id: Int) async throws -> Void { + public override func leaveChat(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/leave")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -510,7 +643,7 @@ public struct MembersService { } } - public func removeMember(id: Int, userId: Int) async throws -> Void { + public override func removeMember(id: Int, userId: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/chats/\(id)/members/\(userId)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -527,7 +660,43 @@ public struct MembersService { } } -public struct GroupTagsService { +open class GroupTagsService { + public init() {} + + open func listTags(names: TagNamesFilter? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListTagsResponse { + throw pachcaNotImplemented("Group tags.listTags") + } + + open func listTagsAll(names: TagNamesFilter? = nil, limit: Int? = nil) async throws -> [GroupTag] { + throw pachcaNotImplemented("Group tags.listTagsAll") + } + + open func getTag(id: Int) async throws -> GroupTag { + throw pachcaNotImplemented("Group tags.getTag") + } + + open func getTagUsers(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { + throw pachcaNotImplemented("Group tags.getTagUsers") + } + + open func getTagUsersAll(id: Int, limit: Int? = nil) async throws -> [User] { + throw pachcaNotImplemented("Group tags.getTagUsersAll") + } + + open func createTag(request body: GroupTagRequest) async throws -> GroupTag { + throw pachcaNotImplemented("Group tags.createTag") + } + + open func updateTag(id: Int, request body: GroupTagRequest) async throws -> GroupTag { + throw pachcaNotImplemented("Group tags.updateTag") + } + + open func deleteTag(id: Int) async throws -> Void { + throw pachcaNotImplemented("Group tags.deleteTag") + } +} + +public final class GroupTagsServiceImpl: GroupTagsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -536,9 +705,10 @@ public struct GroupTagsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listTags(names: TagNamesFilter? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListTagsResponse { + public override func listTags(names: TagNamesFilter? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListTagsResponse { var components = URLComponents(string: "\(baseURL)/group_tags")! var queryItems: [URLQueryItem] = [] if let names { queryItems.append(URLQueryItem(name: "names", value: String(data: try serialize(names), encoding: .utf8)!)) } @@ -559,7 +729,7 @@ public struct GroupTagsService { } } - public func listTagsAll(names: TagNamesFilter? = nil, limit: Int? = nil) async throws -> [GroupTag] { + public override func listTagsAll(names: TagNamesFilter? = nil, limit: Int? = nil) async throws -> [GroupTag] { var items: [GroupTag] = [] var cursor: String? = nil repeat { @@ -570,7 +740,7 @@ public struct GroupTagsService { return items } - public func getTag(id: Int) async throws -> GroupTag { + public override func getTag(id: Int) async throws -> GroupTag { var request = URLRequest(url: URL(string: "\(baseURL)/group_tags/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -585,7 +755,7 @@ public struct GroupTagsService { } } - public func getTagUsers(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { + public override func getTagUsers(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { var components = URLComponents(string: "\(baseURL)/group_tags/{id}/users")! var queryItems: [URLQueryItem] = [] if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) } @@ -605,7 +775,7 @@ public struct GroupTagsService { } } - public func getTagUsersAll(id: Int, limit: Int? = nil) async throws -> [User] { + public override func getTagUsersAll(id: Int, limit: Int? = nil) async throws -> [User] { var items: [User] = [] var cursor: String? = nil repeat { @@ -616,7 +786,7 @@ public struct GroupTagsService { return items } - public func createTag(request body: GroupTagRequest) async throws -> GroupTag { + public override func createTag(request body: GroupTagRequest) async throws -> GroupTag { var request = URLRequest(url: URL(string: "\(baseURL)/group_tags")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -634,7 +804,7 @@ public struct GroupTagsService { } } - public func updateTag(id: Int, request body: GroupTagRequest) async throws -> GroupTag { + public override func updateTag(id: Int, request body: GroupTagRequest) async throws -> GroupTag { var request = URLRequest(url: URL(string: "\(baseURL)/group_tags/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -652,7 +822,7 @@ public struct GroupTagsService { } } - public func deleteTag(id: Int) async throws -> Void { + public override func deleteTag(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/group_tags/\(id)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -669,7 +839,43 @@ public struct GroupTagsService { } } -public struct MessagesService { +open class MessagesService { + public init() {} + + open func listChatMessages(chatId: Int, sortId: SortOrder? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListChatMessagesResponse { + throw pachcaNotImplemented("Messages.listChatMessages") + } + + open func listChatMessagesAll(chatId: Int, sortId: SortOrder? = nil, limit: Int? = nil) async throws -> [Message] { + throw pachcaNotImplemented("Messages.listChatMessagesAll") + } + + open func getMessage(id: Int) async throws -> Message { + throw pachcaNotImplemented("Messages.getMessage") + } + + open func createMessage(request body: MessageCreateRequest) async throws -> Message { + throw pachcaNotImplemented("Messages.createMessage") + } + + open func pinMessage(id: Int) async throws -> Void { + throw pachcaNotImplemented("Messages.pinMessage") + } + + open func updateMessage(id: Int, request body: MessageUpdateRequest) async throws -> Message { + throw pachcaNotImplemented("Messages.updateMessage") + } + + open func deleteMessage(id: Int) async throws -> Void { + throw pachcaNotImplemented("Messages.deleteMessage") + } + + open func unpinMessage(id: Int) async throws -> Void { + throw pachcaNotImplemented("Messages.unpinMessage") + } +} + +public final class MessagesServiceImpl: MessagesService { let baseURL: String let headers: [String: String] let session: URLSession @@ -678,9 +884,10 @@ public struct MessagesService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listChatMessages(chatId: Int, sortId: SortOrder? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListChatMessagesResponse { + public override func listChatMessages(chatId: Int, sortId: SortOrder? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListChatMessagesResponse { var components = URLComponents(string: "\(baseURL)/messages")! var queryItems: [URLQueryItem] = [] queryItems.append(URLQueryItem(name: "chat_id", value: String(chatId))) @@ -702,7 +909,7 @@ public struct MessagesService { } } - public func listChatMessagesAll(chatId: Int, sortId: SortOrder? = nil, limit: Int? = nil) async throws -> [Message] { + public override func listChatMessagesAll(chatId: Int, sortId: SortOrder? = nil, limit: Int? = nil) async throws -> [Message] { var items: [Message] = [] var cursor: String? = nil repeat { @@ -713,7 +920,7 @@ public struct MessagesService { return items } - public func getMessage(id: Int) async throws -> Message { + public override func getMessage(id: Int) async throws -> Message { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -728,7 +935,7 @@ public struct MessagesService { } } - public func createMessage(request body: MessageCreateRequest) async throws -> Message { + public override func createMessage(request body: MessageCreateRequest) async throws -> Message { var request = URLRequest(url: URL(string: "\(baseURL)/messages")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -746,7 +953,7 @@ public struct MessagesService { } } - public func pinMessage(id: Int) async throws -> Void { + public override func pinMessage(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)/pin")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -762,7 +969,7 @@ public struct MessagesService { } } - public func updateMessage(id: Int, request body: MessageUpdateRequest) async throws -> Message { + public override func updateMessage(id: Int, request body: MessageUpdateRequest) async throws -> Message { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -780,7 +987,7 @@ public struct MessagesService { } } - public func deleteMessage(id: Int) async throws -> Void { + public override func deleteMessage(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -796,7 +1003,7 @@ public struct MessagesService { } } - public func unpinMessage(id: Int) async throws -> Void { + public override func unpinMessage(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)/pin")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -813,7 +1020,15 @@ public struct MessagesService { } } -public struct LinkPreviewsService { +open class LinkPreviewsService { + public init() {} + + open func createLinkPreviews(id: Int, request body: LinkPreviewsRequest) async throws -> Void { + throw pachcaNotImplemented("Link Previews.createLinkPreviews") + } +} + +public final class LinkPreviewsServiceImpl: LinkPreviewsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -822,9 +1037,10 @@ public struct LinkPreviewsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func createLinkPreviews(id: Int, request body: LinkPreviewsRequest) async throws -> Void { + public override func createLinkPreviews(id: Int, request body: LinkPreviewsRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)/link_previews")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -843,7 +1059,27 @@ public struct LinkPreviewsService { } } -public struct ReactionsService { +open class ReactionsService { + public init() {} + + open func listReactions(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> ListReactionsResponse { + throw pachcaNotImplemented("Reactions.listReactions") + } + + open func listReactionsAll(id: Int, limit: Int? = nil) async throws -> [Reaction] { + throw pachcaNotImplemented("Reactions.listReactionsAll") + } + + open func addReaction(id: Int, request body: ReactionRequest) async throws -> Reaction { + throw pachcaNotImplemented("Reactions.addReaction") + } + + open func removeReaction(id: Int, code: String, name: String? = nil) async throws -> Void { + throw pachcaNotImplemented("Reactions.removeReaction") + } +} + +public final class ReactionsServiceImpl: ReactionsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -852,9 +1088,10 @@ public struct ReactionsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listReactions(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> ListReactionsResponse { + public override func listReactions(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> ListReactionsResponse { var components = URLComponents(string: "\(baseURL)/messages/{id}/reactions")! var queryItems: [URLQueryItem] = [] if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) } @@ -874,7 +1111,7 @@ public struct ReactionsService { } } - public func listReactionsAll(id: Int, limit: Int? = nil) async throws -> [Reaction] { + public override func listReactionsAll(id: Int, limit: Int? = nil) async throws -> [Reaction] { var items: [Reaction] = [] var cursor: String? = nil repeat { @@ -885,7 +1122,7 @@ public struct ReactionsService { return items } - public func addReaction(id: Int, request body: ReactionRequest) async throws -> Reaction { + public override func addReaction(id: Int, request body: ReactionRequest) async throws -> Reaction { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)/reactions")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -903,7 +1140,7 @@ public struct ReactionsService { } } - public func removeReaction(id: Int, code: String, name: String? = nil) async throws -> Void { + public override func removeReaction(id: Int, code: String, name: String? = nil) async throws -> Void { var components = URLComponents(string: "\(baseURL)/messages/{id}/reactions")! var queryItems: [URLQueryItem] = [] queryItems.append(URLQueryItem(name: "code", value: String(code))) @@ -925,7 +1162,15 @@ public struct ReactionsService { } } -public struct ReadMembersService { +open class ReadMembersService { + public init() {} + + open func listReadMembers(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> String { + throw pachcaNotImplemented("Read members.listReadMembers") + } +} + +public final class ReadMembersServiceImpl: ReadMembersService { let baseURL: String let headers: [String: String] let session: URLSession @@ -934,9 +1179,10 @@ public struct ReadMembersService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listReadMembers(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> String { + public override func listReadMembers(id: Int, limit: Int? = nil, cursor: String? = nil) async throws -> String { var components = URLComponents(string: "\(baseURL)/messages/{id}/read_member_ids")! var queryItems: [URLQueryItem] = [] if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) } @@ -957,7 +1203,19 @@ public struct ReadMembersService { } } -public struct ThreadsService { +open class ThreadsService { + public init() {} + + open func getThread(id: Int) async throws -> Thread { + throw pachcaNotImplemented("Threads.getThread") + } + + open func createThread(id: Int) async throws -> Thread { + throw pachcaNotImplemented("Threads.createThread") + } +} + +public final class ThreadsServiceImpl: ThreadsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -966,9 +1224,10 @@ public struct ThreadsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func getThread(id: Int) async throws -> Thread { + public override func getThread(id: Int) async throws -> Thread { var request = URLRequest(url: URL(string: "\(baseURL)/threads/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -983,7 +1242,7 @@ public struct ThreadsService { } } - public func createThread(id: Int) async throws -> Thread { + public override func createThread(id: Int) async throws -> Thread { var request = URLRequest(url: URL(string: "\(baseURL)/messages/\(id)/thread")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1000,7 +1259,31 @@ public struct ThreadsService { } } -public struct ProfileService { +open class ProfileService { + public init() {} + + open func getTokenInfo() async throws -> AccessTokenInfo { + throw pachcaNotImplemented("Profile.getTokenInfo") + } + + open func getProfile() async throws -> User { + throw pachcaNotImplemented("Profile.getProfile") + } + + open func getStatus() async throws -> String { + throw pachcaNotImplemented("Profile.getStatus") + } + + open func updateStatus(request body: StatusUpdateRequest) async throws -> UserStatus { + throw pachcaNotImplemented("Profile.updateStatus") + } + + open func deleteStatus() async throws -> Void { + throw pachcaNotImplemented("Profile.deleteStatus") + } +} + +public final class ProfileServiceImpl: ProfileService { let baseURL: String let headers: [String: String] let session: URLSession @@ -1009,9 +1292,10 @@ public struct ProfileService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func getTokenInfo() async throws -> AccessTokenInfo { + public override func getTokenInfo() async throws -> AccessTokenInfo { var request = URLRequest(url: URL(string: "\(baseURL)/oauth/token/info")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -1026,7 +1310,7 @@ public struct ProfileService { } } - public func getProfile() async throws -> User { + public override func getProfile() async throws -> User { var request = URLRequest(url: URL(string: "\(baseURL)/profile")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -1041,7 +1325,7 @@ public struct ProfileService { } } - public func getStatus() async throws -> String { + public override func getStatus() async throws -> String { var request = URLRequest(url: URL(string: "\(baseURL)/profile/status")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -1056,7 +1340,7 @@ public struct ProfileService { } } - public func updateStatus(request body: StatusUpdateRequest) async throws -> UserStatus { + public override func updateStatus(request body: StatusUpdateRequest) async throws -> UserStatus { var request = URLRequest(url: URL(string: "\(baseURL)/profile/status")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1074,7 +1358,7 @@ public struct ProfileService { } } - public func deleteStatus() async throws -> Void { + public override func deleteStatus() async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/profile/status")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1091,7 +1375,35 @@ public struct ProfileService { } } -public struct SearchService { +open class SearchService { + public init() {} + + open func searchChats(query: String? = nil, limit: Int? = nil, cursor: String? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, active: Bool? = nil, chatSubtype: ChatSubtype? = nil, personal: Bool? = nil) async throws -> ListChatsResponse { + throw pachcaNotImplemented("Search.searchChats") + } + + open func searchChatsAll(query: String? = nil, limit: Int? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, active: Bool? = nil, chatSubtype: ChatSubtype? = nil, personal: Bool? = nil) async throws -> [Chat] { + throw pachcaNotImplemented("Search.searchChatsAll") + } + + open func searchMessages(query: String? = nil, limit: Int? = nil, cursor: String? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, chatIds: [Int]? = nil, userIds: [Int]? = nil, active: Bool? = nil) async throws -> ListChatMessagesResponse { + throw pachcaNotImplemented("Search.searchMessages") + } + + open func searchMessagesAll(query: String? = nil, limit: Int? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, chatIds: [Int]? = nil, userIds: [Int]? = nil, active: Bool? = nil) async throws -> [Message] { + throw pachcaNotImplemented("Search.searchMessagesAll") + } + + open func searchUsers(query: String? = nil, limit: Int? = nil, cursor: String? = nil, sort: SearchSortOrder? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, companyRoles: [UserRole]? = nil) async throws -> ListMembersResponse { + throw pachcaNotImplemented("Search.searchUsers") + } + + open func searchUsersAll(query: String? = nil, limit: Int? = nil, sort: SearchSortOrder? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, companyRoles: [UserRole]? = nil) async throws -> [User] { + throw pachcaNotImplemented("Search.searchUsersAll") + } +} + +public final class SearchServiceImpl: SearchService { let baseURL: String let headers: [String: String] let session: URLSession @@ -1100,9 +1412,10 @@ public struct SearchService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func searchChats(query: String? = nil, limit: Int? = nil, cursor: String? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, active: Bool? = nil, chatSubtype: ChatSubtype? = nil, personal: Bool? = nil) async throws -> ListChatsResponse { + public override func searchChats(query: String? = nil, limit: Int? = nil, cursor: String? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, active: Bool? = nil, chatSubtype: ChatSubtype? = nil, personal: Bool? = nil) async throws -> ListChatsResponse { var components = URLComponents(string: "\(baseURL)/search/chats")! var queryItems: [URLQueryItem] = [] if let query { queryItems.append(URLQueryItem(name: "query", value: String(query))) } @@ -1129,7 +1442,7 @@ public struct SearchService { } } - public func searchChatsAll(query: String? = nil, limit: Int? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, active: Bool? = nil, chatSubtype: ChatSubtype? = nil, personal: Bool? = nil) async throws -> [Chat] { + public override func searchChatsAll(query: String? = nil, limit: Int? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, active: Bool? = nil, chatSubtype: ChatSubtype? = nil, personal: Bool? = nil) async throws -> [Chat] { var items: [Chat] = [] var cursor: String? = nil repeat { @@ -1140,7 +1453,7 @@ public struct SearchService { return items } - public func searchMessages(query: String? = nil, limit: Int? = nil, cursor: String? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, chatIds: [Int]? = nil, userIds: [Int]? = nil, active: Bool? = nil) async throws -> ListChatMessagesResponse { + public override func searchMessages(query: String? = nil, limit: Int? = nil, cursor: String? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, chatIds: [Int]? = nil, userIds: [Int]? = nil, active: Bool? = nil) async throws -> ListChatMessagesResponse { var components = URLComponents(string: "\(baseURL)/search/messages")! var queryItems: [URLQueryItem] = [] if let query { queryItems.append(URLQueryItem(name: "query", value: String(query))) } @@ -1167,7 +1480,7 @@ public struct SearchService { } } - public func searchMessagesAll(query: String? = nil, limit: Int? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, chatIds: [Int]? = nil, userIds: [Int]? = nil, active: Bool? = nil) async throws -> [Message] { + public override func searchMessagesAll(query: String? = nil, limit: Int? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, chatIds: [Int]? = nil, userIds: [Int]? = nil, active: Bool? = nil) async throws -> [Message] { var items: [Message] = [] var cursor: String? = nil repeat { @@ -1178,7 +1491,7 @@ public struct SearchService { return items } - public func searchUsers(query: String? = nil, limit: Int? = nil, cursor: String? = nil, sort: SearchSortOrder? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, companyRoles: [UserRole]? = nil) async throws -> ListMembersResponse { + public override func searchUsers(query: String? = nil, limit: Int? = nil, cursor: String? = nil, sort: SearchSortOrder? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, companyRoles: [UserRole]? = nil) async throws -> ListMembersResponse { var components = URLComponents(string: "\(baseURL)/search/users")! var queryItems: [URLQueryItem] = [] if let query { queryItems.append(URLQueryItem(name: "query", value: String(query))) } @@ -1204,7 +1517,7 @@ public struct SearchService { } } - public func searchUsersAll(query: String? = nil, limit: Int? = nil, sort: SearchSortOrder? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, companyRoles: [UserRole]? = nil) async throws -> [User] { + public override func searchUsersAll(query: String? = nil, limit: Int? = nil, sort: SearchSortOrder? = nil, order: SortOrder? = nil, createdFrom: String? = nil, createdTo: String? = nil, companyRoles: [UserRole]? = nil) async throws -> [User] { var items: [User] = [] var cursor: String? = nil repeat { @@ -1216,7 +1529,35 @@ public struct SearchService { } } -public struct TasksService { +open class TasksService { + public init() {} + + open func listTasks(limit: Int? = nil, cursor: String? = nil) async throws -> ListTasksResponse { + throw pachcaNotImplemented("Tasks.listTasks") + } + + open func listTasksAll(limit: Int? = nil) async throws -> [Task] { + throw pachcaNotImplemented("Tasks.listTasksAll") + } + + open func getTask(id: Int) async throws -> Task { + throw pachcaNotImplemented("Tasks.getTask") + } + + open func createTask(request body: TaskCreateRequest) async throws -> Task { + throw pachcaNotImplemented("Tasks.createTask") + } + + open func updateTask(id: Int, request body: TaskUpdateRequest) async throws -> Task { + throw pachcaNotImplemented("Tasks.updateTask") + } + + open func deleteTask(id: Int) async throws -> Void { + throw pachcaNotImplemented("Tasks.deleteTask") + } +} + +public final class TasksServiceImpl: TasksService { let baseURL: String let headers: [String: String] let session: URLSession @@ -1225,9 +1566,10 @@ public struct TasksService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listTasks(limit: Int? = nil, cursor: String? = nil) async throws -> ListTasksResponse { + public override func listTasks(limit: Int? = nil, cursor: String? = nil) async throws -> ListTasksResponse { var components = URLComponents(string: "\(baseURL)/tasks")! var queryItems: [URLQueryItem] = [] if let limit { queryItems.append(URLQueryItem(name: "limit", value: String(limit))) } @@ -1247,7 +1589,7 @@ public struct TasksService { } } - public func listTasksAll(limit: Int? = nil) async throws -> [Task] { + public override func listTasksAll(limit: Int? = nil) async throws -> [Task] { var items: [Task] = [] var cursor: String? = nil repeat { @@ -1258,7 +1600,7 @@ public struct TasksService { return items } - public func getTask(id: Int) async throws -> Task { + public override func getTask(id: Int) async throws -> Task { var request = URLRequest(url: URL(string: "\(baseURL)/tasks/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -1273,7 +1615,7 @@ public struct TasksService { } } - public func createTask(request body: TaskCreateRequest) async throws -> Task { + public override func createTask(request body: TaskCreateRequest) async throws -> Task { var request = URLRequest(url: URL(string: "\(baseURL)/tasks")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1291,7 +1633,7 @@ public struct TasksService { } } - public func updateTask(id: Int, request body: TaskUpdateRequest) async throws -> Task { + public override func updateTask(id: Int, request body: TaskUpdateRequest) async throws -> Task { var request = URLRequest(url: URL(string: "\(baseURL)/tasks/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1309,7 +1651,7 @@ public struct TasksService { } } - public func deleteTask(id: Int) async throws -> Void { + public override func deleteTask(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/tasks/\(id)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1326,7 +1668,47 @@ public struct TasksService { } } -public struct UsersService { +open class UsersService { + public init() {} + + open func listUsers(query: String? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { + throw pachcaNotImplemented("Users.listUsers") + } + + open func listUsersAll(query: String? = nil, limit: Int? = nil) async throws -> [User] { + throw pachcaNotImplemented("Users.listUsersAll") + } + + open func getUser(id: Int) async throws -> User { + throw pachcaNotImplemented("Users.getUser") + } + + open func getUserStatus(userId: Int) async throws -> String { + throw pachcaNotImplemented("Users.getUserStatus") + } + + open func createUser(request body: UserCreateRequest) async throws -> User { + throw pachcaNotImplemented("Users.createUser") + } + + open func updateUser(id: Int, request body: UserUpdateRequest) async throws -> User { + throw pachcaNotImplemented("Users.updateUser") + } + + open func updateUserStatus(userId: Int, request body: StatusUpdateRequest) async throws -> UserStatus { + throw pachcaNotImplemented("Users.updateUserStatus") + } + + open func deleteUser(id: Int) async throws -> Void { + throw pachcaNotImplemented("Users.deleteUser") + } + + open func deleteUserStatus(userId: Int) async throws -> Void { + throw pachcaNotImplemented("Users.deleteUserStatus") + } +} + +public final class UsersServiceImpl: UsersService { let baseURL: String let headers: [String: String] let session: URLSession @@ -1335,9 +1717,10 @@ public struct UsersService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func listUsers(query: String? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { + public override func listUsers(query: String? = nil, limit: Int? = nil, cursor: String? = nil) async throws -> ListMembersResponse { var components = URLComponents(string: "\(baseURL)/users")! var queryItems: [URLQueryItem] = [] if let query { queryItems.append(URLQueryItem(name: "query", value: String(query))) } @@ -1358,7 +1741,7 @@ public struct UsersService { } } - public func listUsersAll(query: String? = nil, limit: Int? = nil) async throws -> [User] { + public override func listUsersAll(query: String? = nil, limit: Int? = nil) async throws -> [User] { var items: [User] = [] var cursor: String? = nil repeat { @@ -1369,7 +1752,7 @@ public struct UsersService { return items } - public func getUser(id: Int) async throws -> User { + public override func getUser(id: Int) async throws -> User { var request = URLRequest(url: URL(string: "\(baseURL)/users/\(id)")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -1384,7 +1767,7 @@ public struct UsersService { } } - public func getUserStatus(userId: Int) async throws -> String { + public override func getUserStatus(userId: Int) async throws -> String { var request = URLRequest(url: URL(string: "\(baseURL)/users/\(userId)/status")!) headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } let (data, urlResponse) = try await dataWithRetry(session: session, for: request) @@ -1399,7 +1782,7 @@ public struct UsersService { } } - public func createUser(request body: UserCreateRequest) async throws -> User { + public override func createUser(request body: UserCreateRequest) async throws -> User { var request = URLRequest(url: URL(string: "\(baseURL)/users")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1417,7 +1800,7 @@ public struct UsersService { } } - public func updateUser(id: Int, request body: UserUpdateRequest) async throws -> User { + public override func updateUser(id: Int, request body: UserUpdateRequest) async throws -> User { var request = URLRequest(url: URL(string: "\(baseURL)/users/\(id)")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1435,7 +1818,7 @@ public struct UsersService { } } - public func updateUserStatus(userId: Int, request body: StatusUpdateRequest) async throws -> UserStatus { + public override func updateUserStatus(userId: Int, request body: StatusUpdateRequest) async throws -> UserStatus { var request = URLRequest(url: URL(string: "\(baseURL)/users/\(userId)/status")!) request.httpMethod = "PUT" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1453,7 +1836,7 @@ public struct UsersService { } } - public func deleteUser(id: Int) async throws -> Void { + public override func deleteUser(id: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/users/\(id)")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1469,7 +1852,7 @@ public struct UsersService { } } - public func deleteUserStatus(userId: Int) async throws -> Void { + public override func deleteUserStatus(userId: Int) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/users/\(userId)/status")!) request.httpMethod = "DELETE" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1486,7 +1869,15 @@ public struct UsersService { } } -public struct ViewsService { +open class ViewsService { + public init() {} + + open func openView(request body: OpenViewRequest) async throws -> Void { + throw pachcaNotImplemented("Views.openView") + } +} + +public final class ViewsServiceImpl: ViewsService { let baseURL: String let headers: [String: String] let session: URLSession @@ -1495,9 +1886,10 @@ public struct ViewsService { self.baseURL = baseURL self.headers = headers self.session = session + super.init() } - public func openView(request body: OpenViewRequest) async throws -> Void { + public override func openView(request body: OpenViewRequest) async throws -> Void { var request = URLRequest(url: URL(string: "\(baseURL)/views/open")!) request.httpMethod = "POST" headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } @@ -1546,23 +1938,65 @@ public struct PachcaClient { public let users: UsersService public let views: ViewsService - public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1") { + private init(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) { + self.bots = bots + self.chats = chats + self.common = common + self.groupTags = groupTags + self.linkPreviews = linkPreviews + self.members = members + self.messages = messages + self.profile = profile + self.reactions = reactions + self.readMembers = readMembers + self.search = search + self.security = security + self.tasks = tasks + self.threads = threads + self.users = users + self.views = views + } + + public init(token: String, baseURL: String = "https://api.pachca.com/api/shared/v1", bots: BotsService? = nil, chats: ChatsService? = nil, common: CommonService? = nil, groupTags: GroupTagsService? = nil, linkPreviews: LinkPreviewsService? = nil, members: MembersService? = nil, messages: MessagesService? = nil, profile: ProfileService? = nil, reactions: ReactionsService? = nil, readMembers: ReadMembersService? = nil, search: SearchService? = nil, security: SecurityService? = nil, tasks: TasksService? = nil, threads: ThreadsService? = nil, users: UsersService? = nil, views: ViewsService? = nil) { let headers = ["Authorization": "Bearer \(token)"] - self.bots = BotsService(baseURL: baseURL, headers: headers) - self.chats = ChatsService(baseURL: baseURL, headers: headers) - self.common = CommonService(baseURL: baseURL, headers: headers) - self.groupTags = GroupTagsService(baseURL: baseURL, headers: headers) - self.linkPreviews = LinkPreviewsService(baseURL: baseURL, headers: headers) - self.members = MembersService(baseURL: baseURL, headers: headers) - self.messages = MessagesService(baseURL: baseURL, headers: headers) - self.profile = ProfileService(baseURL: baseURL, headers: headers) - self.reactions = ReactionsService(baseURL: baseURL, headers: headers) - self.readMembers = ReadMembersService(baseURL: baseURL, headers: headers) - self.search = SearchService(baseURL: baseURL, headers: headers) - self.security = SecurityService(baseURL: baseURL, headers: headers) - self.tasks = TasksService(baseURL: baseURL, headers: headers) - self.threads = ThreadsService(baseURL: baseURL, headers: headers) - self.users = UsersService(baseURL: baseURL, headers: headers) - self.views = ViewsService(baseURL: baseURL, headers: headers) + self.init( + bots: bots ?? BotsServiceImpl(baseURL: baseURL, headers: headers), + chats: chats ?? ChatsServiceImpl(baseURL: baseURL, headers: headers), + common: common ?? CommonServiceImpl(baseURL: baseURL, headers: headers), + groupTags: groupTags ?? GroupTagsServiceImpl(baseURL: baseURL, headers: headers), + linkPreviews: linkPreviews ?? LinkPreviewsServiceImpl(baseURL: baseURL, headers: headers), + members: members ?? MembersServiceImpl(baseURL: baseURL, headers: headers), + messages: messages ?? MessagesServiceImpl(baseURL: baseURL, headers: headers), + profile: profile ?? ProfileServiceImpl(baseURL: baseURL, headers: headers), + reactions: reactions ?? ReactionsServiceImpl(baseURL: baseURL, headers: headers), + readMembers: readMembers ?? ReadMembersServiceImpl(baseURL: baseURL, headers: headers), + search: search ?? SearchServiceImpl(baseURL: baseURL, headers: headers), + security: security ?? SecurityServiceImpl(baseURL: baseURL, headers: headers), + tasks: tasks ?? TasksServiceImpl(baseURL: baseURL, headers: headers), + threads: threads ?? ThreadsServiceImpl(baseURL: baseURL, headers: headers), + users: users ?? UsersServiceImpl(baseURL: baseURL, headers: headers), + views: views ?? ViewsServiceImpl(baseURL: baseURL, headers: headers) + ) + } + + public static func stub(bots: BotsService = BotsService(), chats: ChatsService = ChatsService(), common: CommonService = CommonService(), groupTags: GroupTagsService = GroupTagsService(), linkPreviews: LinkPreviewsService = LinkPreviewsService(), members: MembersService = MembersService(), messages: MessagesService = MessagesService(), profile: ProfileService = ProfileService(), reactions: ReactionsService = ReactionsService(), readMembers: ReadMembersService = ReadMembersService(), search: SearchService = SearchService(), security: SecurityService = SecurityService(), tasks: TasksService = TasksService(), threads: ThreadsService = ThreadsService(), users: UsersService = UsersService(), views: ViewsService = ViewsService()) -> PachcaClient { + PachcaClient( + bots: bots, + chats: chats, + common: common, + groupTags: groupTags, + linkPreviews: linkPreviews, + members: members, + messages: messages, + profile: profile, + reactions: reactions, + readMembers: readMembers, + search: search, + security: security, + tasks: tasks, + threads: threads, + users: users, + views: views + ) } } diff --git a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Utils.swift b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Utils.swift index fe3a04ac..d3fbd569 100644 --- a/sdk/swift/generated/Sources/Pachca/GeneratedSources/Utils.swift +++ b/sdk/swift/generated/Sources/Pachca/GeneratedSources/Utils.swift @@ -26,19 +26,32 @@ func deserialize(_ type: T.Type, from data: Data) throws -> T { } private let maxRetries = 3 +private let retryable5xx: Set = [500, 502, 503, 504] + +private func addJitter(_ delay: UInt64) -> UInt64 { + let factor = 0.5 + Double.random(in: 0..<0.5) + return UInt64(Double(delay) * factor) +} 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: addJitter(delay)) + continue + } + if retryable5xx.contains(http.statusCode), attempt < maxRetries { + let delay = UInt64(attempt + 1) * 1_000_000_000 + try await _Concurrency.Task.sleep(nanoseconds: addJitter(delay)) + continue } - try await _Concurrency.Task.sleep(nanoseconds: delay) - continue } return (data, response) } diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 43c26520..dc327c19 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -95,3 +95,28 @@ try { } } ``` + +## Тестирование + +Для unit-тестов используйте `PachcaClient.stub()` — создаёт клиент без HTTP-подключения. + +Методы без переопределения выбрасывают `Error("Service.method is not implemented")`: + +```typescript +import { PachcaClient, MessagesService, Message } from "@pachca/sdk"; + +// Мок-сервис +class MockMessagesService extends MessagesService { + async getMessage(id: number): Promise { + return { id, content: "Test message", entityId: 123 } as Message; + } +} + +// Тест +const client = PachcaClient.stub({ + messages: new MockMessagesService(), +}); + +const message = await client.messages.getMessage(1); +expect(message.content).toBe("Test message"); +``` diff --git a/sdk/typescript/src/generated/client.ts b/sdk/typescript/src/generated/client.ts index 466a0ad8..d142afe0 100644 --- a/sdk/typescript/src/generated/client.ts +++ b/sdk/typescript/src/generated/client.ts @@ -60,11 +60,23 @@ import { } from "./types"; import { deserialize, serialize, fetchWithRetry } from "./utils"; -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(); @@ -104,11 +116,31 @@ class SecurityService { } } -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(); @@ -173,11 +205,43 @@ 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(); @@ -294,11 +358,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}`, { @@ -393,11 +481,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(); @@ -524,11 +648,47 @@ 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(); @@ -656,11 +816,47 @@ 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(); @@ -788,11 +984,19 @@ 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`, { @@ -811,11 +1015,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(); @@ -883,11 +1107,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 +1141,23 @@ class ReadMembersService { } } -class ThreadsService { +export class ThreadsService { + 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 getThread(id: number): Promise { const response = await fetchWithRetry(`${this.baseUrl}/threads/${id}`, { @@ -947,11 +1191,35 @@ 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 updateStatus(request: StatusUpdateRequest): Promise { + throw new Error("Profile.updateStatus 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`, { @@ -1031,11 +1299,39 @@ 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 { const query = new URLSearchParams(); @@ -1148,11 +1444,39 @@ class SearchService { } } -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(); @@ -1249,11 +1573,51 @@ 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 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 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 { const query = new URLSearchParams(); @@ -1398,11 +1762,19 @@ 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`, { @@ -1441,21 +1813,42 @@ export class PachcaClient { 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); + this.bots = new BotsServiceImpl(baseUrl, headers); + this.chats = new ChatsServiceImpl(baseUrl, headers); + this.common = new CommonServiceImpl(baseUrl, headers); + this.groupTags = new GroupTagsServiceImpl(baseUrl, headers); + this.linkPreviews = new LinkPreviewsServiceImpl(baseUrl, headers); + this.members = new MembersServiceImpl(baseUrl, headers); + this.messages = new MessagesServiceImpl(baseUrl, headers); + this.profile = new ProfileServiceImpl(baseUrl, headers); + this.reactions = new ReactionsServiceImpl(baseUrl, headers); + this.readMembers = new ReadMembersServiceImpl(baseUrl, headers); + this.search = new SearchServiceImpl(baseUrl, headers); + this.security = new SecurityServiceImpl(baseUrl, headers); + this.tasks = new TasksServiceImpl(baseUrl, headers); + this.threads = new ThreadsServiceImpl(baseUrl, headers); + this.users = new UsersServiceImpl(baseUrl, headers); + this.views = new ViewsServiceImpl(baseUrl, headers); + } + + static stub(bots: BotsService = new BotsService(), chats: ChatsService = new ChatsService(), common: CommonService = new CommonService(), groupTags: GroupTagsService = new GroupTagsService(), linkPreviews: LinkPreviewsService = new LinkPreviewsService(), members: MembersService = new MembersService(), messages: MessagesService = new MessagesService(), profile: ProfileService = new ProfileService(), reactions: ReactionsService = new ReactionsService(), readMembers: ReadMembersService = new ReadMembersService(), search: SearchService = new SearchService(), security: SecurityService = new SecurityService(), tasks: TasksService = new TasksService(), threads: ThreadsService = new ThreadsService(), users: UsersService = new UsersService(), views: ViewsService = new ViewsService()): PachcaClient { + const client = Object.create(PachcaClient.prototype); + client.bots = bots; + client.chats = chats; + client.common = common; + client.groupTags = groupTags; + client.linkPreviews = linkPreviews; + client.members = members; + client.messages = messages; + client.profile = profile; + client.reactions = reactions; + client.readMembers = readMembers; + client.search = search; + client.security = security; + client.tasks = tasks; + client.threads = threads; + client.users = users; + client.views = views; + return client; } } diff --git a/sdk/typescript/src/generated/utils.ts b/sdk/typescript/src/generated/utils.ts index bdb8b1c3..05657ddc 100644 --- a/sdk/typescript/src/generated/utils.ts +++ b/sdk/typescript/src/generated/utils.ts @@ -60,6 +60,11 @@ export function serialize(obj: unknown): unknown { } const MAX_RETRIES = 3; +const RETRYABLE_5XX = new Set([500, 502, 503, 504]); + +function addJitter(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++) { @@ -67,7 +72,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, addJitter(delay))); + continue; + } + if (RETRYABLE_5XX.has(response.status) && attempt < MAX_RETRIES) { + const delay = 1000 * (attempt + 1); + await new Promise((r) => setTimeout(r, addJitter(delay))); continue; } return response;