< Summary

Information
Class: OzonOrdersWeb.Observability.InvalidModelStateLoggingFilter
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/InvalidModelStateLoggingFilter.cs
Line coverage
85%
Covered lines: 119
Uncovered lines: 21
Coverable lines: 140
Total lines: 320
Line coverage: 85%
Branch coverage
71%
Covered branches: 118
Total branches: 166
Branch coverage: 71%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
OnActionExecuting(...)100%44100%
OnActionExecuted(...)66.66%7671.42%
IsServiceOrApiEndpoint(...)50%773062.5%
PruneTrivialEmptyBooleanErrors(...)100%1414100%
.cctor()100%11100%
PruneEmptyExtraExpensesErrors(...)93.18%454492.3%
CleanOrders(...)80%1010100%
TryParsePositiveDecimal(...)25%6450%
TryParsePositiveInt(...)75%44100%
IsBooleanTarget(...)58.82%1023461.11%
Log(...)50%161696.15%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/InvalidModelStateLoggingFilter.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Text.RegularExpressions;
 3using Microsoft.AspNetCore.Http;
 4using Microsoft.AspNetCore.Mvc;
 5using Microsoft.AspNetCore.Mvc.Controllers;
 6using Microsoft.AspNetCore.Mvc.Filters;
 7using OzonDomains.Models;
 8using OzonOrdersWeb.Authorization;
 9
 10namespace OzonOrdersWeb.Observability;
 11
 912public sealed class InvalidModelStateLoggingFilter(ILogger<InvalidModelStateLoggingFilter> logger) : IActionFilter
 13{
 14    private const string LoggedItemKey = "__studio2_modelstate_logged";
 15
 16    public void OnActionExecuting(ActionExecutingContext context)
 17    {
 918        if (IsServiceOrApiEndpoint(context))
 19        {
 120            return;
 21        }
 22
 823        PruneTrivialEmptyBooleanErrors(context.ModelState, context.ActionArguments, context.ActionDescriptor.Parameters)
 824        PruneEmptyExtraExpensesErrors(context.ModelState, context.ActionArguments);
 25
 826        if (context.ModelState.IsValid)
 27        {
 328            return;
 29        }
 30
 531        Log(context.HttpContext, context.RouteData, context.ModelState);
 532    }
 33
 34    public void OnActionExecuted(ActionExecutedContext context)
 35    {
 136        if (IsServiceOrApiEndpoint(context))
 37        {
 038            return;
 39        }
 40
 141        PruneTrivialEmptyBooleanErrors(context.ModelState, null, context.ActionDescriptor.Parameters);
 142        PruneEmptyExtraExpensesErrors(context.ModelState, null);
 43
 144        if (!context.ModelState.IsValid && !context.HttpContext.Items.ContainsKey(LoggedItemKey))
 45        {
 046            Log(context.HttpContext, context.RouteData, context.ModelState);
 47        }
 148    }
 49
 50    private static bool IsServiceOrApiEndpoint(ActionContext context)
 51    {
 1052        var endpoint = context.HttpContext.GetEndpoint();
 1053        if (endpoint?.Metadata.GetMetadata<ServiceEndpointAttribute>() is not null
 1054            || endpoint?.Metadata.GetMetadata<ApiControllerAttribute>() is not null)
 55        {
 056            return true;
 57        }
 58
 1059        if (context.ActionDescriptor?.EndpointMetadata is not null)
 60        {
 2161            foreach (var meta in context.ActionDescriptor.EndpointMetadata)
 62            {
 163                if (meta is ServiceEndpointAttribute or ApiControllerAttribute)
 64                {
 165                    return true;
 66                }
 67            }
 68        }
 69
 970        if (context.ActionDescriptor is ControllerActionDescriptor cad)
 71        {
 072            if (cad.MethodInfo.GetCustomAttributes(typeof(ServiceEndpointAttribute), inherit: true).Length > 0
 073                || cad.ControllerTypeInfo.GetCustomAttributes(typeof(ServiceEndpointAttribute), inherit: true).Length > 
 074                || cad.MethodInfo.GetCustomAttributes(typeof(ApiControllerAttribute), inherit: true).Length > 0
 075                || cad.ControllerTypeInfo.GetCustomAttributes(typeof(ApiControllerAttribute), inherit: true).Length > 0)
 76            {
 077                return true;
 78            }
 79        }
 80
 981        return false;
 182    }
 83
 84    internal static void PruneTrivialEmptyBooleanErrors(
 85        Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelState,
 86        IDictionary<string, object?>? actionArguments,
 87        IList<Microsoft.AspNetCore.Mvc.Abstractions.ParameterDescriptor>? parameters)
 88    {
 989        if (modelState.IsValid || modelState.Count == 0)
 90        {
 191            return;
 92        }
 93
 894        var keysToRemove = new List<string>();
 95
 5296        foreach (var (key, entry) in modelState)
 97        {
 1898            if (entry.Errors.Count == 0)
 99            {
 100                continue;
 101            }
 102
 103            // Ошибка вызвана пустой строкой в не-nullable поле
 14104            if (!string.IsNullOrEmpty(entry.AttemptedValue))
 105            {
 106                continue;
 107            }
 108
 12109            if (IsBooleanTarget(key, actionArguments, parameters))
 110            {
 3111                keysToRemove.Add(key);
 112            }
 113        }
 114
 22115        foreach (var key in keysToRemove)
 116        {
 3117            modelState.Remove(key);
 118        }
 8119    }
 120
 1121    private static readonly Regex ExtraExpensesPrefixRegex =
 1122        new(@"^(?<prefix>(?:.*?\.)?extraexpenses\[\d+\])(?:\.(?<field>.*))?$",
 1123            RegexOptions.IgnoreCase | RegexOptions.Compiled);
 124
 125    internal static void PruneEmptyExtraExpensesErrors(
 126        Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary modelState,
 127        IDictionary<string, object?>? actionArguments)
 128    {
 9129        if (modelState.Count == 0)
 130        {
 2131            return;
 132        }
 133
 7134        var prefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 44135        foreach (var key in modelState.Keys)
 136        {
 15137            var match = ExtraExpensesPrefixRegex.Match(key);
 15138            if (match.Success)
 139            {
 10140                prefixes.Add(match.Groups["prefix"].Value);
 141            }
 142        }
 143
 7144        if (prefixes.Count == 0)
 145        {
 4146            return;
 147        }
 148
 3149        var keysToRemove = new List<string>();
 150
 12151        foreach (var prefix in prefixes)
 152        {
 3153            string? attemptedAmount = null;
 3154            string? attemptedExpenseTypeId = null;
 155
 28156            foreach (var (key, entry) in modelState)
 157            {
 11158                var match = ExtraExpensesPrefixRegex.Match(key);
 11159                if (!match.Success || !match.Groups["prefix"].Value.Equals(prefix, StringComparison.OrdinalIgnoreCase))
 160                {
 161                    continue;
 162                }
 163
 10164                var field = match.Groups["field"].Value;
 10165                if (field.Equals("Amount", StringComparison.OrdinalIgnoreCase))
 166                {
 3167                    attemptedAmount = entry.AttemptedValue;
 168                }
 7169                else if (field.Equals("ExpenseTypeId", StringComparison.OrdinalIgnoreCase))
 170                {
 3171                    attemptedExpenseTypeId = entry.AttemptedValue;
 172                }
 173            }
 174
 3175            var hasValidAmount = TryParsePositiveDecimal(attemptedAmount, out _);
 3176            var hasValidExpenseTypeId = TryParsePositiveInt(attemptedExpenseTypeId, out _);
 177
 3178            if (!hasValidAmount && !hasValidExpenseTypeId)
 179            {
 22180                foreach (var key in modelState.Keys)
 181                {
 9182                    var match = ExtraExpensesPrefixRegex.Match(key);
 9183                    if (match.Success && match.Groups["prefix"].Value.Equals(prefix, StringComparison.OrdinalIgnoreCase)
 184                    {
 8185                        keysToRemove.Add(key);
 186                    }
 187                }
 188            }
 189        }
 190
 22191        foreach (var key in keysToRemove)
 192        {
 8193            modelState.Remove(key);
 194        }
 195
 3196        if (actionArguments != null)
 197        {
 12198            foreach (var arg in actionArguments.Values)
 199            {
 3200                if (arg == null) continue;
 201
 3202                if (arg is IEnumerable<Order> ordersList)
 203                {
 3204                    CleanOrders(ordersList);
 205                }
 206                else
 207                {
 0208                    var ordersProp = arg.GetType().GetProperty("Orders");
 0209                    if (ordersProp != null && ordersProp.GetValue(arg) is IEnumerable<Order> nestedOrders)
 210                    {
 0211                        CleanOrders(nestedOrders);
 212                    }
 213                }
 214            }
 215        }
 3216    }
 217
 218    private static void CleanOrders(IEnumerable<Order> orders)
 219    {
 12220        foreach (var order in orders)
 221        {
 3222            if (order.ExtraExpenses != null && order.ExtraExpenses.Count > 0)
 223            {
 2224                order.ExtraExpenses.RemoveAll(e => e == null || (e.ExpenseTypeId <= 0 && e.Amount <= 0));
 225            }
 226        }
 3227    }
 228
 229    private static bool TryParsePositiveDecimal(string? value, out decimal result)
 230    {
 3231        result = 0;
 6232        if (string.IsNullOrWhiteSpace(value)) return false;
 0233        var normalized = value.Trim().Replace(',', '.');
 0234        return decimal.TryParse(normalized, NumberStyles.Any, CultureInfo.InvariantCulture, out result) && result > 0;
 235    }
 236
 237    private static bool TryParsePositiveInt(string? value, out int result)
 238    {
 3239        result = 0;
 5240        if (string.IsNullOrWhiteSpace(value)) return false;
 1241        return int.TryParse(value.Trim(), out result) && result > 0;
 242    }
 243
 244    private static bool IsBooleanTarget(
 245        string key,
 246        IDictionary<string, object?>? actionArguments,
 247        IList<Microsoft.AspNetCore.Mvc.Abstractions.ParameterDescriptor>? parameters)
 248    {
 12249        var propName = key.Contains('.') ? key.Split('.').Last() : key;
 250
 12251        if (actionArguments != null)
 252        {
 37253            foreach (var arg in actionArguments.Values)
 254            {
 9255                if (arg == null) continue;
 9256                var prop = arg.GetType().GetProperty(propName, System.Reflection.BindingFlags.Public | System.Reflection
 9257                if (prop != null && (prop.PropertyType == typeof(bool) || prop.PropertyType == typeof(bool?)))
 258                {
 3259                    return true;
 260                }
 261            }
 262        }
 263
 9264        if (parameters != null)
 265        {
 18266            foreach (var param in parameters)
 267            {
 0268                if (param.ParameterType.IsClass && param.ParameterType != typeof(string))
 269                {
 0270                    var prop = param.ParameterType.GetProperty(propName, System.Reflection.BindingFlags.Public | System.
 0271                    if (prop != null && (prop.PropertyType == typeof(bool) || prop.PropertyType == typeof(bool?)))
 272                    {
 0273                        return true;
 274                    }
 275                }
 0276                else if (param.Name.Equals(propName, StringComparison.OrdinalIgnoreCase)
 0277                         && (param.ParameterType == typeof(bool) || param.ParameterType == typeof(bool?)))
 278                {
 0279                    return true;
 280                }
 281            }
 282        }
 283
 9284        return false;
 3285    }
 286
 287    private void Log(HttpContext httpContext, RouteData routeData, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDicti
 288    {
 5289        httpContext.Items[LoggedItemKey] = true;
 5290        var errors = modelState
 6291            .Where(pair => pair.Value is { Errors.Count: > 0 })
 5292            .ToDictionary(
 5293                pair => pair.Key,
 10294                pair => pair.Value!.Errors.Select(error =>
 15295                    string.IsNullOrWhiteSpace(error.ErrorMessage)
 15296                        ? error.Exception?.GetType().Name ?? "ValidationError"
 15297                        : error.ErrorMessage).ToArray());
 298
 10299        var summary = string.Join("; ", errors.Take(3).Select(e => $"{e.Key}: {string.Join(", ", e.Value)}"));
 5300        if (errors.Count > 3)
 301        {
 0302            summary += $" (+{errors.Count - 3} ещё)";
 303        }
 304
 5305        using var scope = logger.BeginScope(new Dictionary<string, object?>
 5306        {
 5307            ["EventKind"] = "FormValidationFailed",
 5308            ["EventCategory"] = "forms",
 5309            ["RequestPath"] = httpContext.Request.Path.Value,
 5310            ["HttpMethod"] = httpContext.Request.Method,
 5311            ["Controller"] = routeData.Values["controller"]?.ToString(),
 5312            ["Action"] = routeData.Values["action"]?.ToString(),
 5313            ["TraceId"] = httpContext.TraceIdentifier,
 5314            ["ValidationErrors"] = errors
 5315        });
 316
 5317        logger.LogWarning("Форма {Method} {Path} не прошла серверную валидацию: {ValidationSummary}",
 5318            httpContext.Request.Method, httpContext.Request.Path, summary);
 10319    }
 320}