| | | 1 | | using System.Net; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using Microsoft.Extensions.Logging; |
| | | 4 | | |
| | | 5 | | namespace Services.ApiServices.PartsnavigatorApi; |
| | | 6 | | |
| | | 7 | | public interface IPartsnavigatorApiClient |
| | | 8 | | { |
| | | 9 | | /// <param name="withAnalogs">false — быстрая карточка (<c>analogs=false</c>), true — вместе со страницей аналогов ( |
| | | 10 | | Task<PartsnavigatorResult> LookupAsync(string brand, string number, bool withAnalogs, CancellationToken ct = default |
| | | 11 | | } |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// HTTP-клиент парсера partsnavigator.com.au. Все сбои (сеть, таймаут, 4xx/5xx, битый JSON) возвращаются |
| | | 15 | | /// в <see cref="PartsnavigatorResult"/>, исключения наружу не выходят (кроме отмены вызывающим). |
| | | 16 | | /// </summary> |
| | | 17 | | public sealed class PartsnavigatorApiClient : IPartsnavigatorApiClient |
| | | 18 | | { |
| | 1 | 19 | | private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; |
| | | 20 | | |
| | | 21 | | private readonly IHttpClientFactory _factory; |
| | | 22 | | private readonly PartsnavigatorConfig _config; |
| | | 23 | | private readonly ILogger<PartsnavigatorApiClient> _logger; |
| | | 24 | | |
| | 12 | 25 | | public PartsnavigatorApiClient(IHttpClientFactory factory, PartsnavigatorConfig config, ILogger<PartsnavigatorApiCli |
| | | 26 | | { |
| | 12 | 27 | | _factory = factory; |
| | 12 | 28 | | _config = config; |
| | 12 | 29 | | _logger = logger; |
| | 12 | 30 | | } |
| | | 31 | | |
| | | 32 | | public async Task<PartsnavigatorResult> LookupAsync(string brand, string number, bool withAnalogs, CancellationToken |
| | | 33 | | { |
| | 13 | 34 | | var path = $"partsnavigator/{Uri.EscapeDataString(brand)}/{Uri.EscapeDataString(number)}?analogs={(withAnalogs ? |
| | 13 | 35 | | var timeout = TimeSpan.FromSeconds(Math.Max(5, withAnalogs ? _config.AnalogsTimeoutSeconds : _config.CardTimeout |
| | | 36 | | try |
| | | 37 | | { |
| | 13 | 38 | | using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); |
| | 13 | 39 | | timeoutCts.CancelAfter(timeout); |
| | 13 | 40 | | var client = _factory.CreateClient(PartsnavigatorConfig.HttpClientName); |
| | 13 | 41 | | using var response = await client.GetAsync(path, timeoutCts.Token); |
| | 9 | 42 | | var body = await response.Content.ReadAsStringAsync(timeoutCts.Token); |
| | | 43 | | |
| | 9 | 44 | | if (response.StatusCode == HttpStatusCode.OK) |
| | 5 | 45 | | return ParseLookup(body, brand, number); |
| | | 46 | | |
| | 4 | 47 | | var error = TryParseError(body); |
| | 4 | 48 | | if (response.StatusCode == HttpStatusCode.NotFound && !string.Equals(error?.Code, "INTERNAL_ERROR", StringCo |
| | 1 | 49 | | return PartsnavigatorResult.NotFound(error?.Message); |
| | | 50 | | |
| | 3 | 51 | | _logger.LogWarning("Partsnavigator {Brand} {Number}: HTTP {Status} {Code}", brand, number, (int)response.Sta |
| | 3 | 52 | | return PartsnavigatorResult.Failure( |
| | 3 | 53 | | error?.Code ?? $"HTTP_{(int)response.StatusCode}", |
| | 3 | 54 | | error?.Message ?? "Сервис Partsnavigator вернул ошибку.", |
| | 3 | 55 | | error?.Retry ?? (int)response.StatusCode >= 500); |
| | | 56 | | } |
| | 3 | 57 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 58 | | { |
| | 1 | 59 | | throw; |
| | | 60 | | } |
| | 2 | 61 | | catch (OperationCanceledException) |
| | | 62 | | { |
| | 2 | 63 | | _logger.LogInformation("Partsnavigator {Brand} {Number}: таймаут запроса", brand, number); |
| | 2 | 64 | | return PartsnavigatorResult.Failure("TIMEOUT", "Сервис Partsnavigator не ответил вовремя.", true); |
| | | 65 | | } |
| | 1 | 66 | | catch (HttpRequestException ex) |
| | | 67 | | { |
| | 1 | 68 | | _logger.LogWarning(ex, "Partsnavigator {Brand} {Number}: сервис недоступен", brand, number); |
| | 1 | 69 | | return PartsnavigatorResult.Failure("UNAVAILABLE", "Сервис Partsnavigator недоступен.", true); |
| | | 70 | | } |
| | 12 | 71 | | } |
| | | 72 | | |
| | | 73 | | private PartsnavigatorResult ParseLookup(string body, string brand, string number) |
| | | 74 | | { |
| | | 75 | | try |
| | | 76 | | { |
| | 5 | 77 | | var lookup = JsonSerializer.Deserialize<PartsnavigatorLookup>(body, JsonOptions); |
| | 4 | 78 | | if (lookup is null) |
| | 0 | 79 | | return PartsnavigatorResult.Failure("BAD_RESPONSE", "Пустой ответ сервиса Partsnavigator.", true); |
| | 4 | 80 | | return lookup.Exists && lookup.Requested is not null ? PartsnavigatorResult.Found(lookup) : PartsnavigatorRe |
| | | 81 | | } |
| | 1 | 82 | | catch (JsonException ex) |
| | | 83 | | { |
| | 1 | 84 | | _logger.LogWarning(ex, "Partsnavigator {Brand} {Number}: не удалось разобрать ответ", brand, number); |
| | 1 | 85 | | return PartsnavigatorResult.Failure("BAD_RESPONSE", "Не удалось разобрать ответ сервиса Partsnavigator.", tr |
| | | 86 | | } |
| | 5 | 87 | | } |
| | | 88 | | |
| | | 89 | | private static PartsnavigatorErrorBody? TryParseError(string body) |
| | | 90 | | { |
| | 4 | 91 | | if (string.IsNullOrWhiteSpace(body)) return null; |
| | 4 | 92 | | try { return JsonSerializer.Deserialize<PartsnavigatorErrorBody>(body, JsonOptions); } |
| | 0 | 93 | | catch (JsonException) { return null; } |
| | 4 | 94 | | } |
| | | 95 | | } |