// Minimal .NET 8+ console client. No third-party packages. // Place this file as Program.cs in a console project. Set VAPETAX_API_URL to // the approved API origin and VAPETAX_API_KEY in private server configuration. // Run: dotnet run -- products.json catalogue-batch-001 // Persist the body file AND the batch key before the first send. Reuse them after // a restart or lost response. Never generate a replacement key inside this loop. // Product import is synchronous: 201 first acceptance, 200 replay. See the // separate ReleaseClient.cs for the isolated warehouse pilot. Reading the // catalogue below confirms catalogue data only, not inventory or duty status. using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; if (args.Length != 2) throw new ArgumentException("Usage: products.json persisted-idempotency-key"); var baseUrl = Environment.GetEnvironmentVariable("VAPETAX_API_URL") ?? throw new InvalidOperationException("Configure VAPETAX_API_URL."); var apiKey = Environment.GetEnvironmentVariable("VAPETAX_API_KEY") ?? throw new InvalidOperationException("Configure VAPETAX_API_KEY securely."); var origin = new Uri(baseUrl.TrimEnd('/') + "/"); if ((!origin.IsLoopback && origin.Scheme != Uri.UriSchemeHttps) || (origin.Scheme != Uri.UriSchemeHttp && origin.Scheme != Uri.UriSchemeHttps) || origin.AbsolutePath != "/" || !string.IsNullOrEmpty(origin.Query) || !string.IsNullOrEmpty(origin.Fragment) || !string.IsNullOrEmpty(origin.UserInfo)) throw new ArgumentException("Use an HTTPS API origin, or an isolated loopback HTTP origin."); var batchKey = args[1]; if (batchKey.Length is < 1 or > 128 || batchKey.Any(c => c < '!' || c > '~')) throw new ArgumentException("The batch key must contain 1–128 non-space ASCII characters."); var body = await File.ReadAllBytesAsync(args[0]); if (body.Length > 1_048_576) throw new ArgumentException("Import exceeds 1 MiB."); using var parsed = JsonDocument.Parse(body); // Validate JSON before making a request. using var handler = new HttpClientHandler { AllowAutoRedirect = false }; using var client = new HttpClient(handler) { BaseAddress = origin, Timeout = TimeSpan.FromSeconds(30) }; client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var capabilities = await SendWithRetry(HttpMethod.Get, "integrations/v1/capabilities"); using (var capability = JsonDocument.Parse(capabilities)) { var scopes = capability.RootElement.GetProperty("operations").EnumerateArray() .Select(value => value.GetString()).ToHashSet(); if (!scopes.Contains("products:write")) throw new InvalidOperationException("This key needs products:write to import the catalogue."); } var accepted = await SendWithRetry(HttpMethod.Post, "integrations/v1/products/import", body, batchKey); // Store this returned import ID and product mappings beside the persisted batch. // The returned JSON contains no API secret. Avoid logging request headers. Console.WriteLine(accepted); // Optional verification for keys that also have products:read. A write-only key // can omit this block. Pagination is bounded and is not provider-status polling. using (var capability = JsonDocument.Parse(capabilities)) { if (capability.RootElement.GetProperty("operations").EnumerateArray() .Any(value => value.GetString() == "products:read")) { string? cursor = null; var seen = new HashSet(); do { var path = "integrations/v1/products" + (cursor == null ? "" : "?cursor=" + Uri.EscapeDataString(cursor)); using var page = JsonDocument.Parse(await SendWithRetry(HttpMethod.Get, path)); Console.WriteLine($"Read {page.RootElement.GetProperty("items").GetArrayLength()} catalogue products."); cursor = page.RootElement.GetProperty("nextCursor").GetString(); if (cursor != null && !seen.Add(cursor)) throw new InvalidOperationException("Repeated catalogue cursor; stop and investigate."); } while (cursor != null); } } async Task SendWithRetry(HttpMethod method, string path, byte[]? payload = null, string? idempotencyKey = null) { const int attempts = 5; for (var attempt = 0; attempt < attempts; attempt++) { var delay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, attempt + 1))); using var request = new HttpRequestMessage(method, path); if (payload != null) { request.Content = new ByteArrayContent(payload); // Exactly the original bytes. request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); } if (idempotencyKey != null) request.Headers.Add("Idempotency-Key", idempotencyKey); try { using var response = await client.SendAsync(request); var text = await response.Content.ReadAsStringAsync(); if (response.IsSuccessStatusCode) return text; var retryable = response.StatusCode == HttpStatusCode.TooManyRequests || (int)response.StatusCode >= 500; if (!retryable) throw new InvalidOperationException($"VapeTax rejected this request ({(int)response.StatusCode}): {text}"); if (response.StatusCode == HttpStatusCode.TooManyRequests) { delay = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.FromMinutes(1); delay = TimeSpan.FromSeconds(Math.Clamp(delay.TotalSeconds, 1, 60)); } } catch (HttpRequestException) when (attempt < attempts - 1) { } catch (TaskCanceledException) when (attempt < attempts - 1) { } if (attempt == attempts - 1) break; await Task.Delay(delay + TimeSpan.FromMilliseconds(Random.Shared.Next(100, 500))); } throw new IOException("Outcome unresolved. Keep the original body and batch key, then retry the same request or investigate. Do not create a replacement batch."); }