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,11 +22,22 @@ public async Task<IActionResult> TestConnection([FromBody] TestRequest req, Canc
|
||||||
if (string.IsNullOrWhiteSpace(req.Token))
|
if (string.IsNullOrWhiteSpace(req.Token))
|
||||||
return BadRequest(new { error = "Token is required." });
|
return BadRequest(new { error = "Token is required." });
|
||||||
|
|
||||||
var org = await _svc.TestConnectionAsync(req.Token, ct);
|
var result = await _svc.TestConnectionAsync(req.Token, ct);
|
||||||
return org is null
|
if (!result.Success)
|
||||||
? Unauthorized(new { error = "Токен недействителен или нет доступа к API." })
|
{
|
||||||
: Ok(new { organization = org.Name, inn = org.Inn });
|
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")]
|
[HttpPost("import-products")]
|
||||||
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
public async Task<ActionResult<MoySkladImportResult>> ImportProducts([FromBody] ImportRequest req, CancellationToken ct)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,20 @@
|
||||||
|
|
||||||
namespace foodmarket.Infrastructure.Integrations.MoySklad;
|
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
|
// Thin HTTP wrapper over MoySklad JSON-API 1.2. Caller supplies the personal token per request
|
||||||
// — we never persist it.
|
// — we never persist it.
|
||||||
public class MoySkladClient
|
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 static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
|
|
@ -29,13 +38,20 @@ private HttpRequestMessage Build(HttpMethod method, string pathAndQuery, string
|
||||||
return req;
|
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 req = Build(HttpMethod.Get, "entity/organization?limit=1", token);
|
||||||
using var res = await _http.SendAsync(req, ct);
|
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);
|
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(
|
public async IAsyncEnumerable<MsProduct> StreamProductsAsync(
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,8 @@ public class MoySkladImportService
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MsOrganization?> TestConnectionAsync(string token, CancellationToken ct)
|
public Task<MoySkladApiResult<MsOrganization>> TestConnectionAsync(string token, CancellationToken ct)
|
||||||
{
|
=> _client.WhoAmIAsync(token, ct);
|
||||||
return await _client.WhoAmIAsync(token, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MoySkladImportResult> ImportProductsAsync(
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
string token,
|
string token,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue