Skip to content

Commit

Permalink
feat(288016): Add localization to template (#40)
Browse files Browse the repository at this point in the history
* Added localization strategy + shared resources to template

* Add localize test

* Remove change in csproj

* Add Internationalization to startup

* Revert format by csharpier

* Reverse format by csharpier part.2

* Remove unused usings

* Add internationalization to functions and console projects

* Add key and resource to resw file
  • Loading branch information
alexmaude committed Jul 25, 2023
1 parent 9ea8f35 commit a446ebd
Show file tree
Hide file tree
Showing 24 changed files with 454 additions and 9 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public Startup(IConfiguration configuration)
/// </summary>
public void ConfigureServices(IServiceCollection services)
{
services.AddInternationalization(_configuration);
services.AddCore(_configuration);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NV.Templates.Backend.Core.Configuration;

namespace Microsoft.AspNetCore.Builder
{
Expand All @@ -24,5 +31,48 @@ public static IApplicationBuilder UseCommonOpenApi(this IApplicationBuilder app)

return app;
}

public static IApplicationBuilder UseInternationalization(this IApplicationBuilder app)
{
return app
.UseStaticFiles()
.UseRequestLocalization(options =>
{
options.SupportedCultures = CultureConfig.SupportedCultures.ToArray();
options.SupportedUICultures = CultureConfig.SupportedCultures.ToArray();
options.DefaultRequestCulture = new RequestCulture(CultureConfig.DefaultCulture);
options.AddInitialRequestCultureProvider(new RequestSupportedCultureProvider());
})
.UseMiddleware<RequestCultureMiddleware>();
}

private class RequestSupportedCultureProvider : RequestCultureProvider
{
public override async Task<ProviderCultureResult?> DetermineProviderCultureResult(HttpContext httpContext)
{
IEnumerable<(double score, CultureInfo culture)> qvps = Array.Empty<(double, CultureInfo)>();
if (httpContext.Request.Query.TryGetValue("culture", out var cultureFromQuery))
{
qvps = qvps.Append((1, new CultureInfo(cultureFromQuery!)));
}

if (httpContext.Request.Headers.TryGetValue("Accept-Language", out var acceptLanguageHeaderValue))
{
qvps = qvps.Concat(acceptLanguageHeaderValue
.ToString()
.Split(',')
.Select(rawQvp => (parsed: StringWithQualityHeaderValue.TryParse(rawQvp, out var qvp), qvp))
.Where(item => item.parsed)
.Select(item => (item.qvp!.Quality ?? 1, new CultureInfo(item.qvp.Value))));
}

var culture = qvps
.OrderByDescending(qvp => qvp.score)
.Select(qvp => (matched: CultureConfig.TryMatchCulture(qvp.culture.Name, out var match), match))
.FirstOrDefault(mvp => mvp.matched).match ?? CultureConfig.DefaultCulture;

return new ProviderCultureResult(culture.Name);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

namespace NV.Templates.Backend.Core.Framework.Internationalization
{
public static class CultureConfig
{
public static readonly CultureInfo DefaultCulture = new CultureInfo("en");

public static readonly IEnumerable<CultureInfo> SupportedCultures = new[]
{
DefaultCulture,
new CultureInfo("fr-CA"),
};

public static readonly IEnumerable<LanguageModel> SupportedLanguages = SupportedCultures
.Select(culture => new LanguageModel
{
DisplayName = culture.NativeName,
CultureName = culture.Name,
})
.Distinct();

public static bool TryMatchCulture(string languageOrCultureName, out CultureInfo culture, CultureInfo? defaultCulture = null)
{
CultureInfo? match = null;
if (!string.IsNullOrWhiteSpace(languageOrCultureName))
{
match = SupportedCultures
// The exact matching culture first
.OrderByDescending(culture => culture.Name.Equals(languageOrCultureName, StringComparison.OrdinalIgnoreCase))

// Then the other matching languages
.ThenByDescending(culture => culture.TwoLetterISOLanguageName.StartsWith(languageOrCultureName, StringComparison.OrdinalIgnoreCase) ||
languageOrCultureName.StartsWith(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))

// Get the first that either exactly matches the culture, of the first culture that matches the language.
.FirstOrDefault(culture => culture.Name.Equals(languageOrCultureName, StringComparison.OrdinalIgnoreCase) ||
culture.TwoLetterISOLanguageName.StartsWith(languageOrCultureName, StringComparison.OrdinalIgnoreCase) ||
languageOrCultureName.StartsWith(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
}

culture = match ?? defaultCulture ?? DefaultCulture;

return match != null;
}

public static void SetCulture(string languageOrCultureName, CultureInfo? fallback = null)
{
TryMatchCulture(languageOrCultureName, out var culture, fallback);
SetCulture(culture);
}

public static void SetCulture(CultureInfo culture)
{
CultureInfo.CurrentUICulture = culture;
CultureInfo.CurrentCulture = culture;
}

public class LanguageModel
{
public string? DisplayName { get; set; }

public string? CultureName { get; set; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;

namespace NV.Templates.Backend.Core.Framework.Internationalization
{
public class RequestCultureMiddleware
{
private readonly RequestDelegate _next;

public RequestCultureMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task Invoke(HttpContext context)
{
var requestCultureFeature = context.Features.Get<IRequestCultureFeature>();
var culture = requestCultureFeature!.RequestCulture.Culture;

CultureConfig.SetCulture(culture);

context.Request.Headers["Accept-Language"] = culture.Name;

await _next.Invoke(context);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Localization;
using NV.Templates.Backend.Core.Framework.Internationalization.Services.Impl;
using NV.Templates.Resources;

namespace Microsoft.Extensions.DependencyInjection
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInternationalization(this IServiceCollection services, IConfiguration configuration)
{
return services
.AddLocalization()
.AddSingleton<IStringLocalizer>(sp => sp.GetRequiredService<IStringLocalizer<SharedResources>>())
.AddSingleton<IStringLocalizerEx, StringLocalizerEx>();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace NV.Templates.Backend.Core.Framework.Internationalization
{
public enum GetStringsMode
{
OnePerLanguage,

OnePerCulture,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Globalization;
using Microsoft.Extensions.Localization;

namespace NV.Templates.Backend.Core.Framework.Internationalization
{
public interface IStringLocalizerEx : IStringLocalizer
{
string GetString(string name, CultureInfo culture, params object[] arguments);

IDictionary<string, string> GetStrings(string name, GetStringsMode getStringsMode = GetStringsMode.OnePerCulture, params object[] arguments);

IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures, params object[] arguments);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#nullable disable

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Resources;
using Microsoft.Extensions.Localization;
using NV.Templates.Resources;

namespace NV.Templates.Backend.Core.Framework.Internationalization.Services.Impl
{
public class StringLocalizerEx : ResourceManager, IStringLocalizerEx
{
private readonly IStringLocalizer _localizer;

public StringLocalizerEx(IStringLocalizer localizer)
: base(typeof(SharedResources))
{
_localizer = localizer;
}

public LocalizedString this[string name] => _localizer[name];

public LocalizedString this[string name, params object[] arguments] => _localizer[name, arguments];

public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => GetAllStrings(includeParentCultures, Array.Empty<object>());

public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures, params object[] arguments)
=> _localizer.GetAllStrings(includeParentCultures).Select(localizedString =>
{
var value = localizedString.Value;
if (!localizedString.ResourceNotFound)
{
value = string.Format(CultureInfo.CurrentCulture, value, arguments);
}
return new LocalizedString(localizedString.Name, value, localizedString.ResourceNotFound, localizedString.SearchedLocation);
});

public string GetString(string name, CultureInfo culture, params object[] arguments)
=> string.Format(culture, base.GetString(name, culture) ?? $"[{name}]", arguments);

public IDictionary<string, string> GetStrings(string name, GetStringsMode getStringsMode = GetStringsMode.OnePerCulture, params object[] arguments)
{
switch (getStringsMode)
{
case GetStringsMode.OnePerLanguage:
return CultureConfig
.SupportedLanguages
.ToDictionary(
language => language.CultureName,
language => CultureConfig.TryMatchCulture(language.CultureName, out var culture) ?
GetString(name, culture, arguments) :
string.Format(culture, $"[{name}]", arguments));
case GetStringsMode.OnePerCulture:
return CultureConfig
.SupportedCultures
.ToDictionary(
culture => culture.Name,
culture => GetString(name, culture, arguments));
default:
throw new ArgumentOutOfRangeException(nameof(getStringsMode));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
global using NV.Templates.Backend.Core.Framework.Continuation;
global using NV.Templates.Backend.Core.Framework.DependencyInjection;
global using NV.Templates.Backend.Core.Framework.HttpDependencies;
global using NV.Templates.Backend.Core.Framework.Internationalization;
global using NV.Templates.Backend.Core.Framework.Json;
global using NV.Templates.Backend.Core.General;
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@
<Compile Include="InternalsVisibleTo.cs" Link="Properties\InternalsVisibleTo.cs" />
</ItemGroup>

<Import Project="..\NV.Templates.Resources\NV.Templates.Resources.projitems" Label="Shared" Condition="Exists('..\NV.Templates.Resources\NV.Templates.Resources.projitems')" />
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public override void Configure(IFunctionsHostBuilder builder)
.AddUserSecrets<Startup>(true)
.Build();

builder.Services.AddInternationalization(configuration);
builder.Services.AddSingleton<IConfiguration>(configuration);
builder.Services.AddCore(configuration);

Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
global using NV.Templates.Backend.Core.Configuration;
global using NV.Templates.Backend.Core.Framework.Continuation;
global using NV.Templates.Backend.Core.Framework.Exceptions;
global using NV.Templates.Backend.Core.Framework.Internationalization;
global using NV.Templates.Backend.Core.Framework.Json;
global using NV.Templates.Backend.Core.General;
global using NV.Templates.Backend.Web.Framework.Middlewares;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:49370",
"sslPort": 44314
Expand All @@ -24,4 +24,4 @@
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.ComponentModel;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

Expand All @@ -8,10 +11,12 @@ namespace NV.Templates.Backend.Web.RestApi.General
public class GeneralController : ControllerBase
{
private readonly IApplicationInfo _applicationInfo;
private readonly IStringLocalizerEx _localizer;

public GeneralController(IApplicationInfo applicationInfo)
public GeneralController(IApplicationInfo applicationInfo, IStringLocalizerEx localizer)
{
_applicationInfo = applicationInfo;
_localizer = localizer;
}

[ApiVersionNeutral]
Expand All @@ -22,5 +27,27 @@ public ActionResult<ApplicationInfoModel> GetInfo()
{
return Ok(new ApplicationInfoModel(_applicationInfo));
}

#if DEBUG
[ApiVersionNeutral]
[AllowAnonymous]
[HttpGet("localization-test")]
[Description("Allows to test localization.")]
public ActionResult LocalizationTest(string? key = null)
{
key ??= "Hello, world!";

// Just to ensure there are enough arguments for string.Format.
var arguments = Enumerable.Range(0, 100).Select(i => $"{i}").ToArray();
return Ok(new Dictionary<string, object>
{
{ "current key", key! },
{ "request culture", CultureInfo.CurrentUICulture.Name },
{ "localized value", _localizer[key, arguments] },
{ "all supported cultures for the given key", _localizer.GetStrings(key, arguments: arguments) },
{ "all strings of the app in the request culture", _localizer.GetAllStrings(true, arguments) },
});
}
#endif
}
}
Loading

0 comments on commit a446ebd

Please sign in to comment.