using System; using System.Globalization; using System.Net.Http; using System.Net.Http.Json; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.IssuerDirectory.Client; internal sealed class IssuerDirectoryClient : IIssuerDirectoryClient { private readonly HttpClient _httpClient; private readonly IMemoryCache _cache; private readonly IssuerDirectoryClientOptions _options; private readonly ILogger _logger; public IssuerDirectoryClient( HttpClient httpClient, IMemoryCache cache, IOptions options, ILogger logger) { _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); ArgumentNullException.ThrowIfNull(options); _options = options.Value; _options.Validate(); } public async ValueTask> GetIssuerKeysAsync( string tenantId, string issuerId, bool includeGlobal, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); tenantId = tenantId.Trim(); issuerId = issuerId.Trim(); var cacheKey = CacheKey("keys", tenantId, issuerId, includeGlobal.ToString(CultureInfo.InvariantCulture)); if (_cache.TryGetValue(cacheKey, out IReadOnlyList? cached) && cached is not null) { return cached; } var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/keys?includeGlobal={includeGlobal.ToString().ToLowerInvariant()}"; using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); request.Headers.TryAddWithoutValidation(_options.TenantHeader, tenantId); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Issuer Directory key lookup failed for {IssuerId} (tenant={TenantId}) {StatusCode}", issuerId, tenantId, response.StatusCode); response.EnsureSuccessStatusCode(); } var payload = await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) .ConfigureAwait(false); IReadOnlyList result = payload?.ToArray() ?? Array.Empty(); _cache.Set(cacheKey, result, _options.Cache.Keys); return result; } public async ValueTask GetIssuerTrustAsync( string tenantId, string issuerId, bool includeGlobal, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); tenantId = tenantId.Trim(); issuerId = issuerId.Trim(); var cacheKey = CacheKey("trust", tenantId, issuerId, includeGlobal.ToString(CultureInfo.InvariantCulture)); if (_cache.TryGetValue(cacheKey, out IssuerTrustResponseModel? cached) && cached is not null) { return cached; } var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/trust?includeGlobal={includeGlobal.ToString().ToLowerInvariant()}"; using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); request.Headers.TryAddWithoutValidation(_options.TenantHeader, tenantId); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Issuer Directory trust lookup failed for {IssuerId} (tenant={TenantId}) {StatusCode}", issuerId, tenantId, response.StatusCode); response.EnsureSuccessStatusCode(); } var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) .ConfigureAwait(false) ?? new IssuerTrustResponseModel(null, null, 0m); _cache.Set(cacheKey, payload, _options.Cache.Trust); return payload; } public async ValueTask SetIssuerTrustAsync( string tenantId, string issuerId, decimal weight, string? reason, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); var normalizedTenant = tenantId.Trim(); var normalizedReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/trust"; using var request = new HttpRequestMessage(HttpMethod.Put, requestUri) { Content = JsonContent.Create(new IssuerTrustSetRequestModel(weight, normalizedReason)) }; request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant); if (!string.IsNullOrWhiteSpace(normalizedReason)) { request.Headers.TryAddWithoutValidation(_options.AuditReasonHeader, normalizedReason); } using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Issuer Directory trust update failed for {IssuerId} (tenant={TenantId}) {StatusCode}", issuerId, normalizedTenant, response.StatusCode); response.EnsureSuccessStatusCode(); } InvalidateTrustCache(normalizedTenant, issuerId); var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) .ConfigureAwait(false) ?? new IssuerTrustResponseModel(null, null, 0m); return payload; } public async ValueTask DeleteIssuerTrustAsync( string tenantId, string issuerId, string? reason, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(issuerId); var normalizedTenant = tenantId.Trim(); var normalizedReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); var requestUri = $"issuer-directory/issuers/{Uri.EscapeDataString(issuerId)}/trust"; using var request = new HttpRequestMessage(HttpMethod.Delete, requestUri); request.Headers.TryAddWithoutValidation(_options.TenantHeader, normalizedTenant); if (!string.IsNullOrWhiteSpace(normalizedReason)) { request.Headers.TryAddWithoutValidation(_options.AuditReasonHeader, normalizedReason); } using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning( "Issuer Directory trust delete failed for {IssuerId} (tenant={TenantId}) {StatusCode}", issuerId, normalizedTenant, response.StatusCode); response.EnsureSuccessStatusCode(); } InvalidateTrustCache(normalizedTenant, issuerId); } private static string CacheKey(string prefix, params string[] parts) { if (parts is null || parts.Length == 0) { return prefix; } var segments = new string[1 + parts.Length]; segments[0] = prefix; Array.Copy(parts, 0, segments, 1, parts.Length); return string.Join('|', segments); } private void InvalidateTrustCache(string tenantId, string issuerId) { _cache.Remove(CacheKey("trust", tenantId, issuerId, bool.FalseString)); _cache.Remove(CacheKey("trust", tenantId, issuerId, bool.TrueString)); } }