< Summary

Information
Class: Services.ApiServices.ProfitLigaApi.ProfitLigaHttpClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ProfitLigaApi/ProfitLigaHttpClient.cs
Line coverage
100%
Covered lines: 62
Uncovered lines: 0
Coverable lines: 62
Total lines: 133
Line coverage: 100%
Branch coverage
95%
Covered branches: 44
Total branches: 46
Branch coverage: 95.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetAsync(...)100%11100%
PostAsync(...)100%11100%
SendAsync()100%1616100%
IsErrorPayload(...)100%1212100%
ReadMessage(...)80%1010100%
BuildUri(...)100%44100%
BuildEndpoint(...)100%44100%
ToQueryString(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ProfitLigaApi/ProfitLigaHttpClient.cs

#LineLine coverage
 1using System.Diagnostics;
 2using Newtonsoft.Json.Linq;
 3using Services.ApiServices.ApiSupplierRequestLog;
 4
 5namespace Services.ApiServices.ProfitLigaApi;
 6
 7/// <summary>Транспорт «Профит-Лиги». Ключ передаётся GET-параметром secret даже в POST-запросах,
 8/// поэтому в журнал endpoint уходит уже без него: иначе боевой ключ осел бы в таблице аудита.</summary>
 9public sealed class ProfitLigaHttpClient
 10{
 11    private const string Provider = "ProfitLiga";
 12
 13    private readonly IHttpClientFactory _factory;
 14    private readonly ProfitLigaConfig _config;
 15    private readonly ApiSupplierRequestLogStore? _logs;
 16
 11717    public ProfitLigaHttpClient(IHttpClientFactory factory, ProfitLigaConfig config,
 11718        ApiSupplierRequestLogStore? logs = null)
 19    {
 11720        _factory = factory;
 11721        _config = config;
 11722        _logs = logs;
 11723    }
 24
 25    public Task<JToken> GetAsync(string path, IReadOnlyDictionary<string, string?>? query = null,
 26        bool throwOnErrorStatus = true, CancellationToken ct = default)
 10427        => SendAsync(HttpMethod.Get, path, query, null, throwOnErrorStatus, ct);
 28
 29    /// <summary>POST-методы Профит-Лиги принимают параметры формой, а ключ — всё той же query.</summary>
 30    public Task<JToken> PostAsync(string path, IReadOnlyDictionary<string, string?> form,
 31        bool throwOnErrorStatus = true, CancellationToken ct = default)
 5132        => SendAsync(HttpMethod.Post, path, null, form, throwOnErrorStatus, ct);
 33
 34    private async Task<JToken> SendAsync(HttpMethod method, string path,
 35        IReadOnlyDictionary<string, string?>? query, IReadOnlyDictionary<string, string?>? form,
 36        bool throwOnErrorStatus, CancellationToken ct)
 37    {
 15538        if (string.IsNullOrWhiteSpace(_config.Secret))
 339            throw new ProfitLigaApiException("Не настроен ProfitLiga:Secret.", requestPath: path);
 40
 15241        var loggedEndpoint = BuildEndpoint(path, query);
 15242        var requestBody = form is null ? null : ToQueryString(form);
 15243        var timer = Stopwatch.StartNew();
 44
 45        HttpResponseMessage response;
 46        try
 47        {
 15248            using var request = new HttpRequestMessage(method, BuildUri(path, query));
 15249            if (form is not null)
 5150                request.Content = new FormUrlEncodedContent(
 39551                    form.Where(x => x.Value is not null).Select(x => new KeyValuePair<string, string>(x.Key, x.Value!)))
 15252            response = await _factory.CreateClient("ProfitLigaApi").SendAsync(request, ct);
 14553        }
 754        catch (Exception ex) when (ex is not ProfitLigaApiException)
 55        {
 656            _logs?.Add(Provider, method.Method, loggedEndpoint, null, timer, requestBody, ex.Message);
 657            throw new ProfitLigaApiException("Профит-Лига недоступна.", requestPath: path, innerException: ex);
 58        }
 59
 14560        using (response)
 61        {
 14562            var text = await response.Content.ReadAsStringAsync(ct);
 14563            _logs?.Add(Provider, method.Method, loggedEndpoint, (int)response.StatusCode, timer, requestBody, text);
 64
 14565            if (!response.IsSuccessStatusCode)
 966                throw new ProfitLigaApiException(
 967                    $"Профит-Лига вернула HTTP {(int)response.StatusCode}. {ReadMessage(text)}".TrimEnd(),
 968                    (int)response.StatusCode, path);
 69
 70            JToken token;
 27171            try { token = JToken.Parse(text); }
 172            catch (Exception ex)
 73            {
 174                throw new ProfitLigaApiException("Профит-Лига вернула невалидный JSON.", (int)response.StatusCode, path,
 75            }
 76
 13577            if (throwOnErrorStatus && IsErrorPayload(token, out var error))
 578                throw new ProfitLigaApiException($"Профит-Лига отклонила запрос. {error}".TrimEnd(), (int)response.Statu
 79
 13080            return token;
 81        }
 13082    }
 83
 84    /// <summary>API отвечает HTTP 200 даже на отвергнутый запрос — признаком служит поле status.
 85    /// Бизнес-результаты корзины (no-quantity, less, cart-success) ошибкой не считаются.</summary>
 86    private static bool IsErrorPayload(JToken token, out string message)
 87    {
 9688        message = string.Empty;
 13289        if (token is not JObject payload) return false;
 90
 6091        var status = payload["status"];
 6092        var isError = status?.Type switch
 6093        {
 994            JTokenType.Boolean => status.Value<bool>() == false,
 4095            JTokenType.String => string.Equals(status.Value<string>(), "error", StringComparison.OrdinalIgnoreCase),
 1196            _ => false
 6097        };
 11598        if (!isError) return false;
 99
 5100        message = ReadMessage(payload.ToString());
 5101        return true;
 102    }
 103
 104    private static string ReadMessage(string text)
 105    {
 106        try
 107        {
 14108            var payload = JToken.Parse(text) as JObject;
 9109            var message = payload?.Value<string>("message") ?? payload?.Value<string>("err");
 9110            return message?.Trim() ?? string.Empty;
 111        }
 10112        catch { return string.Empty; }
 14113    }
 114
 115    private Uri BuildUri(string path, IReadOnlyDictionary<string, string?>? query)
 116    {
 152117        var parameters = new Dictionary<string, string?>(StringComparer.Ordinal) { ["secret"] = _config.Secret };
 152118        if (query is not null)
 439119            foreach (var pair in query) parameters[pair.Key] = pair.Value;
 152120        return new Uri($"{_config.BaseUrl.TrimEnd('/')}/{path.TrimStart('/')}?{ToQueryString(parameters)}");
 121    }
 122
 123    private static string BuildEndpoint(string path, IReadOnlyDictionary<string, string?>? query)
 124    {
 152125        var suffix = query is null ? string.Empty : ToQueryString(query);
 152126        return suffix.Length == 0 ? $"/{path.TrimStart('/')}" : $"/{path.TrimStart('/')}?{suffix}";
 127    }
 128
 129    private static string ToQueryString(IReadOnlyDictionary<string, string?> parameters) =>
 244130        string.Join("&", parameters
 567131            .Where(x => x.Value is not null)
 675132            .Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}"));
 133}