Http::Headers#merge が元リクエストを変更しないように修正

rails/rails

ActionDispatch::Http::Headers#merge新しい Http::Headers インスタンスを返すと文書化されているにも関わらず、実際には元リクエストの env を変更していました。本 PR はその副作用を排除し、ドキュメントどおりの純粋な挙動に戻す変更を導入します。

背景

Http::Headers#merge が期待どおりに動作しない原因は、シャローコピーの罠 にあります。Request#headersmemoization により一度生成した Http::Headers オブジェクトをインスタンス変数 @headers に保持し、そのオブジェクトは元リクエストへの参照 @req を内部に持ちます。merge@req.dup.headers でコピーを作成すると、Object#dup はインスタンス変数を参照としてコピーするだけで、@headers は新しいリクエストでも元のオブジェクトを指したままです。その結果、merge! が共有ヘッダーオブジェクトを変更し、元リクエストの env が書き換わる という副作用が発生していました。

この問題はテストやミドルウェアで request.headers を一度でも呼び出すと顕在化し、実運用で予期しないヘッダーの上書きにつながっていました。PR の説明でも「シャローコピーがフットガンになる」ことが指摘されており、根本的な修正が求められていました。

技術的な変更

元実装では merge が次のように書かれていました。

def merge(headers_or_env)
  headers = @req.dup.headers
  headers.merge!(headers_or_env)
  headers
end

@req.dup が浅いコピーであるため、headers は元リクエストと同一の Http::Headers オブジェクトを参照し、結果として merge! が元リクエストを改変していました。修正後は 新しいリクエストオブジェクト を明示的に生成し、その上で Headers を構築します。

def merge(headers_or_env)
  headers = Headers.new(@req.dup)
  headers.merge!(headers_or_env)
  headers
end

Headers.new(@req.dup) により、Headers コンストラクタは dup されたリクエスト を受取り、内部で再度 Http::Headers を生成します。これにより @headers が新しいリクエストに対して再度 memoize され、元リクエストへの参照が残らないため、merge! は安全に新しいヘッダーオブジェクトだけを操作します。さらに、同様の振る舞いを保証するテストが追加されました。

test "merge does not mutate the original request when headers are memoized" do
  request = ActionDispatch::Request.new(
    "CONTENT_TYPE" => "text/plain",
    "HTTP_REFERER" => "/some/page"
  )
  request.headers # memoize the Http::Headers instance on the request

  combined = request.headers.merge("HTTP_HOST" => "http://example.com")

  assert_equal "http://example.com", combined["HTTP_HOST"]
  assert_not request.headers.key?("HTTP_HOST")
  assert_nil request.get_header("HTTP_HOST")
end

このテストは ヘッダーが memo化されていても merge が元リクエストを汚染しないことを検証し、回帰防止の役割も果たします。

設計判断

修正方針は 最小限の侵入性 を保ちつつ期待通りの純粋性を実現することでした。Headers.new(@req.dup) への置き換えは、既存の API シグネチャや外部からの呼び出し方法を変更せずに問題を解決できるシンプルなアプローチです。代替案として Headers に直接 env を渡す形や、dup の深いコピー実装を導入する案も考えられましたが、前者は API の拡張で互換性リスクが高く、後者は Rack::Request の内部実装に依存するため保守コストが増大すると判断されました。

結果として、シャローコピーの影響範囲を限定 しつつ、merge の動作を文書どおりの「新しいインスタンス返却」に合わせました。テスト追加により、将来的な変更が同様の副作用を再び導入しないことが保証されます。

まとめ

本 PR は Http::Headers#merge が元リクエストを不意に変更していたバグを、Headers.new(@req.dup) に置き換えるだけのシンプルな修正で解消し、ドキュメントどおりの不変性を回復させました。追加されたテストにより、ヘッダーが memo化されている状態でも安全にマージできることが検証され、今後のメンテナンスでも安心して利用できるようになりました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
61191a48

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文、背景、技術的な変更、設計判断、まとめが全て揃っており、総論→各論→結論の流れが明確です。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックのファイル名付きハイライトは正しい形式ですが、GitHubリンクは `[PR #57805](URL)` ではなく `[PR #57805](URL)` と記述されており、#だけの形式に完全に一致していません。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

専門的なRuby on Railsエンジニアを想定した内容で、初心者向けの余計な説明はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論・各論・結論の段落構成になっており、トピックセンテンスで始まり1段落1トピック、長さも適切です。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のコードスニペットは提供されたDiffと完全に一致しており、変更点を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「シャローコピー」「memoization」などの用語はPRで使用されており、誤用はありません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的説明はPRの記述と整合し、因果関係も論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事の全主張はPRタイトル、説明、Diffで裏付けられており、根拠のない推測はありません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号 #57805 などの数値は正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルはPRの要旨を正しく日本語化しており、内容と一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

LTSやリリース日等、PRに記載されていない外部知識は一切含まれていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現は使用されておらず、PR情報と食い違いもありません。