diff --git a/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs b/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs index 089a8f8..1e6a852 100644 --- a/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs +++ b/src/PowderCoating.Application/Interfaces/IColumbiaCoatingsApiClient.cs @@ -32,4 +32,16 @@ public interface IColumbiaCoatingsApiClient /// exception as "incomplete pull" and NOT run discontinuation logic against a partial set. /// Task> GetAllProductsAsync(CancellationToken cancellationToken = default); + + /// + /// Fetches a single product by exact SKU (GET /products?sku=...), or null if not found. + /// For ad-hoc refresh of one record without pulling the whole catalog. + /// + Task GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default); + + /// + /// Fetches a single product by WooCommerce product ID (GET /products/{id}), or null if + /// not found. Useful when we already store the catalog product's ID and want to refresh it. + /// + Task GetProductByIdAsync(int id, CancellationToken cancellationToken = default); } diff --git a/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs b/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs index b05ecf2..aa76b9d 100644 --- a/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs +++ b/src/PowderCoating.Infrastructure/Services/ColumbiaCoatingsApiClient.cs @@ -56,48 +56,51 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient public async Task GetProductsPageAsync( int page, int perPage, CancellationToken cancellationToken = default) { - if (!IsConfigured) - throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey)."); - + EnsureConfigured(); perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage); var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?page={page}&per_page={perPage}"; - for (var attempt = 1; ; attempt++) - { - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Add("X-API-Key", ApiKey); + var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken); + if (json == null) + return new ColumbiaProductsResponse(); - var client = _httpClientFactory.CreateClient(); - using var response = await client.SendAsync(request, cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions) ?? new ColumbiaProductsResponse(); + } - if (response.StatusCode == HttpStatusCode.TooManyRequests) - { - if (attempt > MaxRetriesPer429) - throw new HttpRequestException( - $"Columbia API still rate-limiting after {MaxRetriesPer429} retries on page {page}."); + /// + public async Task GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default) + { + EnsureConfigured(); + if (string.IsNullOrWhiteSpace(sku)) + return null; - var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds; - _logger.LogWarning( - "Columbia API returned 429 on page {Page} (attempt {Attempt}); waiting {Delay}s before retry.", - page, attempt, delaySeconds); - await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken); - continue; - } + var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}?sku={Uri.EscapeDataString(sku)}&per_page=1"; + var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken); + if (json == null) + return null; - response.EnsureSuccessStatusCode(); + var response = JsonSerializer.Deserialize(json, JsonOptions); + return response?.Items.FirstOrDefault(); + } - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonSerializer.Deserialize(json, JsonOptions); - return result ?? new ColumbiaProductsResponse(); - } + /// + public async Task GetProductByIdAsync(int id, CancellationToken cancellationToken = default) + { + EnsureConfigured(); + // The by-id endpoint returns a bare product object (not the {items,pagination} envelope). + var url = $"{BaseUrl}{ColumbiaIntegrationConstants.ProductsPath}/{id}"; + var json = await SendWithRetryAsync(url, $"id {id}", cancellationToken); + if (json == null) + return null; + + return JsonSerializer.Deserialize(json, JsonOptions); } /// public async Task> GetAllProductsAsync( CancellationToken cancellationToken = default) { - if (!IsConfigured) - throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey)."); + EnsureConfigured(); var all = new List(); @@ -119,6 +122,51 @@ public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient return all; } + /// Throws when no API key is configured so callers fail fast rather than 401. + private void EnsureConfigured() + { + if (!IsConfigured) + throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey)."); + } + + /// + /// Issues a GET with the API key header and returns the response body. Retries on HTTP 429 + /// (honoring Retry-After) up to . Returns null on 404 so + /// single-product lookups surface "not found" without throwing; throws on any other non-success. + /// is a short label (e.g. "page 3", "sku ABC") for log/error context. + /// + private async Task SendWithRetryAsync(string url, string describe, CancellationToken cancellationToken) + { + for (var attempt = 1; ; attempt++) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-API-Key", ApiKey); + + var client = _httpClientFactory.CreateClient(); + using var response = await client.SendAsync(request, cancellationToken); + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + if (attempt > MaxRetriesPer429) + throw new HttpRequestException( + $"Columbia API still rate-limiting after {MaxRetriesPer429} retries ({describe})."); + + var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds; + _logger.LogWarning( + "Columbia API returned 429 ({Describe}, attempt {Attempt}); waiting {Delay}s before retry.", + describe, attempt, delaySeconds); + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken); + continue; + } + + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken); + } + } + /// /// Parses the Retry-After header (delta-seconds or HTTP-date form) into whole seconds, /// or null when absent/unparseable so the caller can fall back to a default.