Skip to content

C#

A guide to using Soxom-generated C# SDKs. The generated package is a multi-targeted NuGet library (netstandard2.0;net8.0;net9.0 by default), uses System.Text.Json with a source-generated JsonSerializerContext for trim- and AOT-safety, and has no third-party runtime dependencies beyond the BCL + System.Text.Json.

soxom.yaml
targets:
csharp:
package_id: Acme.Sdk
root_namespace: Acme.Sdk
target_frameworks: [netstandard2.0, net8.0, net9.0]
OptionDefaultDescription
package_idPascalCase form of sdk.nameNuGet package ID.
root_namespacesame as package_idRoot C# namespace for emitted types.
target_frameworks[netstandard2.0, net8.0, net9.0]Multi-target TFM list.

See the soxom.yaml reference for the complete option list.

Terminal window
dotnet add package Acme.Sdk
using Acme.Sdk;
// Bearer token from the ACME_BEARER_TOKEN env var (default name).
var client = new AcmeSdkClient();
var user = await client.Users.GetAsync("user-123");
Console.WriteLine(user.Email);

The constructor reads credentials from the matching environment variable when the option is omitted. Pass them explicitly when you need to:

var client = new AcmeSdkClient(new AcmeSdkClientOptions
{
BearerToken = Environment.GetEnvironmentVariable("ACME_BEARER_TOKEN"),
BaseUrl = new Uri("https://api.acme.com"),
Timeout = TimeSpan.FromSeconds(30),
MaxRetries = 2,
});

The constructor option name and env-var name come from the SDK’s configured auth scheme (see authentication in the soxom.yaml reference). <PREFIX> below is the SCREAMING_SNAKE_CASE of sdk.name (e.g. acme-sdkACME). Custom env-var names override the defaults.

var client = new AcmeSdkClient(new AcmeSdkClientOptions
{
BearerToken = "sk_...", // or omit to read from ACME_BEARER_TOKEN
});
OptionDefault env var
BearerToken<PREFIX>_BEARER_TOKEN
var client = new AcmeSdkClient(new AcmeSdkClientOptions
{
ApiKey = "sk_...", // or omit to read from ACME_API_KEY
});
OptionDefault env var
ApiKey<PREFIX>_API_KEY

The injection location (header name vs. query parameter) is fixed at generation time from the OpenAPI security scheme.

var client = new AcmeSdkClient(new AcmeSdkClientOptions
{
Username = "...", // or read from ACME_USERNAME
Password = "...", // or read from ACME_PASSWORD
});
OptionDefault env var
Username<PREFIX>_USERNAME
Password<PREFIX>_PASSWORD
var client = new AcmeSdkClient(new AcmeSdkClientOptions
{
ClientId = "...", // or ACME_CLIENT_ID
ClientSecret = "...", // or ACME_CLIENT_SECRET
Scope = "read write",
});
OptionDefault env var
ClientId<PREFIX>_CLIENT_ID
ClientSecret<PREFIX>_CLIENT_SECRET

The client_credentials grant runs lazily on the first request. Tokens are cached in memory until 30s before their expires_in, refreshes in flight are coalesced, and a 401 from the API triggers exactly one forced refresh + retry.

Operations are mounted under resource and subresource namespaces as configured in soxom.yaml:

await client.Users.ListAsync();
await client.Users.GetAsync("user-123");
await client.Users.Settings.UpdateAsync("user-123",
new UserSettingsUpdate { Theme = "dark" });

Every method accepts a final RequestOptions argument for per-call overrides and a CancellationToken:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var report = await client.Reports.GenerateAsync(parameters,
new RequestOptions
{
Timeout = TimeSpan.FromMinutes(2),
MaxRetries = 0,
Headers = { ["X-Trace-Id"] = "abc" },
IdempotencyKey = "order-2024-01-01-001",
},
cts.Token);

List operations return a typed page (CursorPage<T> or OffsetPage<T>, selected by the pagination.default_type in soxom.yaml). Both implement IAsyncEnumerable<T>, so the simplest consumer transparently walks every page:

await foreach (var user in client.Users.ListAsync())
{
Console.WriteLine(user.Email);
}

For manual control, hold on to the page object:

var page = await client.Users.ListPageAsync(new ListUsersRequest { Limit = 50 });
foreach (var user in page.Data)
{
Console.WriteLine(user.Email);
}
while (page.HasNextPage)
{
page = await page.GetNextPageAsync();
// ... process page.Data
}

Operations marked streaming in soxom.yaml return an IAsyncEnumerable<TEvent> parsed lazily from the response body as Server-Sent Events:

await foreach (var evt in client.Chat.Completions.StreamAsync(
new ChatCompletionRequest
{
Model = "gpt-4",
Messages = [new ChatMessage { Role = "user", Content = "Hello" }],
Stream = true,
}))
{
Console.Write(evt.Choices[0].Delta?.Content ?? "");
}

Cancel an in-flight stream by cancelling the CancellationToken you passed to the call.

The generated package ships a Microsoft.Extensions.DependencyInjection extension that registers a singleton client (and its underlying HttpClient) through IHttpClientFactory:

using Acme.Sdk.DependencyInjection;
services.AddAcmeSdk(options =>
{
options.BearerToken = builder.Configuration["Acme:BearerToken"];
options.BaseUrl = new Uri("https://api.acme.com");
});
// elsewhere
public class OrdersWorker(AcmeSdkClient client) { /* ... */ }

This is the recommended composition root in ASP.NET Core, generic-host console apps, and Worker Services. The handler chain (HttpClient, retry policy, auth handler) is composed via IHttpClientFactory so connection pooling and DNS refresh work as expected.

All thrown errors descend from SoxomException, so a single is check will catch anything originating in the SDK. The hierarchy:

SoxomException
└── ApiException
├── ApiConnectionException
│ ├── ApiConnectionTimeoutException
│ └── ApiUserAbortException
└── ApiStatusException
├── BadRequestException (400)
├── AuthenticationException (401)
├── PermissionDeniedException (403)
├── NotFoundException (404)
├── ConflictException (409)
├── UnprocessableEntityException (422)
├── RateLimitException (429)
└── InternalServerException (5xx)
try
{
await client.Users.GetAsync("user-123");
}
catch (RateLimitException ex)
{
Console.WriteLine($"rate limited; retry-after: {ex.Headers?["retry-after"]}");
}
catch (AuthenticationException)
{
Console.Error.WriteLine("token rejected");
}
catch (ApiStatusException ex)
{
Console.Error.WriteLine($"HTTP {ex.Status} request id: {ex.RequestId}");
}
catch (ApiConnectionException ex)
{
Console.Error.WriteLine($"network failure: {ex.Message}");
}

ApiStatusException carries Status, Headers, Error (the parsed response body), and RequestId (from the x-request-id or request-id header).

The runtime retries on connection errors, request timeouts, and HTTP 408, 409, 429, 500, 502, 503, 504. Backoff is exponential with full jitter, capped at 30s, and respects a parseable Retry-After header (either delta-seconds or HTTP-date). The default budget is 2 attempts.

var client = new AcmeSdkClient(new AcmeSdkClientOptions { MaxRetries = 5 });
// or per-request:
await client.Payments.CreateAsync(data, new RequestOptions { MaxRetries = 0 });

Retries of unsafe methods (POST, PATCH) are gated on having an idempotency key. By default the runtime auto-generates one on the second attempt; set Idempotency = IdempotencyMode.Always on the client options to send a key on the first try too, or supply your own via RequestOptions.IdempotencyKey.

The generated .csproj multi-targets netstandard2.0;net8.0;net9.0 by default (configurable via targets.csharp.target_frameworks):

  • netstandard2.0 keeps the SDK consumable from .NET Framework 4.6.2+, Mono, Xamarin / Unity, and any older runtime that supports the standard.
  • net8.0 / net9.0 enable trimming and Native AOT. The generator emits a JsonSerializerContext covering every model, so System.Text.Json has no reflection-based fallback on those TFMs. The csproj sets <IsTrimmable>true</IsTrimmable> and <IsAotCompatible>true</IsAotCompatible> for those TFMs and produces no trim/AOT warnings against the OpenAPI 3.1 coverage spec.

The generated package is a vanilla dotnet pack project:

Terminal window
dotnet restore
dotnet build -c Release
dotnet pack -c Release

The emitted .github/workflows/release.yml publishes the package to NuGet on a pushed tag using Trusted Publishing — configure the publisher on nuget.org against your production repo and the workflow’s OIDC token authenticates with no long-lived API key in your repo secrets. Fall back to a NUGET_API_KEY secret if you can’t use Trusted Publishing on your org.