| | | 1 | | using System.Globalization; |
| | | 2 | | using Kestrun.Localization; |
| | | 3 | | using Kestrun.Logging; |
| | | 4 | | using Serilog.Events; |
| | | 5 | | |
| | | 6 | | namespace Kestrun.Middleware; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// Middleware that resolves the request culture once and exposes localized strings. |
| | | 10 | | /// </summary> |
| | 8 | 11 | | public sealed class KestrunRequestCultureMiddleware( |
| | 8 | 12 | | RequestDelegate next, |
| | 8 | 13 | | KestrunLocalizationStore store, |
| | 8 | 14 | | KestrunLocalizationOptions options) |
| | | 15 | | { |
| | | 16 | | private const string CultureItemKey = "KrCulture"; |
| | | 17 | | private const string StringsItemKey = "KrStrings"; |
| | | 18 | | private const string LocalizerItemKey = "KrLocalizer"; |
| | | 19 | | |
| | 8 | 20 | | private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next)); |
| | 8 | 21 | | private readonly KestrunLocalizationStore _store = store ?? throw new ArgumentNullException(nameof(store)); |
| | 8 | 22 | | private readonly KestrunLocalizationOptions _options = options ?? throw new ArgumentNullException(nameof(options)); |
| | | 23 | | |
| | | 24 | | /// <summary> |
| | | 25 | | /// Gets the logger instance. |
| | | 26 | | /// </summary> |
| | 8 | 27 | | 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 | | { |
| | 8 | 36 | | ArgumentNullException.ThrowIfNull(context); |
| | | 37 | | |
| | 8 | 38 | | 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. |
| | 8 | 47 | | 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). |
| | 8 | 51 | | 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. |
| | 8 | 55 | | var contextCulture = !string.IsNullOrWhiteSpace(requestedCulture) |
| | 8 | 56 | | ? requestedCulture |
| | 8 | 57 | | : (!string.IsNullOrWhiteSpace(resourceCulture) ? resourceCulture : _options.DefaultCulture); |
| | | 58 | | |
| | 8 | 59 | | context.Items[CultureItemKey] = contextCulture; |
| | 8 | 60 | | context.Items[StringsItemKey] = strings; |
| | 8 | 61 | | context.Items[LocalizerItemKey] = strings; |
| | | 62 | | |
| | | 63 | | // Preserve the original culture to restore after the request is complete. |
| | 8 | 64 | | var originalCulture = CultureInfo.CurrentCulture; |
| | 8 | 65 | | var originalUICulture = CultureInfo.CurrentUICulture; |
| | 8 | 66 | | var appliedCulture = false; |
| | | 67 | | |
| | | 68 | | // Attempt to set the current thread culture to the requested culture for formatting. |
| | 8 | 69 | | var targetCulture = TryResolveCulture(contextCulture); |
| | | 70 | | |
| | | 71 | | // Determine if we should apply the culture change. |
| | 8 | 72 | | var shouldApplyCulture = targetCulture is not null |
| | 8 | 73 | | && (!string.Equals(originalCulture.Name, targetCulture.Name, StringComparison.OrdinalIgnoreCase) |
| | 8 | 74 | | || !string.Equals(originalUICulture.Name, targetCulture.Name, StringComparison.OrdinalIgnoreCase)); |
| | | 75 | | |
| | | 76 | | try |
| | | 77 | | { |
| | | 78 | | // Set the current thread culture for formatting purposes. |
| | 8 | 79 | | if (targetCulture is not null && shouldApplyCulture) |
| | | 80 | | { |
| | 8 | 81 | | appliedCulture = ApplyCulture(context, targetCulture, |
| | 8 | 82 | | "Applied request culture '{Culture}' for {Method} {Path}."); |
| | | 83 | | } |
| | 8 | 84 | | await _next(context); |
| | 8 | 85 | | } |
| | | 86 | | finally |
| | | 87 | | { |
| | | 88 | | // Restore the original culture if it was changed. |
| | 8 | 89 | | if (appliedCulture) |
| | | 90 | | { |
| | 8 | 91 | | _ = ApplyCulture(context, originalCulture, |
| | 8 | 92 | | "Restored original culture '{Culture}' for {Method} {Path}."); |
| | | 93 | | } |
| | | 94 | | } |
| | 8 | 95 | | } |
| | | 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 | | { |
| | 16 | 106 | | CultureInfo.CurrentCulture = targetCulture; |
| | 16 | 107 | | CultureInfo.CurrentUICulture = targetCulture; |
| | | 108 | | |
| | 16 | 109 | | if (_logger.IsEnabled(LogEventLevel.Debug)) |
| | | 110 | | { |
| | 16 | 111 | | _logger.DebugSanitized(messageTemplate, |
| | 16 | 112 | | targetCulture.Name, |
| | 16 | 113 | | context.Request.Method, |
| | 16 | 114 | | context.Request.Path); |
| | | 115 | | } |
| | 16 | 116 | | 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 | | { |
| | 8 | 126 | | if (string.IsNullOrWhiteSpace(culture)) |
| | | 127 | | { |
| | 0 | 128 | | return null; |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | try |
| | | 132 | | { |
| | 8 | 133 | | return CultureInfo.GetCultureInfo(culture); |
| | | 134 | | } |
| | 0 | 135 | | catch (CultureNotFoundException) |
| | | 136 | | { |
| | 0 | 137 | | if (_logger.IsEnabled(LogEventLevel.Debug)) |
| | | 138 | | { |
| | 0 | 139 | | _logger.DebugSanitized("Invalid culture '{Culture}' requested.", culture); |
| | | 140 | | } |
| | 0 | 141 | | return null; |
| | | 142 | | } |
| | 8 | 143 | | } |
| | | 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 | | { |
| | 8 | 152 | | return _options.EnableQuery && TryGetQueryCulture(context.Request.Query, out var queryCulture) |
| | 8 | 153 | | ? queryCulture |
| | 8 | 154 | | : _options.EnableCookie && TryGetCookieCulture(context.Request.Cookies, out var cookieCulture) |
| | 8 | 155 | | ? cookieCulture |
| | 8 | 156 | | : _options.EnableAcceptLanguage && TryGetAcceptLanguageCulture(context.Request.Headers, out var headerCu |
| | 8 | 157 | | ? headerCulture |
| | 8 | 158 | | : null; |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | private bool TryGetQueryCulture(IQueryCollection query, out string? culture) |
| | | 162 | | { |
| | 2 | 163 | | if (query.TryGetValue(_options.QueryKey, out var values)) |
| | | 164 | | { |
| | 2 | 165 | | var candidate = values.ToString(); |
| | 2 | 166 | | if (!string.IsNullOrWhiteSpace(candidate)) |
| | | 167 | | { |
| | 2 | 168 | | culture = candidate; |
| | 2 | 169 | | return true; |
| | | 170 | | } |
| | | 171 | | } |
| | | 172 | | |
| | 0 | 173 | | culture = null; |
| | 0 | 174 | | return false; |
| | | 175 | | } |
| | | 176 | | |
| | | 177 | | private bool TryGetCookieCulture(IRequestCookieCollection cookies, out string? culture) |
| | | 178 | | { |
| | 1 | 179 | | if (cookies.TryGetValue(_options.CookieName, out var value) && !string.IsNullOrWhiteSpace(value)) |
| | | 180 | | { |
| | 1 | 181 | | culture = value; |
| | 1 | 182 | | return true; |
| | | 183 | | } |
| | | 184 | | |
| | 0 | 185 | | culture = null; |
| | 0 | 186 | | 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 | | { |
| | 4 | 197 | | if (!headers.TryGetValue("Accept-Language", out var values)) |
| | | 198 | | { |
| | 1 | 199 | | culture = null; |
| | 1 | 200 | | return false; |
| | | 201 | | } |
| | | 202 | | |
| | 3 | 203 | | var header = values.ToString(); |
| | 3 | 204 | | if (string.IsNullOrWhiteSpace(header)) |
| | | 205 | | { |
| | 0 | 206 | | culture = null; |
| | 0 | 207 | | return false; |
| | | 208 | | } |
| | | 209 | | |
| | 3 | 210 | | 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". |
| | 3 | 223 | | string? bestCandidate = null; |
| | 3 | 224 | | var bestQuality = -1.0; |
| | | 225 | | |
| | 3 | 226 | | var tokens = header.Split(',', StringSplitOptions.RemoveEmptyEntries); |
| | 18 | 227 | | for (var i = 0; i < tokens.Length; i++) |
| | | 228 | | { |
| | 6 | 229 | | 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. |
| | 4 | 235 | | if (quality > bestQuality) |
| | | 236 | | { |
| | 4 | 237 | | bestQuality = quality; |
| | 4 | 238 | | bestCandidate = candidate; |
| | | 239 | | } |
| | | 240 | | } |
| | | 241 | | |
| | 3 | 242 | | if (!string.IsNullOrWhiteSpace(bestCandidate)) |
| | | 243 | | { |
| | 3 | 244 | | culture = bestCandidate; |
| | 3 | 245 | | return true; |
| | | 246 | | } |
| | | 247 | | |
| | 0 | 248 | | culture = null; |
| | 0 | 249 | | 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 | | { |
| | 6 | 261 | | candidate = string.Empty; |
| | 6 | 262 | | quality = 1.0; |
| | | 263 | | |
| | 6 | 264 | | var segment = token.Trim(); |
| | 6 | 265 | | if (segment.Length == 0) |
| | | 266 | | { |
| | 0 | 267 | | return false; |
| | | 268 | | } |
| | | 269 | | |
| | 6 | 270 | | var parts = segment.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
| | 6 | 271 | | if (parts.Length == 0) |
| | | 272 | | { |
| | 0 | 273 | | return false; |
| | | 274 | | } |
| | | 275 | | |
| | 6 | 276 | | var parsedCandidate = parts[0]; |
| | 6 | 277 | | if (string.IsNullOrWhiteSpace(parsedCandidate) || parsedCandidate == "*") |
| | | 278 | | { |
| | 1 | 279 | | return false; |
| | | 280 | | } |
| | | 281 | | |
| | 5 | 282 | | if (!TryParseQualityParameter(parts, out var parsedQuality)) |
| | | 283 | | { |
| | 1 | 284 | | return false; |
| | | 285 | | } |
| | | 286 | | |
| | 4 | 287 | | candidate = parsedCandidate; |
| | 4 | 288 | | quality = parsedQuality; |
| | 4 | 289 | | 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 | | { |
| | 5 | 300 | | quality = 1.0; |
| | | 301 | | |
| | 10 | 302 | | for (var p = 1; p < parts.Length; p++) |
| | | 303 | | { |
| | 4 | 304 | | var parameter = parts[p]; |
| | 4 | 305 | | if (!parameter.StartsWith("q=", StringComparison.OrdinalIgnoreCase)) |
| | | 306 | | { |
| | | 307 | | continue; |
| | | 308 | | } |
| | | 309 | | |
| | 4 | 310 | | if (double.TryParse( |
| | 4 | 311 | | parameter.AsSpan(2), |
| | 4 | 312 | | NumberStyles.AllowDecimalPoint, |
| | 4 | 313 | | CultureInfo.InvariantCulture, |
| | 4 | 314 | | out var parsedQuality)) |
| | | 315 | | { |
| | 4 | 316 | | quality = parsedQuality; |
| | | 317 | | } |
| | | 318 | | |
| | 4 | 319 | | break; |
| | | 320 | | } |
| | | 321 | | |
| | 5 | 322 | | 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 | | { |
| | 8 | 332 | | if (string.IsNullOrWhiteSpace(culture)) |
| | | 333 | | { |
| | 2 | 334 | | return null; |
| | | 335 | | } |
| | | 336 | | |
| | 6 | 337 | | var candidate = culture.Trim().Replace('_', '-'); |
| | 6 | 338 | | if (string.IsNullOrWhiteSpace(candidate)) |
| | | 339 | | { |
| | 0 | 340 | | return null; |
| | | 341 | | } |
| | | 342 | | |
| | | 343 | | try |
| | | 344 | | { |
| | 6 | 345 | | 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'. |
| | 6 | 349 | | return string.Equals(normalized, candidate, StringComparison.OrdinalIgnoreCase) |
| | 6 | 350 | | ? normalized |
| | 6 | 351 | | : null; |
| | | 352 | | } |
| | 0 | 353 | | catch (CultureNotFoundException) |
| | | 354 | | { |
| | 0 | 355 | | return null; |
| | | 356 | | } |
| | 6 | 357 | | } |
| | | 358 | | } |