< Summary

Information
Class: Services.ApiServices.YandexApi.YandexApiClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/YandexApi/YandexApiClient.cs
Line coverage
58%
Covered lines: 53
Uncovered lines: 37
Coverable lines: 90
Total lines: 265
Line coverage: 58.8%
Branch coverage
62%
Covered branches: 25
Total branches: 40
Branch coverage: 62.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%
get_RequestContext()100%11100%
get_LastError()100%11100%
.ctor(...)100%11100%
.ctor(...)100%210%
GetApiKey()100%210%
SetHttpClientHeaders(...)100%210%
MakeRequestPost(...)100%210%
SendJsonAsync()75%4471.42%
<SendJsonAsync()100%11100%
GetBytesAsync()83.33%66100%
GetTestRequest()0%2040%
SendAsync()58.33%211260.71%
get_ContextSuffix()100%22100%
IsCredentialsError(...)100%44100%
Shorten(...)50%44100%
ParseJson(...)100%11100%
CreateClient()50%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/YandexApi/YandexApiClient.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http;
 3using System.Text;
 4using Newtonsoft.Json;
 5using Newtonsoft.Json.Linq;
 6using SlqStudio.Application.Services.EmailService;
 7
 8namespace Services.ApiServices.YandexApi
 9{
 10    /// <summary>
 11    /// HTTP-клиент Яндекс.Маркет Partner API.
 12    /// </summary>
 13    /// <remarks>
 14    /// Авторизация — только заголовок <c>Api-Key</c>: ключи формата <c>ACMA:…</c> схему
 15    /// <c>Authorization: Bearer</c> не принимают, поэтому Bearer-путь удалён.
 16    /// Все запросы идут через named-клиент <c>"YandexApi"</c> из <see cref="IHttpClientFactory"/>
 17    /// (Retry + Circuit Breaker), а не через <c>HttpWebRequest</c>.
 18    /// </remarks>
 19    public class YandexApiClient : BaseApiClient
 20    {
 21        public const string ApiKeyHeader = "Api-Key";
 22
 23        /// <summary>
 24        /// Запросы к Partner API (offer-mappings, тарифы) отвечают дольше типовых 15 секунд.
 25        /// Значение подобрано под прежнее поведение на <c>HttpWebRequest</c> с дефолтным таймаутом.
 26        /// </summary>
 127        private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
 28
 29        private readonly string _apiKey;
 30
 31        /// <summary>
 32        /// Описание клиента для сообщений об ошибках (например, <c>businessId=83710624</c>):
 33        /// в логе иначе не видно, у какого из клиентов Маркета не приняли ключ.
 34        /// </summary>
 1135        public string? RequestContext { get; set; }
 36
 37        /// <summary>Текст последней ошибки запроса — показывается в отчёте о загрузке.</summary>
 1238        public string? LastError { get; private set; }
 39
 40        public YandexApiClient(
 41            IHttpClientFactory? httpClientFactory,
 42            IEmailService? emailService,
 43            string apiKey)
 644            : base(httpClientFactory, emailService, "YandexApi", DefaultTimeout)
 45        {
 646            _apiKey = apiKey;
 647        }
 48
 49        // Конструктор для обратной совместимости (тесты, legacy-утилиты без DI)
 50        public YandexApiClient(string apiKey)
 051            : this(null, null, apiKey)
 52        {
 053        }
 54
 55        public async Task<string> GetApiKey()
 56        {
 057            await Task.CompletedTask;
 058            return _apiKey;
 059        }
 60
 61        protected override void SetHttpClientHeaders(HttpClient client)
 62        {
 063            client.DefaultRequestHeaders.Remove(ApiKeyHeader);
 064            client.DefaultRequestHeaders.TryAddWithoutValidation(ApiKeyHeader, _apiKey);
 065        }
 66
 67        /// <summary>
 68        /// Синхронный путь базового класса построен на <c>HttpWebRequest</c> и для Яндекса не используется.
 69        /// </summary>
 70        public override JObject MakeRequestPost<T>(T requestModel, string requestUri)
 71        {
 072            throw new NotSupportedException(
 073                "YandexApiClient работает только асинхронно: используйте SendJsonAsync/GetBytesAsync.");
 74        }
 75
 76        /// <summary>
 77        /// Выполняет запрос к Partner API и возвращает распарсенный JSON.
 78        /// Возвращает <c>null</c>, если запрос не удался (ошибка залогирована в <see cref="BaseApiClient.HandleErrorAsy
 79        /// </summary>
 80        /// <param name="method">HTTP-метод (Partner API использует GET, POST и PUT).</param>
 81        /// <param name="requestUri">Полный URI запроса.</param>
 82        /// <param name="body">Тело запроса; сериализуется в JSON. Для GET не передаётся.</param>
 83        public async Task<JObject?> SendJsonAsync(HttpMethod method, string requestUri, object? body = null)
 84        {
 85            try
 86            {
 487                var json = await SendAsync(method, requestUri, body,
 588                    async response => await response.Content.ReadAsStringAsync());
 89
 490                if (json == null)
 91                {
 392                    return null;
 93                }
 94
 195                return string.IsNullOrWhiteSpace(json) ? new JObject() : ParseJson(json);
 96            }
 97            catch (JsonException ex)
 98            {
 099                await HandleErrorAsync($"Ошибка парсинга JSON от {_clientName}", ex, requestUri);
 0100                return null;
 101            }
 4102        }
 103
 104        /// <summary>
 105        /// Скачивает бинарный ответ (PDF этикеток). Бросает исключение при ошибке запроса —
 106        /// вызывающий код показывает её пользователю вместе с номером отправления.
 107        /// </summary>
 108        public async Task<byte[]> GetBytesAsync(string requestUri)
 109        {
 2110            using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
 2111            request.Headers.TryAddWithoutValidation(ApiKeyHeader, _apiKey);
 112
 2113            using var cts = new CancellationTokenSource(_timeout);
 2114            using var response = await CreateClient().SendAsync(request, cts.Token);
 2115            if (!response.IsSuccessStatusCode)
 116            {
 117                // Без тела ответа в списке сбоев печати виден только код статуса, а причину
 118                // (лимит 420, нет прав, заказ не найден) Маркет сообщает в errors[].message.
 1119                var errorBody = await response.Content.ReadAsStringAsync(cts.Token);
 1120                var details = string.IsNullOrWhiteSpace(errorBody) ? string.Empty : $": {Shorten(errorBody)}";
 1121                throw new HttpRequestException(
 1122                    $"HTTP {(int)response.StatusCode} от {_clientName}{ContextSuffix}{details}",
 1123                    null, response.StatusCode);
 124            }
 125
 1126            return await response.Content.ReadAsByteArrayAsync(cts.Token);
 1127        }
 128
 129        public override async Task<bool> GetTestRequest(string businessId, string apiKey)
 130        {
 0131            var requestUri = $"{YandexApiUrl.CAMPAIGNS_V2}?page=&pageSize=20";
 132
 133            try
 134            {
 0135                using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
 0136                request.Headers.TryAddWithoutValidation(ApiKeyHeader, apiKey);
 137
 0138                using var cts = new CancellationTokenSource(_timeout);
 0139                using var response = await CreateClient().SendAsync(request, cts.Token);
 0140                if (!response.IsSuccessStatusCode)
 141                {
 0142                    return false;
 143                }
 144
 0145                var json = await response.Content.ReadAsStringAsync(cts.Token);
 0146                if (string.IsNullOrWhiteSpace(json))
 147                {
 0148                    return false;
 149                }
 150
 0151                return ParseJson(json)["campaigns"] != null;
 152            }
 0153            catch
 154            {
 0155                return false;
 156            }
 0157        }
 158
 159        /// <summary>
 160        /// Общая обвязка запроса: заголовок Api-Key, таймаут, единая обработка ошибок.
 161        /// </summary>
 162        private async Task<TResult?> SendAsync<TResult>(
 163            HttpMethod method,
 164            string requestUri,
 165            object? body,
 166            Func<HttpResponseMessage, Task<TResult>> readResult)
 167        {
 168            try
 169            {
 4170                using var request = new HttpRequestMessage(method, requestUri);
 4171                request.Headers.TryAddWithoutValidation(ApiKeyHeader, _apiKey);
 172
 4173                if (body != null)
 174                {
 0175                    request.Content = new StringContent(
 0176                        JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
 177                }
 178
 4179                using var cts = new CancellationTokenSource(_timeout);
 4180                using var response = await CreateClient().SendAsync(request, cts.Token);
 181
 4182                if (!response.IsSuccessStatusCode)
 183                {
 3184                    var errorBody = await response.Content.ReadAsStringAsync(cts.Token);
 3185                    var message = $"HTTP ошибка {(int)response.StatusCode} при запросе к {_clientName}{ContextSuffix}";
 3186                    LastError = $"{message}: {Shorten(errorBody)}";
 187
 3188                    if (IsCredentialsError(response.StatusCode))
 189                    {
 190                        // Маркет отклонил ключ — это состояние учётной записи, а не инцидент интеграции.
 191                        // Письмо на каждый прогон джоба по каждому клиенту со старым токеном только
 192                        // зашумляет почту, поэтому такие ответы просто пишем в лог.
 2193                        Console.WriteLine($"[{_clientName}] {LastError}");
 194                    }
 195                    else
 196                    {
 1197                        await HandleErrorAsync(message,
 1198                            new HttpRequestException($"Status: {response.StatusCode}, Response: {errorBody}"),
 1199                            requestUri);
 200                    }
 201
 3202                    return default;
 203                }
 204
 1205                return await readResult(response);
 206            }
 207            catch (TaskCanceledException ex)
 208            {
 0209                LastError = $"Таймаут при запросе к {_clientName}{ContextSuffix}";
 0210                await HandleErrorAsync(LastError, ex, requestUri);
 0211                return default;
 212            }
 213            catch (HttpRequestException ex)
 214            {
 0215                LastError = $"HTTP ошибка при запросе к {_clientName}{ContextSuffix}";
 0216                await HandleErrorAsync(LastError, ex, requestUri);
 0217                return default;
 218            }
 219            catch (Exception ex)
 220            {
 0221                LastError = $"Неожиданная ошибка при запросе к {_clientName}{ContextSuffix}";
 0222                await HandleErrorAsync(LastError, ex, requestUri);
 0223                return default;
 224            }
 4225        }
 226
 227        private string ContextSuffix =>
 4228            string.IsNullOrWhiteSpace(RequestContext) ? string.Empty : $" ({RequestContext})";
 229
 230        /// <summary>Ответ означает, что Маркет не принял Api-Key (устаревший OAuth-токен, чужой ключ).</summary>
 231        private static bool IsCredentialsError(HttpStatusCode statusCode) =>
 3232            statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
 233
 234        private static string Shorten(string value) =>
 4235            string.IsNullOrWhiteSpace(value) ? string.Empty
 4236                : value.Length <= 300 ? value.Trim()
 4237                : value.Trim()[..300] + "…";
 238
 239        /// <summary>
 240        /// Разбирает ответ, оставляя даты строками.
 241        /// </summary>
 242        /// <remarks>
 243        /// <c>JObject.Parse</c> с настройками по умолчанию превращает ISO-строку вида
 244        /// <c>2026-08-23T00:16:56.253+03:00</c> в <see cref="DateTime"/> и переводит её в часовой пояс процесса.
 245        /// В контейнере с TZ=UTC это минус три часа, и у заказов, оформленных после полуночи по Москве,
 246        /// дата съезжала на предыдущие сутки: <c>ToString()</c> отдавал «22.08.2026 21:16:56», а
 247        /// <c>OrderCaster</c> берёт из этой строки только дату. С <see cref="DateParseHandling.None"/>
 248        /// значение остаётся ровно таким, каким его прислал Маркет.
 249        /// </remarks>
 250        public static JObject ParseJson(string json)
 251        {
 5252            using var reader = new JsonTextReader(new StringReader(json))
 5253            {
 5254                DateParseHandling = DateParseHandling.None
 5255            };
 256
 5257            return JObject.Load(reader);
 5258        }
 259
 260        private HttpClient CreateClient()
 261        {
 6262            return _httpClientFactory?.CreateClient(_clientName) ?? _fallbackClient;
 263        }
 264    }
 265}