Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

A Better Way to Set and Manage Locale Data in Your Phoenix Applications

DZone's Guide to

A Better Way to Set and Manage Locale Data in Your Phoenix Applications

In this post, we look at how to create a module plug that tries to fetch locale from the GET param, cookie, and HTTP header for i18n purposes.

· Web Dev Zone ·
Free Resource

Deploying code to production can be filled with uncertainty. Reduce the risks, and deploy earlier and more often. Download this free guide to learn more. Brought to you in partnership with Rollbar.

In my previous article, I've explained how to internationalize Phoenix applications with the help of Gettext. We have introduced support for two languages, covered the process of extracting translations, adding pluralizations, and some other topics. Also, we have briefly talked about switching between locales by utilizing a third-party plug called set_locale. This plug is really convenient and easy to use but it appears that a similar solution can be coded from scratch quite easily. After all, it is much better to code some features all by yourself to understand how exactly it works. Also, this way you have total control over how everything ties in together.

So, today I'd like to show you how to set and manage locale data in the Phoenix applications with the help of a module plug. Our solution is going to support three sources of locale data:

  • GET param
  • Cookie
  • HTTP header

This way once a user has chosen some locale setting, it will be persisted and utilized on subsequent visits without the need to adjust this setting again.

We will continue working on the demo application created in the previous article. If you'd like to follow along, simply clone this repo by running:

git clone git@github.com/phrase/PhraseAppPhoenixI18n

The final version of the application is available at the same repo, under the locale branch. All committed changes can be found on this page. Also, note that in order to run the application you'll require:

Some Cleanup

Before proceeding to the main part, let's do some cleanup. As long as we are not going to employ the set_locale plug anymore, the following line can be removed from the mix.exs file:

  defp deps do
    [
      # ...
      {:set_locale, "~> 0.2.1"} # <===
    ]
  end

Also, remove set_locale from the application (inside the same file):

 def application do
    [
      mod: {Demo.Application, []},
      extra_applications: [
        :logger,
        :runtime_tools,
        :set_locale # <===
      ]
    ]
  end

Next, tweak the lib/demo_web/router.ex file by removing the third-party plug:

plug SetLocale, gettext: DemoWeb.Gettext, default_locale: "ru"

And keep only the following scope:

  scope "/", DemoWeb do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
  end

This way, we have gotten rid of the set_locale plug and may proceed to craft our own solution.

Creating a Custom Locale Plug

So, we are going to create our own custom plug called Locale. Its behavior will be somewhat similar to the set_locale plug used in the previous article, but with some differences. Here are the key points:

  • The locale should be initially set based on the value of the locale GET param. So, if I visit http://localhost:4000?locale=ru, a Russian locale should be utilized.
  • If this GET param is not present, try to use the value from a cookie called locale.
  • If the cookie is not set as well, check the Accept-Language HTTP header.
  • Lastly, if the header is not present, fall back to a default locale. The same applies to scenarios when the requested locale is not supported.
  • As long as the default locale is already set in the config/config.exs (line config :demo, DemoWeb.Gettext, default_locale: "ru", locales: ~w(en ru)), there is no need to pass the default value to the plug again as it was done with the set_locale.
  • After the locale was successfully set, its value should be saved under the locale cookie.

All in all, nothing complex. Alright, start by hooking up a new plug by modifying the router.ex file:

  pipeline :browser do
    # ...
    plug DemoWeb.Plugs.Locale
  end

Next, create a new lib/demo_web/plugs/locale_plug.ex file which is going to contain the actual plug:

defmodule DemoWeb.Plugs.Locale do
  import Plug.Conn
end

So, this plug allows us to transform the Connection object somehow. As explained by the documentation, it should define two callbacks:

  • init/1 that initializes options to be passed to call/2. It may simply return nil though.
  • call/2 which performs the actual transformation. It accepts and must return the connection object.

Here is the first draft of these two callbacks:

  def init(_opts), do: nil

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) do
  end

init/1 does not need to initialize any options, so it simply returns nil. If, for example, you want it to accept a default locale, change it to something like:

def init(default_locale), do: default_locale # here you may also want
# to check if the default_locale actually supported by the app

The plug will then accept a default value in the route.ex file like this:

plug DemoWeb.Plugs.Locale, "en" # default locale set to "en"

Now let's talk about the call/2 callback. The part %Plug.Conn{params: %{"locale" => locale}} = conn allows us to fetch the locale param and assign it to the locale variable. _opts has the value of nil (because that's what the init/1 callback returns) and we are not going to use it.

The problem is that the requested locale may not be supported at all, so we should check for such cases. This can be done inside the call function itself, or by using guard clauses:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
  end

  def call(conn, _opts), do: conn

when locale in @locales is our guard clause that checks whether the requested locale is present inside the @locales list (which will be defined in a moment). If it does present, the function will be executed, otherwise, we proceed to the def call(conn, _opts), do: conn line and simply return the connection back without doing anything else.

Now, all we need to do is define the @locales list:

@locales Gettext.known_locales(DemoWeb.Gettext)

Note that you cannot employ know_locales directly in the guard clause as you'll end with an error:

** (ArgumentError) invalid args for operator "in", it expects a compile-time list or compile-time range on the right side when used in guard expressions

Setting Locale

The next step is to actually set the locale by calling the put_locale/2 function that accepts a Gettext backend and the language's code:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
    Gettext.put_locale(DemoWeb.Gettext, locale) # <===
  end

Also, don't forget to return the conn itself:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
    Gettext.put_locale(DemoWeb.Gettext, locale)
    conn # <===
  end

Great! The first iteration is nearly finished and you may boot the server by running:

mix phx.server

Navigate to the http://127.0.0.1:4000/ and make sure that the default locale (Russian, in my case) is used. Next, try switching it by going to http://127.0.0.1:4000?locale=en — all text should be in English. Note that if you try to open http://127.0.0.1:4000/ again, the text will still be in English. If, however, you reboot the server, this setting will be lost and the default language will be utilized again. We'll deal with this problem later.

UI Changes

Before we proceed to the next iteration, let's also present two links to switch between locales for our own convenience. First of all, introduce a new helper inside the views/layout_view.ex file:

  def switch_locale_path(conn, locale, language) do
    "<a href=\"#{page_path(conn, :index, locale: :en)}\">#{language}</a>" |> raw
  end

raw/1 function should be called here because otherwise the HTML will be rendered as plain text, whereas we want this string to turn into a hyperlink.

Next, simply utilize this helper inside the templates/layout/app.html.eex by modifying the default navigation block:

      <header class="header">
        <nav role="navigation">
          <ul class="nav nav-pills pull-right">
            <li><%= switch_locale_path @conn, :en, "English" %></li>
            <li><%= switch_locale_path @conn, :ru, "Russian" %></li>
          </ul>
        </nav>
        <span class="logo"></span>
      </header>

Great! Now you may switch between locales by simply clicking on one of these links.

Persisting Locale Data

Now that we have coded some very basic version of the plug, let's try making it a bit more complex. What I want to do is store the chosen locale in a cookie named, quite unsuprisingly, locale:

  def call(%Plug.Conn{params: %{"locale" => locale}} = conn, _opts) when locale in @locales do
    Gettext.put_locale(DemoWeb.Gettext, locale)
    conn = put_resp_cookie conn, "locale", locale, max_age: 10*24*60*60 # <===
    conn
  end

The cookie is set using the put_resp_cookie/4 function. "locale" is the key, whereas locale is the value that should be stored under this key. Also, I've set the max_age option to 10 days, but you may provide a much greater value so that the cookie becomes virtually permanent. Note that you must assign the result of calling put_resp_cookie/4 to the conn, otherwise the data won't be persisted.

Next, let's make sure that the cookie actually has the correct data by printing out its contents inside the lib/demo_web/controllers/page_controller.ex:

  def index(conn, _params) do
    conn.cookies["locale"] |> IO.inspect # <===
    render conn, "index.html"
  end

Visit the http://127.0.0.1:4000/?locale=en URL and make sure that the console has the following output:

[info] GET /
[debug] Processing with DemoWeb.PageController.index/2
  Parameters: %{"locale" => "en"}
  Pipelines: [:browser]
"en"
[info] Sent 200 in 0ms

Brilliant!

Note that the same result may be achieved by storing the locale inside the session, not the cookie. To save some data inside the session, utilize the put_session/3 function:

conn = conn |> put_session(:locale, locale)

:locale here is a key (which can also be represented as a string), whereas locale is a value. The data can be then read with the help of get_session/2 function:

get_session(conn, :locale)

Fetching Locale Data

The chosen locale is now persisted inside the cookie, but it needs to be properly read. On top of that, we have to make sure that the language is supported. The guard clause is not very suitable for this scenario because we need to perform too many actions. Instead, let's stick with the case macro:

  def call(conn, _opts) do
    case locale_from_params(conn) || locale_from_cookies(conn) do
      nil     -> conn
      locale  ->
        Gettext.put_locale(DemoWeb.Gettext, locale)
        conn = put_resp_cookie conn, "locale", locale, max_age: 10 * 24 * 60 * 60
        conn
    end
  end

Here we are using two new functions that will be defined later: locale_from_params/1 and locale_from_cookies/1. These functions return either the locale itself or nil if the chosen locale is not supported or not provided. If nil was returned by both functions, call/2 simply returns conn and nothing else happens. Otherwise, we perform the same actions as before: set the locale and persist it inside the cookie.

Now let's code the two new functions that will be marked as private:

  defp locale_from_params(conn) do
    conn.params["locale"] |> validate_locale
  end

  defp locale_from_cookies(conn) do
    conn.cookies["locale"] |> validate_locale
  end

Nothing fancy is going on here. We simply fetch params or cookies and then validate the value. validate_locale/1 is yet another private function:

  defp validate_locale(locale) when locale in @locales, do: locale

  defp validate_locale(_locale), do: nil

This is where we are using our old guard clause that makes sure the locale is actually supported.

One thing I don't like about the call/2 function is that we are persisting the locale under any circumstances, even if the same value is already stored. Let's change this behavior by utilizing a new function:

  def call(conn, _opts) do
    case locale_from_params(conn) || locale_from_cookies(conn) do
      nil     -> conn
      locale  ->
        Gettext.put_locale(DemoWeb.Gettext, locale)
        conn = conn |> persist_locale(locale) # <===
        conn
    end
  end

Here is the function itself:

  defp persist_locale(conn, new_locale) do
    if conn.cookies["locale"] != new_locale do
      conn |> put_resp_cookie("locale", new_locale, max_age: 10 * 24 * 60 * 60)
    else
      conn
    end
  end

Now if the cookie's value does not match the newly chosen locale we overwrite it. Unfortunately, we cannot access conn.cookies in the guard clause, so I had to stick with the if macro instead.

Fetching From the HTTP Header

At this point, we are trying to fetch locale data from the GET param and from the cookie. Why don't we also take the Accept-Locale HTTP header into consideration? To do that, we can utilize the functions already introduced in the set_locale plug:

  # Taken from set_locale plug written by Gerard de Brieder
  # https://github.com/smeevil/set_locale/blob/fd35624e25d79d61e70742e42ade955e5ff857b8/lib/headers.ex
  defp locale_from_header(conn) do
    conn
    |> extract_accept_language
    |> Enum.find(nil, fn accepted_locale -> Enum.member?(@locales, accepted_locale) end)
  end

  def extract_accept_language(conn) do
    case Plug.Conn.get_req_header(conn, "accept-language") do
      [value | _] ->
        value
        |> String.split(",")
        |> Enum.map(&parse_language_option/1)
        |> Enum.sort(&(&1.quality > &2.quality))
        |> Enum.map(&(&1.tag))
        |> Enum.reject(&is_nil/1)
        |> ensure_language_fallbacks()

        _ ->
        []
      end
    end

    defp parse_language_option(string) do
      captures = Regex.named_captures(~r/^\s?(?<tag>[\w\-]+)(?:;q=(?<quality>[\d\.]+))?$/i, string)

      quality = case Float.parse(captures["quality"] || "1.0") do
        {val, _} -> val
        _ -> 1.0
      end

      %{tag: captures["tag"], quality: quality}
    end

    defp ensure_language_fallbacks(tags) do
      Enum.flat_map tags, fn tag ->
        [language | _] = String.split(tag, "-")
        if Enum.member?(tags, language), do: [tag], else: [tag, language]
      end
    end

These functions, basically, parse the HTTP header and make sure that the language is present in the list of allowed locales.

Now we may simply the locale_from_header/1 function:

  def call(conn, _opts) do
    case locale_from_params(conn) || locale_from_cookies(conn) || locale_from_header(conn) do # <===
      nil     -> conn
      locale  ->
        Gettext.put_locale(DemoWeb.Gettext, locale)
        conn = conn |> persist_locale(locale)
        conn
    end
  end

And, this is it! You may now play with the application by switching between locales or trying to provide some non-supported language. Everything should work properly, which is really cool.

Conclusion

This is all for today! In this article, we have seen how to set and manage locale data in Phoenix applications. You have seen how to create a module plug that tries to fetch locale from the GET param, cookie, and HTTP header, which is quite flexible. The resulting functionality is somewhat similar to the set_locale plug, but now you have full control over how everything works and (hopefully!) understand the logic behind all this code.

I hope this tutorial was useful for you. As always, thanks for staying with me and see you in the next article!

Deploying code to production can be filled with uncertainty. Reduce the risks, and deploy earlier and more often. Download this free guide to learn more. Brought to you in partnership with Rollbar.

Topics:
phoenix ,i18n ,localization ,internationalization ,web dev

Published at DZone with permission of

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}