From 55ee3ba5a96b7ba9c2afc4df739793a300c72184 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 5 Aug 2023 20:58:03 +0100 Subject: [PATCH] feat: add culture route param --- .../Middleware/CultureRedirectExtensions.cs | 17 ++++++ .../Middleware/CultureRedirectMiddleware.cs | 54 +++++++++++++++++++ oliverbooth.dev/Pages/Error.cshtml | 2 +- oliverbooth.dev/Pages/Index.cshtml | 2 +- .../Pages/Privacy/GooglePlay.cshtml | 2 +- oliverbooth.dev/Pages/Privacy/Index.cshtml | 2 +- oliverbooth.dev/Program.cs | 29 +++++++++- oliverbooth.dev/RouteCultureProvider.cs | 47 ++++++++++++++++ 8 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 oliverbooth.dev/Middleware/CultureRedirectExtensions.cs create mode 100644 oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs create mode 100644 oliverbooth.dev/RouteCultureProvider.cs diff --git a/oliverbooth.dev/Middleware/CultureRedirectExtensions.cs b/oliverbooth.dev/Middleware/CultureRedirectExtensions.cs new file mode 100644 index 0000000..40d3e6b --- /dev/null +++ b/oliverbooth.dev/Middleware/CultureRedirectExtensions.cs @@ -0,0 +1,17 @@ +namespace OliverBooth.Middleware; + +/// +/// Extension methods for . +/// +internal static class CultureRedirectExtensions +{ + /// + /// Adds the to the application's request pipeline. + /// + /// The application builder. + /// The application builder. + public static IApplicationBuilder UseCultureRedirect(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs b/oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs new file mode 100644 index 0000000..a1d8612 --- /dev/null +++ b/oliverbooth.dev/Middleware/CultureRedirectMiddleware.cs @@ -0,0 +1,54 @@ +using System.Globalization; + +namespace OliverBooth.Middleware; + +/// +/// Redirects requests to the default culture if the culture is not specified in the URL. +/// +internal sealed class CultureRedirectMiddleware +{ + private readonly RequestDelegate _next; + + /// + /// Initializes a new instance of the class. + /// + /// The next request delegate. + public CultureRedirectMiddleware(RequestDelegate next) + { + _next = next; + } + + /// + /// Invokes the middleware. + /// + /// The HTTP context. + public async Task Invoke(HttpContext context) + { + const StringComparison comparison = StringComparison.OrdinalIgnoreCase; + + HttpRequest request = context.Request; + PathString requestPath = request.Path; + + if (requestPath.HasValue) + { + string[] pathSegments = requestPath.Value.Split('/'); + CultureInfo[] cultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + + if (pathSegments.Length >= 2 && cultures.Any(CultureMatch)) + { + await _next(context); + return; + } + + bool CultureMatch(CultureInfo culture) + { + string segment = pathSegments[1].Split('-')[0]; + return string.Equals(culture.TwoLetterISOLanguageName, segment, comparison); + } + } + + const string defaultCulture = "en"; + var redirectUrl = $"/{defaultCulture}{requestPath}{request.QueryString}"; + context.Response.Redirect(redirectUrl); + } +} \ No newline at end of file diff --git a/oliverbooth.dev/Pages/Error.cshtml b/oliverbooth.dev/Pages/Error.cshtml index b5105b6..2296a01 100644 --- a/oliverbooth.dev/Pages/Error.cshtml +++ b/oliverbooth.dev/Pages/Error.cshtml @@ -1,4 +1,4 @@ -@page +@page "/{culture=en}/error/{code:int?}" @model ErrorModel @{ ViewData["Title"] = "Error"; diff --git a/oliverbooth.dev/Pages/Index.cshtml b/oliverbooth.dev/Pages/Index.cshtml index fd28464..be8b96a 100644 --- a/oliverbooth.dev/Pages/Index.cshtml +++ b/oliverbooth.dev/Pages/Index.cshtml @@ -1,4 +1,4 @@ -@page +@page "/{culture=en}" @model IndexModel @{ ViewData["Title"] = "Home page"; diff --git a/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml b/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml index 452c86f..812f954 100644 --- a/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml +++ b/oliverbooth.dev/Pages/Privacy/GooglePlay.cshtml @@ -1,4 +1,4 @@ -@page "/privacy/google-play" +@page "/{culture=en}/privacy/google-play" @model OliverBooth.Pages.Privacy.GooglePlay @{ ViewData["Title"] = "Google Play Privacy Policy"; diff --git a/oliverbooth.dev/Pages/Privacy/Index.cshtml b/oliverbooth.dev/Pages/Privacy/Index.cshtml index fae874a..3e34a50 100644 --- a/oliverbooth.dev/Pages/Privacy/Index.cshtml +++ b/oliverbooth.dev/Pages/Privacy/Index.cshtml @@ -1,4 +1,4 @@ -@page +@page "/{culture=en}/privacy" @model OliverBooth.Pages.Privacy.Index @{ ViewData["Title"] = "Privacy Policy"; diff --git a/oliverbooth.dev/Program.cs b/oliverbooth.dev/Program.cs index 7b26c0c..0b42b14 100644 --- a/oliverbooth.dev/Program.cs +++ b/oliverbooth.dev/Program.cs @@ -1,8 +1,24 @@ +using Microsoft.AspNetCore.Localization; +using OliverBooth.Middleware; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.Services.AddRazorPages(); +builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); +builder.Services.AddRazorPages().AddViewLocalization().AddDataAnnotationsLocalization(); builder.Services.AddRouting(options => options.LowercaseUrls = true); +var supportedCultures = new[] +{ + CultureInfo.GetCultureInfo("en"), + CultureInfo.GetCultureInfo("fr") +}; + +builder.Services.Configure(options => +{ + options.DefaultRequestCulture = new RequestCulture(supportedCultures[0]); + options.SupportedCultures = supportedCultures; + options.SupportedUICultures = supportedCultures; +}); + WebApplication app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -17,6 +33,17 @@ app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); + +app.UseRequestLocalization(options => +{ + options.ApplyCurrentCultureToResponseHeaders = true; + options.DefaultRequestCulture = new RequestCulture(supportedCultures[0]); + options.SupportedCultures = supportedCultures; + options.SupportedUICultures = supportedCultures; + options.RequestCultureProviders.Insert(0, new RouteCultureProvider(supportedCultures[0])); +}); +app.UseCultureRedirect(); +app.MapControllerRoute("default", "{culture=en}/{controller=Home}/{action=Index}/{id?}"); app.MapRazorPages(); app.Run(); diff --git a/oliverbooth.dev/RouteCultureProvider.cs b/oliverbooth.dev/RouteCultureProvider.cs new file mode 100644 index 0000000..2629111 --- /dev/null +++ b/oliverbooth.dev/RouteCultureProvider.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Localization; + +namespace OliverBooth; + +internal sealed partial class RouteCultureProvider : IRequestCultureProvider +{ + private static readonly Regex CultureRegex = GetCultureRegex(); + private readonly CultureInfo _defaultCulture; + private readonly CultureInfo _defaultUiCulture; + + public RouteCultureProvider(CultureInfo requestCulture) : this(new RequestCulture(requestCulture)) + { + } + + public RouteCultureProvider(RequestCulture requestCulture) + { + _defaultCulture = requestCulture.Culture; + _defaultUiCulture = requestCulture.UICulture; + } + + public Task DetermineProviderCultureResult(HttpContext httpContext) + { + PathString url = httpContext.Request.Path; + + string defaultCulture = _defaultCulture.TwoLetterISOLanguageName; + string defaultUiCulture = _defaultUiCulture.TwoLetterISOLanguageName; + + if (url.ToString().Length <= 1) + { + return Task.FromResult(new ProviderCultureResult(defaultCulture, defaultUiCulture))!; + } + + string[]? parts = httpContext.Request.Path.Value?.Split('/'); + string requestCulture = parts?[1] ?? string.Empty; + + bool isMatch = CultureRegex.IsMatch(requestCulture); + string culture = isMatch ? requestCulture : defaultCulture; + string uiCulture = isMatch ? requestCulture : defaultUiCulture; + + return Task.FromResult(new ProviderCultureResult(culture, uiCulture))!; + } + + [GeneratedRegex("^[a-z]{2}(-[A-Z]{2})*$")] + private static partial Regex GetCultureRegex(); +}