Search code examples
c#asp.net-corerazor-pagesasp.net-core-localizationiso-3166

URL Based Localization in .NET core Razor - how to?


I am building a .NET 8.0 Razor Page site where localization/internationalization is made with URLs like:

example.org/us // United States
example.org/fr // France
example.org/pt // Portugal
example.org/se // Sweden
...

example.org/us/contact  // contact page for USA (text is displayed in English)
example.org/fr/contact  // contact page for France (text is displayed in French)

i.e. URLs contain ISO 3166-1 alpha-2 codes for countries (e.g. us, fr, pt, se etc). Text is coming from standard Resource files (resx).

Most of my implementation works but I have slight problem where non-existing pages return a StatusCode 200 instead of 404. I have Googled, looked at other examples but they are either dated/no longer working, or only work for MVC. This question is for Razor Pages using @page "/..." directive.

I need help and inputs on the implementation.

All my Razor Pages have {lang} in their page route. The Contact page route will be /{lang}/contact. {lang} refers to the ISO 3166-1 alpha-2 code.

The following code prepends the {lang}-parameter to all pages in the web application:

public class CultureTemplatePageRouteModelConvention: IPageRouteModelConvention
{
    public void Apply(PageRouteModel model)
    {
        foreach (var selector in model.Selectors)
        {
            var template = selector.AttributeRouteModel.Template;

            if (template.StartsWith("MicrosoftIdentity")) continue;  // Skip MicrosoftIdentity pages

            // Prepend {lang}/ to the page routes allow for route-based localization
            selector.AttributeRouteModel.Order = -1;
            selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{lang}", template);
        }
    }
}

...instantiated in program.cs with:

builder.Services.AddRazorPages(options =>
{
    // decorate all page routes with {lang} e.g. @page "/{lang}..."
    options.Conventions.Add(new CultureTemplatePageRouteModelConvention());
});

To set the actual Culture for the page requested, I have implemented a custom RequestCultureProvider with the following:

public class CustomRouteDataRequestCultureProvider : RequestCultureProvider
{
    public SupportedAppLanguages SupportedAppLanguages;
    public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
    {

        var lang = (string)httpContext.GetRouteValue("lang");
        var urlCulture = httpContext.Request.Path.Value.Split('/')[1];

        string[] container = [lang, urlCulture];
        
        var culture = SupportedAppLanguages.Dict.Values.SingleOrDefault(langInApp => container.Contains(langInApp.Icc) );

        if (culture != null)
        {
            return Task.FromResult(new ProviderCultureResult(culture.Culture));
        }

        // if no match, return 404
        httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
        return Task.FromResult(new ProviderCultureResult(Options.DefaultRequestCulture.Culture.TwoLetterISOLanguageName));
    }
}

...instantiated via program.cs:

// get all languages supported by app via `appsettings.json`:
var supportedAppLanguages = builder.Configuration.GetSection("SupportedAppLanguages").Get<SupportedAppLanguages>();
var supportedCultures = supportedAppLanguages.Dict.Values.Select(langInApp => new CultureInfo(langInApp.Culture)).ToList();

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    options.DefaultRequestCulture = new RequestCulture(culture: "en-us", uiCulture: "en-us");
    options.SupportedCultures = supportedCultures;
    options.SupportedUICultures = supportedCultures;
    options.FallBackToParentCultures = true;

    options.RequestCultureProviders.Clear();
    options.RequestCultureProviders.Insert(0, new CustomRouteDataRequestCultureProvider() { Options = options, SupportedAppLanguages = supportedAppLanguages });
});

To prevent users from entering weird locale/country codes (and confusing search engines/preventing bad SEO), I created a MiddlewareFilter to deal with such cases:

public class RouteConstraintMiddleware(RequestDelegate next, SupportedAppLanguages supportedAppLanguages) 
{
    public async Task Invoke(HttpContext context)
    {

        // The context.Response.StatusCode for pages like example.org/us/non-existing is 200 and not the correct 404.
        // How can I implement a proper check?
        if (context.Response.StatusCode == StatusCodes.Status404NotFound) return; 
        if (string.IsNullOrEmpty(context?.GetRouteValue("lang")?.ToString())) return;

        // check for a match 
        var lang = context.GetRouteValue("lang").ToString();
        var supported = supportedAppLanguages.Dict.Values.Any(langInApp => lang == langInApp.Icc);
       
        if (!supported)
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            return;
        }

        await next(context);
    }
}

The current state of the app:

example.org/us       // 200. (correct)
example.org/fr       // 200. (correct)
example.org/nonsense/contact // 404. (correct)
example.org/nonsense         // 404. (correct)
example.org/us/non-existing  // 200. (wrong, must return 404)

How can I fix this? Is this a good implementation as well (seems kind of clunky to me)? Can it be solved in a better way? A simplified Github repro is available here: https://github.com/shapeh/TestLocalization

Hoping for some pointers.


Solution

  • The issue is caused by your RouteConstraintMiddleware

    this line would shortcut your middleware pineline

    if (string.IsNullOrEmpty(context?.GetRouteValue("lang")?.ToString())) return;
    

    without await next(context); it won't get into next middleware,the codes that return 404 response won't be executed

    In my opinion,you may try add a RouteConstraint to your {lang} section,the default Route middleware would handle it for you,so that you don't need to add another middleware

    A minimal example:

    public class CultureConstraint : IRouteConstraint
    {
       
        public bool Match(
            HttpContext? httpContext, IRouter? route, string routeKey,
            RouteValueDictionary values, RouteDirection routeDirection)
        {
            if (!values.TryGetValue(routeKey, out var routeValue))
            {
                return false;
            }
    
            var supportedAppLanguages = httpContext.RequestServices.GetService<IConfiguration>().GetSection("SupportedAppLanguages").Get<SupportedAppLanguages>();
            
    
    
            var routeValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture);
    
            
    
            return supportedAppLanguages.Dict.Values.Select(x=>x.Icc).Contains(routeValueString);
        }
    }
    

    Register the constraint:

    builder.Services.AddRouting(options =>
    {
        options.LowercaseUrls = true;
        options.AppendTrailingSlash = false;
        options.ConstraintMap.Add("cultureconstraint", typeof(CultureConstraint));
    });
    

    Remove the middleware:

    app.UseMiddleware<RouteConstraintMiddleware>(supportedAppLanguages);
    

    modify CultureTemplatePageRouteModelConvention:

    public class CultureTemplatePageRouteModelConvention: IPageRouteModelConvention
    {
        public void Apply(PageRouteModel model)
        {
            foreach (var selector in model.Selectors)
            {
                var template = selector.AttributeRouteModel.Template;
    
                if (template.StartsWith("MicrosoftIdentity")) continue;  // Skip MicrosoftIdentity pages
    
                // Prepend {lang}/ to the page routes allow for route-based localization
                selector.AttributeRouteModel.Order = -1;
                selector.AttributeRouteModel.Template = AttributeRouteModel.CombineTemplates("{lang:cultureconstraint}", template);
            }
        }
    }
    

    The result:

    enter image description here