3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Phoenix アプリはおそらく Accept-Language を正しく処理していない

3
Posted at

ブラウザに複数の言語設定がある場合、Safari は次のようなヘッダーを送信することがある:

Accept-Language: ja-KS, ja;q=0.9, en-GB;q=0.7, en;q=0.6, *;q=0.1, de;q=0

このヘッダーの意味:関西方言を最も希望し、次に標準日本語、イギリス英語も許容、他の言語も最低限なら受け入れるが、ドイツ語は明示的に拒否する。

多くの Phoenix アプリケーションはこのヘッダーを正しく処理できていない。その理由を見ていこう。

罠 1:最初の言語だけを取得する

よくある素朴な実装:

accept = get_req_header(conn, "accept-language") |> List.first()
locale = accept |> String.split(",") |> List.first() |> String.trim()
# => "ja-KS"

結果は "ja-KS" になる。サーバーが [:ja, :en] をサポートしている場合、完全一致するものがないため、デフォルトの英語が返される。日本語が通じるにもかかわらず。

問題は、ja-KS がプレフィックス一致で :ja に対応すべきだという点にある。RFC 4647 Section 3.3.1 では、range が tag と一致するのは、完全一致するか、tag のプレフィックスとして一致し、その直後の文字がハイフン - である場合と定義されている。

罠 2:quality value を無視する

もう少し改善したコードでは、すべての言語を抽出するかもしれないが、q-value を無視してしまう:

accept
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&(String.split(&1, ";") |> List.first()))
# => ["ja-KS", "ja", "en-GB", "en", "*", "de"]

リストを順に走査して最初に一致したものを選ぶ。この例ではうまくいくが、ヘッダーが en;q=0.7, fr;q=0.9 の場合、フランス語が優先されるべきところで英語が選ばれてしまう。

罠 3:偽のプレフィックス一致

プレフィックス zhzh-TW(台湾華語)に一致すべきだが、zhx(別の言語)には一致してはならない。境界はハイフンでなければならない。String.starts_with?/2 を使った実装では誤判定が起きる:

String.starts_with?("zhx", "zh")
# => true — 誤検出!

罠 4:除外指定を忘れる

上記のヘッダーで de;q=0 は「ドイツ語は明示的に受け入れ不可」を意味する。サーバーが [:de, :en] をサポートし、ワイルドカード * が存在する場合でも、ドイツ語を選んではならない。q=0 による除外はプレフィックス一致にも適用される:en;q=0en-USen-GB も除外する。

正しい解決策

accept_language ライブラリは RFC 4647 の Basic Filtering スキームを実装し、上記のケースをすべて正しく処理する。2019年に公開された同名の Ruby gem当時 Qiita でも紹介)に基づいた Elixir 実装である。

AcceptLanguage.negotiate("ja-KS, ja;q=0.9, en-GB;q=0.7, en;q=0.6, *;q=0.1, de;q=0", [:ja, :en])
# => :ja

# ハイフン境界を尊重したプレフィックス一致
AcceptLanguage.negotiate("zh", [:"zh-TW"])
# => :"zh-TW"

AcceptLanguage.negotiate("zh", [:zhx])
# => nil

# ワイルドカードは除外を尊重する
AcceptLanguage.negotiate("*, de;q=0", [:de, :fr])
# => :fr

# q=0 はプレフィックスでも除外される
AcceptLanguage.negotiate("*, en;q=0", [:"en-GB", :fr])
# => :fr

依存関係ゼロ、公開関数は negotiate/2 のみ、Elixir ~> 1.14 対応。

インストール

mix.exs に追加:

def deps do
  [
    {:accept_language, "~> 0.1.0"}
  ]
end

Phoenix との統合

実際のアプリケーションでは、表示言語はコンテキストによって異なる:

  1. ログイン済み — アカウントに保存された言語設定を優先し、Accept-Language ヘッダーは無視する
  2. 未ログイン、ヘッダーあり — サポート言語の中から最適な言語をネゴシエーションする
  3. 未ログイン、ヘッダーなし — 初期化時に指定したデフォルト言語を使用する

このロジックを実装する Plug を示す。lib/my_app_web/plugs/locale.ex

defmodule MyAppWeb.Plugs.Locale do
  @moduledoc """
  Sets the Gettext locale for each request.

  Resolution order:

  1. **Authenticated** — uses the account's `lang_preference`
     unconditionally. The `Accept-Language` header is ignored.
  2. **Unauthenticated, with `Accept-Language` header** — negotiates
     the best locale among all supported locales.
  3. **Unauthenticated, without header** — falls back to the default
     locale given at plug initialization.

  ## Usage in the router

      plug MyAppWeb.Plugs.Locale, "en"

  """

  import Plug.Conn

  @supported_locales ~w(en ja)a

  @supported_locale_strings Enum.map(@supported_locales, &Atom.to_string/1)

  def init(default) when default in @supported_locale_strings, do: default

  def call(conn, default) do
    locale = extract_locale(conn, default)
    Gettext.put_locale(MyAppWeb.Gettext, locale)
    assign(conn, :locale, locale)
  end

  defp extract_locale(conn, default) do
    case conn.assigns[:current_scope] do
      %{account: %{lang_preference: lang}} when not is_nil(lang) ->
        to_string(lang)

      _ ->
        conn
        |> get_req_header("accept-language")
        |> List.first()
        |> negotiate(default)
    end
  end

  defp negotiate(nil, default), do: default

  defp negotiate(header, default) do
    case AcceptLanguage.negotiate(header, @supported_locales) do
      nil -> default
      locale -> Atom.to_string(locale)
    end
  end
end

lib/my_app_web/router.ex:browser パイプラインに追加:

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug MyAppWeb.Plugs.Locale, "en"
end

@supported_locales が言語を追加する際の唯一の変更箇所になる。デフォルトロケール "en"init/1 の guard でコンパイル時に検証される。

リンク

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?