< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Middleware.KestrunRequestCultureMiddleware
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Middleware/KestrunRequestCultureMiddleware.cs
Tag: Kestrun/Kestrun@ca54e35c77799b76774b3805b6f075cdbc0c5fbe
Line coverage
86%
Covered lines: 112
Uncovered lines: 18
Coverable lines: 130
Total lines: 358
Line coverage: 86.1%
Branch coverage
73%
Covered branches: 59
Total branches: 80
Branch coverage: 73.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 01/24/2026 - 19:35:59 Line coverage: 86.1% (112/130) Branch coverage: 73.7% (59/80) Total lines: 358 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51 01/24/2026 - 19:35:59 Line coverage: 86.1% (112/130) Branch coverage: 73.7% (59/80) Total lines: 358 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)50%66100%
InvokeAsync()75%1212100%
ApplyCulture(...)100%22100%
TryResolveCulture(...)25%8437.5%
ResolveRequestedCulture(...)100%1212100%
TryGetQueryCulture(...)50%4471.42%
TryGetCookieCulture(...)50%5460%
TryGetAcceptLanguageCulture(...)75%4475%
TrySelectBestAcceptLanguageCandidate(...)87.5%8884.61%
TryParseAcceptLanguageToken(...)80%101087.5%
TryParseQualityParameter(...)75%88100%
NormalizeCultureName(...)66.66%7675%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Middleware/KestrunRequestCultureMiddleware.cs

#LineLine coverage
 1using System.Globalization;
 2using Kestrun.Localization;
 3using Kestrun.Logging;
 4using Serilog.Events;
 5
 6namespace Kestrun.Middleware;
 7
 8/// <summary>
 9/// Middleware that resolves the request culture once and exposes localized strings.
 10/// </summary>
 811public sealed class KestrunRequestCultureMiddleware(
 812    RequestDelegate next,
 813    KestrunLocalizationStore store,
 814    KestrunLocalizationOptions options)
 15{
 16    private const string CultureItemKey = "KrCulture";
 17    private const string StringsItemKey = "KrStrings";
 18    private const string LocalizerItemKey = "KrLocalizer";
 19
 820    private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next));
 821    private readonly KestrunLocalizationStore _store = store ?? throw new ArgumentNullException(nameof(store));
 822    private readonly KestrunLocalizationOptions _options = options ?? throw new ArgumentNullException(nameof(options));
 23
 24    /// <summary>
 25    /// Gets the logger instance.
 26    /// </summary>
 827    private readonly Serilog.ILogger _logger = store.Logger;
 28
 29    /// <summary>
 30    /// Invokes the middleware for the specified request context.
 31    /// </summary>
 32    /// <param name="context">The HTTP context.</param>
 33    /// <returns>A task that represents the completion of request processing.</returns>
 34    public async Task InvokeAsync(HttpContext context)
 35    {
 836        ArgumentNullException.ThrowIfNull(context);
 37
 838        var requestedCulture = NormalizeCultureName(ResolveRequestedCulture(context));
 39
 40        // Resolve which resource culture to use for string table lookup (may be a fallback
 41        // such as using 'it-IT' resources for a requested 'it-CH'). Keep the original
 42        // requested culture so formatting (dates/currency) uses the exact requested culture.
 43        // Obtain a strings table that performs per-key fallback across candidate cultures
 44        // based on the original requested culture. This ensures that missing keys in a
 45        // specific culture (e.g., fr-CA) can fall back to a related resource (e.g., fr-FR)
 46        // while preserving the requested culture for formatting.
 847        var strings = _store.GetStringsForCulture(requestedCulture);
 48
 49        // Resolve which resource culture was chosen for logging/diagnostics; this may be
 50        // different from the requested culture (it's the resolved culture folder).
 851        var resourceCulture = _store.ResolveCulture(requestedCulture);
 52
 53        // Expose the request culture (preferred) to the pipeline; if none was requested,
 54        // fall back to the resolved resource culture or the configured default.
 855        var contextCulture = !string.IsNullOrWhiteSpace(requestedCulture)
 856            ? requestedCulture
 857            : (!string.IsNullOrWhiteSpace(resourceCulture) ? resourceCulture : _options.DefaultCulture);
 58
 859        context.Items[CultureItemKey] = contextCulture;
 860        context.Items[StringsItemKey] = strings;
 861        context.Items[LocalizerItemKey] = strings;
 62
 63        // Preserve the original culture to restore after the request is complete.
 864        var originalCulture = CultureInfo.CurrentCulture;
 865        var originalUICulture = CultureInfo.CurrentUICulture;
 866        var appliedCulture = false;
 67
 68        // Attempt to set the current thread culture to the requested culture for formatting.
 869        var targetCulture = TryResolveCulture(contextCulture);
 70
 71        // Determine if we should apply the culture change.
 872        var shouldApplyCulture = targetCulture is not null
 873        && (!string.Equals(originalCulture.Name, targetCulture.Name, StringComparison.OrdinalIgnoreCase)
 874        || !string.Equals(originalUICulture.Name, targetCulture.Name, StringComparison.OrdinalIgnoreCase));
 75
 76        try
 77        {
 78            // Set the current thread culture for formatting purposes.
 879            if (targetCulture is not null && shouldApplyCulture)
 80            {
 881                appliedCulture = ApplyCulture(context, targetCulture,
 882                    "Applied request culture '{Culture}' for {Method} {Path}.");
 83            }
 884            await _next(context);
 885        }
 86        finally
 87        {
 88            // Restore the original culture if it was changed.
 889            if (appliedCulture)
 90            {
 891                _ = ApplyCulture(context, originalCulture,
 892                    "Restored original culture '{Culture}' for {Method} {Path}.");
 93            }
 94        }
 895    }
 96
 97    /// <summary>
 98    /// Applies the specified culture to the current thread.
 99    /// </summary>
 100    /// <param name="context">The current HTTP context.</param>
 101    /// <param name="targetCulture">The culture to apply to the current thread.</param>
 102    /// <param name="messageTemplate">The message template for logging.</param>
 103    /// <returns>True if the culture was applied; otherwise, false.</returns>
 104    private bool ApplyCulture(HttpContext context, CultureInfo targetCulture, string messageTemplate)
 105    {
 16106        CultureInfo.CurrentCulture = targetCulture;
 16107        CultureInfo.CurrentUICulture = targetCulture;
 108
 16109        if (_logger.IsEnabled(LogEventLevel.Debug))
 110        {
 16111            _logger.DebugSanitized(messageTemplate,
 16112                targetCulture.Name,
 16113                context.Request.Method,
 16114                context.Request.Path);
 115        }
 16116        return true;
 117    }
 118
 119    /// <summary>
 120    /// Attempts to resolve a CultureInfo object from the specified culture name.
 121    /// </summary>
 122    /// <param name="culture">The culture name to resolve.</param>
 123    /// <returns>The resolved CultureInfo, or null if not found.</returns>
 124    private CultureInfo? TryResolveCulture(string? culture)
 125    {
 8126        if (string.IsNullOrWhiteSpace(culture))
 127        {
 0128            return null;
 129        }
 130
 131        try
 132        {
 8133            return CultureInfo.GetCultureInfo(culture);
 134        }
 0135        catch (CultureNotFoundException)
 136        {
 0137            if (_logger.IsEnabled(LogEventLevel.Debug))
 138            {
 0139                _logger.DebugSanitized("Invalid culture '{Culture}' requested.", culture);
 140            }
 0141            return null;
 142        }
 8143    }
 144
 145    /// <summary>
 146    /// Resolves the requested culture from the HTTP context based on enabled sources.
 147    /// </summary>
 148    /// <param name="context">The HTTP context.</param>
 149    /// <returns>The resolved culture name, or null if none found.</returns>
 150    private string? ResolveRequestedCulture(HttpContext context)
 151    {
 8152        return _options.EnableQuery && TryGetQueryCulture(context.Request.Query, out var queryCulture)
 8153            ? queryCulture
 8154            : _options.EnableCookie && TryGetCookieCulture(context.Request.Cookies, out var cookieCulture)
 8155                ? cookieCulture
 8156                : _options.EnableAcceptLanguage && TryGetAcceptLanguageCulture(context.Request.Headers, out var headerCu
 8157                    ? headerCulture
 8158                    : null;
 159    }
 160
 161    private bool TryGetQueryCulture(IQueryCollection query, out string? culture)
 162    {
 2163        if (query.TryGetValue(_options.QueryKey, out var values))
 164        {
 2165            var candidate = values.ToString();
 2166            if (!string.IsNullOrWhiteSpace(candidate))
 167            {
 2168                culture = candidate;
 2169                return true;
 170            }
 171        }
 172
 0173        culture = null;
 0174        return false;
 175    }
 176
 177    private bool TryGetCookieCulture(IRequestCookieCollection cookies, out string? culture)
 178    {
 1179        if (cookies.TryGetValue(_options.CookieName, out var value) && !string.IsNullOrWhiteSpace(value))
 180        {
 1181            culture = value;
 1182            return true;
 183        }
 184
 0185        culture = null;
 0186        return false;
 187    }
 188
 189    /// <summary>
 190    /// Tries to resolve the culture from the Accept-Language header.
 191    /// </summary>
 192    /// <param name="headers">The request headers.</param>
 193    /// <param name="culture">The resolved culture, if any.</param>
 194    /// <returns>True if a culture was resolved; otherwise, false.</returns>
 195    private static bool TryGetAcceptLanguageCulture(IHeaderDictionary headers, out string? culture)
 196    {
 4197        if (!headers.TryGetValue("Accept-Language", out var values))
 198        {
 1199            culture = null;
 1200            return false;
 201        }
 202
 3203        var header = values.ToString();
 3204        if (string.IsNullOrWhiteSpace(header))
 205        {
 0206            culture = null;
 0207            return false;
 208        }
 209
 3210        return TrySelectBestAcceptLanguageCandidate(header, out culture);
 211    }
 212
 213    /// <summary>
 214    /// Selects the best Accept-Language candidate from a raw header value.
 215    /// </summary>
 216    /// <param name="header">The raw Accept-Language header string.</param>
 217    /// <param name="culture">The best candidate culture (language range), if any.</param>
 218    /// <returns>True if a candidate was selected; otherwise, false.</returns>
 219    private static bool TrySelectBestAcceptLanguageCandidate(string header, out string? culture)
 220    {
 221        // Select the highest-quality culture token as the requested culture (do not resolve here).
 222        // Example: "en-US;q=0.1, fr-FR;q=0.9" should prefer "fr-FR".
 3223        string? bestCandidate = null;
 3224        var bestQuality = -1.0;
 225
 3226        var tokens = header.Split(',', StringSplitOptions.RemoveEmptyEntries);
 18227        for (var i = 0; i < tokens.Length; i++)
 228        {
 6229            if (!TryParseAcceptLanguageToken(tokens[i], out var candidate, out var quality))
 230            {
 231                continue;
 232            }
 233
 234            // For ties, keep the first token in header order.
 4235            if (quality > bestQuality)
 236            {
 4237                bestQuality = quality;
 4238                bestCandidate = candidate;
 239            }
 240        }
 241
 3242        if (!string.IsNullOrWhiteSpace(bestCandidate))
 243        {
 3244            culture = bestCandidate;
 3245            return true;
 246        }
 247
 0248        culture = null;
 0249        return false;
 250    }
 251
 252    /// <summary>
 253    /// Parses a single Accept-Language token (language-range plus optional parameters).
 254    /// </summary>
 255    /// <param name="token">A token from the Accept-Language header (e.g. "fr-FR;q=0.9").</param>
 256    /// <param name="candidate">The parsed language-range candidate.</param>
 257    /// <param name="quality">The parsed q-value (defaults to 1.0 if not specified).</param>
 258    /// <returns>True if the token produced a usable candidate; otherwise, false.</returns>
 259    private static bool TryParseAcceptLanguageToken(string token, out string candidate, out double quality)
 260    {
 6261        candidate = string.Empty;
 6262        quality = 1.0;
 263
 6264        var segment = token.Trim();
 6265        if (segment.Length == 0)
 266        {
 0267            return false;
 268        }
 269
 6270        var parts = segment.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 6271        if (parts.Length == 0)
 272        {
 0273            return false;
 274        }
 275
 6276        var parsedCandidate = parts[0];
 6277        if (string.IsNullOrWhiteSpace(parsedCandidate) || parsedCandidate == "*")
 278        {
 1279            return false;
 280        }
 281
 5282        if (!TryParseQualityParameter(parts, out var parsedQuality))
 283        {
 1284            return false;
 285        }
 286
 4287        candidate = parsedCandidate;
 4288        quality = parsedQuality;
 4289        return true;
 290    }
 291
 292    /// <summary>
 293    /// Parses the q-value parameter from an Accept-Language token's parameters.
 294    /// </summary>
 295    /// <param name="parts">Token parts split by ';' where index 0 is the language-range.</param>
 296    /// <param name="quality">The parsed q-value, defaulting to 1.0.</param>
 297    /// <returns>True if the q-value is within [0,1]; otherwise, false.</returns>
 298    private static bool TryParseQualityParameter(string[] parts, out double quality)
 299    {
 5300        quality = 1.0;
 301
 10302        for (var p = 1; p < parts.Length; p++)
 303        {
 4304            var parameter = parts[p];
 4305            if (!parameter.StartsWith("q=", StringComparison.OrdinalIgnoreCase))
 306            {
 307                continue;
 308            }
 309
 4310            if (double.TryParse(
 4311                    parameter.AsSpan(2),
 4312                    NumberStyles.AllowDecimalPoint,
 4313                    CultureInfo.InvariantCulture,
 4314                    out var parsedQuality))
 315            {
 4316                quality = parsedQuality;
 317            }
 318
 4319            break;
 320        }
 321
 5322        return quality is >= 0.0 and <= 1.0;
 323    }
 324
 325    /// <summary>
 326    /// Normalizes and validates a culture name.
 327    /// </summary>
 328    /// <param name="culture">The culture name to normalize.</param>
 329    /// <returns>The normalized culture name, or null if invalid.</returns>
 330    private static string? NormalizeCultureName(string? culture)
 331    {
 8332        if (string.IsNullOrWhiteSpace(culture))
 333        {
 2334            return null;
 335        }
 336
 6337        var candidate = culture.Trim().Replace('_', '-');
 6338        if (string.IsNullOrWhiteSpace(candidate))
 339        {
 0340            return null;
 341        }
 342
 343        try
 344        {
 6345            var normalized = CultureInfo.GetCultureInfo(candidate).Name;
 346
 347            // Treat inputs that normalize by truncation or otherwise changing the tag (beyond casing)
 348            // as invalid. Example: 'not-a-culture' normalizing to 'not'.
 6349            return string.Equals(normalized, candidate, StringComparison.OrdinalIgnoreCase)
 6350                ? normalized
 6351                : null;
 352        }
 0353        catch (CultureNotFoundException)
 354        {
 0355            return null;
 356        }
 6357    }
 358}