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;
+ }
+ }
+}