-
Notifications
You must be signed in to change notification settings - Fork 1
Working with CSharp Client
The source generator can emit a C# client for any OpenAPI specification. Two shapes are available, selected with generationMode in the Marker Files:
| Mode | Shape | Extra packages |
|---|---|---|
TypedClient (default)
|
One client class, one method per operation | None |
EndpointPerOperation |
One endpoint class + interface per operation | Atc.Rest.Client |
Both modes generate the same models. The difference is how you call the API and how you handle failures.
π‘ Runnable versions of everything on this page live in the repository under
sample/ThirdParty-EPO-Clients/EloverblikThirdPartyApiClient-Appandsample/ThridParty-Typed-Clients/EloverblikThirdPartyApiClient-App.
Pick TypedClient when you want the smallest surface and the fewest dependencies. One injected class exposes every operation, methods return the payload directly, and non-success responses throw. This is the natural fit for consuming a third-party API where you mostly care about the happy path and are content to let failures bubble up as exceptions.
Pick EndpointPerOperation when you want to handle each status code explicitly. Every operation gets its own interface, so a class can depend on exactly the one call it needs rather than the whole API β which keeps constructors honest and makes unit tests trivial to mock. Results are returned as wrappers with IsOk / IsUnauthorized / IsNotFound branches instead of throwing, so error handling becomes a compile-time concern.
.atc-rest-api-client:
{
"generate": true,
"generationMode": "TypedClient",
"namespace": "Eloverblik.Api.ThirdPartyApi"
}.csproj:
<ItemGroup>
<AdditionalFiles Include="api-1.yaml" />
<AdditionalFiles Include=".atc-rest-api-client" />
</ItemGroup>.atc-rest-api-client:
{
"generate": true,
"generationMode": "EndpointPerOperation",
"namespace": "Eloverblik.Api.ThirdPartyApi",
"httpClientName": "Eloverblik-ApiClient"
}.csproj:
<ItemGroup>
<PackageReference Include="Atc.Rest.Client" Version="2.0.36" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="api-1.yaml" />
<AdditionalFiles Include=".atc-rest-api-client" />
</ItemGroup>The httpClientName value becomes the generated Constants.HttpClientName constant β see HTTP Client Name Resolution.
The generated client takes an HttpClient in its constructor, so register it as a typed client and let IHttpClientFactory own the lifetime:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<ThirdPartyApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.eloverblik.dk");
client.Timeout = TimeSpan.FromSeconds(100);
});
using var host = builder.Build();
var client = host.Services.GetRequiredService<ThirdPartyApiClient>();Each method takes a generated parameters record and returns the response payload directly:
var isAlive = await client.GetThirdpartyapiApiIsaliveAsync(
new GetThirdpartyapiApiIsaliveParameters());
var response = await client.GetThirdpartyapiApiAuthorizationAuthorizationMeteringpointsScopeIdentifierAsync(
new GetThirdpartyapiApiAuthorizationAuthorizationMeteringpointsScopeIdentifierParameters(
Scope: "CVR",
Identifier: "12345678"));
foreach (var meteringPoint in response.Result ?? [])
{
Console.WriteLine(meteringPoint.MeteringPointId);
}Operations with a request body take it as the record's Request property β the shape mirrors the OpenAPI schema exactly:
var details = await client.PostThirdpartyapiApiMeteringpointGetdetailsAsync(
new PostThirdpartyapiApiMeteringpointGetdetailsParameters(
Request: new MeteringPointsRequest(
new MeteringPoints(meteringPointIds))));By default (clientGranularity: "PerArea") one client is generated per top-level path area. To generate a single client for the whole specification, set clientGranularity to Single and optionally name it explicitly:
{
"generate": true,
"generationMode": "TypedClient",
"clientGranularity": "Single",
"clientName": "ElOverblikThirdPartyApiClient"
}clientName is used verbatim β clientSuffix is not appended, so no ClientClient is produced. When omitted, the name is derived from the namespace as before.
Single also flattens the namespace layout:
| Type |
PerArea (default) |
Single |
|---|---|---|
| Client | {root}.Generated.{Area}.Client |
{root}.Generated |
| Parameter records | {root}.Generated.{Area}.Client |
{root}.Generated |
| Models | {root}.Generated.{Area}.Models |
{root}.Generated.Models |
So a consumer of a Single client needs just two usings:
global using Eloverblik.Api.ThirdPartyApi.Generated;
global using Eloverblik.Api.ThirdPartyApi.Generated.Models;and resolves the client by its explicit name:
builder.Services.AddHttpClient<ElOverblikThirdPartyApiClient>(client =>
{
client.BaseAddress = new Uri("https://api.eloverblik.dk");
});
var client = host.Services.GetRequiredService<ElOverblikThirdPartyApiClient>();
β οΈ Note: Flattening removes the per-area namespaces that keep otherwise-identical type names apart. If two schemas would collide, generation stops withATC_API_CLT001. SettingclientNamewhile onPerAreareportsATC_API_CLT002, since the name cannot be applied.
A non-success status code throws HttpRequestException with the status code and response body in the message:
try
{
var result = await client.GetThirdpartyapiApiIsaliveAsync(new());
}
catch (HttpRequestException ex)
{
// Covers both transport failures and non-2xx responses.
Console.WriteLine($"Request failed: {ex.Message}");
}Exception-based error handling is awkward to unit test: asserting on a 404 means catching an exception and parsing its message. Set typedClientResultStyle to Result in the marker file to have every operation return an EndpointResponse envelope instead:
{
"generationMode": "TypedClient",
"clientGranularity": "Single",
"clientName": "DeviceApiClient",
"typedClientResultStyle": "Result"
}| Style | Operation returns | Non-success status |
|---|---|---|
Throw (default)
|
Task<T> / Task
|
throws HttpRequestException
|
Result |
Task<EndpointResponse<T>> / Task<EndpointResponse>
|
reported via IsSuccess / StatusCode
|
The status becomes an ordinary value you can branch on and assert against:
var response = await client.GetDeviceByIdAsync(new("device-1"));
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
var device = response.SuccessContent;The envelope also exposes Content (the raw body, populated for error responses too) and Headers, so a failing call can be inspected without any exception plumbing.
Response shapes map as follows:
| Response | Return type |
|---|---|
| JSON body | EndpointResponse<T> |
No content (204) |
EndpointResponse |
Binary (application/octet-stream) |
EndpointResponse<byte[]> |
Text (text/plain) |
EndpointResponse<string> |
Location header only |
EndpointResponse<Uri> |
| Streaming | StreamingEndpointResponse<T> |
β οΈ SuccessContentis only populated for success responses - reading it after a failure throwsInvalidCastException. CheckIsSuccess(or the status code) first.
Using Result requires a reference to the Atc.Rest.Client package, which supplies the envelope types. Throw remains the default, so existing clients are unchanged.
An operation marked with x-return-async-enumerable: true (see Working with OpenAPI) generates a client method that returns IAsyncEnumerable<T> instead of Task<T>. Consume it with await foreach - items are yielded as they arrive on the wire instead of waiting for the full response body:
await foreach (var account in client.ListAsyncEnumerableAccountsAsync(cancellationToken))
{
Console.WriteLine(account.Name);
}Pass a CancellationToken to stop enumerating early (for example, a Blazor component's OnDisposed token) - the generated method forwards it to the underlying HTTP read so the connection is torn down instead of continuing to buffer.
An operation whose response schema composes a pagination wrapper via allOf (see Working with OpenAPI) generates a client method that returns Task<PaginatedResult<T>>. Page through the results by feeding Continuation (or PageIndex) back into the next request:
var page = await client.ListPaginatedAccountsAsync(new ListPaginatedAccountsParameters(
PageSize: 50,
PageIndex: 0));
var accounts = new List<Account>(page.Results);
while (page.Continuation is not null)
{
page = await client.ListPaginatedAccountsAsync(new ListPaginatedAccountsParameters(
PageSize: 50,
PageIndex: page.PageIndex + 1,
Continuation: page.Continuation));
accounts.AddRange(page.Results);
}PaginatedResult<T> is generated once per pagination wrapper name (PaginatedResult, PaginationResult or PagedResult) and reused across every operation that composes it, so TotalCount, Count, PageSize and PageIndex are always available for building a pager UI regardless of which endpoint produced the page.
π‘ The Showcase sample's
/accounts/paginatedand/accounts/async-enumerableendpoints demonstrate both patterns side by side - see Showcase Demo.
The generator emits a DI extension named after your namespace β Add{Namespace}Endpoints() β which registers every I{Operation}Endpoint β {Operation}Endpoint pair along with the Atc.Rest.Client core services:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddEloverblikApiThirdPartyApiEndpoints();
builder.Services.AddHttpClient(Constants.HttpClientName, client =>
{
client.BaseAddress = new Uri("https://api.eloverblik.dk");
client.Timeout = TimeSpan.FromSeconds(100);
});
using var host = builder.Build();The endpoints resolve their HttpClient by name, so registering the client under Constants.HttpClientName is what connects the two halves. Using the generated constant rather than a string literal means a later change to httpClientName cannot silently break the wiring.
Inject only the operations a class actually uses:
public sealed class MeteringPointService(
IGetThirdpartyapiApiAuthorizationAuthorizationMeteringpointsScopeIdentifierEndpoint endpoint)
{
public async Task<IReadOnlyList<string>> GetIdsAsync(string scope, string identifier)
{
var result = await endpoint.ExecuteAsync(
new GetThirdpartyapiApiAuthorizationAuthorizationMeteringpointsScopeIdentifierParameters(
Scope: scope,
Identifier: identifier));
return result.IsOk
? result.OkContent.Result?.Select(x => x.MeteringPointId!).ToList() ?? []
: [];
}
}Endpoints do not throw on HTTP status codes. Each result exposes an Is{Status} flag and a matching {Status}Content property holding the typed payload for that branch:
var result = await endpoint.ExecuteAsync(parameters);
if (result.IsOk)
{
Process(result.OkContent.Result);
}
else if (result.IsUnauthorized)
{
await RefreshCredentialsAsync();
}
else
{
logger.LogWarning("Unexpected status {StatusCode}", result.StatusCode);
}Always check the flag before reading content β the {Status}Content properties are only populated for the branch that actually occurred.
Do not set the token on DefaultRequestHeaders at registration time. IHttpClientFactory pools and reuses handlers, so a token written once at startup cannot be rotated afterwards. Use a DelegatingHandler that reads the current token per request instead. This keeps the generated code free of auth concerns and works identically in both modes.
internal sealed class BearerTokenProvider
{
public string? Token { get; set; }
}
internal sealed class BearerTokenHandler(BearerTokenProvider tokenProvider)
: DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(tokenProvider.Token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", tokenProvider.Token);
}
return base.SendAsync(request, cancellationToken);
}
}Register it against whichever client shape you chose:
builder.Services.AddSingleton<BearerTokenProvider>();
builder.Services.AddTransient<BearerTokenHandler>();
// TypedClient
builder.Services
.AddHttpClient<ThirdPartyApiClient>(ConfigureClient)
.AddHttpMessageHandler<BearerTokenHandler>();
// EndpointPerOperation
builder.Services
.AddHttpClient(Constants.HttpClientName, ConfigureClient)
.AddHttpMessageHandler<BearerTokenHandler>();Because the provider is a mutable singleton, a token swap is immediately visible to every subsequent call β which is what makes a two-step flow work:
// 1. Authenticate with the long-lived refresh token.
tokenProvider.Token = refreshToken;
var tokenResponse = await client.GetThirdpartyapiApiTokenAsync(new());
// 2. Swap in the short-lived access token for everything that follows.
tokenProvider.Token = tokenResponse.Result;Handlers compose, so resilience stacks on top of authentication:
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />builder.Services
.AddHttpClient<ThirdPartyApiClient>(ConfigureClient)
.AddHttpMessageHandler<BearerTokenHandler>()
.AddStandardResilienceHandler();See Working with Resilience for spec-driven x-retry-* configuration.
| Concern | TypedClient |
EndpointPerOperation |
|---|---|---|
| Generated types | One client class | Interface + class + result per operation |
| DI registration | AddHttpClient<TClient>() |
Add{Namespace}Endpoints() + named client |
| Client resolution | Typed client | Named client via Constants.HttpClientName
|
| Return value | Payload directly | Result wrapper with status branches |
| Non-success status | Throws HttpRequestException
|
IsOk / IsUnauthorized / β¦ flags |
| Extra packages | None | Atc.Rest.Client |
| Mocking in tests | Mock HttpMessageHandler
|
Mock the single endpoint interface |
| Best for | Small surface, few dependencies | Explicit status handling, fine-grained injection |
β‘οΈ See Working with C# Client Testing for full unit-testing patterns for both modes.
- π§ͺ Working with C# Client Testing β unit-testing code that consumes a generated client
- π Marker Files β
generationMode,httpClientName,clientSuffix - βοΈ Working with Configuration β full configuration reference
- π Working with Resilience β retry and circuit breaker policies
- π Working with Security β OAuth token management
- π¦ Working with TypeScript Client β the frontend equivalent
π Home
- πΌ FAQ Business Value
- π Getting Started with Basic
- π οΈ Getting Started with CLI
- π Migration Guide
- β¬οΈ Upgrading to v2
- π Working with OpenAPI
- π³οΈ Working with Nullability
- π οΈ Working with CLI
- π How-To Guides
- π Working with Security
- π¦ Working with Rate Limiting
- π Working with Resilience
- ποΈ Working with Caching
- π’ Working with Versioning
- β Working with Validations
- π Working with Webhooks
- βοΈ Working with Aspire
- π£οΈ Working with Endpoint Definitions
- π Working with Multi-Part Specs
- π§ͺ Working with Code Coverage
- π Working with C# Client
- π§ͺ Working with C# Client Testing
- π¦ Working with TypeScript Client
- πͺ Showcase Demo
- π§ͺ Working with E2E Testing
- βοΈ Working with Configuration
- π Marker Files
- π API Reference
- π Analyzer Rules
- β FAQ and Troubleshooting
- πΊοΈ Roadmap
- π§ Development Notes
- π¦ GitHub Repository
- π₯ NuGet Package
- π Report Issues