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:
parent
e4a2030ad9
commit
05553bdc3d
|
|
@ -22,12 +22,23 @@ 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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue