< Summary

Information
Class: Services.ApiServices.InterpartsEngine.InterpartsEngineApiClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/InterpartsEngine/InterpartsEngineApiClient.cs
Line coverage
94%
Covered lines: 56
Uncovered lines: 3
Coverable lines: 59
Total lines: 141
Line coverage: 94.9%
Branch coverage
67%
Covered branches: 23
Total branches: 34
Branch coverage: 67.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
GetBrandsAsync(...)100%11100%
SearchOffersAsync(...)100%11100%
GetOfferAsync(...)100%11100%
SendAsync()100%44100%
Parse(...)50%22100%
MapError(...)68.18%2222100%
LogDuration(...)50%2260%
TryParseError(...)50%4475%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/InterpartsEngine/InterpartsEngineApiClient.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Net;
 3using System.Text.Json;
 4using Microsoft.Extensions.Logging;
 5
 6namespace Services.ApiServices.InterpartsEngine;
 7
 8public interface IInterpartsEngineApiClient
 9{
 10    Task<InterpartsEngineResult<EngineBrandsResponse>> GetBrandsAsync(string article, CancellationToken ct = default);
 11    Task<InterpartsEngineResult<EngineOffersResponse>> SearchOffersAsync(string article, string brand, CancellationToken
 12
 13    /// <summary>Актуальные цена и наличие одного предложения (перепроверка перед покупкой).</summary>
 14    Task<InterpartsEngineResult<EngineOffer>> GetOfferAsync(string offerId, CancellationToken ct = default);
 15}
 16
 17/// <summary>
 18/// HTTP-клиент движка Interparts (контракт docs/engine-api.md, v0.3 + обязательный <c>X-Customer-Login</c>).
 19/// Ретраев нет: каждый запрос без кэша движка опрашивает поставщиков. Все сбои возвращаются в
 20/// <see cref="InterpartsEngineResult{T}"/>; исключения наружу не выходят, кроме отмены вызывающим.
 21/// </summary>
 22public sealed class InterpartsEngineApiClient : IInterpartsEngineApiClient
 23{
 124    private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
 25
 26    /// <summary>Обещание контракта; дольше — повод для предупреждения в логах.</summary>
 127    private static readonly TimeSpan ContractSearchTime = TimeSpan.FromSeconds(30);
 28
 29    private readonly IHttpClientFactory _factory;
 30    private readonly InterpartsEngineConfig _config;
 31    private readonly ILogger<InterpartsEngineApiClient> _logger;
 32
 1433    public InterpartsEngineApiClient(IHttpClientFactory factory, InterpartsEngineConfig config, ILogger<InterpartsEngine
 34    {
 1435        _factory = factory;
 1436        _config = config;
 1437        _logger = logger;
 1438    }
 39
 40    public Task<InterpartsEngineResult<EngineBrandsResponse>> GetBrandsAsync(string article, CancellationToken ct = defa
 541        SendAsync<EngineBrandsResponse>($"search/brands?article={Uri.EscapeDataString(article)}", ct);
 42
 43    public Task<InterpartsEngineResult<EngineOffersResponse>> SearchOffersAsync(string article, string brand, Cancellati
 244        SendAsync<EngineOffersResponse>(
 245            $"search/offers?article={Uri.EscapeDataString(article)}&brand={Uri.EscapeDataString(brand)}", ct);
 46
 47    public Task<InterpartsEngineResult<EngineOffer>> GetOfferAsync(string offerId, CancellationToken ct = default) =>
 748        SendAsync<EngineOffer>($"offers/{Uri.EscapeDataString(offerId)}", ct);
 49
 50    private async Task<InterpartsEngineResult<T>> SendAsync<T>(string path, CancellationToken ct) where T : class
 51    {
 1452        if (!_config.IsConfigured)
 153            return InterpartsEngineResult<T>.Failure("NOT_CONFIGURED",
 154                "Не настроен доступ к движку Interparts (InterpartsEngine:ApiKey).", false);
 55
 1356        var stopwatch = Stopwatch.StartNew();
 57        try
 58        {
 1359            var client = _factory.CreateClient(InterpartsEngineConfig.HttpClientName);
 1360            using var response = await client.GetAsync(path, ct);
 1061            var body = await response.Content.ReadAsStringAsync(ct);
 1062            LogDuration(path, response.StatusCode, stopwatch.Elapsed);
 63
 1064            if (response.StatusCode == HttpStatusCode.OK)
 365                return Parse<T>(body, path);
 66
 767            return MapError<T>(response.StatusCode, TryParseError(body), path);
 68        }
 269        catch (OperationCanceledException) when (ct.IsCancellationRequested)
 70        {
 171            throw;
 72        }
 173        catch (OperationCanceledException)
 74        {
 75            // Движок досчитает ответ и закэширует его на ~7 минут: повтор того же запроса будет быстрым.
 176            _logger.LogWarning("Interparts engine {Path}: нет ответа за {Seconds} с", path, (int)stopwatch.Elapsed.Total
 177            return InterpartsEngineResult<T>.Failure("TIMEOUT",
 178                "Движок не успел опросить поставщиков. Повторите запрос через минуту — ответ будет быстрее.", true);
 79        }
 180        catch (HttpRequestException ex)
 81        {
 182            _logger.LogWarning(ex, "Interparts engine {Path}: сервис недоступен", path);
 183            return InterpartsEngineResult<T>.Failure("UNAVAILABLE", "Сервис подбора запчастей временно недоступен.", tru
 84        }
 1385    }
 86
 87    private InterpartsEngineResult<T> Parse<T>(string body, string path) where T : class
 88    {
 89        try
 90        {
 391            var value = JsonSerializer.Deserialize<T>(body, JsonOptions);
 292            return value is null
 293                ? InterpartsEngineResult<T>.Failure("BAD_RESPONSE", "Пустой ответ движка.", true)
 294                : InterpartsEngineResult<T>.Ok(value);
 95        }
 196        catch (JsonException ex)
 97        {
 198            _logger.LogWarning(ex, "Interparts engine {Path}: не удалось разобрать ответ", path);
 199            return InterpartsEngineResult<T>.Failure("BAD_RESPONSE", "Не удалось разобрать ответ движка.", true);
 100        }
 3101    }
 102
 103    private InterpartsEngineResult<T> MapError<T>(HttpStatusCode status, EngineErrorBody? error, string path) where T : 
 104    {
 7105        var code = error?.Code ?? $"HTTP_{(int)status}";
 106        switch (status)
 107        {
 108            case HttpStatusCode.NotFound:
 1109                return InterpartsEngineResult<T>.NotFound(error?.Message ?? "Предложение не найдено.");
 110            case HttpStatusCode.Gone:
 1111                return InterpartsEngineResult<T>.Expired("Предложение устарело — повторите поиск.");
 112            case HttpStatusCode.Unauthorized:
 113            case HttpStatusCode.Forbidden:
 2114                _logger.LogError("Interparts engine {Path}: HTTP {Status} {Code} — проверьте InterpartsEngine:ApiKey и C
 2115                    path, (int)status, code);
 2116                return InterpartsEngineResult<T>.Failure(code, "Движок отклонил доступ: проверьте ключ и учётку Interpar
 117            case HttpStatusCode.BadRequest:
 1118                _logger.LogWarning("Interparts engine {Path}: HTTP 400 {Code} {Message}", path, code, error?.Message);
 1119                return InterpartsEngineResult<T>.Failure(code, error?.Message ?? "Движок отклонил запрос.", false);
 120            default:
 2121                _logger.LogWarning("Interparts engine {Path}: HTTP {Status} {Code}", path, (int)status, code);
 2122                return InterpartsEngineResult<T>.Failure(code, "Сервис подбора запчастей временно недоступен.", true);
 123        }
 124    }
 125
 126    private void LogDuration(string path, HttpStatusCode status, TimeSpan elapsed)
 127    {
 10128        if (elapsed > ContractSearchTime)
 0129            _logger.LogWarning("Interparts engine {Path}: HTTP {Status} за {Seconds} с — дольше контракта (30 с)",
 0130                path, (int)status, (int)elapsed.TotalSeconds);
 131        else
 10132            _logger.LogDebug("Interparts engine {Path}: HTTP {Status} за {Ms} мс", path, (int)status, (long)elapsed.Tota
 10133    }
 134
 135    private static EngineErrorBody? TryParseError(string body)
 136    {
 7137        if (string.IsNullOrWhiteSpace(body)) return null;
 7138        try { return JsonSerializer.Deserialize<EngineErrorEnvelope>(body, JsonOptions)?.Error; }
 0139        catch (JsonException) { return null; }
 7140    }
 141}