| | | 1 | | using System.Diagnostics; |
| | | 2 | | using Newtonsoft.Json.Linq; |
| | | 3 | | using Services.ApiServices.ApiSupplierRequestLog; |
| | | 4 | | |
| | | 5 | | namespace Services.ApiServices.ProfitLigaApi; |
| | | 6 | | |
| | | 7 | | /// <summary>Транспорт «Профит-Лиги». Ключ передаётся GET-параметром secret даже в POST-запросах, |
| | | 8 | | /// поэтому в журнал endpoint уходит уже без него: иначе боевой ключ осел бы в таблице аудита.</summary> |
| | | 9 | | public 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 | | |
| | 117 | 17 | | public ProfitLigaHttpClient(IHttpClientFactory factory, ProfitLigaConfig config, |
| | 117 | 18 | | ApiSupplierRequestLogStore? logs = null) |
| | | 19 | | { |
| | 117 | 20 | | _factory = factory; |
| | 117 | 21 | | _config = config; |
| | 117 | 22 | | _logs = logs; |
| | 117 | 23 | | } |
| | | 24 | | |
| | | 25 | | public Task<JToken> GetAsync(string path, IReadOnlyDictionary<string, string?>? query = null, |
| | | 26 | | bool throwOnErrorStatus = true, CancellationToken ct = default) |
| | 104 | 27 | | => 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) |
| | 51 | 32 | | => 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 | | { |
| | 155 | 38 | | if (string.IsNullOrWhiteSpace(_config.Secret)) |
| | 3 | 39 | | throw new ProfitLigaApiException("Не настроен ProfitLiga:Secret.", requestPath: path); |
| | | 40 | | |
| | 152 | 41 | | var loggedEndpoint = BuildEndpoint(path, query); |
| | 152 | 42 | | var requestBody = form is null ? null : ToQueryString(form); |
| | 152 | 43 | | var timer = Stopwatch.StartNew(); |
| | | 44 | | |
| | | 45 | | HttpResponseMessage response; |
| | | 46 | | try |
| | | 47 | | { |
| | 152 | 48 | | using var request = new HttpRequestMessage(method, BuildUri(path, query)); |
| | 152 | 49 | | if (form is not null) |
| | 51 | 50 | | request.Content = new FormUrlEncodedContent( |
| | 395 | 51 | | form.Where(x => x.Value is not null).Select(x => new KeyValuePair<string, string>(x.Key, x.Value!))) |
| | 152 | 52 | | response = await _factory.CreateClient("ProfitLigaApi").SendAsync(request, ct); |
| | 145 | 53 | | } |
| | 7 | 54 | | catch (Exception ex) when (ex is not ProfitLigaApiException) |
| | | 55 | | { |
| | 6 | 56 | | _logs?.Add(Provider, method.Method, loggedEndpoint, null, timer, requestBody, ex.Message); |
| | 6 | 57 | | throw new ProfitLigaApiException("Профит-Лига недоступна.", requestPath: path, innerException: ex); |
| | | 58 | | } |
| | | 59 | | |
| | 145 | 60 | | using (response) |
| | | 61 | | { |
| | 145 | 62 | | var text = await response.Content.ReadAsStringAsync(ct); |
| | 145 | 63 | | _logs?.Add(Provider, method.Method, loggedEndpoint, (int)response.StatusCode, timer, requestBody, text); |
| | | 64 | | |
| | 145 | 65 | | if (!response.IsSuccessStatusCode) |
| | 9 | 66 | | throw new ProfitLigaApiException( |
| | 9 | 67 | | $"Профит-Лига вернула HTTP {(int)response.StatusCode}. {ReadMessage(text)}".TrimEnd(), |
| | 9 | 68 | | (int)response.StatusCode, path); |
| | | 69 | | |
| | | 70 | | JToken token; |
| | 271 | 71 | | try { token = JToken.Parse(text); } |
| | 1 | 72 | | catch (Exception ex) |
| | | 73 | | { |
| | 1 | 74 | | throw new ProfitLigaApiException("Профит-Лига вернула невалидный JSON.", (int)response.StatusCode, path, |
| | | 75 | | } |
| | | 76 | | |
| | 135 | 77 | | if (throwOnErrorStatus && IsErrorPayload(token, out var error)) |
| | 5 | 78 | | throw new ProfitLigaApiException($"Профит-Лига отклонила запрос. {error}".TrimEnd(), (int)response.Statu |
| | | 79 | | |
| | 130 | 80 | | return token; |
| | | 81 | | } |
| | 130 | 82 | | } |
| | | 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 | | { |
| | 96 | 88 | | message = string.Empty; |
| | 132 | 89 | | if (token is not JObject payload) return false; |
| | | 90 | | |
| | 60 | 91 | | var status = payload["status"]; |
| | 60 | 92 | | var isError = status?.Type switch |
| | 60 | 93 | | { |
| | 9 | 94 | | JTokenType.Boolean => status.Value<bool>() == false, |
| | 40 | 95 | | JTokenType.String => string.Equals(status.Value<string>(), "error", StringComparison.OrdinalIgnoreCase), |
| | 11 | 96 | | _ => false |
| | 60 | 97 | | }; |
| | 115 | 98 | | if (!isError) return false; |
| | | 99 | | |
| | 5 | 100 | | message = ReadMessage(payload.ToString()); |
| | 5 | 101 | | return true; |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | private static string ReadMessage(string text) |
| | | 105 | | { |
| | | 106 | | try |
| | | 107 | | { |
| | 14 | 108 | | var payload = JToken.Parse(text) as JObject; |
| | 9 | 109 | | var message = payload?.Value<string>("message") ?? payload?.Value<string>("err"); |
| | 9 | 110 | | return message?.Trim() ?? string.Empty; |
| | | 111 | | } |
| | 10 | 112 | | catch { return string.Empty; } |
| | 14 | 113 | | } |
| | | 114 | | |
| | | 115 | | private Uri BuildUri(string path, IReadOnlyDictionary<string, string?>? query) |
| | | 116 | | { |
| | 152 | 117 | | var parameters = new Dictionary<string, string?>(StringComparer.Ordinal) { ["secret"] = _config.Secret }; |
| | 152 | 118 | | if (query is not null) |
| | 439 | 119 | | foreach (var pair in query) parameters[pair.Key] = pair.Value; |
| | 152 | 120 | | return new Uri($"{_config.BaseUrl.TrimEnd('/')}/{path.TrimStart('/')}?{ToQueryString(parameters)}"); |
| | | 121 | | } |
| | | 122 | | |
| | | 123 | | private static string BuildEndpoint(string path, IReadOnlyDictionary<string, string?>? query) |
| | | 124 | | { |
| | 152 | 125 | | var suffix = query is null ? string.Empty : ToQueryString(query); |
| | 152 | 126 | | return suffix.Length == 0 ? $"/{path.TrimStart('/')}" : $"/{path.TrimStart('/')}?{suffix}"; |
| | | 127 | | } |
| | | 128 | | |
| | | 129 | | private static string ToQueryString(IReadOnlyDictionary<string, string?> parameters) => |
| | 244 | 130 | | string.Join("&", parameters |
| | 567 | 131 | | .Where(x => x.Value is not null) |
| | 675 | 132 | | .Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}")); |
| | | 133 | | } |