Creating a json resource reader for dotnet core

In this post, we're going to create a custom Resource reader to use with dotnet core libraries. In the end, we can have a project dedicated to resources.

First, we need a class that represents our json file:

using System.Collections.Generic;

namespace I18N
{
    internal class JsonLocalization
    {
        public string Key { get; set; }
        public Dictionary<string, string> LocalizedValues { get; set; }
    }
}

The Key is an unique identifier for the localization and LocalizedValues is a dictionary, which its key is the language and the value the text that must be displayed.

We also going to create a new type of Exception, to easily pinpoint what went wrong with the application.

using System;

namespace I18N
{
    public class I18NException : Exception
    {
        public I18NException(string message) : base(message)
        {
        }

        public I18NException(string message, Exception innerException) : base(message, innerException)
        {
        }

        public I18NException()
        {
        }
    }
}

Here is where the magic happens, the JsonLocalizer class will read our json resources files, store them in memory and make them available to our application.

In our constructor we expect two parameters, useBase and additionalPaths.

If useBase is set to true, the localizer will load *.json files that are in the Resources folder.
additionalPaths uses a type as a key, the localizer will use this type to find the assembly path and read the *.json files in the Resources folder.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Newtonsoft.Json;

namespace I18N
{
    public class JsonLocalizer
    {
        private readonly Dictionary<string, JsonLocalization[]> _localization
            = new Dictionary<string, JsonLocalization[]>();

        public JsonLocalizer(bool useBase = true, Dictionary<Type, string> additionalPaths = null)
        {
            if (useBase)
                PopulateLocalization("Resources");

            if (additionalPaths == null) return;
            foreach (var additional in additionalPaths)
            {
                var codeBase = additional.Key.Assembly.CodeBase;
                var uri = new UriBuilder(codeBase);
                var data = Uri.UnescapeDataString(uri.Path);
                var path = Path.GetDirectoryName(data);
                var fullPath = Path.Combine(path, additional.Value);
                PopulateLocalization(fullPath);
            }
        }

        /// <summary>
        /// resource:key:culture
        /// resource is the resource name
        /// key is the key you're looking for
        /// culture is optional
        /// </summary>
        /// <param name="key"></param>
        public string this[string key] => GetString(key);


        private void PopulateLocalization(string path)
        {
            foreach (var resource in Directory.GetFiles(path, "*.json", SearchOption.AllDirectories))
            {
                try
                {
                    var fileInfo = new FileInfo(resource);
                    var fileName = fileInfo.Name.Substring(0, fileInfo.Name.IndexOf('.'));
                    var loc = JsonConvert.DeserializeObject<JsonLocalization[]>(File.ReadAllText(resource));
                    _localization.Add(fileName, loc);
                }
                catch (ArgumentException e)
                {
                    throw new I18NException($"Resource {resource} was already added, check your files.", e);
                }
                catch (Exception ex)
                {
                    throw new I18NException("Something wrong is not right, check inner exception", ex);
                }
            }
        }

        private string GetString(string query)
        {
            try
            {
                string culture = null;

                var split = query.Split(':');
                var resource = split[0];
                var key = split[1];
                if (split.Length > 2)
                    culture = split[2];

                culture = culture ?? CultureInfo.CurrentCulture.Name;

                return _localization
                    .Single(l => l.Key == resource)
                    .Value.Single(x => x.Key == key)
                    .LocalizedValues[culture];
            }
            catch (Exception ex)
            {
                throw new I18NException($"Couldn't find key: {query}", ex);
            }
        }
    }
}

In dotnet core applications, you can add the JsonLocalizer using the IServiceCollection in the ConfigureServices method.

// use it in DI as a singleton
public void ConfigureServices(IServiceCollection services)
{
   // Other configurations ...
   services.AddSingleton<JsonLocalizer>();
}

To handle additionalPaths

var additional = new Dictionary<Type, string>
             {
                { typeof(MyClass), "My Resource Folder" },
                { typeof(MyAnotherClass), "My Resource Folder/Even Handles sub folders" }
             };

var withExternalSources = new JsonLocalizer(additionalPaths: additional);

Now that we have everything set up, we can start using our localizer:

private readonly JsonLocalizer _localizer;

public class MySampleClass(JsonLocalizer localizer)
{
   _localizer = localizer;
}

public string GetLocalizedMessage()
{
   return _localizer["MyAppResource:MyKey"];
}

The Localizer will find your text by:

FileName:Key:Language

Here are some examples of how to write your resource files:

File Name Resource Name
MyResource.json MyResource
MyApp.Resource.json MyApp
MyApp-Errors.Resource.json MyApp-Errors
MyApp.Errors.Resource.json MyApp

The Key is the key inside the resource file, and the Language is the culture, if not informed, will use the CultureInfo.CurrentCulture value.

The json resource file should follow this format:

[
   {
      "Key":"Name",
      "LocalizedValues":{
         "en-US":"Name",
         "pt-BR":"Nome"
      }
   },
   {
      "Key":"Age",
      "LocalizedValues":{
         "en-US":"Age",
         "pt-BR":"Idade"
      }
   }
]

18