Thursday, March 17, 2016

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

Leave a Comment

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.

1 Answers

Answers 1

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" });     } }  

The other options lets you use the route table approach for the api controllers, using 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?}"); 

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

Right now, you don't have access to the route data inside the IRequestCultureProvider, you only have access to the HttpContext. This means you will neede to manually extract the culture parameter from the url path.

However in RC2 a new extension method HttpContext.GetRouteData has been added that lets you get the route data from the httpContext (internally this is done via adding a new http context feature IRoutingFeature after finding a route match but before running the route handler, see the udpated router middleware)

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

  • Search for the culture parameter in the request url path. (Remember in RC2 you can get access to the route data, otherwise you will need to manually extract the parameter)
  • 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 wont be needed in RC2 since an extension method GetRouteData has been added to HttpContext         //which means we could just get the the "language" parameter from the route data         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 Controllers.UrlCultureProvider());  //Add request localization middleware app.UseRequestLocalization(localizationOptions, new RequestCulture("en-US")); 
If You Enjoyed This, Take 5 Seconds To Share It

0 comments:

Post a Comment