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.
Configuration
Section titled “Configuration”targets: csharp: package_id: Acme.Sdk root_namespace: Acme.Sdk target_frameworks: [netstandard2.0, net8.0, net9.0]| Option | Default | Description |
|---|---|---|
package_id | PascalCase form of sdk.name | NuGet package ID. |
root_namespace | same as package_id | Root 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.
Installation
Section titled “Installation”dotnet add package Acme.Sdk<PackageReference Include="Acme.Sdk" Version="1.0.0" />Install-Package Acme.SdkQuickstart
Section titled “Quickstart”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,});Authentication
Section titled “Authentication”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-sdk → ACME). Custom env-var names override the
defaults.
Bearer token
Section titled “Bearer token”var client = new AcmeSdkClient(new AcmeSdkClientOptions{ BearerToken = "sk_...", // or omit to read from ACME_BEARER_TOKEN});| Option | Default env var |
|---|---|
BearerToken | <PREFIX>_BEARER_TOKEN |
API key (header or query)
Section titled “API key (header or query)”var client = new AcmeSdkClient(new AcmeSdkClientOptions{ ApiKey = "sk_...", // or omit to read from ACME_API_KEY});| Option | Default env var |
|---|---|
ApiKey | <PREFIX>_API_KEY |
The injection location (header name vs. query parameter) is fixed at generation time from the OpenAPI security scheme.
Basic auth
Section titled “Basic auth”var client = new AcmeSdkClient(new AcmeSdkClientOptions{ Username = "...", // or read from ACME_USERNAME Password = "...", // or read from ACME_PASSWORD});| Option | Default env var |
|---|---|
Username | <PREFIX>_USERNAME |
Password | <PREFIX>_PASSWORD |
OAuth2
Section titled “OAuth2”var client = new AcmeSdkClient(new AcmeSdkClientOptions{ ClientId = "...", // or ACME_CLIENT_ID ClientSecret = "...", // or ACME_CLIENT_SECRET Scope = "read write",});| Option | Default 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.
Making requests
Section titled “Making requests”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);Pagination
Section titled “Pagination”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}Streaming
Section titled “Streaming”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.
Dependency Injection
Section titled “Dependency Injection”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");});
// elsewherepublic 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.
Errors
Section titled “Errors”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).
Retries
Section titled “Retries”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.
Multi-targeting, trimming, and AOT
Section titled “Multi-targeting, trimming, and AOT”The generated .csproj multi-targets netstandard2.0;net8.0;net9.0 by
default (configurable via targets.csharp.target_frameworks):
netstandard2.0keeps the SDK consumable from .NET Framework 4.6.2+, Mono, Xamarin / Unity, and any older runtime that supports the standard.net8.0/net9.0enable trimming and Native AOT. The generator emits aJsonSerializerContextcovering 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.
Build & publish
Section titled “Build & publish”The generated package is a vanilla dotnet pack project:
dotnet restoredotnet build -c Releasedotnet pack -c ReleaseThe 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.