< Summary

Information
Class: Services.ApiServices._1CApi.OData.OneCODataHttpClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataHttpClient.cs
Line coverage
93%
Covered lines: 180
Uncovered lines: 12
Coverable lines: 192
Total lines: 393
Line coverage: 93.7%
Branch coverage
82%
Covered branches: 82
Total branches: 100
Branch coverage: 82%
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%
CreateClient()100%11100%
GetListAsync()70%101089.47%
GetByGuidAsync()100%22100%
GetSinglePageAsync()83.33%1212100%
GetPageAsync()83.33%1212100%
GetCountAsync()33.33%6680%
GetRawJsonAsync()100%22100%
PostAsync()50%22100%
PostDocumentAsync()100%66100%
PatchAsync()66.66%6692.85%
SetPostedAsync(...)100%210%
PutAsync()100%11100%
BuildQuery(...)100%1414100%
HasRefKey(...)100%66100%
EnsureSuccess(...)100%44100%
LogSwallowed(...)50%3240%
ExecuteWithRetryAsync()75%9880%
ShouldRetry(...)100%44100%
TruncateError(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/1CApi/OData/OneCODataHttpClient.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http.Headers;
 3using System.Text;
 4using Microsoft.Extensions.Logging;
 5using Newtonsoft.Json;
 6using Services.ApiServices._1CApi.OData.Models;
 7
 8namespace Services.ApiServices._1CApi.OData;
 9
 10public class OneCODataHttpClient : IOneCODataClient
 11{
 12    private readonly IHttpClientFactory _httpClientFactory;
 13    private readonly string _baseUrl;
 14    private readonly string _base64Auth;
 15    private readonly ILogger<OneCODataHttpClient>? _logger;
 16    private const int PageSize = 1000;
 117    private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60);
 18
 20419    public OneCODataHttpClient(IHttpClientFactory httpClientFactory, OneCODataConfig config,
 20420        ILogger<OneCODataHttpClient>? logger = null)
 21    {
 20422        _httpClientFactory = httpClientFactory;
 20423        _logger = logger;
 20424        _baseUrl = config.BaseUrl.TrimEnd('/');
 20425        var authString = $"{config.User}:{config.Password}";
 20426        _base64Auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString));
 20427    }
 28
 29    private HttpClient CreateClient()
 30    {
 67431        var client = _httpClientFactory.CreateClient("OneCOData");
 67432        client.Timeout = DefaultTimeout;
 67433        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", _base64Auth);
 67434        return client;
 35    }
 36
 37    /// <summary>
 38    /// Загружает все записи с автоматической пагинацией.
 39    /// </summary>
 40    public async Task<List<T>> GetListAsync<T>(
 41        string endpoint,
 42        Dictionary<string, string>? queryParams = null,
 43        CancellationToken ct = default)
 44    {
 11445        var result = new List<T>();
 11446        int skip = 0;
 47        const int MaxIterations = 500; // не более 500 000 записей; защита от зависания
 48
 23049        for (int iteration = 0; iteration < MaxIterations; iteration++)
 50        {
 11551            var query = BuildQuery(queryParams, skip, endpoint);
 11552            var url = $"{_baseUrl}/{endpoint}?{query}";
 11553            var client = CreateClient();
 24254            var response = await ExecuteWithRetryAsync(() => client.GetAsync(url, ct), ct);
 11255            var json = await response.Content.ReadAsStringAsync(ct);
 11256            EnsureSuccess(response, json, "GET", endpoint);
 57
 11058            var page = JsonConvert.DeserializeObject<ODataListResponse<T>>(json);
 11059            var batch = page?.Value ?? new List<T>();
 11060            result.AddRange(batch);
 61
 11062            if (batch.Count < PageSize)
 63                break;
 64
 165            skip += batch.Count;
 66
 167            if (iteration == MaxIterations - 1)
 068                throw new InvalidOperationException(
 069                    $"[OData] GetListAsync<{typeof(T).Name}> для {endpoint}: превышено максимальное число итераций ({Max
 170        }
 71
 10972        return result;
 10973    }
 74
 75    /// <summary>
 76    /// GET одного документа по GUID — для получения номера после проведения.
 77    /// </summary>
 78    public async Task<T?> GetByGuidAsync<T>(string endpoint, Guid refKey, string select = "Ref_Key,Number,Date,Posted") 
 79    {
 5680        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json&$select={Uri.EscapeDataString(select)}";
 5681        var client = CreateClient();
 11282        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 5683        var body = await response.Content.ReadAsStringAsync();
 5684        if (!response.IsSuccessStatusCode)
 85        {
 186            LogSwallowed(response, body, "GET", endpoint);
 187            return null;
 88        }
 5589        return JsonConvert.DeserializeObject<T>(body);
 5690    }
 91
 92    /// <summary>
 93    /// Загружает ОДНУ страницу (без автопагинации). Используется для прямого $filter-поиска.
 94    /// </summary>
 95    public async Task<List<T>> GetSinglePageAsync<T>(
 96        string endpoint,
 97        int top = 100,
 98        Dictionary<string, string>? queryParams = null)
 99    {
 113100        var parts = new List<string> { "$format=json", $"$top={top}" };
 113101        if (queryParams != null)
 102        {
 658103            foreach (var kv in queryParams)
 104            {
 216105                if (kv.Key == "$top" || kv.Key == "$skip") continue;
 216106                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 107            }
 108        }
 109
 113110        var url = $"{_baseUrl}/{endpoint}?{string.Join("&", parts)}";
 113111        var client = CreateClient();
 229112        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 113113        var json = await response.Content.ReadAsStringAsync();
 113114        EnsureSuccess(response, json, "GET", endpoint);
 115
 112116        var page = JsonConvert.DeserializeObject<ODataListResponse<T>>(json);
 112117        return page?.Value ?? new List<T>();
 112118    }
 119
 120    /// <summary>
 121    /// Загружает страницу документов с поддержкой пагинации (top + skip).
 122    /// </summary>
 123    public async Task<List<T>> GetPageAsync<T>(
 124        string endpoint,
 125        int top = 100,
 126        int skip = 0,
 127        Dictionary<string, string>? queryParams = null)
 128    {
 265129        var parts = new List<string> { "$format=json", $"$top={top}", $"$skip={skip}" };
 265130        if (queryParams != null)
 131        {
 2112132            foreach (var kv in queryParams)
 133            {
 792134                if (kv.Key == "$top" || kv.Key == "$skip") continue;
 792135                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 136            }
 137        }
 138
 265139        var url = $"{_baseUrl}/{endpoint}?{string.Join("&", parts)}";
 265140        var client = CreateClient();
 650141        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 225142        var json = await response.Content.ReadAsStringAsync();
 225143        EnsureSuccess(response, json, "GET", endpoint);
 144
 224145        var page = JsonConvert.DeserializeObject<ODataListResponse<T>>(json);
 224146        return page?.Value ?? new List<T>();
 224147    }
 148
 149    /// <summary>
 150    /// Получает общее количество записей ($count).
 151    /// </summary>
 152    public async Task<int> GetCountAsync(string endpoint, string? filter = null)
 153    {
 1154        var url = $"{_baseUrl}/{endpoint}/$count?$format=json";
 1155        if (!string.IsNullOrWhiteSpace(filter))
 0156            url += $"&$filter={Uri.EscapeDataString(filter)}";
 157
 1158        var client = CreateClient();
 2159        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 1160        var body = await response.Content.ReadAsStringAsync();
 1161        if (!response.IsSuccessStatusCode)
 162        {
 1163            LogSwallowed(response, body, "GET", endpoint + "/$count");
 1164            return 0;
 165        }
 0166        return int.TryParse(body.Trim(), out var count) ? count : 0;
 1167    }
 168
 169    /// <summary>
 170    /// Возвращает сырой JSON документа по GUID (без $select — включая вложенные табличные части).
 171    /// </summary>
 172    public async Task<string> GetRawJsonAsync(string endpoint, Guid refKey)
 173    {
 43174        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json";
 43175        var client = CreateClient();
 86176        var response = await ExecuteWithRetryAsync(() => client.GetAsync(url));
 43177        var body = await response.Content.ReadAsStringAsync();
 43178        if (!response.IsSuccessStatusCode)
 179        {
 1180            LogSwallowed(response, body, "GET", endpoint);
 1181            return "{}";
 182        }
 42183        return body;
 43184    }
 185
 186    /// <summary>
 187    /// POST — создаёт документ или запись. Возвращает десериализованный ответ.
 188    /// </summary>
 189    public async Task<T> PostAsync<TReq, T>(string endpoint, TReq body)
 190    {
 22191        var url = $"{_baseUrl}/{endpoint}?$format=json";
 22192        var json = JsonConvert.SerializeObject(body);
 22193        using var content = new StringContent(json, Encoding.UTF8, "application/json");
 194
 22195        var response = await CreateClient().PostAsync(url, content);
 22196        var responseBody = await response.Content.ReadAsStringAsync();
 22197        EnsureSuccess(response, responseBody, "POST", endpoint);
 198
 22199        return JsonConvert.DeserializeObject<T>(responseBody)
 22200               ?? throw new InvalidOperationException($"Пустой ответ от OData POST {endpoint}");
 22201    }
 202
 203    /// <summary>
 204    /// Проводит документ через action-эндпоинт /Post. Бросает исключение при ошибке.
 205    /// </summary>
 206    public async Task PostDocumentAsync(string endpoint, Guid refKey)
 207    {
 18208        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')/Post?$format=json";
 18209        var body = JsonConvert.SerializeObject(new { PostingModeOperational = false });
 210
 18211        var response = await ExecuteWithRetryAsync(() =>
 18212        {
 18213            var content = new StringContent(body, Encoding.UTF8, "application/json");
 18214            return CreateClient().PostAsync(url, content);
 18215        });
 18216        var responseBody = await response.Content.ReadAsStringAsync();
 217
 18218        if (!response.IsSuccessStatusCode)
 219        {
 9220            var error = OneCODataError.From("POST", $"{endpoint}/Post", response.StatusCode, response.ReasonPhrase, resp
 9221            var detail = OneCODataErrorFormatter.Format(responseBody);
 9222            if (string.IsNullOrWhiteSpace(detail))
 1223                detail = TruncateError(responseBody);
 8224            else if (detail.Length > 500)
 2225                detail = detail[..500] + "…";
 226            // Сообщение исключения уходит пользователю в алерт транзакции (PostingError), поэтому сразу
 227            // собираем его читаемым — без имени эндпоинта и голой фразы 1С «Не удалось провести "…"!».
 9228            throw new OneCDocumentPostingException(
 9229                OneCDocumentPostingMessage.Build(endpoint, detail), endpoint, detail, error);
 230        }
 9231    }
 232
 233    /// <summary>
 234    /// PATCH — обновляет поля документа. Возвращает обновлённый документ.
 235    /// </summary>
 236    public async Task<T?> PatchAsync<T>(string endpoint, Guid refKey, object patchBody) where T : class
 237    {
 21238        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json";
 21239        var json = JsonConvert.SerializeObject(patchBody);
 240
 21241        var response = await ExecuteWithRetryAsync(() =>
 21242        {
 21243            var content = new StringContent(json, Encoding.UTF8, "application/json");
 21244            var request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content };
 21245            return CreateClient().SendAsync(request);
 21246        });
 21247        var body = await response.Content.ReadAsStringAsync();
 248
 21249        if (!response.IsSuccessStatusCode)
 1250            throw new OneCODataException(
 1251                $"OData PATCH {endpoint}({refKey}) вернул {(int)response.StatusCode}: {TruncateError(body)}",
 1252                OneCODataError.From("PATCH", endpoint, response.StatusCode, response.ReasonPhrase, body));
 253
 20254        if (string.IsNullOrWhiteSpace(body) || response.StatusCode == System.Net.HttpStatusCode.NoContent)
 20255            return null;
 256
 0257        return JsonConvert.DeserializeObject<T>(body);
 20258    }
 259
 260    /// <summary>
 261    /// Проводит или отменяет проведение документа через PATCH Posted (не через /Post): ошибка приходит в том же
 262    /// формате <see cref="OneCODataException"/>, что и у остальных методов.
 263    /// </summary>
 264    public Task SetPostedAsync(string endpoint, Guid refKey, bool posted)
 0265        => PatchAsync<object>(endpoint, refKey, new { Posted = posted });
 266
 267    /// <summary>PUT — заменяет объект OData целиком, включая табличные части.</summary>
 268    public async Task PutAsync(string endpoint, Guid refKey, object body)
 269    {
 20270        var url = $"{_baseUrl}/{endpoint}(guid'{refKey}')?$format=json";
 20271        var json = JsonConvert.SerializeObject(body);
 20272        var response = await ExecuteWithRetryAsync(() =>
 20273        {
 20274            var content = new StringContent(json, Encoding.UTF8, "application/json");
 20275            return CreateClient().PutAsync(url, content);
 20276        });
 20277        var responseBody = await response.Content.ReadAsStringAsync();
 20278        EnsureSuccess(response, responseBody, "PUT", endpoint);
 18279    }
 280
 281    private static string BuildQuery(Dictionary<string, string>? queryParams, int skip, string endpoint)
 282    {
 115283        var parts = new List<string> { "$format=json", $"$top={PageSize}", $"$skip={skip}" };
 115284        var hasOrderBy = false;
 115285        if (queryParams != null)
 286        {
 474287            foreach (var kv in queryParams)
 288            {
 152289                if (kv.Key == "$top" || kv.Key == "$skip") continue;
 196290                if (kv.Key == "$orderby") hasOrderBy = true;
 152291                parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}");
 292            }
 293        }
 294
 295        // 1С Fresh не гарантирует стабильный порядок строк между независимыми $skip-запросами без
 296        // явного $orderby: при выборках, не помещающихся в одну страницу (> PageSize), часть записей
 297        // (напр. "JAPANPARTS" среди >1000 значений производителя) может выпасть между страницами и
 298        // не попасть в результат GetListAsync — что порождало дубли номенклатуры/производителей.
 299        // Регистры и их виртуальные таблицы поля Ref_Key не имеют: там такой $orderby даёт
 300        // "HTTP 400 Bad request: Сегмент пути Ref_Key не найден!".
 115301        if (!hasOrderBy && HasRefKey(endpoint))
 41302            parts.Add("$orderby=Ref_Key");
 303
 115304        return string.Join("&", parts);
 305    }
 306
 307    /// <summary>
 308    /// Есть ли у сущности OData поле Ref_Key. Справочники, документы, их табличные части,
 309    /// планы видов характеристик и перечисления — да; регистры и их виртуальные таблицы — нет.
 310    /// </summary>
 311    private static bool HasRefKey(string endpoint)
 312    {
 71313        var name = endpoint.TrimStart('/');
 71314        return !name.StartsWith("AccumulationRegister_", StringComparison.OrdinalIgnoreCase)
 71315               && !name.StartsWith("InformationRegister_", StringComparison.OrdinalIgnoreCase)
 71316               && !name.StartsWith("AccountingRegister_", StringComparison.OrdinalIgnoreCase)
 71317               && !name.StartsWith("CalculationRegister_", StringComparison.OrdinalIgnoreCase);
 318    }
 319
 320    private static void EnsureSuccess(HttpResponseMessage response, string body, string method, string endpoint)
 321    {
 492322        if (response.IsSuccessStatusCode)
 486323            return;
 324
 6325        var detail = OneCODataErrorFormatter.Format(body);
 6326        if (string.IsNullOrWhiteSpace(detail))
 1327            detail = TruncateError(body);
 328
 329        // Формат текста ("1С OData HTTP 500 …") сохраняем: по нему ветвится код вызывающей стороны.
 6330        throw new OneCODataException(
 6331            $"1С OData HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {detail}",
 6332            OneCODataError.From(method, endpoint, response.StatusCode, response.ReasonPhrase, body));
 333    }
 334
 335    /// <summary>
 336    /// Методы, которые на не-2xx возвращают null/0/"{}" (поведение вызывающих не меняем), раньше теряли причину
 337    /// без следа. Пишем её полностью, но как Information: «не найдено» здесь штатная ситуация.
 338    /// </summary>
 339    private void LogSwallowed(HttpResponseMessage response, string body, string method, string endpoint)
 340    {
 3341        if (_logger is null)
 3342            return;
 343
 0344        var error = OneCODataError.From(method, endpoint, response.StatusCode, response.ReasonPhrase, body);
 0345        _logger.LogInformation("[OData] Ответ 1С без исключения: {Error}", error.ToDetailText());
 0346    }
 347
 348    private static async Task<HttpResponseMessage> ExecuteWithRetryAsync(
 349        Func<Task<HttpResponseMessage>> action,
 350        CancellationToken ct = default)
 351    {
 652352        int[] retryDelays = { 2000, 4000, 8000 };
 652353        HttpResponseMessage? lastResponse = null;
 354
 1574355        for (int attempt = 0; attempt <= retryDelays.Length; attempt++)
 356        {
 357            try
 358            {
 787359                lastResponse = await action();
 360
 615361                if (ShouldRetry(lastResponse.StatusCode) && attempt < retryDelays.Length)
 362                {
 6363                    await Task.Delay(retryDelays[attempt], ct);
 6364                    continue;
 365                }
 366
 609367                return lastResponse;
 368            }
 172369            catch (HttpRequestException) when (attempt < retryDelays.Length)
 370            {
 129371                await Task.Delay(retryDelays[attempt], ct);
 129372            }
 0373            catch (TaskCanceledException ex) when (attempt < retryDelays.Length && !ex.CancellationToken.IsCancellationR
 374            {
 0375                await Task.Delay(retryDelays[attempt], ct);
 376            }
 377        }
 378
 0379        return lastResponse!;
 609380    }
 381
 382    private static bool ShouldRetry(HttpStatusCode code)
 615383        => code is HttpStatusCode.InternalServerError
 615384            or HttpStatusCode.BadGateway
 615385            or HttpStatusCode.ServiceUnavailable
 615386            or HttpStatusCode.GatewayTimeout;
 387
 388    private static string TruncateError(string? text, int maxLen = 500)
 389    {
 5390        if (string.IsNullOrEmpty(text)) return "(пустое тело)";
 1391        return text.Length <= maxLen ? text : text[..maxLen] + "…";
 392    }
 393}