< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.Controllers.OzonWebhookController
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OzonWebhookController.cs
Line coverage
100%
Covered lines: 43
Uncovered lines: 0
Coverable lines: 43
Total lines: 140
Line coverage: 100%
Branch coverage
62%
Covered branches: 5
Total branches: 8
Branch coverage: 62.5%
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%
Receive()50%22100%
ClientAborted(...)50%22100%
BadRequestFromClient(...)100%22100%
LogClientProblem(...)50%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Controllers/OzonWebhookController.cs

#LineLine coverage
 1using System.IO;
 2using System.Text;
 3using System.Text.Json;
 4using System.Text.Json.Serialization;
 5using System.Threading;
 6using System.Threading.Tasks;
 7using Microsoft.AspNetCore.Http;
 8using Microsoft.AspNetCore.Mvc;
 9using Microsoft.Extensions.Logging;
 10using OzonOrdersWeb.Middleware;
 11using Services.OzonWebhookServices;
 12
 13namespace OzonOrdersWeb.Areas.Studio2.Controllers
 14{
 15    /// <summary>
 16    /// Контроллер для приёма push-уведомлений от Ozon Seller API.
 17    ///
 18    /// Endpoint: POST /Studio2/OzonWebhook/Receive
 19    ///
 20    /// Особенности:
 21    /// — НЕТ атрибута [Authorize]: endpoint публичный, авторизация через IP-фильтр.
 22    /// — [ServiceFilter(typeof(OzonIpWhitelistFilter))]: разрешает запросы только
 23    ///   с IP-диапазонов Ozon (195.34.21.0/24, 185.73.192.0/22, 91.223.93.0/24)
 24    ///   и localhost (для локальной разработки).
 25    /// — EndpointAccessMiddleware пропускает этот путь (добавлен в ignorePatterns).
 26    /// — Content-Type ответа: application/json.
 27    ///
 28    /// Документация Ozon: https://docs.ozon.ru/api/seller/#tag/push-notifications
 29    /// </summary>
 30    [Area("Studio2")]
 31    [Route("[area]/[controller]/[action]")]
 32    [ServiceFilter(typeof(OzonIpWhitelistFilter))]
 33    public class OzonWebhookController : Controller
 34    {
 135        private static readonly JsonSerializerOptions ResponseSerializerOptions = new()
 136        {
 137            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
 138            PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
 139            WriteIndented = false
 140        };
 41
 42        private readonly IOzonWebhookService _webhookService;
 43        private readonly ILogger<OzonWebhookController> _logger;
 44
 545        public OzonWebhookController(
 546            IOzonWebhookService webhookService,
 547            ILogger<OzonWebhookController> logger)
 48        {
 549            _webhookService = webhookService;
 550            _logger = logger;
 551        }
 52
 53        /// <summary>
 54        /// Принимает push-уведомление от Ozon.
 55        ///
 56        /// POST /Studio2/OzonWebhook/Receive
 57        ///
 58        /// Ozon ожидает:
 59        /// — HTTP 200 + JSON-тело при успешной обработке.
 60        /// — HTTP 4xx или 5xx + JSON-тело с полем "error" при ошибке.
 61        ///
 62        /// Если ответ не придёт за 5 секунд или придёт несоответствующий формат,
 63        /// Ozon может приостановить доставку уведомлений.
 64        /// </summary>
 65        [HttpPost]
 66        public async Task<IActionResult> Receive(CancellationToken ct)
 67        {
 68            try
 69            {
 70                // Читаем тело запроса как строку (Ozon отправляет application/json)
 71                string rawJson;
 572                using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: false))
 73                {
 574                    rawJson = await reader.ReadToEndAsync(ct);
 175                }
 76
 177                var sourceIp = HttpContext.Connection.RemoteIpAddress?.ToString();
 78
 179                _logger.LogDebug(
 180                    "Ozon webhook: получен запрос от {Ip}, длина payload: {Length} байт",
 181                    sourceIp, rawJson.Length);
 82
 183                var (response, statusCode) = await _webhookService.ProcessAsync(rawJson, sourceIp, ct);
 84
 85                // Сериализуем с явным snake_case и устанавливаем Content-Type
 186                var jsonString = JsonSerializer.Serialize(response, response.GetType(), ResponseSerializerOptions);
 187                Response.StatusCode = statusCode;
 188                return Content(jsonString, "application/json");
 89            }
 190            catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested || ct.IsCancella
 91            {
 192                return ClientAborted("запрос был отменен клиентом/прокси");
 93            }
 94            // Ozon ждёт ответ 5 секунд и при таймауте рвёт соединение, не дослав тело.
 95            // Kestrel сигналит об этом BadHttpRequestException из ReadToEndAsync — это дефект
 96            // запроса, а не сбой приложения, поэтому не даём ему всплыть в 500 с полной
 97            // трассировкой в лог.
 398            catch (BadHttpRequestException ex)
 99            {
 3100                return BadRequestFromClient(ex);
 101            }
 5102        }
 103
 104        /// <summary>
 105        /// Ответ на отменённый клиентом запрос: информационное сообщение в лог вместо ошибки/предупреждения и
 106        /// 499 (Client Closed Request, соглашение nginx), чтобы не шуметь как 500.
 107        /// Ozon этот ответ уже не прочитает — соединения нет.
 108        /// </summary>
 109        private IActionResult ClientAborted(string reason)
 110        {
 3111            _logger.LogInformation(
 3112                "Ozon webhook: {Reason} (IP: {Ip}, path: {Path})",
 3113                reason,
 3114                HttpContext.Connection.RemoteIpAddress?.ToString(),
 3115                HttpContext.Request.Path);
 3116            return StatusCode(499, new { error = "request_canceled" });
 117        }
 118
 119        /// <summary>
 120        /// Ответ на некорректный запрос. Оборванное соединение (Ozon не дослал тело) отдаём
 121        /// как 499, остальные случаи — со статусом, который предложил Kestrel (400 на битый
 122        /// запрос, 413 на превышение лимита тела).
 123        /// </summary>
 124        private IActionResult BadRequestFromClient(BadHttpRequestException ex)
 125        {
 3126            if (HttpContext.RequestAborted.IsCancellationRequested)
 2127                return ClientAborted($"тело запроса не дочитано ({ex.Message.TrimEnd('.')})");
 128
 1129            LogClientProblem($"некорректный запрос ({ex.Message.TrimEnd('.')})");
 1130            return StatusCode(ex.StatusCode, new { error = "bad_request" });
 131        }
 132
 133        private void LogClientProblem(string reason) =>
 1134            _logger.LogWarning(
 1135                "Ozon webhook: {Reason} (IP: {Ip}, path: {Path})",
 1136                reason,
 1137                HttpContext.Connection.RemoteIpAddress?.ToString(),
 1138                HttpContext.Request.Path);
 139    }
 140}