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; /// /// Creates per-instance Refit proxies with /// OAuth2 bearer-token caching, auto-refresh on 401, and Polly retry. /// Registered as a singleton. /// public sealed class XiboClientFactory { private readonly IHttpClientFactory _httpClientFactory; private readonly ConcurrentDictionary _tokenCache = new(); private readonly SemaphoreSlim _tokenLock = new(1, 1); private static readonly TimeSpan TokenCacheTtl = TimeSpan.FromMinutes(5); public XiboClientFactory(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } /// /// Build a Refit client targeting /api. /// Tokens are cached per base URL for 5 minutes and auto-refreshed on 401. /// public async Task 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() .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, Delay = TimeSpan.FromSeconds(1), ShouldHandle = new PredicateBuilder() .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(httpClient); } // ── Token management ──────────────────────────────────────────────────── internal async Task 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 RequestTokenAsync( string instanceBaseUrl, string clientId, string clientSecret) { using var http = new HttpClient(); var content = new FormUrlEncodedContent(new Dictionary { ["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 _retryPipeline; private string _accessToken; public XiboDelegatingHandler( XiboClientFactory factory, string instanceBaseUrl, string clientId, string clientSecret, string accessToken, ResiliencePipeline retryPipeline) { _factory = factory; _instanceBaseUrl = instanceBaseUrl; _clientId = clientId; _clientSecret = clientSecret; _accessToken = accessToken; _retryPipeline = retryPipeline; } protected override async Task 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 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; } } }