ASP.NET Core localization middleware with JSON resource files

I'm working on a ASP.NET Core RC1 project that requires localization of the UI. Damien Bod does a very good job of explaining how to do this using good old resx files. However, I'm writing the entire front-end with Visual Studio Code and resx somehow didn't seem like a good match. It's a clunky XML format that requires a header containing an XML schema definition and although .NET Core supports the format, Visual Studio Code lacks any support (at least none that I could find).

So I thought, why not store resources in JSON files? My initial requirements are text resources only so a simple key-value format should do the trick. JSON seems an obvious match. For comparison, I have a (Dutch) JSON resource file first:

{
   "ResourceKey.Welcome": "Welkom"
}

And the corresponding resx file for expressing 'the same' information. This comparison is of course not entirely fair but when you need just a simple key-value mapping, the resx format is a bit bloated to say the least...

<?xml version="1.0" encoding="utf-8"?>  
<root>  
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
                        xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" 
                             msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" 
                             msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" 
                             msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" 
                             msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms</value>
  </resheader>
  <data name="ResourceKey.Welcome" xml:space="preserve">
    <value>Welkom</value>
  </data>
</root>  

Did you find my key and value entirely at the bottom of the file? The rest of the file is metadata.

So, what does a localization middleware component look like that reads its resources from JSON files? By the way, all the code for this post can be found in this GitHub repository (still very much in beta at the moment of writing). And here's a good explanation of doing something similar but then from a database.

Configuration

First, the configuration. This happens in the ConfigureServices method:

public void ConfigureServices(IServiceCollection services)  
{
    // Add localization based on JSON files.
    services.AddJsonLocalization(options => options.ResourcesPath = "Resources");

    // Add MVC service and view localization.
    services
        .AddMvc()
        .AddViewLocalization();
}

The call to AddJsonLocalization installs the required localization services, which we will discuss next. At the moment, it has one configuration parameter: ResourcesPath to specify where to look for the JSON resource files.

The framework-supported AddViewLocalization installs an html-safe wrapper around our localization services and an IViewLocationExpander that selects views based on current culture: LanguageViewLocationExpander. For example, it can generate the view name Views/Home/nl/Action when you're in Holland, pretty cool.

Middleware

Middleware configuration begins with the AddJsonLocalization call which is an extension method for IServicesCollection.

using System;  
using Microsoft.Extensions.DependencyInjection.Extensions;  
using Microsoft.Extensions.Localization;

namespace Microsoft.Extensions.DependencyInjection  
{
  using global::Localization.JsonLocalizer;
  using global::Localization.JsonLocalizer.StringLocalizer;

  public static class JsonLocalizationServiceCollectionExtensions
  {
    public static IServiceCollection AddJsonLocalization(
        this IServiceCollection services)
    {
      return AddJsonLocalization(services, setupAction: null);
    }

    public static IServiceCollection AddJsonLocalization(
        this IServiceCollection services,
        Action<JsonLocalizationOptions> setupAction)
    {
      services.TryAdd(new ServiceDescriptor(typeof(IStringLocalizerFactory),
          typeof(JsonStringLocalizerFactory), ServiceLifetime.Singleton));
      services.TryAdd(new ServiceDescriptor(typeof(IStringLocalizer),
          typeof(JsonStringLocalizer), ServiceLifetime.Singleton));

      if (setupAction != null)
      {
        services.Configure(setupAction);
      }
      return services;
    }
  }
}

The AddJsonLocalization method basically adds two additional singleton services: JsonStringLocalizerFactory and JsonStringLocalizer. JsonStringLocalizerFactory is an implementation of IStringLocalizerFactory and this interface provides two factory methods:

public interface IStringLocalizerFactory  
{
  IStringLocalizer Create(Type resourceSource);
  IStringLocalizer Create(string baseName, string location);
}

These correspond to the two usage patterns for localizers. The first is for injection into classes, a controller class for example:

public class HomeController : Controller  
{
  public HomeController(IHtmlLocalizer<HomeController> localizer)
  {
    var welcomeText = localizer["ResourceKey.Welcome"];
  }
}

The second is called when a localizer is injected directly into a view:

@inject IViewLocalizer Localizer
<span>@Localizer["ResourceKey.Welcome"], Ronald</span>  

Suppose the view is Views/Home/Index.cshtml and your application is located in a folder called My.Application then the second IStringLocalizerFactory method is called with parameters (baseName: "Views.Home.Index.cshtml", location: "My.Application").

Resource location algorithm

I'm not going into details on the JsonStringLocalizerFactory and JsonStringLocalizer classes themselves because the code is on GitHub so you can check it out there. What's more interesting is the algorithm that looks for resource files. If you want to actually use this middleware that may be more useful, I think.

Suppose we inject a IHtmlLocalizer<HomeController> into a My.Application.HomeController class. Suppose furthermore we are in Holland so the culture is nl-NL and we have set the JsonLocalizationOptions.ResourcesPath to "Resources". The algorithm will look for a JSON resource file with the following paths in order:

  • My.Application.HomeController.nl-NL.json
  • My/Application.HomeController.nl-NL.json
  • My/Application/HomeController.nl-NL.json
  • Resources.HomeController.nl-NL.json
  • Resources/HomeController.nl-NL.json
  • My.Application.HomeController.nl.json
  • My/Application.HomeController.nl.json
  • My/Application/HomeController.nl.json
  • Resources.HomeController.nl.json
  • Resources/HomeController.nl.json
  • My.Application.HomeController.json
  • My/Application.HomeController.json
  • My/Application/HomeController.json
  • Resources.HomeController.json
  • Resources/HomeController.json

So the algorithm starts with the most specific culture and falls back to less specific cultures. This 'looking for resource files' operation is relatively expensive so the result is cached for later use and guaranteed to execute just once.

What's next?

The library as it stands now is far from finished. There are still some NotImplementedExceptions to be dealt with so that's the first step. And some other ideas that I have:

  • Implement 'lazy' functionality that allows re-loading of JSON resource files in development mode. This allows you to edit resource files on the fly and see the result after a browser refresh. This should work particularly well in combination with dnx-watch.
  • Offer this as a NuGet package.
  • Allow other resource types. The resx format allows a lot more resource types than just strings. One obvious candidate is images. Of course, this requires an extension of the simple JSON file format I have now with metadata about the type of value I'm reading.

So that's it. I hope you like this localization middleware component. If you have feedback or ideas on further extensions, please email me at rwwildenatgmaildotcom.