diff --git a/lib/localize/plug/put_locale.ex b/lib/localize/plug/put_locale.ex index 652f36d..fdbba45 100644 --- a/lib/localize/plug/put_locale.ex +++ b/lib/localize/plug/put_locale.ex @@ -14,6 +14,8 @@ defmodule Localize.Plug.PutLocale do If a locale is found then `conn.private[:localize_locale]` is also set. It can be retrieved with `Localize.Plug.PutLocale.get_locale/1`. + The response gets a `vary` header for each request header the plug read to find the locale: `accept-language` for `:accept_language`, and `cookie` for `:session` and `:cookie`. A source read before the one that decided counts too, because a value there would have changed the locale. Existing `vary` values are kept. This lets a shared cache store one response per language. A `{Module, function}` source that reads a request header must add its own `vary` value. + ### Options * `:from` is a list specifying where in the request to look for the locale. The default is `#{inspect(@default_from)}`. The valid options are: @@ -89,7 +91,9 @@ defmodule Localize.Plug.PutLocale do @doc false def call(conn, options) do - locale = locale_from_params(conn, options[:from], options) || default(conn, options) + {locale, consulted} = locale_from_params(conn, options[:from], options) + locale = locale || default(conn, options) + conn = put_vary(conn, consulted) if locale do Localize.put_locale(locale) @@ -213,14 +217,47 @@ defmodule Localize.Plug.PutLocale do end end + # Returns the locale of the first source that has one, and the sources + # consulted to find it (all of them when none has one). 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() + Enum.reduce_while(from, {nil, []}, fn source, {nil, consulted} -> + consulted = consulted ++ [source] + + case fetch_param(conn, source, options[:param], options) do + {:ok, locale} -> {:halt, {locale, consulted}} + _other -> {:cont, {nil, consulted}} + end end) end + # A shared cache must key the response on every request header that + # could have changed the locale, or it serves one language to everyone. + defp put_vary(conn, consulted) do + existing = + conn + |> get_resp_header("vary") + |> Enum.flat_map(&String.split(&1, ",")) + |> Enum.map(&String.trim/1) + + known = Enum.map(existing, &String.downcase/1) + + new = + consulted + |> Enum.flat_map(&vary_fields/1) + |> Enum.uniq() + |> Enum.reject(&(&1 in known)) + + if new == [] or "*" in known do + conn + else + put_resp_header(conn, "vary", Enum.join(existing ++ new, ", ")) + end + end + + defp vary_fields(:accept_language), do: ["accept-language"] + defp vary_fields(source) when source in [:session, :cookie], do: ["cookie"] + defp vary_fields(_source), do: [] + defp fetch_param(conn, :accept_language, _param, _options) do case get_req_header(conn, @language_header) do [accept_language | _] -> Localize.AcceptLanguage.best_match(accept_language) @@ -303,14 +340,6 @@ defmodule Localize.Plug.PutLocale do Localize.validate_locale(locale) end - defp return_if_valid_locale({:ok, locale}) do - {:halt, locale} - end - - defp return_if_valid_locale(_) do - {:cont, nil} - end - defp validate_from(options, nil), do: Keyword.put(options, :from, @default_from) defp validate_from(options, from) when is_atom(from) do diff --git a/test/plug/put_locale_test.exs b/test/plug/put_locale_test.exs index e9551c2..da5e89a 100644 --- a/test/plug/put_locale_test.exs +++ b/test/plug/put_locale_test.exs @@ -326,6 +326,55 @@ defmodule Localize.Plug.PutLocaleTest do end end + describe "call/2 - Vary response header" do + defp vary_after(conn, from) do + conn + |> PutLocale.call(PutLocale.init(from: from)) + |> get_resp_header("vary") + end + + test "fr from accept-language sets Vary: accept-language" do + conn = conn(:get, "/") |> put_req_header("accept-language", "fr") + assert vary_after(conn, [:query, :accept_language]) == ["accept-language"] + end + + test "de from the query after an empty cookie sets Vary: cookie only" do + conn = conn(:get, "/?locale=de") |> fetch_cookies() + assert vary_after(conn, [:cookie, :query, :accept_language]) == ["cookie"] + end + + test "the default after an empty cookie and !!! accept-language sets both" do + conn = conn(:get, "/") |> fetch_cookies() |> put_req_header("accept-language", "!!!") + assert vary_after(conn, [:cookie, :accept_language]) == ["cookie, accept-language"] + end + + test "de from the query sets no Vary" do + conn = conn(:get, "/?locale=de") |> put_req_header("accept-language", "fr") + assert vary_after(conn, [:query, :accept_language]) == [] + end + + test "accept-language is added to an existing Vary: Accept-Encoding once" do + conn = + conn(:get, "/") + |> put_req_header("accept-language", "fr") + |> put_resp_header("vary", "Accept-Encoding, Accept-Language") + + assert vary_after(conn, [:accept_language]) == ["Accept-Encoding, Accept-Language"] + + conn = put_resp_header(conn, "vary", "Accept-Encoding") + assert vary_after(conn, [:accept_language]) == ["Accept-Encoding, accept-language"] + end + + test "an existing Vary: * is kept as is" do + conn = + conn(:get, "/") + |> put_req_header("accept-language", "fr") + |> put_resp_header("vary", "*") + + assert vary_after(conn, [:accept_language]) == ["*"] + end + end + describe "call/2 - default locale" do test "uses default locale when no source provides one" do options = PutLocale.init(from: [:query])