【Ruby】Rack の env に格納される "CONTENT_TYPE" が なぜ大文字・アンダースコア区切りなのか調べてみた

記事タイトルとURLをコピーする

こんにちは、サービス開発課の丸山です。

最近REST APIのController Spec*1をかいていたところ、なかなか思ったようにテストが通らずはまってしまいました。

最終的には原因は「Content-Type」ヘッダーの指定のミスだったのですが、これが結構奥が深い問題だったのでご紹介します。 (この記事で紹介しているRailsのコードは全て記事作成時点での 6-0-stable のものを引用しています。)

私が編集していたコードでは以下のように「Content-Type」ヘッダーが設定してあったのですが、これが間違いでした。

request.env["Content-Type"] = "application/json"     

正しくは以下のどれかを使う必要があります。

request.env["CONTENT_TYPE"] = "application/json"
# または
request.headers["Content-Type"] = "application/json"
# または
request.content_type = "application/json"

# Request Spec なら、以下でOK
post 'path/to/endpoint', params: params, headers: { 'Content-Type': 'application/json' }

要約すると、envを操作する場合はアッパーケースかつ、ダッシュ(ハイフン)ではなくアンダースコアを使う必要があります。

一方で、content_typeheadersを使う場合は文字列のフォーマットを気にしなくてもOKです。

ちなみに、headerscontent_typeの実装を確認すると、どちらもenvをラップしているメソッドであることがわかりました。

request.content_typeの実装

request.content_type = "application/json"

content_typeActionController::TestRequestに定義されているテスト用のメソッドで、実際のアプリケーションコードでは使えません。

ここではCONTENT_TYPEとハードコードされているため、文字列のフォーマットが問題になることはありません。

def content_type=(type)
  set_header "CONTENT_TYPE", type
end

https://github.com/rails/rails/blob/291a3d2ef29a3842d1156ada7526f4ee60dd2b59/actionpack/lib/action_controller/test_case.rb#L69-L71

ここで利用されているset_headerRack::Request::Env に定義されていて、インスタンス変数 @envを操作するためのメソッドです。

この@envは Rack App(or Rack Middleware)のcallメソッドに渡されるあのenvです。

# Set a request specific value for `name` to `v`
def set_header(name, v)
  @env[name] = v
end

get_headerもあります。

# Get a request specific value for `name`.
def get_header(name)
  @env[name]
end

https://github.com/rack/rack/blob/bcefe0c8b33596639cc596aa2cd943090033dd38/lib/rack/request.rb#L77-L80

request.headersの実装

request.headers["Content-Type"] = "application/json"

headersの実態は、ActionDispatch::Http::Headers オブジェクトです。

Headersオブジェクトはrequest自体をラップし、request.envへのアクセスを透過的に処理できるようにしています。

# Provides access to the request's HTTP headers, for example:
#
#   request.headers["Content-Type"] # => "text/plain"
def headers
  @headers ||= Http::Headers.new(self)
end

https://github.com/rails/rails/blob/ce2d72a089f53ec75201cfae27de17d52c00b580/actionpack/lib/action_dispatch/http/headers.rb#L63-L66

新しいkey, valueを登録する前に、keyに対してenv_nameメソッドを実行しています。

# Sets the given value for the key mapped to @env.
def []=(key, value)
  @req.set_header env_name(key), value
end

https://github.com/rails/rails/blob/ce2d72a089f53ec75201cfae27de17d52c00b580/actionpack/lib/action_dispatch/http/headers.rb#L63-L66

このenv_name メソッドが文字列フォーマットの差分を吸収していて、たとえば 「Content-Type」を渡しても「CONTENT_TYPE」で登録されることになります。

ちなみに CONTENT_TYPECGI_VARIABLES(後で説明します)に含まれるため、HTTP_prefixは付加されません。

# Converts an HTTP header name to an environment variable name if it is
# not contained within the headers hash.
def env_name(key)
  key = key.to_s
  if HTTP_HEADER.match?(key)
    key = key.upcase.tr("-", "_")
    key = "HTTP_" + key unless CGI_VARIABLES.include?(key)
  end
  key
end

https://github.com/rails/rails/blob/ce2d72a089f53ec75201cfae27de17d52c00b580/actionpack/lib/action_dispatch/http/headers.rb#L119-L128

もちろん、取り出す時にも env_name メソッドを経由するため、呼び出す側からは「Content-Type」で保存し、「Content-Type」で取り出せるように見えます。

# Returns the value for the given key mapped to @env.
def [](key)
  @req.get_header env_name(key)
end

https://github.com/rails/rails/blob/ce2d72a089f53ec75201cfae27de17d52c00b580/actionpack/lib/action_dispatch/http/headers.rb#L58-L61

なぜ「CONTENT_TYPE」でなければならないのか

ところで、実装を見てみてenvに対して「CONTENT_TYPE」として保持されることはわかりましたが、なぜこの書式でなければならないのでしょうか。

調べたところ、Rackの仕様にヒントがありました。

Rackの仕様では「大文字」「アンダースコア区切り」とは厳密には明記されていないのですが、envに格納するキーが定義されています。

REQUEST_METHOD, SCRIPT_NAME, PATH_INFO, QUERY_STRING, SERVER_NAME, SERVER_PORTは必須のキーとして定義されており、

さらに、HTTPのヘッダーに対応する値には HTTP_prefixをつけてenvに格納するというルールも定義されています。

HTTP_ Variables

Variables corresponding to the client-supplied HTTP request headers (i.e., variables whose names begin with HTTP_). The presence or absence of these variables should correspond with the presence or absence of the appropriate HTTP header in the request. See RFC3875 section 4.1.18 for specific behavior.

https://github.com/rack/rack/blob/master/SPEC.rdoc

おや、Content-TypeもHTTPヘッダーに対応する値なので、HTTP_プレフィックスが必要では? と思いましたが、リンクされている RFC3875 section 4.1.18を読むと、以下のように書いてあります。

The server is not required to create meta-variables for all the header fields that it receives.

In particular, it SHOULD remove any header fields carrying authentication information, such as 'Authorization'; or that are available to the script in other variables, such as 'Content-Length' and 'Content-Type'.

https://tools.ietf.org/html/rfc3875#section-4.1.18

Authorizationのように認証に関わるものや、Content-Typeのように他の変数で利用可能なものは、meta-variables を削除すべきと書いてあります。

ここだけだとよくわからなかったので、「他の変数で利用可能」とはなんだろうと思い周辺を読み進めると、同じセクションの冒頭 4.1. Request Meta-Variables に答えがありました。
ここではCONTENT_TYPEはMeta-Variablesの一種として記載されています。

Meta-variables contain data about the request passed from the server to the script, and are accessed by the script in a system-defined manner.

Meta-variables are identified by case-insensitive names; there cannot be two different variables whose names differ in case only.

Here they are shown using a canonical representation of capitals plus underscore ("_").

A particular system can define a different representation.

https://tools.ietf.org/html/rfc3875#section-4.1

ここを読むと、Content-TypeヘッダーはもともとMeta-Variables として定義されているため、 HTTP_* プレフィックスをつける必要がなかったことがわかりました。

また、同時に、Meta-Variables の標準的な書式(a canonical representation)がcapitals(大文字)とアンダースコアであることも明記されていました。

ちなみにこのRFC3875はCGI Version 1.1の仕様なので、RackのenvのContent-Typeヘッダーの書式が「CONTENT_TYPE」になっている源流が、CGIの仕様にあったことが確認できました。

改めて、先ほど紹介したRailsのActionDispatch::Http::Headersの変換処理部分を確認すると、CGI_VARIABLESというキーワードが登場していました。

# Converts an HTTP header name to an environment variable name if it is
# not contained within the headers hash.
def env_name(key)
  key = key.to_s
  if HTTP_HEADER.match?(key)
    key = key.upcase.tr("-", "_")
    key = "HTTP_" + key unless CGI_VARIABLES.include?(key)
  end
  key
end

https://github.com/rails/rails/blob/ce2d72a089f53ec75201cfae27de17d52c00b580/actionpack/lib/action_dispatch/http/headers.rb#L119-L128

このCGI_VARIABLESの中身は先ほどの RFC3875の4.1 Request Meta-Variablesで定義されている meta variables の一覧と完全に一致します。

CGI_VARIABLESに含まれていればHTTP_プレフィックスをつけないという挙動はRack(とそれが元にしているCGI)の仕様と一致していることが確認できました。

    class Headers
      CGI_VARIABLES = Set.new(%W[
        AUTH_TYPE
        CONTENT_LENGTH
        CONTENT_TYPE
        GATEWAY_INTERFACE
        HTTPS
        PATH_INFO
        PATH_TRANSLATED
        QUERY_STRING
        REMOTE_ADDR
        REMOTE_HOST
        REMOTE_IDENT
        REMOTE_USER
        REQUEST_METHOD
        SCRIPT_NAME
        SERVER_NAME
        SERVER_PORT
        SERVER_PROTOCOL
        SERVER_SOFTWARE
      ]).freeze

https://github.com/rails/rails/blob/72719a923be68c33680f83e71d5f630461a3abb9/actionpack/lib/action_dispatch/http/headers.rb#L25-L44

まとめ

Controller Specが失敗したことをきっかけに仕様を追った結果、"CONTENT_TYPE"の書式の源流に迫ることができました。

RailsのコードにいきなりCGI_VARIABLESが出てきも正直ピンときませんでしたが、このように背景を調べることによってコード自体の理解も深まりますね。

*1: かなり古くからあるAPIだったのでRequest SpecではなくController Spec のままでした。

丸山 礼 (記事一覧)

サービス開発課でCloud Automatorを開発しています。