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,40 @@
|
||||
namespace PowderCoating.Application.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Central constants for the Columbia Coatings catalog integration — config keys, platform-setting
|
||||
/// keys, and the derived provenance/manufacturer/category values the mapper assigns. Kept in one
|
||||
/// place so the API client, sync mapper, scheduled job, and purge logic all agree on the strings.
|
||||
/// </summary>
|
||||
public static class ColumbiaIntegrationConstants
|
||||
{
|
||||
// ── Configuration keys (appsettings.json / environment) ───────────────
|
||||
/// <summary>API key — secret, lives in config not the platform-settings DB.</summary>
|
||||
public const string ConfigApiKey = "Columbia:ApiKey";
|
||||
public const string ConfigBaseUrl = "Columbia:BaseUrl";
|
||||
|
||||
public const string DefaultBaseUrl = "https://columbiacoatings.com";
|
||||
public const string ProductsPath = "/wp-json/cca/v1/products";
|
||||
|
||||
/// <summary>API caps per_page at 100.</summary>
|
||||
public const int MaxPerPage = 100;
|
||||
|
||||
// ── Platform setting keys (SuperAdmin-managed, non-secret) ────────────
|
||||
public const string SettingEnabled = "ColumbiaSyncEnabled";
|
||||
public const string SettingIntervalDays = "ColumbiaSyncIntervalDays";
|
||||
public const string SettingLastSyncedAt = "ColumbiaLastSyncedAt";
|
||||
public const string SettingLastResult = "ColumbiaLastSyncResult";
|
||||
|
||||
public const int DefaultSyncIntervalDays = 7;
|
||||
|
||||
// ── Provenance ────────────────────────────────────────────────────────
|
||||
/// <summary>Stored in <c>PowderCatalogItem.Source</c> — the purge key for right-to-delete.</summary>
|
||||
public const string SourceName = "Columbia Coatings API";
|
||||
|
||||
// ── Derived manufacturers (PowderCatalogItem.VendorName) ──────────────
|
||||
public const string ManufacturerColumbia = "Columbia Coatings";
|
||||
public const string ManufacturerPpg = "PPG";
|
||||
public const string ManufacturerKp = "KP Pigments";
|
||||
|
||||
// ── Derived category (PowderCatalogItem.Category) ─────────────────────
|
||||
public const string CategoryPowderAdditives = "Powder Additives";
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Columbia;
|
||||
|
||||
/// <summary>
|
||||
/// One page of the Columbia Coatings <c>GET /products</c> response: a list of products plus
|
||||
/// pagination metadata. Property names are snake_case in the API and bound via the snake-case
|
||||
/// naming policy configured on the client's <see cref="JsonSerializerOptions"/>.
|
||||
/// </summary>
|
||||
public class ColumbiaProductsResponse
|
||||
{
|
||||
public List<ColumbiaProduct> Items { get; set; } = new();
|
||||
public ColumbiaPagination? Pagination { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Pagination block returned alongside a product page.</summary>
|
||||
public class ColumbiaPagination
|
||||
{
|
||||
public int Page { get; set; }
|
||||
public int PerPage { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int TotalPages { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
|
||||
/// catalog model — mapping into <c>PowderCatalogItem</c> happens in the sync mapper. Prices arrive
|
||||
/// as strings; cure/spec fields are free text; documents are direct URLs.
|
||||
/// </summary>
|
||||
public class ColumbiaProduct
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>"simple" or "variable". Variable products carry packaging/size variants in
|
||||
/// <see cref="VariationPricing"/> and leave <see cref="TieredPricing"/> as an empty array.</summary>
|
||||
public string ProductType { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public string Permalink { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Columbia-specific values seen include "In Stock"/"instock", "formulated",
|
||||
/// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.</summary>
|
||||
public string StockStatus { get; set; } = string.Empty;
|
||||
|
||||
// ── Pricing (all strings on the wire) ─────────────────────────────────
|
||||
public string Price { get; set; } = string.Empty;
|
||||
public string RegularPrice { get; set; } = string.Empty;
|
||||
public string SalePrice { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Quantity-break pricing. POLYMORPHIC on the wire: an object
|
||||
/// (<c>{type, minimum_quantity, tiers:[...]}</c>) on simple products, but an empty ARRAY
|
||||
/// (<c>[]</c>) on variable products. Captured as a raw <see cref="JsonElement"/> so
|
||||
/// deserialization never throws on the type mismatch; the mapper interprets it.
|
||||
/// </summary>
|
||||
public JsonElement TieredPricing { get; set; }
|
||||
|
||||
/// <summary>Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes).
|
||||
/// Each variant has its own SKU and price. Empty for simple products.</summary>
|
||||
public List<ColumbiaVariationPricing>? VariationPricing { get; set; }
|
||||
|
||||
// ── Documents (direct URLs) ───────────────────────────────────────────
|
||||
public string SafetyDataSheet { get; set; } = string.Empty;
|
||||
public string TechnicalDataSheet { get; set; } = string.Empty;
|
||||
public string ProductFlyer { get; set; } = string.Empty;
|
||||
public string ProductBrochure { get; set; } = string.Empty;
|
||||
|
||||
// ── Coating spec free-text ────────────────────────────────────────────
|
||||
public string CureSchedule { get; set; } = string.Empty;
|
||||
public string MilThickness { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
public string ReleaseDate { get; set; } = string.Empty;
|
||||
public string FormulationDate { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Free-text reformulation log, e.g. "Formulation Change: 05/22/26".</summary>
|
||||
public string FormulationDateChanges { get; set; } = string.Empty;
|
||||
|
||||
// ── Content ───────────────────────────────────────────────────────────
|
||||
/// <summary>HTML product description (WordPress markup).</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string ShortDescription { get; set; } = string.Empty;
|
||||
|
||||
public ColumbiaImage? FeaturedImage { get; set; }
|
||||
public List<ColumbiaImage> GalleryImages { get; set; } = new();
|
||||
|
||||
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
|
||||
public List<ColumbiaNamed> Categories { get; set; } = new();
|
||||
public List<ColumbiaNamed> Tags { get; set; } = new();
|
||||
public List<ColumbiaNamed> PaColorGroup { get; set; } = new();
|
||||
public List<ColumbiaAttribute> Attributes { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A pricing variant of a variable product (own SKU, own price/tiers).</summary>
|
||||
public class ColumbiaVariationPricing
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Sku { get; set; } = string.Empty;
|
||||
public List<ColumbiaAttributeValue> Attributes { get; set; } = new();
|
||||
public string StockStatus { get; set; } = string.Empty;
|
||||
public string Price { get; set; } = string.Empty;
|
||||
public string RegularPrice { get; set; } = string.Empty;
|
||||
public string SalePrice { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Same polymorphic object-or-array shape as the parent; captured raw.</summary>
|
||||
public JsonElement TieredPricing { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>An image object — featured or gallery.</summary>
|
||||
public class ColumbiaImage
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Src { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Alt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>A simple <c>{name}</c> taxonomy entry (category, tag, or color group).</summary>
|
||||
public class ColumbiaNamed
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].</summary>
|
||||
public class ColumbiaAttribute
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public List<ColumbiaNamed> Options { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".</summary>
|
||||
public class ColumbiaAttributeValue
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using PowderCoating.Application.DTOs.Columbia;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Typed client for the Columbia Coatings product catalog API (<c>/wp-json/cca/v1</c>).
|
||||
/// Read-only: lists products via the paged <c>GET /products</c> endpoint.
|
||||
/// <para>
|
||||
/// We deliberately page <c>/products</c> rather than using the bulk <c>export.json</c> download:
|
||||
/// the export returns a temporary <c>download_url</c> to a static file under <c>/wp-content/uploads</c>,
|
||||
/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
|
||||
/// <c>/wp-json</c> API routes are allowlisted via the API key, so paging is the only path that
|
||||
/// works reliably from a server.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IColumbiaCoatingsApiClient
|
||||
{
|
||||
/// <summary>
|
||||
/// True when an API key is configured (<c>Columbia:ApiKey</c>). When false, callers should
|
||||
/// skip the sync entirely rather than issue unauthenticated requests.
|
||||
/// </summary>
|
||||
bool IsConfigured { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single page of products. <paramref name="perPage"/> is capped at 100 by the API.
|
||||
/// </summary>
|
||||
Task<ColumbiaProductsResponse> GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pages through the entire catalog and returns every product. Honors rate limiting
|
||||
/// (429 / Retry-After). THROWS if any page fails after retries — callers must treat an
|
||||
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
Generated
+11375
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SeedColumbiaSyncSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Seed the SuperAdmin-managed platform settings for the Columbia Coatings catalog sync.
|
||||
// Idempotent so it is safe against a DB where keys were added manually. The API key
|
||||
// itself is NOT here — secrets live in configuration (Columbia:ApiKey), not this table.
|
||||
migrationBuilder.Sql(@"
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncEnabled')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaSyncEnabled','false','Columbia Coatings Sync Enabled','Master switch for the scheduled Columbia Coatings catalog sync. When off, no automatic or manual sync runs regardless of the configured API key.','Integrations');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncIntervalDays')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaSyncIntervalDays','7','Columbia Sync Interval (days)','How many days between automatic Columbia catalog syncs. A full sync is cheap (~25 API calls), so daily (1) or weekly (7) keeps pricing fresh.','Integrations');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncedAt')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaLastSyncedAt',NULL,'Columbia Last Synced At','Timestamp (UTC) of the last successful Columbia catalog sync. Set automatically by the sync job.','Integrations');
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncResult')
|
||||
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||
VALUES ('ColumbiaLastSyncResult',NULL,'Columbia Last Sync Result','Summary of the last Columbia catalog sync run (inserted/updated/discontinued counts or error). Set automatically by the sync job.','Integrations');
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
DELETE FROM PlatformSettings WHERE [Key] IN (
|
||||
'ColumbiaSyncEnabled','ColumbiaSyncIntervalDays','ColumbiaLastSyncedAt','ColumbiaLastSyncResult'
|
||||
);
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7234,7 +7234,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7245,7 +7245,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7256,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652),
|
||||
CreatedAt = new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(
|
||||
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
|
||||
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddScoped<IColumbiaCoatingsApiClient, ColumbiaCoatingsApiClient>();
|
||||
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
||||
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
||||
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
"ApiKey": "25651af3a4829559ef0dfa1758fa3edbf92b6b76"
|
||||
}
|
||||
},
|
||||
"Columbia": {
|
||||
"ApiKey": "",
|
||||
"BaseUrl": "https://columbiacoatings.com"
|
||||
},
|
||||
"SendGrid": {
|
||||
"ApiKey": "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",
|
||||
"FromEmail": "spouliot@scppowdercoating.com",
|
||||
|
||||
Reference in New Issue
Block a user