fix(moysklad): trailing slash on BaseUrl so HttpClient keeps /1.2/ in path

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<T> 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) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-21 23:26:32 +05:00
parent e4a2030ad9
commit 05553bdc3d
3 changed files with 37 additions and 12 deletions

View file

@ -22,11 +22,22 @@ public async Task<IActionResult> 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<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)

View file

@ -4,11 +4,20 @@
namespace foodmarket.Infrastructure.Integrations.MoySklad;
public record MoySkladApiResult<T>(bool Success, T? Value, int? StatusCode, string? Error)
{
public static MoySkladApiResult<T> Ok(T value) => new(true, value, 200, null);
public static MoySkladApiResult<T> 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<MsOrganization?> WhoAmIAsync(string token, CancellationToken ct)
public async Task<MoySkladApiResult<MsOrganization>> 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<MsOrganization>.Fail((int)res.StatusCode, body);
}
var list = await res.Content.ReadFromJsonAsync<MsListResponse<MsOrganization>>(Json, ct);
return list?.Rows.FirstOrDefault();
var org = list?.Rows.FirstOrDefault();
return org is null
? MoySkladApiResult<MsOrganization>.Fail(200, "Empty organization list returned by MoySklad.")
: MoySkladApiResult<MsOrganization>.Ok(org);
}
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(

View file

@ -32,10 +32,8 @@ public class MoySkladImportService
_log = log;
}
public async Task<MsOrganization?> TestConnectionAsync(string token, CancellationToken ct)
{
return await _client.WhoAmIAsync(token, ct);
}
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
=> _client.WhoAmIAsync(token, ct);
public async Task<MoySkladImportResult> ImportProductsAsync(
string token,