こんにちは、サービス開発課の丸山です。
最近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_type
や headers
を使う場合は文字列のフォーマットを気にしなくてもOKです。
ちなみに、headers
やcontent_type
の実装を確認すると、どちらもenv
をラップしているメソッドであることがわかりました。
request.content_typeの実装
request.content_type = "application/json"
content_type
は ActionController::TestRequest
に定義されているテスト用のメソッドで、実際のアプリケーションコードでは使えません。
ここではCONTENT_TYPE
とハードコードされているため、文字列のフォーマットが問題になることはありません。
def content_type=(type) set_header "CONTENT_TYPE", type end
ここで利用されているset_header
は Rack::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
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
新しい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
このenv_name メソッドが文字列フォーマットの差分を吸収していて、たとえば 「Content-Type」を渡しても「CONTENT_TYPE」で登録されることになります。
ちなみに CONTENT_TYPE
はCGI_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
もちろん、取り出す時にも 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
なぜ「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_
VariablesVariables 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
この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
まとめ
Controller Specが失敗したことをきっかけに仕様を追った結果、"CONTENT_TYPE"の書式の源流に迫ることができました。
RailsのコードにいきなりCGI_VARIABLESが出てきも正直ピンときませんでしたが、このように背景を調べることによってコード自体の理解も深まりますね。
*1: かなり古くからあるAPIだったのでRequest SpecではなくController Spec のままでした。
丸山 礼 (記事一覧)
サービス開発課でCloud Automatorを開発しています。