Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 110 additions & 17 deletions packages/generator/src/lang/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
snakeToUpperSnake,
tagToProperty,
tagToServiceName,
serviceToImplName,
} from '../naming.js';

const CSHARP_KEYWORDS = new Set([
Expand Down Expand Up @@ -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;
Expand All @@ -415,13 +417,21 @@ namespace Pachca.Sdk;
internal static class PachcaUtils
{
private const int MaxRetries = 3;
private static readonly HashSet<int> Retryable5xx = new() { 500, 502, 503, 504 };
private static readonly Random JitterRandom = new();

internal static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
};

private static TimeSpan AddJitter(TimeSpan delay)
{
var factor = 0.5 + JitterRandom.NextDouble() * 0.5;
return TimeSpan.FromMilliseconds(delay.TotalMilliseconds * factor);
}

internal static async Task<HttpResponseMessage> SendWithRetryAsync(
HttpClient client,
HttpRequestMessage request,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;');
Expand All @@ -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');
Expand All @@ -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<List<${itemType}>> ${methodName}(`);
lines.push(`${indent}${modifier} async System.Threading.Tasks.Task<List<${itemType}>> ${methodName}(`);
for (let i = 0; i < params.length; i++) {
const comma = i < params.length - 1 ? ',' : ')';
lines.push(`${indent2}${params[i]}${comma}`);
Expand Down Expand Up @@ -648,6 +671,7 @@ function emitOperation(
op: IROperation,
ir: IR,
globalHasApiError: boolean,
modifier = 'public override',
): void {
const indent = ' ';
const indent2 = ' ';
Expand All @@ -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},`);
}
Expand All @@ -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<List<${itemType}>> ${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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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('}');
Expand Down
Loading