Skip to content

Working with CSharp Client

David Kallesen edited this page Sep 8, 2026 · 5 revisions

πŸ”Œ Working with C# 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-App and sample/ThridParty-Typed-Clients/EloverblikThirdPartyApiClient-App.


🧭 Choosing a Mode

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.


πŸ“¦ Project Setup

TypedClient

.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>

EndpointPerOperation

.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.


πŸ”§ Wiring Up a TypedClient

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))));

πŸ“± Client Granularity and Naming

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 with ATC_API_CLT001. Setting clientName while on PerArea reports ATC_API_CLT002, since the name cannot be applied.

⚠️ Error Handling

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}");
}

🎁 Returning Results Instead of Throwing (typedClientResultStyle)

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>

⚠️ SuccessContent is only populated for success responses - reading it after a failure throws InvalidCastException. Check IsSuccess (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.

🌊 Consuming Streaming Responses (IAsyncEnumerable<T>)

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.

πŸ“„ Consuming Paginated Responses (PaginatedResult<T>)

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/paginated and /accounts/async-enumerable endpoints demonstrate both patterns side by side - see Showcase Demo.


πŸ”§ Wiring Up EndpointPerOperation

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() ?? []
			: [];
	}
}

⚠️ Error Handling

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.


πŸ” Adding Authentication

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;

πŸ”„ Adding Resilience

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.


πŸ“‹ Mode Comparison

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.


πŸ”— Related Pages

🏠 Home

πŸ’Ό Why This Tool?

πŸ“– Getting Started

βš™οΈ Features

🌐 Frontend

πŸ“‹ Reference


πŸ”— Resources

Clone this wiki locally