< Summary

Information
Class: Services.ApiServices.ArmtekApi.ArmtekHttpClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekApi/ArmtekHttpClient.cs
Line coverage
75%
Covered lines: 104
Uncovered lines: 34
Coverable lines: 138
Total lines: 267
Line coverage: 75.3%
Branch coverage
56%
Covered branches: 52
Total branches: 92
Branch coverage: 56.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
TimeoutFor(...)100%22100%
.ctor(...)100%22100%
CreateClient(...)100%22100%
GetAsync()50%4487.5%
PostFormAsync()83.33%121294.44%
IsSafeToRetry(...)100%66100%
IsTransientTransportFailure(...)100%88100%
BuildUrl(...)100%44100%
ParseEnvelopeAsync()80%101095.83%
DailyQuotaExceededResponse()100%11100%
TruncateError(...)75%44100%
TryParseEnvelopeFallback(...)31.81%1332238.7%
ParseMessages(...)0%342180%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ArmtekApi/ArmtekHttpClient.cs

#LineLine coverage
 1using System.Net.Http.Headers;
 2using System.Text;
 3using Newtonsoft.Json;
 4using Newtonsoft.Json.Linq;
 5using Services.ApiServices.ArmtekApi.Models.Envelope;
 6using Services.ApiServices.ApiSupplierRequestLog;
 7
 8namespace Services.ApiServices.ArmtekApi;
 9
 10/// <summary>
 11/// Низкоуровневый HTTP-клиент Armtek Web-Services (https://ws.armtek.ru). Basic Auth, конверт
 12/// ответа STATUS/MESSAGES/RESP (см. docs/api/armtek.md). Ретраи на транспортном уровне —
 13/// через Polly (BaseApiClient.GetRetryPolicy() на именованном HttpClient("ArmtekApi") в DI),
 14/// здесь ручного retry-цикла нет.
 15/// </summary>
 16public class ArmtekHttpClient
 17{
 18    private readonly IHttpClientFactory _httpClientFactory;
 19    private readonly string _baseUrl;
 20    private readonly string _base64Auth;
 21    private readonly ApiSupplierRequestLogStore? _logs;
 22    private readonly string _httpClientName;
 23    private readonly string _providerKey;
 24    private readonly string _quotaKey;
 25    private readonly ArmtekDailyQuotaGuard _dailyQuotaGuard;
 26
 27    // Армтек документирует таймаут getOrder2?STATUS=1 в 90 секунд — клиентский таймаут больше,
 28    // иначе .NET оборвёт соединение раньше, чем сервис успеет вернуть даже частичный ответ.
 129    private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(100);
 30
 31    // getStoreList отдаёт весь справочник складов: ~100 тыс. записей, 13–16 МБ без сжатия (замер 09.10.2026, API отвеча
 32    // На канале прод-сервера 100 секунд на такой объём не хватает, поэтому ему даётся отдельный, больший таймаут.
 133    private static readonly TimeSpan StoreListTimeout = TimeSpan.FromMinutes(5);
 34
 35    private static TimeSpan TimeoutFor(string path) =>
 8536        string.Equals(path, "ws_user/getStoreList", StringComparison.OrdinalIgnoreCase) ? StoreListTimeout : DefaultTime
 37
 15738    public ArmtekHttpClient(IHttpClientFactory httpClientFactory, ArmtekConfig config, ApiSupplierRequestLogStore? logs 
 15739        string httpClientName = "ArmtekApi", string providerKey = "Armtek", ArmtekDailyQuotaGuard? dailyQuotaGuard = nul
 40    {
 15741        _httpClientFactory = httpClientFactory;
 15742        _baseUrl = config.BaseUrl.TrimEnd('/');
 15743        var authString = $"{config.User}:{config.Password}";
 15744        _base64Auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString));
 15745        _logs = logs;
 15746        _httpClientName = httpClientName;
 15747        _providerKey = providerKey;
 15748        _quotaKey = $"{providerKey}:{config.User}";
 15749        _dailyQuotaGuard = dailyQuotaGuard ?? new ArmtekDailyQuotaGuard();
 15750    }
 51
 52    private HttpClient CreateClient(TimeSpan? timeout = null)
 53    {
 13154        var client = _httpClientFactory.CreateClient(_httpClientName);
 13155        client.Timeout = timeout ?? DefaultTimeout;
 56        // ping формально не требует авторизации, но лишний заголовок его не ломает —
 57        // проще слать Basic Auth на все запросы одним и тем же CreateClient().
 13158        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", _base64Auth);
 13159        return client;
 60    }
 61
 62    /// <summary>GET-запрос. path — без ведущего /api, например "ws_ping/index".</summary>
 63    public async Task<ArmtekResponse<T>> GetAsync<T>(string path, Dictionary<string, string>? query = null, Cancellation
 64    {
 4665        if (_dailyQuotaGuard.IsBlocked(_quotaKey))
 066            return DailyQuotaExceededResponse<T>();
 67
 4668        var url = BuildUrl(_baseUrl, path, query);
 4669        var client = CreateClient();
 4670        var timer = System.Diagnostics.Stopwatch.StartNew();
 9171        try { var response = await client.GetAsync(url, ct); return await ParseEnvelopeAsync<T>(response, url, "GET", nu
 1572        catch (Exception ex) { _logs?.Add(_providerKey, "GET", url, null, timer, null, ex.Message); throw; }
 4173    }
 74
 75    /// <summary>POST application/x-www-form-urlencoded. path — без ведущего /api.</summary>
 76    public async Task<ArmtekResponse<T>> PostFormAsync<T>(string path, Dictionary<string, string> form, CancellationToke
 77    {
 8578        if (_dailyQuotaGuard.IsBlocked(_quotaKey))
 279            return DailyQuotaExceededResponse<T>();
 80
 8381        var url = BuildUrl(_baseUrl, path, null);
 8382        var formWithFormat = new Dictionary<string, string>(form) { ["format"] = "json" };
 8383        var timer = System.Diagnostics.Stopwatch.StartNew();
 66884        var requestForLog = string.Join("&", formWithFormat.Select(x => $"{x.Key}={x.Value}"));
 8385        var attempts = IsSafeToRetry(path) ? 2 : 1;
 86
 17087        for (var attempt = 1; attempt <= attempts; attempt++)
 88        {
 8589            using var content = new FormUrlEncodedContent(formWithFormat);
 90            try
 91            {
 8592                using var response = await CreateClient(TimeoutFor(path)).PostAsync(url, content, ct);
 7493                return await ParseEnvelopeAsync<T>(response, url, "POST", await content.ReadAsStringAsync(ct), timer);
 94            }
 1595            catch (Exception ex) when (attempt < attempts && IsTransientTransportFailure(ex, ct))
 96            {
 297                await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), ct);
 98            }
 1399            catch (Exception ex)
 100            {
 13101                _logs?.Add(_providerKey, "POST", url, null, timer, requestForLog, ex.Message);
 13102                throw;
 103            }
 2104        }
 105
 0106        throw new InvalidOperationException("Не удалось выполнить запрос Armtek.");
 72107    }
 108
 109    // getStoreList отвечает десятки секунд или не отвечает вовсе: второй 100-секундный заход только удваивает время и ч
 110    private static bool IsSafeToRetry(string path) =>
 83111        path.StartsWith("ws_search/", StringComparison.OrdinalIgnoreCase) ||
 83112        (path.StartsWith("ws_user/", StringComparison.OrdinalIgnoreCase) &&
 83113         !string.Equals(path, "ws_user/getStoreList", StringComparison.OrdinalIgnoreCase)) ||
 83114        string.Equals(path, "ws_order/createTestOrder", StringComparison.OrdinalIgnoreCase);
 115
 116    private static bool IsTransientTransportFailure(Exception exception, CancellationToken ct) =>
 6117        !ct.IsCancellationRequested && exception is HttpRequestException or IOException or TaskCanceledException;
 118
 119    private static string BuildUrl(string baseUrl, string path, Dictionary<string, string>? query)
 120    {
 129121        var parts = new List<string> { "format=json" };
 129122        if (query != null)
 123        {
 254124            foreach (var kv in query)
 97125                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 126        }
 127
 129128        return $"{baseUrl}/api/{path.TrimStart('/')}?{string.Join("&", parts)}";
 129    }
 130
 131    private async Task<ArmtekResponse<T>> ParseEnvelopeAsync<T>(HttpResponseMessage response, string requestUri, string 
 132    {
 119133        var body = await response.Content.ReadAsStringAsync();
 119134        _logs?.Add(_providerKey, method, requestUri, (int)response.StatusCode, timer, request, body);
 135
 119136        if (!response.IsSuccessStatusCode)
 137        {
 9138            if (ArmtekDailyQuotaGuard.IsExceeded(body))
 139            {
 4140                _dailyQuotaGuard.Block(_quotaKey);
 4141                return DailyQuotaExceededResponse<T>();
 142            }
 143
 5144            throw new ArmtekApiException(
 5145                $"Armtek HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {TruncateError(body)}",
 5146                requestUri: requestUri);
 147        }
 148
 149        ArmtekResponse<T>? envelope;
 150        try
 151        {
 110152            envelope = JsonConvert.DeserializeObject<ArmtekResponse<T>>(body);
 108153        }
 2154        catch (JsonException ex)
 155        {
 2156            var fallback = TryParseEnvelopeFallback<T>(body);
 2157            if (fallback != null)
 0158                return fallback;
 159
 2160            throw new ArmtekApiException(
 2161                $"Armtek вернул тело, не являющееся валидным конвертом STATUS/MESSAGES/RESP: {TruncateError(body)}",
 2162                requestUri: requestUri, inner: ex);
 163        }
 164
 108165        if (envelope == null)
 166        {
 1167            throw new ArmtekApiException(
 1168                $"Armtek вернул пустой или нераспознаваемый ответ: {TruncateError(body)}",
 1169                requestUri: requestUri);
 170        }
 171
 107172        return envelope;
 111173    }
 174
 6175    private static ArmtekResponse<T> DailyQuotaExceededResponse<T>() => new()
 6176    {
 6177        Status = 401,
 6178        Messages =
 6179        [
 6180            new ArmtekMessage
 6181            {
 6182                Type = "A",
 6183                Text = "Превышено суточное количество запросов к API Armtek для данного логина.",
 6184                Date = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")
 6185            }
 6186        ]
 6187    };
 188
 189    private static string TruncateError(string? text, int maxLen = 500)
 190    {
 9191        if (string.IsNullOrEmpty(text)) return "(пустое тело)";
 7192        return text.Length <= maxLen ? text : text[..maxLen] + "…";
 193    }
 194
 195    /// <summary>
 196    /// Armtek в некоторых сценариях поиска (например "ничего не найдено") возвращает RESP как объект:
 197    /// {"MSG":"..."} вместо ожидаемого массива. Для T=List&lt;&gt; нормализуем RESP в пустой список и
 198    /// переносим MSG в MESSAGES[I], чтобы не падать исключением на штатном бизнес-ответе.
 199    /// </summary>
 200    private static ArmtekResponse<T>? TryParseEnvelopeFallback<T>(string body)
 201    {
 202        try
 203        {
 2204            var root = JObject.Parse(body);
 1205            if (root["STATUS"] == null || root["MESSAGES"] == null || root["RESP"] == null)
 0206                return null;
 207
 1208            var respToken = root["RESP"];
 1209            if (respToken?.Type != JTokenType.Object)
 0210                return null;
 211
 1212            var targetType = typeof(T);
 1213            var isGenericList = targetType.IsGenericType &&
 1214                                targetType.GetGenericTypeDefinition() == typeof(List<>);
 1215            if (!isGenericList)
 1216                return null;
 217
 0218            var envelope = new ArmtekResponse<T>
 0219            {
 0220                Status = root["STATUS"]?.Value<int>() ?? 0,
 0221                Messages = ParseMessages(root["MESSAGES"]),
 0222                Resp = (T)Activator.CreateInstance(targetType)!
 0223            };
 224
 0225            var msg = respToken["MSG"]?.ToString();
 0226            if (!string.IsNullOrWhiteSpace(msg))
 227            {
 0228                envelope.Messages ??= new List<ArmtekMessage>();
 0229                envelope.Messages.Add(new ArmtekMessage
 0230                {
 0231                    Type = "I",
 0232                    Text = msg,
 0233                    Date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
 0234                });
 235            }
 236
 0237            envelope.Resp = (T)Activator.CreateInstance(targetType)!;
 0238            return envelope;
 239        }
 1240        catch
 241        {
 1242            return null;
 243        }
 2244    }
 245
 246    private static List<ArmtekMessage> ParseMessages(JToken? token)
 247    {
 0248        var result = new List<ArmtekMessage>();
 0249        if (token is not JArray arr)
 0250            return result;
 251
 0252        foreach (var item in arr)
 253        {
 0254            if (item is not JObject obj)
 255                continue;
 256
 0257            result.Add(new ArmtekMessage
 0258            {
 0259                Type = obj["TYPE"]?.ToString() ?? string.Empty,
 0260                Text = obj["TEXT"]?.ToString() ?? string.Empty,
 0261                Date = obj["DATE"]?.ToString() ?? string.Empty
 0262            });
 263        }
 264
 0265        return result;
 266    }
 267}