using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; namespace foodmarket.Infrastructure.Integrations.MoySklad; public record MoySkladApiResult(bool Success, T? Value, int? StatusCode, string? Error) { public static MoySkladApiResult Ok(T value) => new(true, value, 200, null); public static MoySkladApiResult Fail(int status, string? error) => new(false, default, status, error); } // Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request // — we never persist it. public class MoySkladClient { // Trailing slash is critical: otherwise HttpClient drops the last path segment // when resolving relative URIs (RFC 3986 §5.3), so "entity/product" would hit // "/api/remap/entity/product" instead of "/api/remap/1.2/entity/product". private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2/"; private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private readonly HttpClient _http; public MoySkladClient(HttpClient http) { _http = http; _http.BaseAddress ??= new Uri(BaseUrl); _http.Timeout = TimeSpan.FromSeconds(90); } private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string token) { var req = new HttpRequestMessage(method, pathAndQuery); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); // MoySklad requires the exact literal "application/json;charset=utf-8" (no space // after ';'). The typed MediaTypeWithQualityHeaderValue API normalizes to // "application/json; charset=utf-8" which MoySklad rejects with code 1062. req.Headers.TryAddWithoutValidation("Accept", "application/json;charset=utf-8"); // MoySklad's nginx edge returns 415 for requests without a User-Agent, and we want // auto-decompression (Accept-Encoding is added automatically by HttpClient when // AutomaticDecompression is set on the primary handler — see Program.cs). if (!req.Headers.UserAgent.Any()) { req.Headers.TryAddWithoutValidation("User-Agent", "food-market/0.1 (+https://github.com/nurdotnet/food-market)"); } return req; } public async Task> WhoAmIAsync(string token, CancellationToken ct) { using var req = Build(HttpMethod.Get, "entity/organization?limit=1", token); using var res = await _http.SendAsync(req, ct); if (!res.IsSuccessStatusCode) { var body = await res.Content.ReadAsStringAsync(ct); return MoySkladApiResult.Fail((int)res.StatusCode, body); } var list = await res.Content.ReadFromJsonAsync>(Json, ct); var org = list?.Rows.FirstOrDefault(); return org is null ? MoySkladApiResult.Fail(200, "Empty organization list returned by MoySklad.") : MoySkladApiResult.Ok(org); } public async IAsyncEnumerable StreamProductsAsync( string token, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { const int pageSize = 1000; var offset = 0; while (true) { using var req = Build(HttpMethod.Get, $"entity/product?limit={pageSize}&offset={offset}", token); using var res = await _http.SendAsync(req, ct); res.EnsureSuccessStatusCode(); var page = await res.Content.ReadFromJsonAsync>(Json, ct); if (page is null || page.Rows.Count == 0) yield break; foreach (var p in page.Rows) yield return p; if (page.Rows.Count < pageSize) yield break; offset += pageSize; } } public async Task> GetAllFoldersAsync(string token, CancellationToken ct) { var all = new List(); var offset = 0; const int pageSize = 1000; while (true) { using var req = Build(HttpMethod.Get, $"entity/productfolder?limit={pageSize}&offset={offset}", token); using var res = await _http.SendAsync(req, ct); res.EnsureSuccessStatusCode(); var page = await res.Content.ReadFromJsonAsync>(Json, ct); if (page is null || page.Rows.Count == 0) break; all.AddRange(page.Rows); if (page.Rows.Count < pageSize) break; offset += pageSize; } return all; } }