From 05553bdc3d8fcece93e2d2b8e79309577b864745 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:26:32 +0500 Subject: [PATCH] fix(moysklad): trailing slash on BaseUrl so HttpClient keeps /1.2/ in path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logs showed every outbound MoySklad call was hitting https://api.moysklad.ru/api/remap/entity/organization instead of the intended https://api.moysklad.ru/api/remap/1.2/entity/organization Cause: per RFC 3986 §5.3, when HttpClient resolves a relative URI against a base URI whose path does not end with '/', the last segment of the base path is discarded. So BaseAddress "…/api/remap/1.2" + relative "entity/…" produced "…/api/remap/entity/…". MoySklad returned 503 and we translated it into a useless "401 сессия истекла" for the user. Fixes: - Append trailing slash to BaseUrl. - Surface the real upstream status + body: MoySkladApiResult wrapper, and the controller now maps 401/403 → "invalid token", 502/503 → "MoySklad unavailable", anything else → "MoySklad returned {code}: {body}". No more lying-as-401. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Admin/MoySkladImportController.cs | 19 +++++++++++---- .../Integrations/MoySklad/MoySkladClient.cs | 24 +++++++++++++++---- .../MoySklad/MoySkladImportService.cs | 6 ++--- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index 9645967..c23d150 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -22,12 +22,23 @@ public async Task TestConnection([FromBody] TestRequest req, Canc if (string.IsNullOrWhiteSpace(req.Token)) return BadRequest(new { error = "Token is required." }); - var org = await _svc.TestConnectionAsync(req.Token, ct); - return org is null - ? Unauthorized(new { error = "Токен недействителен или нет доступа к API." }) - : Ok(new { organization = org.Name, inn = org.Inn }); + var result = await _svc.TestConnectionAsync(req.Token, ct); + if (!result.Success) + { + var msg = result.StatusCode switch + { + 401 or 403 => "Токен недействителен или не имеет доступа к API.", + 503 or 502 => "МойСклад временно недоступен. Повтори через минуту.", + _ => $"МойСклад вернул {result.StatusCode}: {Truncate(result.Error, 400)}", + }; + return StatusCode(result.StatusCode ?? 502, new { error = msg }); + } + return Ok(new { organization = result.Value!.Name, inn = result.Value.Inn }); } + private static string? Truncate(string? s, int max) + => string.IsNullOrEmpty(s) ? s : (s.Length <= max ? s : s[..max] + "…"); + [HttpPost("import-products")] public async Task> ImportProducts([FromBody] ImportRequest req, CancellationToken ct) { diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs index 35d6207..6d2cbd2 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladClient.cs @@ -4,11 +4,20 @@ 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 { - private const string BaseUrl = "https://api.moysklad.ru/api/remap/1.2"; + // 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; @@ -29,13 +38,20 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string return req; } - public async Task WhoAmIAsync(string token, CancellationToken ct) + 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) return null; + 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); - return list?.Rows.FirstOrDefault(); + 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( diff --git a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs index c1176bf..e5cf59a 100644 --- a/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs +++ b/src/food-market.infrastructure/Integrations/MoySklad/MoySkladImportService.cs @@ -32,10 +32,8 @@ public class MoySkladImportService _log = log; } - public async Task TestConnectionAsync(string token, CancellationToken ct) - { - return await _client.WhoAmIAsync(token, ct); - } + public Task> TestConnectionAsync(string token, CancellationToken ct) + => _client.WhoAmIAsync(token, ct); public async Task ImportProductsAsync( string token,