Add Columbia Coatings API client, DTOs, and sync settings
Phase 1b of the Columbia Coatings integration: the typed read client and its configuration, ahead of the sync/mapper service. - ColumbiaProductDtos: wire-shape models for GET /products. tiered_pricing is captured as JsonElement because the API returns it as an object on simple products but an empty array on variable ones — binding it raw avoids a deserialization throw; the mapper interprets it. - IColumbiaCoatingsApiClient / ColumbiaCoatingsApiClient: pages the catalog via GET /products (NOT the export download_url, which is Cloudflare-blocked for server clients). Sends X-API-Key from config, honors 429/Retry-After, and THROWS on any page failure so a partial pull can never be mistaken for the full catalog (protects the later discontinuation sweep). - ColumbiaIntegrationConstants: single home for config keys, setting keys, and the derived Source/manufacturer/category values. - Config: Columbia:ApiKey (blank — secret supplied per environment) and Columbia:BaseUrl in appsettings. - SeedColumbiaSyncSettings migration: seeds SuperAdmin-managed platform settings ColumbiaSyncEnabled (off by default), ColumbiaSyncIntervalDays (7), and last-sync tracking, under a new "Integrations" group. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.DTOs.Columbia;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the Columbia Coatings product catalog API. Reads the API key and base URL from
|
||||
/// configuration (<c>Columbia:ApiKey</c> / <c>Columbia:BaseUrl</c>), sends the <c>X-API-Key</c>
|
||||
/// header, and pages the catalog via <c>GET /products</c>. Honors the documented rate limit
|
||||
/// (120 requests / 60s) by retrying on HTTP 429 after the <c>Retry-After</c> interval.
|
||||
/// </summary>
|
||||
public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
|
||||
{
|
||||
private const int MaxRetriesPer429 = 5;
|
||||
private const int DefaultRetryAfterSeconds = 5;
|
||||
private const int MaxPagesSafetyCap = 1000; // guards against a server that never reports last page
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<ColumbiaCoatingsApiClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Columbia returns snake_case JSON; the snake-case naming policy binds it to our PascalCase DTOs
|
||||
/// without per-property attributes. Case-insensitive as a belt-and-braces fallback.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public ColumbiaCoatingsApiClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration config,
|
||||
ILogger<ColumbiaCoatingsApiClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string? ApiKey => _config[ColumbiaIntegrationConstants.ConfigApiKey];
|
||||
|
||||
private string BaseUrl =>
|
||||
(_config[ColumbiaIntegrationConstants.ConfigBaseUrl] ?? ColumbiaIntegrationConstants.DefaultBaseUrl)
|
||||
.TrimEnd('/');
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
|
||||
int page, int perPage, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||
|
||||
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 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 on page {page}.");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
|
||||
return result ?? new ColumbiaProductsResponse();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||
|
||||
var all = new List<ColumbiaProduct>();
|
||||
|
||||
for (var page = 1; page <= MaxPagesSafetyCap; page++)
|
||||
{
|
||||
var response = await GetProductsPageAsync(page, ColumbiaIntegrationConstants.MaxPerPage, cancellationToken);
|
||||
|
||||
if (response.Items.Count == 0)
|
||||
break;
|
||||
|
||||
all.AddRange(response.Items);
|
||||
|
||||
// Stop when the pagination block says we've reached the last page.
|
||||
if (response.Pagination is { TotalPages: > 0 } p && page >= p.TotalPages)
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Columbia API: retrieved {Count} products across paged requests.", all.Count);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
|
||||
/// or null when absent/unparseable so the caller can fall back to a default.
|
||||
/// </summary>
|
||||
private static int? GetRetryAfterSeconds(HttpResponseMessage response)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter;
|
||||
if (retryAfter == null)
|
||||
return null;
|
||||
|
||||
if (retryAfter.Delta is { } delta)
|
||||
return Math.Max(1, (int)Math.Ceiling(delta.TotalSeconds));
|
||||
|
||||
if (retryAfter.Date is { } date)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds);
|
||||
return seconds > 0 ? seconds : 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user