212 lines
7.6 KiB
C#
212 lines
7.6 KiB
C#
|
|
using System.Collections.Concurrent;
|
||
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Headers;
|
||
|
|
using System.Text.Json;
|
||
|
|
using Polly;
|
||
|
|
using Polly.Retry;
|
||
|
|
using Refit;
|
||
|
|
|
||
|
|
namespace OTSSignsOrchestrator.Server.Clients;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Creates per-instance <see cref="IXiboApiClient"/> Refit proxies with
|
||
|
|
/// OAuth2 bearer-token caching, auto-refresh on 401, and Polly retry.
|
||
|
|
/// Registered as a singleton.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class XiboClientFactory
|
||
|
|
{
|
||
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
||
|
|
private readonly ConcurrentDictionary<string, TokenEntry> _tokenCache = new();
|
||
|
|
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||
|
|
|
||
|
|
private static readonly TimeSpan TokenCacheTtl = TimeSpan.FromMinutes(5);
|
||
|
|
|
||
|
|
public XiboClientFactory(IHttpClientFactory httpClientFactory)
|
||
|
|
{
|
||
|
|
_httpClientFactory = httpClientFactory;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Build a Refit client targeting <paramref name="instanceBaseUrl"/>/api.
|
||
|
|
/// Tokens are cached per base URL for 5 minutes and auto-refreshed on 401.
|
||
|
|
/// </summary>
|
||
|
|
public async Task<IXiboApiClient> CreateAsync(
|
||
|
|
string instanceBaseUrl,
|
||
|
|
string clientId,
|
||
|
|
string clientSecret)
|
||
|
|
{
|
||
|
|
// Ensure we have a valid token up-front
|
||
|
|
var token = await GetOrRefreshTokenAsync(instanceBaseUrl, clientId, clientSecret);
|
||
|
|
|
||
|
|
var retryPipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
|
||
|
|
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
|
||
|
|
{
|
||
|
|
MaxRetryAttempts = 3,
|
||
|
|
BackoffType = DelayBackoffType.Exponential,
|
||
|
|
Delay = TimeSpan.FromSeconds(1),
|
||
|
|
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
|
||
|
|
.HandleResult(r =>
|
||
|
|
r.StatusCode is HttpStatusCode.RequestTimeout
|
||
|
|
or HttpStatusCode.TooManyRequests
|
||
|
|
or >= HttpStatusCode.InternalServerError),
|
||
|
|
})
|
||
|
|
.Build();
|
||
|
|
|
||
|
|
var handler = new XiboDelegatingHandler(
|
||
|
|
this, instanceBaseUrl, clientId, clientSecret, token, retryPipeline)
|
||
|
|
{
|
||
|
|
InnerHandler = new HttpClientHandler(),
|
||
|
|
};
|
||
|
|
|
||
|
|
var httpClient = new HttpClient(handler)
|
||
|
|
{
|
||
|
|
BaseAddress = new Uri(instanceBaseUrl.TrimEnd('/') + "/api"),
|
||
|
|
};
|
||
|
|
|
||
|
|
return RestService.For<IXiboApiClient>(httpClient);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Token management ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
internal async Task<string> GetOrRefreshTokenAsync(
|
||
|
|
string instanceBaseUrl,
|
||
|
|
string clientId,
|
||
|
|
string clientSecret,
|
||
|
|
bool forceRefresh = false)
|
||
|
|
{
|
||
|
|
var key = instanceBaseUrl.TrimEnd('/').ToLowerInvariant();
|
||
|
|
|
||
|
|
if (!forceRefresh
|
||
|
|
&& _tokenCache.TryGetValue(key, out var cached)
|
||
|
|
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||
|
|
{
|
||
|
|
return cached.AccessToken;
|
||
|
|
}
|
||
|
|
|
||
|
|
await _tokenLock.WaitAsync();
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Double-check after acquiring lock
|
||
|
|
if (!forceRefresh
|
||
|
|
&& _tokenCache.TryGetValue(key, out cached)
|
||
|
|
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||
|
|
{
|
||
|
|
return cached.AccessToken;
|
||
|
|
}
|
||
|
|
|
||
|
|
var token = await RequestTokenAsync(instanceBaseUrl, clientId, clientSecret);
|
||
|
|
_tokenCache[key] = new TokenEntry(token, DateTimeOffset.UtcNow.Add(TokenCacheTtl));
|
||
|
|
return token;
|
||
|
|
}
|
||
|
|
finally
|
||
|
|
{
|
||
|
|
_tokenLock.Release();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async Task<string> RequestTokenAsync(
|
||
|
|
string instanceBaseUrl,
|
||
|
|
string clientId,
|
||
|
|
string clientSecret)
|
||
|
|
{
|
||
|
|
using var http = new HttpClient();
|
||
|
|
|
||
|
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||
|
|
{
|
||
|
|
["grant_type"] = "client_credentials",
|
||
|
|
["client_id"] = clientId,
|
||
|
|
["client_secret"] = clientSecret,
|
||
|
|
});
|
||
|
|
|
||
|
|
var response = await http.PostAsync(
|
||
|
|
$"{instanceBaseUrl.TrimEnd('/')}/api/authorize/access_token",
|
||
|
|
content);
|
||
|
|
|
||
|
|
response.EnsureSuccessStatusCode();
|
||
|
|
|
||
|
|
var json = await response.Content.ReadAsStringAsync();
|
||
|
|
using var doc = JsonDocument.Parse(json);
|
||
|
|
return doc.RootElement.GetProperty("access_token").GetString()
|
||
|
|
?? throw new InvalidOperationException("Token response missing access_token.");
|
||
|
|
}
|
||
|
|
|
||
|
|
private sealed record TokenEntry(string AccessToken, DateTimeOffset ExpiresAt);
|
||
|
|
|
||
|
|
// ── Delegating handler ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
private sealed class XiboDelegatingHandler : DelegatingHandler
|
||
|
|
{
|
||
|
|
private readonly XiboClientFactory _factory;
|
||
|
|
private readonly string _instanceBaseUrl;
|
||
|
|
private readonly string _clientId;
|
||
|
|
private readonly string _clientSecret;
|
||
|
|
private readonly ResiliencePipeline<HttpResponseMessage> _retryPipeline;
|
||
|
|
private string _accessToken;
|
||
|
|
|
||
|
|
public XiboDelegatingHandler(
|
||
|
|
XiboClientFactory factory,
|
||
|
|
string instanceBaseUrl,
|
||
|
|
string clientId,
|
||
|
|
string clientSecret,
|
||
|
|
string accessToken,
|
||
|
|
ResiliencePipeline<HttpResponseMessage> retryPipeline)
|
||
|
|
{
|
||
|
|
_factory = factory;
|
||
|
|
_instanceBaseUrl = instanceBaseUrl;
|
||
|
|
_clientId = clientId;
|
||
|
|
_clientSecret = clientSecret;
|
||
|
|
_accessToken = accessToken;
|
||
|
|
_retryPipeline = retryPipeline;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||
|
|
HttpRequestMessage request,
|
||
|
|
CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
return await _retryPipeline.ExecuteAsync(async ct =>
|
||
|
|
{
|
||
|
|
// Clone the request for retries (original may already be disposed)
|
||
|
|
using var clone = await CloneRequestAsync(request);
|
||
|
|
clone.Headers.Authorization =
|
||
|
|
new AuthenticationHeaderValue("Bearer", _accessToken);
|
||
|
|
|
||
|
|
var response = await base.SendAsync(clone, ct);
|
||
|
|
|
||
|
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||
|
|
{
|
||
|
|
// Force-refresh the token and retry once
|
||
|
|
_accessToken = await _factory.GetOrRefreshTokenAsync(
|
||
|
|
_instanceBaseUrl, _clientId, _clientSecret, forceRefresh: true);
|
||
|
|
|
||
|
|
using var retry = await CloneRequestAsync(request);
|
||
|
|
retry.Headers.Authorization =
|
||
|
|
new AuthenticationHeaderValue("Bearer", _accessToken);
|
||
|
|
|
||
|
|
response = await base.SendAsync(retry, ct);
|
||
|
|
}
|
||
|
|
|
||
|
|
return response;
|
||
|
|
}, cancellationToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static async Task<HttpRequestMessage> CloneRequestAsync(
|
||
|
|
HttpRequestMessage original)
|
||
|
|
{
|
||
|
|
var clone = new HttpRequestMessage(original.Method, original.RequestUri);
|
||
|
|
|
||
|
|
if (original.Content != null)
|
||
|
|
{
|
||
|
|
var body = await original.Content.ReadAsByteArrayAsync();
|
||
|
|
clone.Content = new ByteArrayContent(body);
|
||
|
|
foreach (var header in original.Content.Headers)
|
||
|
|
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||
|
|
}
|
||
|
|
|
||
|
|
foreach (var header in original.Headers)
|
||
|
|
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||
|
|
|
||
|
|
return clone;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|