Skip to content

Commit

Permalink
#485 Pick up locale requested by the client (#577)
Browse files Browse the repository at this point in the history
  • Loading branch information
Matasx authored Dec 9, 2024
1 parent 2b63c74 commit e97acc1
Show file tree
Hide file tree
Showing 10 changed files with 985 additions and 9 deletions.
25 changes: 16 additions & 9 deletions GenHTTP.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
47 changes: 47 additions & 0 deletions Modules/GenHTTP.Modules.I18n/GenHTTP.Modules.I18n.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>

<TargetFrameworks>net8.0;net9.0</TargetFrameworks>

<LangVersion>13.0</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>

<AssemblyVersion>9.3.0.0</AssemblyVersion>
<FileVersion>9.3.0.0</FileVersion>
<Version>9.3.0</Version>

<Authors>Andreas Nägeli, Martin Maťátko</Authors>
<Company />

<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>https://genhttp.org/</PackageProjectUrl>

<Description>Allows to handle requests in a culture specific manner.</Description>
<PackageTags>HTTP Webserver C# Module i18n</PackageTags>

<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>

<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>CS1591,CS1587,CS1572,CS1573</NoWarn>

<PackageIcon>icon.png</PackageIcon>

</PropertyGroup>

<ItemGroup>

<None Include="..\..\LICENSE" Pack="true" PackagePath="\" />
<None Include="..\..\Resources\icon.png" Pack="true" PackagePath="\" />

</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\API\GenHTTP.Api.csproj" />
</ItemGroup>

</Project>
19 changes: 19 additions & 0 deletions Modules/GenHTTP.Modules.I18n/Localization.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Globalization;
using GenHTTP.Modules.I18n.Provider;

namespace GenHTTP.Modules.I18n;

public static class Localization
{
#region Builder

/// <summary>
/// Creates a localization handler that parses and sets
/// <see cref="CultureInfo"/> based on defined rules.
/// </summary>
/// <returns>By default culture is read from request header
/// and is set to <see cref=" CultureInfo.CurrentUICulture"/>.</returns>
public static LocalizationConcernBuilder Create() => new();

#endregion
}
139 changes: 139 additions & 0 deletions Modules/GenHTTP.Modules.I18n/Parsers/CultureInfoParser.cs
Original file line number Diff line number Diff line change
@@ -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));

/// <summary>
/// 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.
/// </summary>
/// <param name="language">The language header string to parse.</param>
/// <returns>An array of <see cref="CultureInfo"/> objects parsed from the input string.</returns>
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<char> 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<char> 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<CultureInfo> 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);
}
}
}
76 changes: 76 additions & 0 deletions Modules/GenHTTP.Modules.I18n/Provider/LocalizationConcern.cs
Original file line number Diff line number Diff line change
@@ -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<IResponse?> 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<CultureInfo?> ResolveCultureInfoAsync(IRequest request)
{
await foreach (var candidate in _cultureSelector(request))
{
if (await _cultureFilter(request, candidate))
{
return candidate;
}
}
return null;
}

#endregion

}
Loading

0 comments on commit e97acc1

Please sign in to comment.