lib/cldr/plug/plug_set_locale.ex

if Code.ensure_loaded?(Plug) do
  defmodule Cldr.Plug.SetLocale do
    @moduledoc """
    Sets the Cldr and/or Gettext locales derived from the accept-language
    header, a query parameter, a url parameter, a body parameter or the
    session.

    ## Options

      * `:apps` - list of apps for which to set locale.
        See the apps configuration section.

      * `:from` - where in the request to look for the locale.
        The default is `[:session, :accept_language]`. The valid
        options are:
        * `:accept_language` will parse the `accept-language` header
           and finds the best matched configured locale
        * `:path` will look for a locale by examining `conn.path_params`
        * `:query` will look for a locale by examining `conn.query_params`
        * `:body` will look for a locale by examining `conn.body_params`
        * `:cookie` will look for a locale in the request cookie(s)
        * `:session` will look for a locale in the session

      * `:default` - the default locale to set if no locale is
        found by other configured methods.  It can be a string like "en"
        or a `Cldr.LanguageTag` struct. The default is
        `Cldr.default_locale/1`

      * `:gettext` - the name of the `Gettext` backend module upon which
        the locale will be validated. This option is not required if a
        gettext module is specified in the `:apps` configuration.

      * `:cldr` - the name of the `Cldr` backend module upon which
        the locale will be validated.  This option is not required if a
        gettext module is specified in the `:apps` configuration.

      * `:session_key` - defines the key used to look for the locale
        in the session.  The default is `locale`.

    If a locale is found then `conn.private[:cldr_locale]` is also set.
    It can be retrieved with `Cldr.Plug.SetLocale.get_cldr_locale/1`.

    ## App configuration

    The `:apps` configuration key defines which applications will have
    their locale *set* by this plug.

    `Cldr.Plug.SetLocale` can set the locale for `cldr`, `gettext` or both.
    The basic configuration of the `:app` key is an atom, or list of atoms,
    containing one or both of these app names.  For example:

        apps: :cldr
        apps: :gettext
        apps: [:cldr, :gettext]

    In each of these cases, the locale is set globally
    **for the current process**.

    Sometimes setting the locale for only a specific backend is required.
    In this case, configure the `:apps` key as a keyword list pairing an
    application with the required backend module.  The value `:global` signifies
    setting the local for the global context. For example:

        apps: [cldr: MyApp.Cldr]
        apps: [gettext: MyAppGettext]
        apps: [gettext: :global]
        apps: [cldr: MyApp.Cldr, gettext: MyAppGettext]

    ## Using Cldr.Plug.SetLocale without Phoenix

    If you are using `Cldr.Plug.SetLocale` without Phoenix and you
    plan to use `:path_param` to identify the locale of a request
    then `Cldr.Plug.SetLocale` must be configured *after* `plug :match`
    and *before* `plug :dispatch`.  For example:

        defmodule MyRouter do
          use Plug.Router

          plug :match

          plug Cldr.Plug.SetLocale,
            apps: [:cldr, :gettext],
            from: [:path, :query],
            gettext: MyApp.Gettext,
            cldr: MyApp.Cldr

          plug :dispatch

          get "/hello/:locale" do
            send_resp(conn, 200, "world")
          end
        end

    ## Using Cldr.Plug.SetLocale with Phoenix

    If you are using `Cldr.Plug.SetLocale` with Phoenix and you plan
    to use the `:path_param` to identify the locale of a request then
    `Cldr.Plug.SetLocale` must be configured in the router module, *not*
    in the endpoint module. This is because `conn.path_params` has
    not yet been populated in the endpoint. For example:

        defmodule MyAppWeb.Router do
          use MyAppWeb, :router

          pipeline :browser do
            plug :accepts, ["html"]
            plug :fetch_session
            plug Cldr.Plug.SetLocale,
        	    apps: [:cldr, :gettext],
        	    from: [:path, :query],
        	    gettext: MyApp.Gettext,
        	    cldr: MyApp.Cldr
            plug :fetch_flash
            plug :protect_from_forgery
            plug :put_secure_browser_headers
          end

          scope "/:locale", HelloWeb do
            pipe_through :browser

            get "/", PageController, :index
          end
        end

    ## Examples

        # Will set the global locale for the current process
        # for both `:cldr` and `:gettext`
        plug Cldr.Plug.SetLocale,
          apps:    [:cldr, :gettext],
          from:    [:query, :path, :body, :cookie, :accept_language],
          param:   "locale",
          gettext: GetTextModule,
          cldr:    MyApp.Cldr

        # Will set the backend-only locale for the current process
        # for both `:cldr` and `:gettext`
        plug Cldr.Plug.SetLocale,
          apps:    [cldr: MyApp.Cldr, gettext: GetTextModule],
          from:    [:query, :path, :body, :cookie, :accept_language],
          param:   "locale"

        # Will set the backend-only locale for the current process
        # for `:cldr` and globally for `:gettext`
        plug Cldr.Plug.SetLocale,
          apps:    [cldr: MyApp.Cldr, gettext: :global],
          from:    [:query, :path, :body, :cookie, :accept_language],
          param:   "locale"

    """

    import Plug.Conn
    require Logger
    alias Cldr.AcceptLanguage

    @default_apps [cldr: :global]
    @default_from [:session, :accept_language, :query, :path]
    @default_param_name "locale"

    @private_key :cldr_locale
    @session_key "cldr_locale"

    @from_options [:accept_language, :path, :body, :query, :session, :cookie]
    @app_options [:cldr, :gettext]

    @language_header "accept-language"

    @doc false
    def init(options) do
      options
      |> validate_apps(options[:apps])
      |> validate_from(options[:from])
      |> validate_param(options[:param])
      |> validate_cldr(options[:cldr])
      |> validate_gettext(options[:gettext])
      |> validate_default(options[:default])
      |> validate_session_key(options[:session_key])
    end

    @doc false
    def call(conn, options) do
      if locale = locale_from_params(conn, options[:from], options) || options[:default] do
        Enum.each(options[:apps], fn app ->
          put_locale(app, locale, options)
        end)

        put_private(conn, @private_key, locale)
      else
        conn
      end
    end

    @doc """
    Returns the name of the session key used
    to store the CLDR locale name.

    ## Example

      iex> Cldr.Plug.SetLocale.session_key()
      "cldr_locale"

    """
    def session_key do
      @session_key
    end

    @doc false
    def private_key do
      @private_key
    end

    @doc """
    Return the locale set by `Cldr.Plug.SetLocale`

    """
    def get_cldr_locale(conn) do
      conn.private[:cldr_locale]
    end

    defp locale_from_params(conn, from, options) do
      Enum.reduce_while(from, nil, fn param, _acc ->
        conn
        |> fetch_param(param, options[:param], options)
        |> return_if_valid_locale
      end)
    end

    defp fetch_param(conn, :accept_language, _param, options) do
      case get_req_header(conn, @language_header) do
        [accept_language] -> AcceptLanguage.best_match(accept_language, options[:cldr])
        [accept_language | _] -> AcceptLanguage.best_match(accept_language, options[:cldr])
        [] -> nil
      end
    end

    defp fetch_param(
           %Plug.Conn{query_params: %Plug.Conn.Unfetched{aspect: :query_params}} = conn,
           :query,
           param,
           options
         ) do
      conn = fetch_query_params(conn)
      fetch_param(conn, :query, param, options)
    end

    defp fetch_param(conn, :query, param, options) do
      conn
      |> Map.get(:query_params)
      |> Map.get(param)
      |> Cldr.validate_locale(options[:cldr])
    end

    defp fetch_param(conn, :path, param, options) do
      conn
      |> Map.get(:path_params)
      |> Map.get(param)
      |> Cldr.validate_locale(options[:cldr])
    end

    defp fetch_param(conn, :body, param, options) do
      conn
      |> Map.get(:body_params)
      |> Map.get(param)
      |> Cldr.validate_locale(options[:cldr])
    end

    defp fetch_param(conn, :session, _param, options) do
      conn
      |> get_session(options[:session_key])
      |> Cldr.validate_locale(options[:cldr])
    end

    defp fetch_param(conn, :cookie, param, options) do
      conn
      |> Map.get(:cookies)
      |> Map.get(param)
      |> Cldr.validate_locale(options[:cldr])
    end

    defp return_if_valid_locale(nil) do
      {:cont, nil}
    end

    defp return_if_valid_locale({:error, _}) do
      {:cont, nil}
    end

    defp return_if_valid_locale({:ok, locale}) do
      {:halt, locale}
    end

    defp put_locale({:cldr, :global}, locale, _options) do
      Cldr.put_locale(locale)
    end

    # Deprecated option :all.  Use :global
    defp put_locale({:cldr, :all}, locale, _options) do
      Cldr.put_locale(locale)
    end

    defp put_locale({:cldr, backend}, locale, _options) do
      backend.put_locale(locale)
    end

    defp put_locale({:gettext, _}, %Cldr.LanguageTag{gettext_locale_name: nil} = locale, _options) do
      Logger.warn(
        "Locale #{inspect(locale.requested_locale_name)} does not have a known " <>
          "Gettext locale.  No Gettext locale has been set."
      )

      nil
    end

    defp put_locale(
           {:gettext, :global},
           %Cldr.LanguageTag{gettext_locale_name: locale_name},
           _options
         ) do
      {:ok, apply(Gettext, :put_locale, [locale_name])}
    end

    # Deprecated option :all.  Use :global
    defp put_locale(
           {:gettext, :all},
           %Cldr.LanguageTag{gettext_locale_name: locale_name},
           _options
         ) do
      {:ok, apply(Gettext, :put_locale, [locale_name])}
    end

    defp put_locale(
           {:gettext, backend},
           %Cldr.LanguageTag{gettext_locale_name: locale_name},
           _options
         ) do
      {:ok, apply(Gettext, :put_locale, [backend, locale_name])}
    end

    defp validate_apps(options, nil), do: Keyword.put(options, :apps, @default_apps)

    defp validate_apps(options, app) when is_atom(app) do
      options
      |> Keyword.put(:apps, [app])
      |> validate_apps([app])
    end

    defp validate_apps(options, apps) when is_list(apps) do
      app_config =
        Enum.map(apps, fn
          {app, scope} ->
            validate_app_and_scope!(app, scope)
            {app, scope}

          app ->
            validate_app_and_scope!(app, nil)
            {app, :global}
        end)

      Keyword.put(options, :apps, app_config)
    end

    defp validate_apps(_options, apps) do
      raise(
        ArgumentError,
        "Invalid apps list: #{inspect(apps)}."
      )
    end

    defp validate_app_and_scope!(app, nil) when app in @app_options do
      :ok
    end

    defp validate_app_and_scope!(app, :global) when app in @app_options do
      :ok
    end

    # Deprecated option :all.  Use :global
    defp validate_app_and_scope!(app, :all) when app in @app_options do
      :ok
    end

    defp validate_app_and_scope!(:cldr, module) when is_atom(module) do
      Cldr.validate_backend!(module)
      :ok
    end

    defp validate_app_and_scope!(:gettext, module) when is_atom(module) do
      Cldr.Code.ensure_compiled?(module) ||
        raise(ArgumentError, "Gettext backend #{inspect(module)} is unknown")

      :ok
    end

    defp validate_app_and_scope!(app, scope) do
      raise(
        ArgumentError,
        "Invalid app #{inspect(app)} or scope #{inspect(scope)} detected."
      )
    end

    defp validate_from(options, nil), do: Keyword.put(options, :from, @default_from)

    defp validate_from(options, from) when is_atom(from) do
      options
      |> Keyword.put(:from, [from])
      |> validate_from([from])
    end

    defp validate_from(options, from) when is_list(from) do
      Enum.each(from, fn f ->
        if f not in @from_options do
          raise(
            ArgumentError,
            "Invalid :from option #{inspect(f)} detected.  " <>
              " Valid :from options are #{inspect(@from_options)}"
          )
        end
      end)

      options
    end

    defp validate_from(_options, from) do
      raise(
        ArgumentError,
        "Invalid :from list #{inspect(from)} detected.  " <>
          "Valid from options are #{inspect(@from_options)}"
      )
    end

    defp validate_param(options, nil), do: Keyword.put(options, :param, @default_param_name)
    defp validate_param(options, param) when is_binary(param), do: options

    defp validate_param(options, param) when is_atom(param) do
      validate_from(options, param)
    end

    defp validate_param(_options, param) do
      raise(
        ArgumentError,
        "Invalid :param #{inspect(param)} detected. " <> ":param must be a string"
      )
    end

    defp validate_default(options, nil) do
      default = options[:cldr].default_locale()
      Keyword.put(options, :default, default)
    end

    defp validate_default(options, default) do
      case Cldr.validate_locale(default, options[:cldr]) do
        {:ok, locale} -> Keyword.put(options, :default, locale)
        {:error, {exception, reason}} -> raise exception, reason
      end
    end

    # No configured gettext.  See if there is one configured
    # on the Cldr backend
    defp validate_gettext(options, nil) do
      gettext = options[:cldr].__cldr__(:config).gettext

      if gettext && get_in(options, [:apps, :gettext]) do
        Keyword.put(options, :gettext, gettext)
      else
        options
      end
    end

    defp validate_gettext(options, gettext) do
      case Code.ensure_compiled(gettext) do
        {:error, _} ->
          raise ArgumentError, "Gettext module #{inspect(gettext)} is not known"

        {:module, _} ->
          options
      end
    end

    defp validate_session_key(options, nil),
      do: Keyword.put(options, :session_key, @session_key)

    defp validate_session_key(options, session_key) when is_binary(session_key) do
      IO.warn(
        "The :session_key option is deprecated and will be removed in " <>
          "a future release",
        []
      )

      options
    end

    defp validate_session_key(_options, session_key) do
      raise(
        ArgumentError,
        "Invalid :session_key #{inspect(session_key)} detected. " <>
          ":session_key must be a string"
      )
    end

    defp validate_cldr(options, nil) do
      backend = Keyword.get_lazy(options[:apps], :cldr, &Cldr.default_locale/0)
      validate_cldr(options, backend)
    end

    defp validate_cldr(options, backend) when is_atom(backend) do
      with {:ok, backend} <- Cldr.validate_backend(backend) do
        Keyword.put(options, :cldr, backend)
      else
        {:error, {exception, reason}} -> raise exception, reason
      end
    end
  end
end