blog post image
Andrew Lock avatar

Andrew Lock

~6 min read

Redirecting unknown cultures when using the url culture provider

Adding a URL culture provider using middleware as filters - Part 5

This is the next in a series of posts on using the middleware as filters feature of ASP.NET Core 1.1 to add a url culture provider to your application. In this post I show how to handle the case where a user requests a culture that does not exist, or that we do not support, by redirecting to a URL with a supported culture.

By working through each of these posts we are slowly building a full system for having a useable url culture provider. We now have globally defined routing conventions that ensure our urls are prefixed with a culture like en-GB or fr-FR. In the last post we added a culture constraint and catch-all routes to ensure that requests to a culture-less url like Home/Index/ are redirected to a cultured one, like en-GB/Home/Index.

One of the remaining holes in our current implementation is handling the case when users request a URL for a culture that does not exist, or we do not support. For example, in the example below, we do not support Spanish in the application, so the request localisation is set to the default culture en-GB:

The spanish culture is not supported, so falls back to the default culture

This is fine from the application's point of view , but it is not great for the user. It looks to the user as though we support Spanish, as we have a Spanish culture url, but all the text will be in English. A potentially better approach would be to redirect the user to a URL with the culture that is actually being used. This also helps reduce the number of pages which are essentially equivalent, which is good for SEO.

Handling redirects in middleware as filters

The technique I'm going to use involves adding an additional piece of middleware to our middleware-as-filters pipeline. If you're not comfortable with how this works I suggest checking out the earlier posts in this series.

This middleware checks the culture that has been applied to the current request to see if it matches the value that was requested via the routing {culture} value. If the values match (ignoring case differences), the middleware just moves on to the next middleware in the pipeline and nothing else happens.

If the requested and actual cultures are different, then the middleware short-circuits the request, sending a redirect to the same URL but with the correct culture. Middleware-as-filters run as ResourceFilters, so they can bypass the action method completely, as in this case.

That is the high level approach, now onto the code. Brace yourself, there's quite a lot, which I'll walk through afterwards.

public class RedirectUnsupportedCulturesMiddleware
{
    private readonly RequestDelegate _next;
    private readonly string _routeDataStringKey;

    public RedirectUnsupportedCulturesMiddleware(
        RequestDelegate next,
        RequestLocalizationOptions options)
    {
        _next = next;
        var provider = options.RequestCultureProviders
            .Select(x => x as RouteDataRequestCultureProvider)
            .Where(x => x != null)
            .FirstOrDefault();
        _routeDataStringKey = provider.RouteDataStringKey;
    }

    public async Task Invoke(HttpContext context)
    {
        var requestedCulture = context.GetRouteValue(_routeDataStringKey)?.ToString();
        var cultureFeature = context.Features.Get<IRequestCultureFeature>();

        var actualCulture = cultureFeature?.RequestCulture.Culture.Name;

        if (string.IsNullOrEmpty(requestedCulture) ||
            !string.Equals(requestedCulture, actualCulture, StringComparison.OrdinalIgnoreCase))
        {
            var newCulturedPath = GetNewPath(context, actualCulture);
            context.Response.Redirect(newCulturedPath);
            return;
        }

        await _next.Invoke(context);
    }

    private string GetNewPath(HttpContext context, string newCulture)
    {
        var routeData = context.GetRouteData();
        var router = routeData.Routers[0];
        var virtualPathContext = new VirtualPathContext(
            context,
            routeData.Values,
            new RouteValueDictionary { { _routeDataStringKey, newCulture } });

        return router.GetVirtualPath(virtualPathContext).VirtualPath;
    }
}

Breaking down the code

This is a standard piece of ASP.NET Core middleware, so our constructor takes a RequestDelegate which it calls in order to invoke the next middleware in the pipeline.

Our middleware also takes in an instance of RequestLocalizationOptions. It uses this to attempt to determine how the RouteDataRequestCultureProvider has been configured. In particular we need the RouteDataStringKey which represents culture in our URLs. By default it is "culture", but this approach would pick up any changes too.

Note that we assume that we will always have a RouteDataRequestCultureProvider here. That sort of makes sense, as redirecting to a different URL based on culture only makes sense if we are taking the culture from the URL!

We have implemented the standard middleware Invoke function without any further dependencies other than the HttpContext. When invoked, the middleware will attempt to find a route value corresponding to the specified RouteDataStringKey. This will give the name of the culture the user requested, for example es-ES.

Next, we obtain the current culture. I chose to retrieve this from the context using the IRequestCultureFeature, mostly just to show it is possible, but you could also just use the thread culture directly by using CultureInfo.CurrentCulture.Name.

We then compare the culture requested with the actual culture that was set. If the requested culture was one we support, then these should be the same (ignoring case). If the culture requested was not a real culture, was not a culture we support, or was a more-specific culture than we support, then these will not match.

Considering that last point - if the user requested de-DE but we only support de then the culture provider will automatically fall back to de. This is desirable behaviour, but the requested and actual cultures will not match.

Once we have identified that the cultures do not match, we need to redirect the user to the correct url. Achieving this goal seemed surprisingly tricky, and potentially rather fragile, but it worked for me.

In order to route to a url you need an instance of an IRouter. You can obtain a collection of these, along with all the current route values by calling HttpContext.GetData(). I simply chose the first IRouter instance, passed in all the current route values, and provided a new value for the "culture" route value to create a VirtualPathContext, which can in turn be used to generate a path. Hard work!

Adding the middleware to your application

Now we have our middleware, we actually need to add it to our application somewhere. Luckily, we are already using middleware as filters to extract the culture from the url, so we can simply insert our middleware into the pipeline.

public class LocalizationPipeline
{
    public void Configure(IApplicationBuilder app, RequestLocalizationOptions options)
    {
        app.UseRequestLocalization(options);
        app.UseMiddleware<RedirectUnsupportedCulturesMiddleware>();
    }
}

So our localisation pipeline (which will be run as a filter, thanks to a global MiddlewareFilterAttribute) will first attempt to resolve the request's culture. Immediately after doing so, we run our new middleware, and redirect the request if it is not a culture we support.

If you're not sure what's going on here, I suggest checking out my earlier posts on setting up url localisation in your apps.

Trying it out

That should be all we need to do in order to automatically redirect requests that don't match in culture.

Trying a gibberish culture localhost/zz-ZZ redirects to our default culture:

Redirecting gibberish cultures

Using a culture we don't support localhost/es-ES similarly redirects to the default culture:

redirecting an unsupported culture

If we support a fallback culture de then the request localhost/de-DE is redirected to that:

Redirecting to a fallback culture

Caveats

One thing I haven't handled here is the difference between CurrentCulture and CurrentUICulture. These two can be different, and are supported by both the RequestLocalizationMiddleware and the RouteDataRequestCultureProvider. I chose not to address it here, but if you are using both in your application, you could easily extend the middleware to handle differences in either value.

Summary

Will these redirects in place, you should hopefully have the last piece of the puzzle for implementing the url culture provider in your ASP.NET Core 1.1 apps. If you come across anything I've missed, comments, or improvements, then do let me know!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?