< Summary

Information
Class: Services.ApiServices.ShateMApi.ShateMHttpClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ShateMApi/ShateMHttpClient.cs
Line coverage
91%
Covered lines: 85
Uncovered lines: 8
Coverable lines: 93
Total lines: 161
Line coverage: 91.3%
Branch coverage
68%
Covered branches: 44
Total branches: 64
Branch coverage: 68.7%
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%
PatchAsync(...)100%210%
DeleteAsync(...)100%11100%
SendAsync()50%121294.73%
SendAuthorizedAsync()100%22100%
GetTokenAsync()71.42%292890.62%
BuildUri(...)100%22100%
GetErrorDescription(...)81.25%171687.5%
TranslateOrderError(...)25%4480%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ShateMApi/ShateMHttpClient.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http.Headers;
 3using Newtonsoft.Json.Linq;
 4using Services.ApiServices.ApiSupplierRequestLog;
 5
 6namespace Services.ApiServices.ShateMApi;
 7
 8/// <summary>Bearer-клиент Shate-M. Токен существует только в памяти текущего процесса.</summary>
 9public sealed class ShateMHttpClient
 10{
 11    private readonly IHttpClientFactory _factory;
 12    private readonly ShateMConfig _config;
 6113    private readonly SemaphoreSlim _tokenLock = new(1, 1);
 14    private string? _accessToken;
 6115    private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
 16    private readonly ApiSupplierRequestLogStore? _logs;
 17
 6118    public ShateMHttpClient(IHttpClientFactory factory, ShateMConfig config, ApiSupplierRequestLogStore? logs = null)
 19    {
 6120        _factory = factory;
 6121        _config = config;
 6122        _logs = logs;
 6123    }
 24
 25    public Task<JToken> GetAsync(string path, IReadOnlyDictionary<string, string?>? query, CancellationToken ct = defaul
 3826        => SendAsync(HttpMethod.Get, path, query, null, ct);
 27
 28    public Task<JToken> PostAsync(string path, JToken body, CancellationToken ct = default)
 3429        => SendAsync(HttpMethod.Post, path, null, body, ct);
 30
 31    public Task<JToken> PatchAsync(string path, JToken body, CancellationToken ct = default)
 032        => SendAsync(HttpMethod.Patch, path, null, body, ct);
 33
 34    public Task<JToken> DeleteAsync(string path, CancellationToken ct = default)
 635        => SendAsync(HttpMethod.Delete, path, null, null, ct);
 36
 37    private async Task<JToken> SendAsync(HttpMethod method, string path, IReadOnlyDictionary<string, string?>? query, JT
 38    {
 7839        var timer = System.Diagnostics.Stopwatch.StartNew();
 40        HttpResponseMessage response;
 41        try
 42        {
 7843            response = await SendAuthorizedAsync(method, path, query, body, false, ct);
 7444            if (response.StatusCode == HttpStatusCode.Unauthorized)
 45            {
 146                _accessToken = null;
 147                _expiresAt = DateTimeOffset.MinValue;
 148                response.Dispose();
 149                response = await SendAuthorizedAsync(method, path, query, body, true, ct);
 50            }
 7451        }
 452        catch (Exception ex)
 53        {
 454            _logs?.Add("ShateM", method.Method, path, null, timer, body?.ToString(), ex.Message);
 455            throw;
 56        }
 57
 7458        using (response)
 59        {
 7460            var text = await response.Content.ReadAsStringAsync(ct);
 7461            _logs?.Add("ShateM", method.Method, path, (int)response.StatusCode, timer, body?.ToString(), text);
 7462            if (!response.IsSuccessStatusCode)
 663                throw new ShateMApiException($"Шате-М вернул HTTP {(int)response.StatusCode}. {GetErrorDescription(text)
 64
 6865            try { return JToken.Parse(text); }
 066            catch (Exception ex) { throw new ShateMApiException("Шате-М вернул невалидный JSON.", requestPath: path, inn
 67        }
 6868    }
 69
 70    private async Task<HttpResponseMessage> SendAuthorizedAsync(HttpMethod method, string path, IReadOnlyDictionary<stri
 71    {
 7972        var token = await GetTokenAsync(forceRefresh, ct);
 7573        var request = new HttpRequestMessage(method, BuildUri(path, query));
 7574        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
 10975        if (body != null) request.Content = new StringContent(body.ToString(), System.Text.Encoding.UTF8, "application/j
 7576        return await _factory.CreateClient("ShateMApi").SendAsync(request, ct);
 7577    }
 78
 79    private async Task<string> GetTokenAsync(bool forceRefresh, CancellationToken ct)
 80    {
 10981        if (!forceRefresh && !string.IsNullOrWhiteSpace(_accessToken) && _expiresAt > DateTimeOffset.UtcNow.AddMinutes(1
 4982        await _tokenLock.WaitAsync(ct);
 83        try
 84        {
 4985            if (!forceRefresh && !string.IsNullOrWhiteSpace(_accessToken) && _expiresAt > DateTimeOffset.UtcNow.AddMinut
 4986            if (string.IsNullOrWhiteSpace(_config.Login) || string.IsNullOrWhiteSpace(_config.Password))
 087                throw new ShateMApiException("Не настроены ShateM:Login и/или ShateM:Password.");
 88
 4989            using var content = new FormUrlEncodedContent(new Dictionary<string, string>
 4990            {
 4991                ["login"] = _config.Login,
 4992                ["password"] = _config.Password
 4993            });
 4994            var timer = System.Diagnostics.Stopwatch.StartNew();
 95            HttpResponseMessage response;
 96            string text;
 97            try
 98            {
 4999                response = await _factory.CreateClient("ShateMApi").PostAsync(BuildUri("/api/v1/auth/login", null), cont
 45100                text = await response.Content.ReadAsStringAsync(ct);
 45101            }
 4102            catch (Exception ex)
 103            {
 4104                _logs?.Add("ShateM", HttpMethod.Post.Method, "/api/v1/auth/login", null, timer, await content.ReadAsStri
 4105                throw;
 0106            }
 45107            using (response)
 45108                _logs?.Add("ShateM", HttpMethod.Post.Method, "/api/v1/auth/login", (int)response.StatusCode, timer, awai
 45109            if (!response.IsSuccessStatusCode) throw new ShateMApiException($"Авторизация Шате-М завершилась HTTP {(int)
 45110            var json = JObject.Parse(text);
 45111            var token = json.Value<string>("access_token");
 45112            var expiresIn = json.Value<int?>("expires_in") ?? 3600;
 45113            if (string.IsNullOrWhiteSpace(token)) throw new ShateMApiException("Ответ авторизации Шате-М не содержит acc
 45114            _accessToken = token;
 45115            _expiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, expiresIn));
 45116            return token;
 117        }
 0118        catch (ShateMApiException) { throw; }
 8119        catch (Exception ex) { throw new ShateMApiException("Не удалось авторизоваться в Шате-М.", requestPath: "/api/v1
 49120        finally { _tokenLock.Release(); }
 75121    }
 122
 123    private Uri BuildUri(string path, IReadOnlyDictionary<string, string?>? query)
 124    {
 124125        var builder = new UriBuilder(_config.BaseUrl.TrimEnd('/') + "/" + path.TrimStart('/'));
 124126        if (query != null)
 55127            builder.Query = string.Join("&", query.Where(x => !string.IsNullOrWhiteSpace(x.Value)).Select(x => $"{Uri.Es
 124128        return builder.Uri;
 129    }
 130
 131    private static string GetErrorDescription(string response)
 132    {
 133        try
 134        {
 6135            var json = JToken.Parse(response);
 6136            var description = json.Value<string>("description") ?? json.Value<string>("message");
 6137            var messages = json["messages"]?.Values<string>().Where(x => !string.IsNullOrWhiteSpace(x));
 6138            var errorDescriptions = json["errorDescriptions"] is JObject errors
 2139                ? errors.Properties().Select(x => x.Value.Value<string>()).Where(x => !string.IsNullOrWhiteSpace(x))
 6140                : [];
 141            // byCartItems возвращает cartItemErrors, byPriceItems — priceItemErrors.
 6142            var itemErrors = new[] { "cartItemErrors", "priceItemErrors" }
 12143                .SelectMany(name => json[name] as JArray ?? new JArray())
 6144                .OfType<JObject>()
 1145                .SelectMany(x => x["errors"]?.Values<string>() ?? [])
 6146                .Select(TranslateOrderError);
 14147            var details = string.Join(" ", new[] { description }.Concat(messages ?? []).Concat(errorDescriptions).Concat
 6148            return string.IsNullOrWhiteSpace(details) ? "Подробности доступны в журнале API." : details;
 149        }
 0150        catch
 151        {
 0152            return "Подробности доступны в журнале API.";
 153        }
 6154    }
 155
 1156    private static string TranslateOrderError(string? error) => error switch
 1157    {
 1158        "NeedFullPrepayment" => "Требуется полная предоплата по строке заказа.",
 0159        _ => error ?? string.Empty
 1160    };
 161}