Optionally override request culture via url/route in an ASP.NET Core 1.0 Application

Tseng picture Tseng · Mar 6, 2016 · Viewed 9.5k times · Source

I am trying to override the culture of the current request. I got it working partly using a custom ActionFilterAttribute.

public sealed class LanguageActionFilter : ActionFilterAttribute
{
    private readonly ILogger logger;
    private readonly IOptions<RequestLocalizationOptions> localizationOptions;

    public LanguageActionFilter(ILoggerFactory loggerFactory, IOptions<RequestLocalizationOptions> options)
    {
        if (loggerFactory == null)
            throw new ArgumentNullException(nameof(loggerFactory));

        if (options == null)
            throw new ArgumentNullException(nameof(options));

        logger = loggerFactory.CreateLogger(nameof(LanguageActionFilter));
        localizationOptions = options;
    }

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        string culture = context.RouteData.Values["culture"]?.ToString();

        if (!string.IsNullOrWhiteSpace(culture))
        {
            logger.LogInformation($"Setting the culture from the URL: {culture}");

#if DNX46
            System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo(culture);
            System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(culture);
#else
            CultureInfo.CurrentCulture = new CultureInfo(culture);
            CultureInfo.CurrentUICulture = new CultureInfo(culture);
#endif
        }

        base.OnActionExecuting(context);
    }
}

On the controller I use the LanguageActionFilter.

[ServiceFilter(typeof(LanguageActionFilter))]
[Route("api/{culture}/[controller]")]
public class ProductsController : Controller
{
    ...
}

This works so far, but I have two issues with it:

  1. I don't like having to declare {culture} on every controller, as I am going to need it on every route.
  2. I having a default culture doesn't work with this approach, even if I declare it as [Route("api/{culture=en-US}/[controller]")] for obvious reasons.

Setting a default route results is not working neither.

app.UseMvc( routes =>
{
    routes.MapRoute(
        name: "DefaultRoute",
        template: "api/{culture=en-US}/{controller}"
    );
});

I also investigated in a custom IRequestCultureProvider implementation and add it to the UseRequestLocalization method like

app.UseRequestLocalization(new RequestLocalizationOptions
{
    RequestCultureProviders = new List<IRequestCultureProvider>
    {
        new UrlCultureProvider()
    },
    SupportedCultures = new List<CultureInfo>
    {
        new CultureInfo("de-de"),
        new CultureInfo("en-us"),
        new CultureInfo("en-gb")
    },
    SupportedUICultures = new List<CultureInfo>
    {
        new CultureInfo("de-de"),
        new CultureInfo("en-us"),
        new CultureInfo("en-gb")
    }
}, new RequestCulture("en-US"));

but then I don't have access to the routes there (I assume cause the routes are done later in the pipeline). Of course I could also try to parse the requested url. And I don't even know if I could change the route at this place so it would match the above route with the culture in it.

Passing the culture via query parameter or changing the order of the parameters inside the route is not an option.

Both urls api/en-us/products as we as api/products should route to the same controller, where the former don't change the culture.

The order in which the culture will be determined should be

  1. If defined in url, take it
  2. If not defined in url, check query string and use that
  3. If not defined in query, check cookies
  4. If not defined in cookie, use Accept-Language header.

2-4 is done via UseRequestLocalization and that works. Also I don't like the current approach having to add two attributes to each controller ({culture} in route and the [ServiceFilter(typeof(LanguageActionFilter))]).

Edit: I also like to limit the number of valid locales to the one set in SupportedCultures property of the RequestLocalizationOptions passed to the UseRequestLocalization.

IOptions<RequestLocalizationOptions> localizationOptions in the LanguageActionFilter above doesn't work as it returns a new instance of RequestLocalizationOptions where SupportedCultures is always null and not the one passed to the.

FWIW it's an RESTful WebApi project.

Answer

Daniel J.G. picture Daniel J.G. · Mar 13, 2016

Update ASP.Net Core 1.1

A new RouteDataRequestCultureProvider is coming as part of the 1.1 release, which hopefully means you won't have to create your own request provider anymore. You might still find the information here useful (like the routing bits) or you might be interested in creating your own request culture provider.


You can create 2 routes that will let you access your endpoints with and without a culture segment in the url. Both /api/en-EN/home and /api/home will be routed to the home controller. (So /api/blah/home won't match the route with culture and will get 404 since the blah controller doesn't exists)

For these routes to work, the one that includes the culture parameter has higher preference and the culture parameter includes a regex:

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "apiCulture",
        template: "api/{culture:regex(^[a-z]{{2}}-[A-Z]{{2}}$)}/{controller}/{action=Index}/{id?}");

    routes.MapRoute(
        name: "defaultApi",
        template: "api/{controller}/{action=Index}/{id?}");                

});

The above routes will work with MVC style controller, but if you are building a rest interface using wb api style of controllers, the attribute routing is the favored way in MVC 6.

  • One option is to use attribute routing, but use a base class for all your api controllers were you can set the base segments of the url:

    [Route("api/{language:regex(^[[a-z]]{{2}}-[[A-Z]]{{2}}$)}/[controller]")]
    [Route("api/[controller]")]
    public class BaseApiController: Controller
    {
    }
    
    public class ProductController : BaseApiController
    {
        //This will bind to /api/product/1 and /api/en-EN/product/1
        [HttpGet("{id}")]
        public IActionResult GetById(string id)
        {
            return new ObjectResult(new { foo = "bar" });
        }
    } 
    
  • A quick way of avoiding the base class without needing too much custom code is through the web api compatibility shim:

    • Add the package "Microsoft.AspNet.Mvc.WebApiCompatShim": "6.0.0-rc1-final"
    • Add the shim conventions:

      services.AddMvc().AddWebApiConventions();
      
    • Make sure your controllers inherit from ApiController, which is added by the shim package
    • Define the routes including the culture parameter with te MapApiRoute overload:

      routes.MapWebApiRoute("apiLanguage", 
       "api/{language:regex(^[a-z]{{2}}-[A-Z]{{2}}$)}/{controller}/{id?}");
      
      routes.MapWebApiRoute("DefaultApi", 
       "api/{controller}/{id?}");
      
  • The cleaner and better option would be creating and applying your own IApplicationModelConvention which takes care of adding the culture prefix to your attribute routes. This is out of the scope for this question, but I have implemented the idea for this localization article

Then you need to create a new IRequestCultureProvider that will look at the request url and extract the culture from there (if provided).

Once you upgrade to ASP .Net Core 1.1 you might avoid manually parsing the request url and extract the culture segment.

I have checked the implementation of RouteDataRequestCultureProvider in ASP.Net Core 1.1, and they use an HttpContext extension method GetRouteValue(string) for getting url segments inside the request provider:

culture = httpContext.GetRouteValue(RouteDataStringKey)?.ToString();

However I suspect (I haven't had a chance to try it yet) that this would only work when adding middleware as MVC filters. That way your middleware runs after the Routing middleware, which is the one adding the IRoutingFeature into the HttpContext. As a quick test, adding the following middleware before UseMvc will get you no route data:

app.Use(async (context, next) =>
{
    //always null
    var routeData = context.GetRouteData();
    await next();
});

In order to implement the new IRequestCultureProvider you just need to:

  • Search for the culture parameter in the request url path.
  • If no parameter is found, return null. (If all the providers return null, the default culture will be used)
  • If a culture parameter is found, return a new ProviderCultureResult with that culture.
  • The localization middleware will fallback to the default one if it is not one of the supported cultures.

The implementation will look like:

public class UrlCultureProvider : IRequestCultureProvider
{
    public Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
    {
        var url = httpContext.Request.Path;

        //Quick and dirty parsing of language from url path, which looks like "/api/de-DE/home"
        //This could be skipped after 1.1 if using the middleware as an MVC filter
        //since you will be able to just call the method GetRouteValue("culture") 
        //on the HttpContext and retrieve the route value
        var parts = httpContext.Request.Path.Value.Split('/');
        if (parts.Length < 3)
        {
            return Task.FromResult<ProviderCultureResult>(null);
        }
        var hasCulture = Regex.IsMatch(parts[2], @"^[a-z]{2}-[A-Z]{2}$");
        if (!hasCulture)
        {
            return Task.FromResult<ProviderCultureResult>(null);
        }

        var culture = parts[2];
        return Task.FromResult(new ProviderCultureResult(culture));
    }
}

Finally enable the localization features including your new provider as the first one in the list of supported providers. As they are evaluated in order and the first one returning a not null result wins, your provider will take precedence and next will come the default ones (query string, cookie and header).

var localizationOptions = new RequestLocalizationOptions
{
    SupportedCultures = new List<CultureInfo>
    {
        new CultureInfo("de-DE"),
        new CultureInfo("en-US"),
        new CultureInfo("en-GB")
    },
    SupportedUICultures = new List<CultureInfo>
    {
        new CultureInfo("de-DE"),
        new CultureInfo("en-US"),
        new CultureInfo("en-GB")
    }
};
//Insert this at the beginning of the list since providers are evaluated in order until one returns a not null result
localizationOptions.RequestCultureProviders.Insert(0, new UrlCultureProvider());

//Add request localization middleware
app.UseRequestLocalization(localizationOptions, new RequestCulture("en-US"));