diff --git a/GenHTTP.sln b/GenHTTP.sln index c7a84e8d..f933e45f 100644 --- a/GenHTTP.sln +++ b/GenHTTP.sln @@ -87,23 +87,25 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Testing", "Testing\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.Pages", "Modules\Pages\GenHTTP.Modules.Pages.csproj", "{4CDA31EB-A6C2-4634-9379-9306D3996B21}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Modules.OpenApi", "Modules\OpenApi\GenHTTP.Modules.OpenApi.csproj", "{A5149821-D510-4854-9DC9-D489323BC545}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.OpenApi", "Modules\OpenApi\GenHTTP.Modules.OpenApi.csproj", "{A5149821-D510-4854-9DC9-D489323BC545}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Modules.Websockets", "Modules\Websockets\GenHTTP.Modules.Websockets.csproj", "{9D3D3B40-691D-4EE1-B948-82525F28FBB2}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.Websockets", "Modules\Websockets\GenHTTP.Modules.Websockets.csproj", "{9D3D3B40-691D-4EE1-B948-82525F28FBB2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Modules.ServerSentEvents", "Modules\ServerSentEvents\GenHTTP.Modules.ServerSentEvents.csproj", "{69F3862A-0027-4312-A890-45549AF5D2B1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.ServerSentEvents", "Modules\ServerSentEvents\GenHTTP.Modules.ServerSentEvents.csproj", "{69F3862A-0027-4312-A890-45549AF5D2B1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Modules.Inspection", "Modules\Inspection\GenHTTP.Modules.Inspection.csproj", "{2FE9B758-187F-41B3-96BF-1C2BB006F809}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Modules.Inspection", "Modules\Inspection\GenHTTP.Modules.Inspection.csproj", "{2FE9B758-187F-41B3-96BF-1C2BB006F809}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Engine.Internal", "Engine\Internal\GenHTTP.Engine.Internal.csproj", "{4A492C9D-4338-4CCD-A227-F7829D032221}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Engine.Internal", "Engine\Internal\GenHTTP.Engine.Internal.csproj", "{4A492C9D-4338-4CCD-A227-F7829D032221}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Engine.Kestrel", "Engine\Kestrel\GenHTTP.Engine.Kestrel.csproj", "{4137673D-9218-4D42-924C-A36A6F412F5E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Engine.Kestrel", "Engine\Kestrel\GenHTTP.Engine.Kestrel.csproj", "{4137673D-9218-4D42-924C-A36A6F412F5E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Engine.Shared", "Engine\Shared\GenHTTP.Engine.Shared.csproj", "{7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Engine.Shared", "Engine\Shared\GenHTTP.Engine.Shared.csproj", "{7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Adapters", "Adapters", "{C3265C1A-E9A9-45FD-BD24-66DE9C7062F1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Adapters.AspNetCore", "Adapters\AspNetCore\GenHTTP.Adapters.AspNetCore.csproj", "{AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenHTTP.Adapters.AspNetCore", "Adapters\AspNetCore\GenHTTP.Adapters.AspNetCore.csproj", "{AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenHTTP.Modules.I18n", "Modules\GenHTTP.Modules.I18n\GenHTTP.Modules.I18n.csproj", "{E17F6CF0-295D-408C-9664-FE18C6E83433}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -255,6 +257,10 @@ Global {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78}.Release|Any CPU.Build.0 = Release|Any CPU + {E17F6CF0-295D-408C-9664-FE18C6E83433}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E17F6CF0-295D-408C-9664-FE18C6E83433}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E17F6CF0-295D-408C-9664-FE18C6E83433}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E17F6CF0-295D-408C-9664-FE18C6E83433}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -298,9 +304,10 @@ Global {4137673D-9218-4D42-924C-A36A6F412F5E} = {AFBFE61E-0C33-42F6-9370-9F5088EB8633} {7CEE048E-FA6A-4D8C-B6A6-EEEA0B048C54} = {AFBFE61E-0C33-42F6-9370-9F5088EB8633} {AD7904BC-27BE-4EB5-84BC-62FF32DCBB78} = {C3265C1A-E9A9-45FD-BD24-66DE9C7062F1} + {E17F6CF0-295D-408C-9664-FE18C6E83433} = {23B23225-275E-4F52-8B29-6F44C85B6ACE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - LessCompiler = 2603124e-1287-4d61-9540-6ac3efad4eb9 SolutionGuid = {9C67B3AF-0BF6-4E21-8C39-3F74CFCF9632} + LessCompiler = 2603124e-1287-4d61-9540-6ac3efad4eb9 EndGlobalSection EndGlobal diff --git a/Modules/GenHTTP.Modules.I18n/GenHTTP.Modules.I18n.csproj b/Modules/GenHTTP.Modules.I18n/GenHTTP.Modules.I18n.csproj new file mode 100644 index 00000000..fe46d604 --- /dev/null +++ b/Modules/GenHTTP.Modules.I18n/GenHTTP.Modules.I18n.csproj @@ -0,0 +1,47 @@ + + + + + net8.0;net9.0 + + 13.0 + enable + true + enable + + 9.3.0.0 + 9.3.0.0 + 9.3.0 + + Andreas Nägeli, Martin Maťátko + + + LICENSE + https://genhttp.org/ + + Allows to handle requests in a culture specific manner. + HTTP Webserver C# Module i18n + + true + true + snupkg + + true + CS1591,CS1587,CS1572,CS1573 + + icon.png + + + + + + + + + + + + + + + diff --git a/Modules/GenHTTP.Modules.I18n/Localization.cs b/Modules/GenHTTP.Modules.I18n/Localization.cs new file mode 100644 index 00000000..43ce3a19 --- /dev/null +++ b/Modules/GenHTTP.Modules.I18n/Localization.cs @@ -0,0 +1,19 @@ +using System.Globalization; +using GenHTTP.Modules.I18n.Provider; + +namespace GenHTTP.Modules.I18n; + +public static class Localization +{ + #region Builder + + /// + /// Creates a localization handler that parses and sets + /// based on defined rules. + /// + /// By default culture is read from request header + /// and is set to . + public static LocalizationConcernBuilder Create() => new(); + + #endregion +} diff --git a/Modules/GenHTTP.Modules.I18n/Parsers/CultureInfoParser.cs b/Modules/GenHTTP.Modules.I18n/Parsers/CultureInfoParser.cs new file mode 100644 index 00000000..9e5460ad --- /dev/null +++ b/Modules/GenHTTP.Modules.I18n/Parsers/CultureInfoParser.cs @@ -0,0 +1,139 @@ +using System.Buffers; +using System.Globalization; + +namespace GenHTTP.Modules.I18n.Parsers; + +public static class CultureInfoParser +{ + private static readonly Comparer<(string Language, double Quality)> QualityComparer = + Comparer<(string Language, double Quality)>.Create((a, b) => b.Quality.CompareTo(a.Quality)); + + /// + /// Parses the given language header (e.g. from Accept-Language) into an array of CultureInfo, + /// sorted by their quality values in descending order. If no valid languages are found, + /// returns an empty array. + /// + /// Specification: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language + /// + /// This implementation uses ArrayPool to minimize allocations. + /// + /// The language header string to parse. + /// An array of objects parsed from the input string. + public static CultureInfo[] ParseFromLanguage(string? language) + { + if (string.IsNullOrWhiteSpace(language)) + { + return []; + } + + var span = language.AsSpan().Trim(); + if (span.IsEmpty) + { + return []; + } + + // Count how many segments (comma-delimited) + var count = 1; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == ',') count++; + } + + var pool = ArrayPool<(string Language, double Quality)>.Shared; + (string Language, double Quality)[] rentedArray = pool.Rent(count); + + try + { + var actualCount = 0; + var start = 0; + int commaIndex; + do + { + commaIndex = span[start..].IndexOf(','); + ReadOnlySpan segment; + if (commaIndex >= 0) + { + segment = span.Slice(start, commaIndex).Trim(); + start += commaIndex + 1; + } + else + { + segment = span[start..].Trim(); + } + + if (!segment.IsEmpty) + { + // segment format: "lang[-region][;q=...]" or just "lang[-region]" + var semicolonIndex = segment.IndexOf(';'); + var qValue = 1d; + ReadOnlySpan languagePart; + + if (semicolonIndex >= 0) + { + languagePart = segment[..semicolonIndex].Trim(); + var afterSemicolon = segment[(semicolonIndex + 1)..].Trim(); + + // afterSemicolon should look like "q=0.5" + if (afterSemicolon.StartsWith("q=", StringComparison.OrdinalIgnoreCase)) + { + var qSpan = afterSemicolon[2..].Trim(); // skip 'q=' + if (!double.TryParse(qSpan, NumberStyles.Number, CultureInfo.InvariantCulture, out qValue)) + { + qValue = 1; + } + } + } + else + { + languagePart = segment; + } + + if (!languagePart.IsEmpty && actualCount < rentedArray.Length) + { + // Convert languagePart to string only once here + rentedArray[actualCount++] = (languagePart.ToString(), qValue); + } + } + } while (commaIndex >= 0); + + if (actualCount == 0) + { + return []; + } + + // Sort by quality descending + Array.Sort(rentedArray, 0, actualCount, QualityComparer); + + // Convert to CultureInfo array and return rented array + List results = new(actualCount); + + for (int i = 0; i < actualCount; i++) + { + var (lang, _) = rentedArray[i]; + if (string.IsNullOrWhiteSpace(lang)) + { + continue; + } + + try + { + var parsed = CultureInfo.CreateSpecificCulture(lang); + if (parsed.LCID != CultureInfo.InvariantCulture.LCID) + { + results.Add(parsed); + } + } + catch (CultureNotFoundException) + { + // skip invalid cultures + } + } + + return results.Count > 0 ? [.. results] : []; + } + finally + { + pool.Return(rentedArray); + } + } +} diff --git a/Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcern.cs b/Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcern.cs new file mode 100644 index 00000000..371aa0b6 --- /dev/null +++ b/Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcern.cs @@ -0,0 +1,76 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using System.Globalization; + +namespace GenHTTP.Modules.I18n.Provider; + +public sealed class LocalizationConcern : IConcern +{ + #region Get-/Setters + + public IHandler Content { get; } + + private readonly CultureInfo _defaultCulture; + private readonly CultureSelectorCombinedAsyncDelegate _cultureSelector; + private readonly CultureFilterAsyncDelegate _cultureFilter; + private readonly AsyncOrSyncSetter[] _cultureSetters; + + #endregion + + #region Initialization + + public LocalizationConcern( + IHandler content, + CultureInfo defaultCulture, + CultureSelectorCombinedAsyncDelegate cultureSelector, + CultureFilterAsyncDelegate cultureFilter, + AsyncOrSyncSetter[] cultureSetters + ) + { + Content = content; + + _defaultCulture = defaultCulture; + _cultureSelector = cultureSelector; + _cultureFilter = cultureFilter; + + _cultureSetters = cultureSetters; + } + + #endregion + + #region Functionality + + public ValueTask PrepareAsync() => Content.PrepareAsync(); + + public async ValueTask HandleAsync(IRequest request) + { + var culture = await ResolveCultureInfoAsync(request) ?? _defaultCulture; + + foreach(var _cultureSetter in _cultureSetters) + { + _cultureSetter.SyncSetter?.Invoke(request, culture); + + if (_cultureSetter.AsyncSetter != null) + { + await _cultureSetter.AsyncSetter(request, culture); + } + } + + return await Content.HandleAsync(request); + } + + private async ValueTask ResolveCultureInfoAsync(IRequest request) + { + await foreach (var candidate in _cultureSelector(request)) + { + if (await _cultureFilter(request, candidate)) + { + return candidate; + } + } + return null; + } + + #endregion + +} diff --git a/Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcernBuilder.cs b/Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcernBuilder.cs new file mode 100644 index 00000000..4aab9e5a --- /dev/null +++ b/Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcernBuilder.cs @@ -0,0 +1,275 @@ +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.I18n.Parsers; +using System.Globalization; + +namespace GenHTTP.Modules.I18n.Provider; + +/// +/// Builder class to configure and create an instance of . +/// +public sealed class LocalizationConcernBuilder : IConcernBuilder +{ + #region Fields + + private CultureInfo _defaultCulture = CultureInfo.CurrentUICulture; + + private readonly List _cultureSelectors = []; + private CultureFilterAsyncDelegate _cultureFilter = (_, _) => ValueTask.FromResult(true); + private readonly List _cultureSetters = []; + + #endregion + + #region Functionality + + #region Selectors + + /// + /// Extracts the language from a cookie. + /// + /// The name of the cookie to extract the language from. + /// + public LocalizationConcernBuilder FromCookie(string cookieName = "lang") + => FromRequest(request => + { + request.Cookies.TryGetValue(cookieName, out var languageCookie); + return languageCookie.Value; + }); + + /// + /// Extracts the language from a query parameter. + /// + /// The name of the query parameter to extract the language from. + /// + public LocalizationConcernBuilder FromQuery(string queryName = "lang") + => FromRequest(request => + { + request.Query.TryGetValue(queryName, out var language); + return language; + }); + + /// + /// Extracts the language from a header. + /// + /// The name of the header to extract the language from. + /// + public LocalizationConcernBuilder FromHeader(string headerName = "Accept-Language") + => FromRequest(request => + { + request.Headers.TryGetValue(headerName, out var language); + return language; + }); + + /// + /// Extracts the language from a custom selector. + /// + /// The selector to extract the language from. + /// + public LocalizationConcernBuilder FromRequest(Func languageSelector) + => FromRequest(request => ValueTask.FromResult(languageSelector(request))); + + /// + /// Extracts the language from a custom async selector. + /// + /// The async selector to extract the language from. + /// + public LocalizationConcernBuilder FromRequest(Func> languageSelector) + => FromRequest(async request => + { + var language = await languageSelector(request); + return CultureInfoParser.ParseFromLanguage(language); + }); + + /// + /// Extracts the culture from a custom selector. + /// + /// The selector to extract the culture from. + /// + public LocalizationConcernBuilder FromRequest(CultureSelectorDelegate cultureSelector) + => FromRequest(request => ValueTask.FromResult(cultureSelector(request))); + + /// + /// Extracts the culture from a custom async selector. + /// + /// The async selector to extract the culture from. + /// + public LocalizationConcernBuilder FromRequest(CultureSelectorAsyncDelegate cultureSelector) + { + _cultureSelectors.Add(cultureSelector); + return this; + } + + #endregion + + #region Filters + + /// + /// Sets the supported cultures. + /// + /// The supported cultures. + /// + public LocalizationConcernBuilder Supports(params CultureInfo[] supportedCultures) + { + var closure = supportedCultures.ToHashSet(); + return Supports(closure.Contains); + } + + /// + /// Sets a custom filter of supported cultures. + /// + /// The predicate to filter the cultures. + /// + public LocalizationConcernBuilder Supports(Predicate culturePredicate) + => Supports((_, culture) => culturePredicate(culture)); + + /// + /// Sets a custom async filter of supported cultures. + /// + /// The async predicate to filter the cultures. + /// + public LocalizationConcernBuilder Supports(Func> culturePredicate) + => Supports((_, culture) => culturePredicate(culture)); + + /// + /// Sets a custom filter (using ) of supported cultures. + /// + /// The filter to filter the cultures. + /// + public LocalizationConcernBuilder Supports(CultureFilterDelegate cultureFilter) + => Supports((request, culture) => ValueTask.FromResult(cultureFilter(request, culture))); + + /// + /// Sets a custom async filter (using ) of supported cultures. + /// + /// The async filter to filter the cultures. + /// + public LocalizationConcernBuilder Supports(CultureFilterAsyncDelegate cultureFilter) + { + _cultureFilter = cultureFilter; + return this; + } + + #endregion + + #region Setters + + /// + /// Sets the current culture and UI culture. + /// + /// Whether to set the current culture. + /// Whether to set the current UI culture. + /// + /// Thrown when both flags are set to false. + public LocalizationConcernBuilder Setter(bool currentCulture = false, bool currentUICulture = true) + { + if (!currentCulture && !currentUICulture) + { + throw new ArgumentException("At least one of the flags must be set to true.", nameof(currentCulture)); + } + + //Note: this is a minor optimization so that the flags are not evaluated for each request + if (currentCulture) + { + Setter(culture => CultureInfo.CurrentCulture = culture); + } + if (currentUICulture) + { + Setter(culture => CultureInfo.CurrentUICulture = culture); + } + return this; + } + + /// + /// Sets a custom async culture setter. + /// + /// The async culture setter. + /// + public LocalizationConcernBuilder Setter(Func cultureSetter) + => Setter((_, culture) => cultureSetter(culture)); + + /// + /// Sets a custom culture setter. + /// + /// The culture setter. + /// + public LocalizationConcernBuilder Setter(Action cultureSetter) + => Setter((_, culture) => cultureSetter(culture)); + + /// + /// Sets a custom culture setter (using ). + /// + /// The culture setter. + /// + public LocalizationConcernBuilder Setter(CultureSetterDelegate cultureSetter) + { + _cultureSetters.Add(new(SyncSetter: cultureSetter)); + return this; + } + + /// + /// Sets a custom async culture setter (using ). + /// + /// The async culture setter. + /// + public LocalizationConcernBuilder Setter(CultureSetterAsyncDelegate cultureSetter) + { + _cultureSetters.Add(new (AsyncSetter: cultureSetter)); + return this; + } + + #endregion + + #region Default + + /// + /// Sets the default culture that is used as a fallback. + /// + /// The default culture. + /// + public LocalizationConcernBuilder Default(CultureInfo culture) + { + _defaultCulture = culture; + return this; + } + + #endregion + + public IConcern Build(IHandler content) + { + if (_cultureSelectors.Count == 0) + { + FromHeader(); + } + + if (_cultureSetters.Count == 0) + { + Setter(); + } + + return new LocalizationConcern( + content, + _defaultCulture, + CultureSelector, + _cultureFilter, + [.. _cultureSetters] + ); + } + + #endregion + + #region Composite functions + + private async IAsyncEnumerable CultureSelector(IRequest request) + { + foreach (var selector in _cultureSelectors) + { + var cultures = await selector(request); + foreach (var culture in cultures) + { + yield return culture; + } + } + } + + #endregion +} diff --git a/Modules/GenHTTP.Modules.I18n/Provider/Types.cs b/Modules/GenHTTP.Modules.I18n/Provider/Types.cs new file mode 100644 index 00000000..538dc950 --- /dev/null +++ b/Modules/GenHTTP.Modules.I18n/Provider/Types.cs @@ -0,0 +1,57 @@ +using GenHTTP.Api.Protocol; +using System.Globalization; + +namespace GenHTTP.Modules.I18n.Provider; + +public sealed record AsyncOrSyncSetter(CultureSetterDelegate? SyncSetter = null, CultureSetterAsyncDelegate? AsyncSetter = null); + +/// +/// Delegate to extract the cultures for a given request. +/// +/// The request to extract cultures for. +/// An enumerable of CultureInfo objects representing the extracted cultures. +public delegate IAsyncEnumerable CultureSelectorCombinedAsyncDelegate(IRequest request); + +/// +/// Delegate to extract the cultures for a given request. +/// +/// The request to extract cultures for. +/// An enumerable of CultureInfo objects representing the extracted cultures. +public delegate ValueTask> CultureSelectorAsyncDelegate(IRequest request); + +/// +/// Delegate to set the culture for a given request. +/// +/// The request to set the culture for. +/// The CultureInfo object representing the culture to be set. +public delegate ValueTask CultureSetterAsyncDelegate(IRequest request, CultureInfo cultureInfo); + +/// +/// Delegate to filter the cultures for a given request. +/// +/// The request to filter cultures for. +/// The CultureInfo object representing the culture to be filtered. +/// True if the culture is valid for the request, otherwise false. +public delegate ValueTask CultureFilterAsyncDelegate(IRequest request, CultureInfo cultureInfo); + +/// +/// Delegate to extract the cultures for a given request. +/// +/// The request to extract cultures for. +/// An enumerable of CultureInfo objects representing the extracted cultures. +public delegate IEnumerable CultureSelectorDelegate(IRequest request); + +/// +/// Delegate to set the culture for a given request. +/// +/// The request to set the culture for. +/// The CultureInfo object representing the culture to be set. +public delegate void CultureSetterDelegate(IRequest request, CultureInfo cultureInfo); + +/// +/// Delegate to filter the cultures for a given request. +/// +/// The request to filter cultures for. +/// The CultureInfo object representing the culture to be filtered. +/// True if the culture is valid for the request, otherwise false. +public delegate bool CultureFilterDelegate(IRequest request, CultureInfo cultureInfo); diff --git a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj index 0ebd2df4..a0265ee4 100644 --- a/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj +++ b/Testing/Acceptance/GenHTTP.Testing.Acceptance.csproj @@ -66,6 +66,8 @@ + + diff --git a/Testing/Acceptance/Modules/I18n/LanguageParserTests.cs b/Testing/Acceptance/Modules/I18n/LanguageParserTests.cs new file mode 100644 index 00000000..a6dc0ca1 --- /dev/null +++ b/Testing/Acceptance/Modules/I18n/LanguageParserTests.cs @@ -0,0 +1,89 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.I18n; +using GenHTTP.Modules.I18n.Parsers; +using GenHTTP.Modules.I18n.Provider; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Globalization; + +namespace GenHTTP.Testing.Acceptance.Modules.I18n; + +[TestClass] +public sealed class LanguageParserTests +{ + #region Helper method + + private static CultureInfo Culture(string name) => CultureInfo.CreateSpecificCulture(name); + + #endregion + + public static IEnumerable<(string? Input, CultureInfo[] ExpectedResult)> TestData => + [ + //Empty input + new (null, []), + new (string.Empty, []), + new (" ", []), + new (",", []), + new (" ,", []), + new (", ", []), + new (" , ", []), + + //Invalid input + new ("unknown", []), + new ("unknown,zw", []), + + //Invalid quality + new ("en;q=0_8,de;q=0#9", [Culture("en"), Culture("de")]), + + //Trailing/leading whitespaces + new (" en", [Culture("en")]), + new ("en ", [Culture("en")]), + + //Single language + new ("en", [Culture("en")]), + new ("eN", [Culture("en")]), + new ("EN", [Culture("en")]), + new ("en-US", [Culture("en-US")]), + new ("en-UK", [Culture("en-UK")]), + new ("en-uk", [Culture("en-uk")]), + + //Multiple languages + new ("en,de", [Culture("en"), Culture("de")]), + new ("en,de,", [Culture("en"), Culture("de")]), + new (",en,de", [Culture("en"), Culture("de")]), + new ("de,en", [Culture("de"), Culture("en")]), + new ("en, de", [Culture("en"), Culture("de")]), + new ("en, de, fr", [Culture("en"), Culture("de"), Culture("fr")]), + new ("en,en,de", [Culture("en"), Culture("en"), Culture("de")]), + + new ("de,en-uk,fr", [Culture("de"), Culture("en-UK"), Culture("fr")]), + new ("de, en-uk,fr", [Culture("de"), Culture("en-UK"), Culture("fr")]), + new ("de,en-uk ,fr", [Culture("de"), Culture("en-UK"), Culture("fr")]), + + //Preference order + new ("en;q=0.8,de;q=0.9", [Culture("de"), Culture("en")]), + new ("en;q=0.9,de;q=0.8", [Culture("en"), Culture("de")]), + new ("en;q=0.9,de;q=0.9", [Culture("en"), Culture("de")]), + new ("en;q=0.9,de;q=0.9,fr;q=0.8", [Culture("en"), Culture("de"), Culture("fr")]), + + //Preference order mixed + new ("en;q=0.9,fr;q=0.8,de", [Culture("de"), Culture("en"), Culture("fr")]), + new ("en ;q=0.9,fr;q=0.8,de", [Culture("de"), Culture("en"), Culture("fr")]), + new ("en; q=0.9 , fr; q= 0.8, de", [Culture("de"), Culture("en"), Culture("fr")]), + new ("en-UK;q=0.9,fr;q=0.8,de", [Culture("de"), Culture("en-uk"), Culture("fr")]), + new ("en-UK;q=0.9,ww;q=0.8,de", [Culture("de"), Culture("en-uk")]), + + //Malformed + new ("en;q=0.9,fr;;q=0.8,de", [Culture("fr"), Culture("de"), Culture("en")]), + new ("en;q=0.9,frq=0.8,de", [Culture("de"), Culture("en")]), + ]; + + [TestMethod] + [DynamicData(nameof(TestData))] + public void TestLanguageParser(string input, CultureInfo[] expectedResult) + { + var actualResult = CultureInfoParser.ParseFromLanguage(input); + + CollectionAssert.AreEqual(expectedResult, actualResult); + } +} diff --git a/Testing/Acceptance/Modules/I18n/LocalizationTests.cs b/Testing/Acceptance/Modules/I18n/LocalizationTests.cs new file mode 100644 index 00000000..b670bfa5 --- /dev/null +++ b/Testing/Acceptance/Modules/I18n/LocalizationTests.cs @@ -0,0 +1,265 @@ +using GenHTTP.Api.Protocol; +using GenHTTP.Modules.Functional; +using GenHTTP.Modules.I18n; +using GenHTTP.Modules.I18n.Provider; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Globalization; + +namespace GenHTTP.Testing.Acceptance.Modules.I18n; + +[TestClass] +public sealed class LocalizationTests +{ + [TestMethod] + [MultiEngineTest] + public async Task TestDefaultBehavior(TestEngine engine) + { + var currentCulture = CultureInfo.CurrentUICulture; + + var localization = Localization.Create(); + + await TestLocalization(engine, localization, _ => + { + Assert.AreEqual(currentCulture, CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestDefault(TestEngine engine) + { + var localization = Localization + .Create() + .Default(CultureInfo.CreateSpecificCulture("fr")); + + await TestLocalization(engine, localization, _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("fr"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestFromStatic(TestEngine engine) + { + var localization = Localization + .Create() + .FromRequest(_ => [CultureInfo.CreateSpecificCulture("fr")]); + + await TestLocalization(engine, localization, _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("fr"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestFromQuery(TestEngine engine) + { + var localization = Localization + .Create() + .FromQuery(); + + await TestLocalization(engine, localization, + path: "?lang=cs-CZ", + _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("cs-CZ"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestFromHeader(TestEngine engine) + { + var localization = Localization + .Create() + .FromHeader(); + + await TestLocalization(engine, localization, + request => + { + request.Headers.Add("Accept-Language", "mn-MN"); + }, + _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("mn-MN"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestFromCookie(TestEngine engine) + { + var localization = Localization + .Create() + .FromCookie(); + + await TestLocalization(engine, localization, + request => + { + request.Headers.Add("Cookie", "lang=quz-EC"); + }, + _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("quz-EC"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSupportsList(TestEngine engine) + { + var localization = Localization + .Create() + .FromRequest(_ => "en,de,cs,fr") + .Supports([CultureInfo.CreateSpecificCulture("fr"), CultureInfo.CreateSpecificCulture("cs")]); + + await TestLocalization(engine, localization, + _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("cs"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSupportsPredicate(TestEngine engine) + { + var localization = Localization + .Create() + .FromRequest(_ => "en,de,cs,fr") + .Supports(culture => culture.Equals(CultureInfo.CreateSpecificCulture("fr"))); + + await TestLocalization(engine, localization, + _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("fr"), CultureInfo.CurrentUICulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSetterCurrentCulture(TestEngine engine) + { + var localization = Localization + .Create() + .FromRequest(_ => [CultureInfo.CreateSpecificCulture("fr")]) + .Setter(currentCulture: true, currentUICulture: false); + + await TestLocalization(engine, localization, _ => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("fr"), CultureInfo.CurrentCulture); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSetterCustom(TestEngine engine) + { + var localization = Localization + .Create() + .FromRequest(_ => "de") + .Setter((request, culture) => request.Properties["culture"] = culture); + + await TestLocalization(engine, localization, request => + { + Assert.AreEqual(CultureInfo.CreateSpecificCulture("de"), request.Properties["culture"]); + }); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestMultipleMixed(TestEngine engine) + { + var localization = Localization + .Create() + .FromQuery() + .FromCookie() + .FromHeader() + .FromRequest(_ => "de") + .Supports([CultureInfo.CreateSpecificCulture("de")]) + .Setter(currentCulture: true) + .Setter((request, culture) => request.Properties["culture"] = culture); + + await TestLocalization(engine, localization, + path: "?lang=cs-CZ", + request => + { + request.Headers.Add("Accept-Language", "mn-MN"); + request.Headers.Add("Cookie", "lang=quz-EC"); + }, + request => + { + var expected = CultureInfo.CreateSpecificCulture("de"); + + Assert.AreEqual(expected, request.Properties["culture"]); + Assert.AreEqual(expected, CultureInfo.CurrentCulture); + Assert.AreEqual(expected, CultureInfo.CurrentUICulture); + }); + } + + private static Task TestLocalization( + TestEngine engine, + LocalizationConcernBuilder localization, + Action requestAssert + ) + => TestLocalization(engine, localization, null, null, requestAssert); + + private static Task TestLocalization( + TestEngine engine, + LocalizationConcernBuilder localization, + string path, + Action requestAssert + ) + => TestLocalization(engine, localization, path, null, requestAssert); + + private static Task TestLocalization( + TestEngine engine, + LocalizationConcernBuilder localization, + Action requestSetup, + Action requestAssert + ) + => TestLocalization(engine, localization, null, requestSetup, requestAssert); + + private static async Task TestLocalization( + TestEngine engine, + LocalizationConcernBuilder localization, + string? path, + Action? requestSetup, + Action requestAssert + ) + { + Exception? assertException = null; + + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + + var handler = Inline + .Create() + .Add(localization) + .Get((IRequest request) => + { + try + { + requestAssert(request); + } + catch (Exception e) + { + assertException = e; + } + }); + + await using var host = await TestHost.RunAsync(handler, engine: engine); + + using var request = host.GetRequest(path); + requestSetup?.Invoke(request); + + using var _ = await host.GetResponseAsync(request); + + if (assertException != null) + { + throw assertException; + } + } +}