Pacem.Net.Http 1.0.0-peano

This is a prerelease version of Pacem.Net.Http.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package Pacem.Net.Http --version 1.0.0-peano
                    
NuGet\Install-Package Pacem.Net.Http -Version 1.0.0-peano
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pacem.Net.Http" Version="1.0.0-peano" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pacem.Net.Http" Version="1.0.0-peano" />
                    
Directory.Packages.props
<PackageReference Include="Pacem.Net.Http" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pacem.Net.Http --version 1.0.0-peano
                    
#r "nuget: Pacem.Net.Http, 1.0.0-peano"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pacem.Net.Http@1.0.0-peano
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pacem.Net.Http&version=1.0.0-peano&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Pacem.Net.Http&version=1.0.0-peano&prerelease
                    
Install as a Cake Tool

Pacem.Net.Http

A small toolbox for outgoing HTTP calls on top of IHttpClientFactory: the boring, easy-to-get-wrong parts of talking to OAuth-protected and key-protected APIs, done once.

  • Bearer tokens attached by a DelegatingHandler, renewed and retried once when the server refuses them:
    • client_credentials for service-to-service calls (cached, renewed underneath, no HttpContext needed),
    • the signed-in user's token, forwarded from ASP.NET Core,
    • rotating refresh-token chains (one consent, then refresh forever; the rotated pair always reaches your store).
  • API keys (X-Api-Key, Ocp-Apim-Subscription-Key…) set per request.
  • RestClient: a JSON client with uri templates and failures thrown as HttpApiException, usable as a base for typed clients.

Everything composes with the standard handlers, e.g. AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience. Public types live in the Pacem.Net.Http namespace, DI extensions in Microsoft.Extensions.DependencyInjection.

Install

dotnet add package Pacem.Net.Http

Targets net10.0. Depends on the ASP.NET Core shared framework (for user-token forwarding) and on Pacem.IdentityModel for the OAuth protocol calls.

Service-to-service: client credentials

"Erp": {
  "BaseUrl": "https://erp.example.com/api/",
  "ClientCredentials": {
    "DiscoveryDocumentUrl": "https://id.example.com/acme/.well-known/openid-configuration",
    "ClientId": "erp-worker",
    "ClientSecret": "<secret>",
    "Scope": "erp.read erp.write"
  }
}
public static class Program
{
    public static void Main(string[] args)
    {
        var builder = Host.CreateApplicationBuilder(args);
        IConfigurationSection erp = builder.Configuration.GetSection("Erp");
        builder.Services.AddHttpClient<ErpClient>(client => client.BaseAddress = new Uri(erp["BaseUrl"]!))
            .AddClientCredentialsToken(options => erp.GetSection("ClientCredentials").Bind(options))
            .AddStandardResilienceHandler();
        builder.Services.AddHostedService<InvoiceSyncWorker>();
        builder.Build().Run();
    }
}

The token is requested on the first call, cached per discovery url + client + scope, shared by concurrent callers and renewed shortly before it expires (ClientCredentialsOptions.Skew, 30s by default). The token endpoint is read from the discovery document; ClientAuthenticationMethod picks client_secret_basic (default) or client_secret_post.

Typed clients: RestClient

Derive from RestClient and every call is one line:

public sealed class ErpClient(HttpClient http) : RestClient(http)
{
    public Task<InvoiceDto?> FindInvoiceAsync(string erpId, int number, CancellationToken cancellationToken)
        => GetOrNullAsync<InvoiceDto>("erp/{erp}/invoices/{number}", new { erp = erpId, number }, cancellationToken);

    public Task<InvoiceDto?> CreateInvoiceAsync(string erpId, InvoiceDraft draft, CancellationToken cancellationToken)
        => PostAsync<InvoiceDto>("erp/{erp}/invoices", draft, new { erp = erpId }, cancellationToken);

    public Task CancelInvoiceAsync(string erpId, int number, CancellationToken cancellationToken)
        => DeleteAsync("erp/{erp}/invoices/{number}", new { erp = erpId, number }, cancellationToken);
}
Call Success Notes
GetAsync<T> the JSON body; default for 204 or an empty body
GetOrNullAsync<T> same 404 is default, not a failure
GetStringAsync the body as text
PostAsync, PutAsync, PatchAsync nothing, or the JSON answer with <TResult> a null body sends no content
DeleteAsync nothing

Every other status throws HttpApiException. Http exposes the underlying HttpClient for anything else (streams, headers, other content types). JSON uses JsonSerializerDefaults.Web unless RestClientOptions says otherwise.

Uri templates

{name} placeholders take the matching parameter, escaped; every other parameter goes to the query string. Parameters are an object (its public properties) or a dictionary.

HttpRequestUri.Build("blog/{blog}/article/{id}", new { blog = 3, id = "a b", edit = true });
// blog/3/article/a%20b?edit=True
HttpRequestUri.Build("users", new { role = new[] { "admin", "editor" }, q = (string?)null });
// users?role=admin&role=editor   (null left out, sequences repeat their key)

Values are formatted with the invariant culture, dates as ISO 8601. The same HttpRequestUri.Build is used by every RestClient call.

Named clients

When a dedicated class is overkill, register named RestClients and get them from IRestClientFactory. Each call wraps a fresh HttpClient, so singletons can ask for one per call:

services.AddRestClient("erp", client => client.BaseAddress = new Uri("https://erp.example.com/api/"),
        options => options.JsonSerializerOptions = new JsonSerializerOptions())   // e.g. PascalCase DTOs
    .AddClientCredentialsToken(options => erp.GetSection("ClientCredentials").Bind(options));
public sealed class InvoiceReport(IRestClientFactory rest)
{
    public Task<List<InvoiceDto>?> GetOpenAsync(string erpId, CancellationToken cancellationToken)
        => rest.CreateClient("erp").GetAsync<List<InvoiceDto>>("erp/{erp}/invoices", new { erp = erpId, open = true }, cancellationToken);
}

Errors

try
{
    await erp.CreateInvoiceAsync("acme", draft, stoppingToken);
}
catch (HttpApiException exc) when (exc.StatusCode == HttpStatusCode.Conflict)
{
    logger.LogWarning("Invoice already there: {body}", exc.ResponseBody);
}

HttpApiException derives from HttpRequestException and carries StatusCode, ReasonPhrase, Method, RequestUri and the start of the body (ResponseBody, up to 4096 characters). The same checks are available on any HttpResponseMessage: EnsureSuccessAsync(), ReadJsonOrDefaultAsync<T>(), ReadJsonOrNullOnNotFoundAsync<T>().

Per-request credentials and the signed-in user

A multi-tenant web app may call an API with a different client per tenant (resolved from the current request), and on behalf of the signed-in user for writes:

services.AddRestClient("api", ConfigureBaseAddress)
    .AddClientCredentialsToken(ResolveTenantClientAsync);
services.AddRestClient("api.user", ConfigureBaseAddress)
    .AddUserAccessToken();

private static async ValueTask<ClientCredentialsOptions?> ResolveTenantClientAsync(IServiceProvider services, CancellationToken cancellationToken)
{
    var httpContext = services.GetRequiredService<IHttpContextAccessor>().HttpContext;
    if (httpContext is null)
    {
        // no request: anonymous
        return null;
    }
    var tenants = httpContext.RequestServices.GetRequiredService<ITenantClients>();
    var client = await tenants.FindByRequestAsync(httpContext, cancellationToken);
    if (client is null)
    {
        return null;
    }
    return new ClientCredentialsOptions
    {
        DiscoveryDocumentUrl = client.DiscoveryDocumentUrl,
        ClientId = client.ClientId,
        ClientSecret = client.ClientSecret,
    };
}
  • The resolver runs on every request; returning null sends the request without Authorization.
  • AddUserAccessToken() forwards the access_token saved by the authentication handler (SaveTokens = true). With no HttpContext, no signed-in user or no saved token the request goes out anonymously.
  • A refused user token is renewed by the registered IUserAccessTokenRenewer, if any, and the call retried once; the renewed token serves the rest of the request. Whoever owns the session provides the renewer: Pacem.Authentication.OpenIdConnect registers one that refreshes the session and rewrites its cookie. Without a renewer the 401 is returned as it is.
public sealed class MySessionRenewer : IUserAccessTokenRenewer
{
    public async ValueTask<string?> RenewAsync(HttpContext httpContext, string rejected, CancellationToken cancellationToken)
    {
        // refresh the user's session; null when it can't be done (e.g. httpContext.Response.HasStarted)
        …
    }
}

services.AddSingleton<IUserAccessTokenRenewer, MySessionRenewer>();

API keys

services.AddHttpClient<StoreClient>((sp, client) => client.BaseAddress = new Uri(GetStoreOptions(sp).BaseUrl))
    .AddApiKey("X-Api-Key", sp => GetStoreOptions(sp).ApiKey)   // read on every request; null/empty sends none
    .AddStandardResilienceHandler();

AddApiKey(headerName, key) also takes a fixed string. The header is set on each request, never on the client's shared DefaultRequestHeaders.

Rotating refresh tokens

For cloud APIs where a user consents once (authorization code) and the app then keeps a refresh token alive, often rotated on every use. One call registers the chain, its store and its endpoint, and puts the bearer handler in front of the client:

"Thermostat": {
  "ClientId": "<client id>",
  "ClientSecret": "<secret>",
  "RedirectUri": "https://home.example.com/thermostat/consent",
  "SubscriptionKey": "<key>"
}
IConfigurationSection thermostat = builder.Configuration.GetSection("Thermostat");
builder.Services.AddHttpClient<ThermostatClient>(client => client.BaseAddress = new Uri("https://api.thermostat.example/v2/"))
    .AddApiKey("Ocp-Apim-Subscription-Key", thermostat["SubscriptionKey"])
    .AddRefreshTokenChain<FileTokenStore>("thermostat", chain =>
    {
        thermostat.Bind(chain);
        chain.TokenEndpointUrl = "https://login.thermostat.example/token";
        chain.ClientAuthenticationMethod = KnownClientAuthenticationMethods.ClientSecretPost;
        chain.Skew = TimeSpan.FromMinutes(5);
    });
  • Endpoint: TokenEndpointUrl posts the grant there (providers without a discovery document); otherwise DiscoveryDocumentUrl reads it from the discovery document. A custom ITokenEndpoint goes in with AddRefreshTokenChain<TStore, TEndpoint>(…).
  • Store: InMemoryTokenStore unless you pass TStore. Implement ITokenStore (LoadAsync/SaveAsync) to survive restarts; it's a keyed singleton under the chain name, so it can take a [ServiceKey] string constructor parameter.
  • Sharing: several clients can use the same chain by name. Their configure callbacks add up, so it may be omitted on all but one: .AddRefreshTokenChain("thermostat"). The first registration of a name decides its store and endpoint.

The chain itself is a keyed singleton. Start it from the consent callback:

app.MapGet("/thermostat/consent", RedeemAsync);

private static async Task<IResult> RedeemAsync(string code, [FromKeyedServices("thermostat")] RefreshTokenChain chain,
    CancellationToken cancellationToken)
{
    await chain.RedeemAuthorizationCodeAsync(code, cancellationToken: cancellationToken);
    return Results.Redirect("/");
}

…or seed it with a refresh token obtained elsewhere: await chain.ImportAsync(TokenSet.FromRefreshToken(token)) (refreshed at once: with rotating providers the copy you imported is dead afterwards). ExchangeAuthorizationCodeAsync redeems a code without storing the pair, e.g. to hand it to another host; PeekAccessToken() reads the token in memory without refreshing.

From then on every call refreshes as needed: when the access token is about to expire, or when the server refuses it. One caller refreshes at a time. Once a refresh request is out it is not cancelled, so the rotated pair always reaches the store; if saving fails, it's logged as critical and kept in memory. A broken chain (nothing stored, or invalid_grant) surfaces from the HTTP call:

try
{
    var status = await thermostats.GetStatusAsync(plantId, moduleId, cancellationToken);
}
catch (ReauthorizationRequiredException)
{
    // ask the user to consent again
}

When "refused" isn't just a 401

By default only a 401 triggers the refresh-and-retry. Some APIs answer 403 and explain in the body; the body of a failed response is buffered, so the check can read it and the caller can read it again:

.AddRefreshTokenChain<FileTokenStore>("weather", chain =>
{
    weather.Bind(chain);
    chain.TokenEndpointUrl = "https://api.weather.example/oauth2/token";
    chain.Handler.IsRefused = IsTokenRefusedAsync;
});

private static async ValueTask<bool> IsTokenRefusedAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
    if (response.StatusCode is not (HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden))
    {
        return false;
    }
    string body = await response.Content.ReadAsStringAsync(cancellationToken);
    // e.g. {"error":{"code":3,"message":"Access token expired"}}
    return body.Contains("\"code\":3", StringComparison.Ordinal);
}

The same IsRefused is available to every token handler through the optional configureHandler argument of AddClientCredentialsToken and AddBearerToken.

Your own token source

Anything that can hand out a token plugs into the same handler:

public sealed class VaultTokenSource(IVault vault) : IAccessTokenSource
{
    public async ValueTask<string?> GetAccessTokenAsync(string? rejected, CancellationToken cancellationToken)
    {
        // rejected: the token the server just refused, if any; hand out another one, or the same to stop retrying
        return await vault.GetSecretAsync("api-token", forceReload: rejected is not null, cancellationToken);
    }
}

services.AddHttpClient<ReportsClient>(…)
    .AddBearerToken<VaultTokenSource>();

Returning null sends the request without Authorization.

How the handlers behave

  • The request content is buffered before the first send, so a retry can send it again.
  • At most one retry per request, and only when the source hands out a different token.
  • Add the authentication handlers first and AddStandardResilienceHandler() last.
  • Nothing calls BuildServiceProvider() during registration; registrations are idempotent (TryAdd), and AddPacemIdentityModelClient() is called for you unless an IClientFactory is already registered.

License

Apache-2.0.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on Pacem.Net.Http:

Package Downloads
Pacem.Domotics.Legrand

Pacem domotics implementation for the Legrand Smarther v2.0 cloud API, i.e. BTicino Smarther (X8000).

Pacem.Authentication.OpenIdConnect

OpenID Connect sign-in for ASP.NET Core apps in the Pacem ecosystem: cookie session, clients resolved per request (multi-tenant), session token refresh and on-demand renewal for downstream calls.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0-ricci 0 10/28/2026
1.0.0-peano 37 10/27/2026
1.0.0-maiorana 42 10/27/2026
0.10.19 41 10/27/2026
0.10.19-galilei 45 10/27/2026
0.10.19-fermi 45 10/27/2026
0.10.19-degiorgi 56 10/26/2026