Core functionality for TBC Open API SDKs
Repository contains the basic functionality used to work with Open Api SDKs.
Library is written in the C # programming language and is compatible with .netstandard2.0 and .net6.0. Depends only on the components manufactured by Microsoft.
Note
The Example* types are not part of this package; they ship in the separate
TBC.OpenAPI.SDK.ExampleClient
reference package as a template for your own client. Core provides the primitives:
AddOpenApiClient<TInterface, TImplementation, TOptions> for DI, and
OpenApiClientFactoryBuilder.AddClient<...> / OpenApiClientFactory.GetOpenApiClient<TInterface>()
for the factory. AddExampleClient is a thin wrapper you write yourself.
- Create interface "IExampleClient" and inherit from "TBC.OpenAPI.SDK.Core.IOpenApiClient"
public interface IExampleClient : IOpenApiClient
{
Task<SomeObject> GetSomeObjectAsync(CancellationToken cancellationToken = default);
}- Create class "ExampleClient" and inherit from "IExampleClient"
public class ExampleClient : IExampleClient
{
private readonly IHttpHelper<ExampleClient> _http;
public ExampleClient(IHttpHelper<ExampleClient> http)
{
_http = http;
}
public async Task<SomeObject> GetSomeObjectAsync(CancellationToken cancellationToken = default)
{
var result = await _http.GetJsonAsync<SomeObject>("/", cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
throw new OpenApiException(result.Problem?.Title ?? "Unexpected error occurred", result.Exception);
return result.Data!;
}
}Important
Inject the IHttpHelper<T> abstraction (only the interface is registered) and
parameterize it with the implementation type — IHttpHelper<ExampleClient>, not
IHttpHelper<IExampleClient>. That is the named HttpClient AddOpenApiClient
configures, and the same type argument AddOAuthTokenCaching<T> expects.
- Create class "ExampleClientOptions" and inherit from "TBC.OpenAPI.SDK.Core.OptionsBase"
- If you need client secret in options, inherit from "TBC.OpenAPI.SDK.Core.BasicAuthOptions"
public class ExampleClientOptions : OptionsBase{}
// Both base classes are abstract, so a client secret needs its own concrete class:
public class ExampleClientBasicAuthOptions : BasicAuthOptions{}- Create class "ServiceCollectionExtensions" with extension method "AddExampleClient" for "Microsoft.Extensions.DependencyInjection.IServiceCollection", used for adding client to middleware
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddExampleClient(this IServiceCollection services, ExampleClientOptions options)
=> AddExampleClient(services, options, null, null);
public static IServiceCollection AddExampleClient(this IServiceCollection services, ExampleClientOptions options,
Action<HttpClient>? configureClient = null,
Func<HttpClientHandler>? configureHttpMessageHandler = null)
{
services.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options, configureClient, configureHttpMessageHandler);
return services;
}
}- Create class "FactoryExtensions" with extension method "AddExampleClient" for "TBC.OpenAPI.SDK.Core.OpenApiClientFactoryBuilder", used for passing options "ExampleClientOptions" into "OpenApiClientFactoryBuilder"
public static class FactoryExtensions
{
public static OpenApiClientFactoryBuilder AddExampleClient(this OpenApiClientFactoryBuilder builder,
ExampleClientOptions options) => AddExampleClient(builder, options, null, null);
public static OpenApiClientFactoryBuilder AddExampleClient(this OpenApiClientFactoryBuilder builder,
ExampleClientOptions options,
Action<HttpClient>? configureClient = null,
Func<HttpClientHandler>? configureHttpMessageHandler = null)
{
return builder.AddClient<IExampleClient, ExampleClient, ExampleClientOptions>(options, configureClient, configureHttpMessageHandler);
}
public static IExampleClient GetExampleClient(this OpenApiClientFactory factory) =>
factory.GetOpenApiClient<IExampleClient>();
}Repository contains three example projects:
- UsageExample1 - .net Core API Application
- UsageExample2 - Console Application
- UsageExample3 - .net WebApi Application
Add "AddExampleClient" to Program.cs file with Dependency Injection and read settings for "ExampleClientOptions" from appsettings.json file
Program.cs
builder.Services.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get<ExampleClientOptions>());appsettings.json
{
"ExampleClient": {
"BaseUrl": "https://jsonplaceholder.typicode.com/users/1",
"ApiKey": "abc"
}
}- In case you need client secret
Program.cs
builder.Services.AddExampleClient(builder.Configuration.GetSection("ExampleClient").Get<ExampleClientBasicAuthOptions>());appsettings.json
{
"ExampleClient": {
"BaseUrl": "https://jsonplaceholder.typicode.com/users/1",
"ApiKey": "abc",
"ClientSecret": "abc"
}
}Create variable "_exampleClient" of type "IExampleClient" in controller and initialize it using dependency injection
private readonly IExampleClient _exampleClient;
public TestController(IExampleClient exampleClient)
{
_exampleClient = exampleClient;
}[HttpGet]
public async Task<ActionResult<SomeObject>> GetSomeObject(CancellationToken cancellationToken = default)
{
var result = await _exampleClient.GetSomeObjectAsync(cancellationToken);
return Ok(result);
}{
"id": 1,
"name": "Leanne Graham"
}Core can transparently acquire and cache OAuth access tokens using the
client_credentials grant. Tokens are requested on demand, cached per scope in an
IDistributedCache and attached as an Authorization: Bearer header by an HttpClient
message handler (OAuthDelegatingHandler<TClient>), so client code never fetches or
stores tokens itself.
Important
There is no token refresh. Tokens are acquired lazily and renewed only when they expire out of the cache or are evicted. See What happens on 401 Unauthorized.
Call AddOAuthTokenCaching<TClient> after registering the client with
AddOpenApiClient, then finish the chain with exactly one of the terminal methods below
— the cache choice is not optional and is made per client. The SDK never registers a
cache backend on your behalf; if the terminal call is missing, resolving the client
throws an InvalidOperationException naming the available options instead of silently
falling back to a per-process cache.
Important
TClient must be the client's implementation type (ExampleClient), the same type
argument the client passes to IHttpHelper<T>. Passing an interface throws an
InvalidOperationException at registration time.
| Terminal call | Where tokens live | Use when |
|---|---|---|
.UseInMemoryCache() |
A private in-memory cache owned by this SDK, per process | Single-instance apps, local development, tests |
.UseRegisteredDistributedCache() |
The IDistributedCache registered in your container |
You already configure Redis / SQL Server / etc. centrally |
.UseDistributedCache(cache).UseDistributedCache(sp => ...) |
A cache instance you supply directly | You want a dedicated cache for tokens, separate from the app's |
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(
builder.Configuration.GetSection("ExampleClient").Get<ExampleClientOptions>())
.AddOAuthTokenCaching<ExampleClient>()
.UseInMemoryCache();
// reuse the IDistributedCache registered in the container (Redis, SQL Server, ...)
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>()
.UseRegisteredDistributedCache();
// or hand over an instance directly, without registering it
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>()
.UseDistributedCache(sp => sp.GetRequiredKeyedService<IDistributedCache>("tokens"));Warning
.UseInMemoryCache() is per process and not shared: in a multi-instance deployment
every instance keeps its own token cache. Pick a distributed option if that is not
acceptable.
Registration order does not matter — .UseRegisteredDistributedCache() resolves the
cache lazily, the first time a token is needed. If nothing is registered by then, or if
the registered implementation is the non-shared MemoryDistributedCache (what
AddDistributedMemoryCache() registers), it throws; use .UseInMemoryCache() if a
per-process cache is what you want.
The same terminal methods are available on OpenApiClientFactoryBuilder and return the
builder so the chain continues:
var factory = new OpenApiClientFactoryBuilder()
.AddClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>()
.UseInMemoryCache()
.Build();
var client = factory.GetOpenApiClient<IExampleClient>();AddOAuthTokenCaching<TClient>() used to fall back to an in-memory distributed cache
when no IDistributedCache was registered, which made the caching topology depend on
registration order. A terminal call is now required, and TClient must be the
implementation type — AddOAuthTokenCaching<IExampleClient>() never worked (the handler
was attached to a named HttpClient nothing resolved, so requests went out without an
Authorization header) and now throws instead of failing silently.
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
- .AddOAuthTokenCaching<IExampleClient>();
+ .AddOAuthTokenCaching<ExampleClient>()
+ .UseInMemoryCache(); // previous behaviour without a registered IDistributedCache
+ // .UseRegisteredDistributedCache(); // previous behaviour with a registered IDistributedCacheThe scope is supplied per request through the X-TBC-OAuth-Scope marker header
(OAuthConstants.ScopeHeaderName). The handler reads and removes that header, resolves a
token for the scope, and injects the Authorization: Bearer <token> header. Requests
that do not carry the marker header are passed through untouched (for example the token
endpoint call itself), which prevents recursion.
public async Task<SomeObject> GetSomeObjectAsync(CancellationToken cancellationToken = default)
{
var headers = new HeaderParamCollection
{
[OAuthConstants.ScopeHeaderName] = "read:some-object"
};
var result = await _http.GetJsonAsync<SomeObject>("/", query: null, headers, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
throw new OpenApiException(result.Problem?.Title ?? "Unexpected error occurred", result.Exception);
return result.Data!;
}- The outgoing request carries the
X-TBC-OAuth-Scopeheader with the required scope. OAuthDelegatingHandler<TClient>extracts and removes that header.- A cached token for the scope is looked up in the
IDistributedCache. If none exists, a new token is requested viaPOST oauth/tokenusinggrant_type=client_credentialsand the given scope, then cached. - The
Authorization: Bearer <access_token>header is added and the request is sent. - If the response is
401 Unauthorized, the cached token is invalidated so the next request obtains a fresh one. The401itself is returned to the caller.
The handler evicts, it does not refresh: the failed request is not retried, only the
next request for that scope gets a fresh token. The SDK never retries with a new token,
never uses the refresh_token grant (no refresh token is stored) and never renews
proactively. The only protection against sending an about-to-expire token is the
30-second grace period subtracted from expires_in, so treat a 401 as a normal failed
response.
Retry and backoff are the caller's responsibility — Core ships no retry logic and takes no dependency on Polly or any resilience library. It does, however, give you the hook needed to turn that eviction into a retry that actually recovers; see Retrying on 401.
Because the handler evicts the token on a 401 but only the next request for that scope picks up a fresh one, a retry only recovers if it re-enters the OAuth handler after the eviction. That means the retry has to sit outside OAuthDelegatingHandler<TClient> in the HttpClient pipeline. AddOAuthTokenCaching<TClient> therefore takes an optional Action<IHttpClientBuilder> configurePipeline hook: any handler it registers is placed outside the OAuth handler, so a retried attempt re-enters token handling and, because the 401 already evicted the token, acquires a fresh one.
Core supplies no retry implementation — you plug in whichever mechanism you prefer.
Important
A retry handler must clone the request on every attempt. The OAuth handler consumes the X-TBC-OAuth-Scope marker header, and the request content is consumed once it is sent, so re-sending the same HttpRequestMessage fails. Microsoft.Extensions.Http.Resilience clones automatically. Scope the retry to 401 Unauthorized so genuinely failed requests are not re-issued.
With Microsoft.Extensions.Http.Resilience (Polly):
using Microsoft.Extensions.Http.Resilience;
using Polly;
builder.Services
.AddOpenApiClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>(configurePipeline: pipeline =>
pipeline.AddResilienceHandler("example-401-retry", b =>
b.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 1,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode == System.Net.HttpStatusCode.Unauthorized)
})))
.UseInMemoryCache();The same configurePipeline parameter is available on the factory builder's
AddOAuthTokenCaching<TClient>:
var factory = new OpenApiClientFactoryBuilder()
.AddClient<IExampleClient, ExampleClient, ExampleClientOptions>(options)
.AddOAuthTokenCaching<ExampleClient>(configurePipeline: pipeline =>
pipeline.AddResilienceHandler("example-401-retry", b =>
b.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 1,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode == System.Net.HttpStatusCode.Unauthorized)
})))
.UseInMemoryCache()
.Build();- Cache key — composed as
TbcOpenApiOAuthToken:{ClientTypeName}:{scope}, so tokens are isolated per client type and per scope. - Lifetime — a token is cached for its
expires_invalue minus a 30-second grace period (OAuthConstants.TokenTimeoutGracePeriodSec), with a minimum of 30 seconds (OAuthConstants.MinCacheTtlSec). Tokens without anexpires_invalue use the minimum. - Stored value — only the access-token string is persisted. A cache hit therefore
yields a token response whose
TokenType,ExpiresInandScopeare null. - Concurrency — concurrent requests for the same scope are de-duplicated so only a single token request is made while the others await the same result. This de-duplication is per process: in a multi-instance deployment each instance still issues its own initial token request per scope, after which the shared cache takes over.