Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 42 additions & 13 deletions lib/localize/plug/put_locale.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions test/plug/put_locale_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading