1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【2025年版】Railsのdestroyアクションにstatus: :see_otherを付けるべきか否か?

Last updated at Posted at 2025-02-24

TL;DR(長いので最初に結論)

  • この記事が書かれた2021年当時は、destroyのredirect_toにはstatus: :see_otherを付けないと問題が起きる可能性があった
  • turbo-rails 1.3.0がリリースされた2022年9月以降はかなりリスクが低くなった
  • とはいえ、HTTPやブラウザの仕様を考えるとupdateやdestroyには常にstatus: :see_otherを付けた方が安心(つまり、status: :see_otherは付けるべき)
  • Rails 7.1以降ならscaffoldで生成したコードにstatus: :see_otherが付いている

はじめに

以前、筆者はこんな記事を書きました。

この記事の中に「status: :see_other がないと非常に危険!!」という項目があります。この記事を書いた当時(2021年)はたしかに危険性が高かったのですが、現在ではそのリスクはかなり低くなっています。

しかし、なぜリスクが低くなったのかについては、様々な技術要素が絡み合っているため、ひとことで説明するのが非常に難しいです。そこでこの記事では「Railsのdestroyアクションにstatus: :see_otherを付けるべきか否か?」を判断するために各技術要素について順に説明し、最後になぜ現在ではリスクが低くなったのかを説明します。

結論に至るまでちょっと難しい話題が続きますが、Railsアプリケーション開発者はしっかり理解しておきたい内容なので、じっくり読んで理解を深めていってください。

HTTPのリダイレクトの仕様を理解する

302だけではないリダイレクト用のステータスコード

「リダイレクト用のステータスコードって302でしょ?」と思いこんでいる方は要注意。実は302以外にも様々なステータスコードが存在します(下記MDNを参照)。

この中でも今回の話の中心になるのが、以下の2種類です。

  • 302 Found
  • 303 See Other

302 Found

302 Foundはリダイレクト時のメソッドの扱いに要注意です。

GETでリクエストが送られてきた場合はGETでリダイレクトします。しかし、それ以外のメソッド(POST、PUT、PATCH、DELETE)でリクエストされた場合は、リダイレクト時に使われるメソッドの仕様があいまいです。

メソッドの扱い
仕様書ではメソッドの変更を意図していませんが、実際はメソッドを変更するユーザーエージェントが存在します。

HTTP のリダイレクト - HTTP | MDN

慣習的にPOSTでリクエストが送信された場合のみ、GETでリダイレクトするユーザーエージェント(ブラウザ)が多いようです。

これは言い換えると、PUT/PATCH/DELETEでリクエストが送られてきた場合は、GETではなく、PUT/PATCH/DELETEでリダイレクトする可能性が高いことを意味します。

303 See Other

一方、303 See Otherはどのメソッドでリクエストが送られてきても必ずGETでリダイレクトします。

メソッドの扱い
GET メソッドは変更しません。 他のメソッドは GET に変更します

主な使用例
ページの再読み込みによって操作が再度実施されることを防ぐために、PUT や POST の後のリダイレクトで使用します。

HTTP のリダイレクト - HTTP | MDN

主な仕様例で書かれているように、Railsアプリケーションなどでデータを更新し、それから他の画面へリダイレクトする場合は、まさにこの303 See Otherが適任です。

HTMLフォームの仕様を理解する

ブラウザでHTMLフォーム(つまりformタグ)を使う場合、method属性に指定できるのはGETかPOSTだけです。

RailsがPUT/PATCH/DELETEでリクエストする場合の仕組み

そのため、PUT/PATCH/DELETEのリクエストを送信したい場合、Railsでは隠し項目として_methodを用意し、ここに指定したアクションをフォームのメソッドと見なすようになっています。

<!-- ブラウザからはPOSTとしてフォームを送信する -->
<form action="/blogs/123" method="post">
  <!-- Railsは_methodで指定されているPATCHをこのフォームのメソッドと見なす -->
  <input type="hidden" name="_method" value="patch">

Railsのbutton_toとlink_toの違いを理解する

button_toはformタグを出力する

button_to を使うとHTMLにはformタグが出力されます。

<%= button_to 'Delete', [task.project, task], method: :delete %>
<form action="/projects/3/tasks/6" method="post">
  <input type="hidden" name="_method" value="delete">
  <button type="submit">Delete</button>
</form>

erbのコード例では method: :delete を指定していますが、HTML上ではformタグのmethodはPOSTが、隠し項目の_methodにはDELETEが指定されている点に注目してください。

link_toはaタグを出力し、Turboがリクエストを送信する

一方、link_toを使った場合はaタグが出力されます。

<%= link_to 'Delete', [task.project, task], data: { turbo_method: :delete } %>
<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>

link_toはaタグなので、通常であればGETでリクエストを送信しますが、data: { turbo_method: :delete }のようなオプションを付けることでPOST/PUT/PATCH/DELETEのリクエストも送信できます。

なぜaタグなのにGET以外のリクエストが送信できるのかというと、TurboがJavaScriptの力でaタグの送信リクエストを乗っ取ってむりやりPOST/PUT/PATCH/DELETEのリクエストを送信しているからです。

Turboで実装されている data-turbo-method の挙動を理解する

Turboはdata-turbo-method="delete"のような属性が付いていると、リンククリック時に動的に生成したformオブジェクトのmethod属性にそのままセットします。

// https://github.com/hotwired/turbo/blob/v8.0.11/src/observers/form_link_click_observer.js#L50-L51
const method = link.getAttribute("data-turbo-method")
if (method) form.setAttribute("method", method)

このため、通常のHTMLフォームでは指定できないPUT/PATCH/DELETEがmethod属性に指定されることになります。

turbo-rails gemのバージョンによる挙動の違いを理解する

バージョン1.1.1まで

Turboのデフォルトの実装をそのまま利用します。つまり、

<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>

というようなリンクがあれば、/projects/6/tasks/12に対してDELETEでリクエストが送信されます。

バージョン1.3.0以降

バージョン1.3.0以降(バージョン1.2はなぜか存在しない)ではTurboのフォーム送信イベントをフックして、Railsでよく利用される「formのmethodはPOST + _methodでPUT/PATCH/DELETEを指定」という送信形式に変換します。

つまり、

<a data-turbo-method="delete" href="/projects/6/tasks/12">Delete</a>

というようなリンクがあれば、/projects/6/tasks/12に対してPOSTでリクエストが送信されます。(DELETEメソッドであることは_methodで指定する)

status: :see_otherを付けなければいけないのはどんなとき?

さて、ここまでの前提条件が揃ってようやく本記事の本題に入れます。

以前書いたこちらの記事では「status: :see_other がないと非常に危険!!」と書きましたが、これは以下の条件を満たしていたためです。

  • link_toメソッドでdata: { turbo_method: :delete }を指定している → TurboがDELETEでリクエストを送信する
  • turbo-railsのバージョンが1.1.1以下 → Turboの実装がそのまま使われる
  • controllerでredirect_tostatus: :see_otherを指定していない → ステータスコードとして302が使われる。302だとGETではなくDELETEでリダイレクト先にリクエストを送信する

このような挙動になるため、リダイレクト先のURL(たとえば /projects/3)がDELETEリクエストを受け付ける場合、意図せずid=3のProjectが削除される危険性がありました。

よって、この問題を回避するためにredirect_tostatus: :see_otherを付ける必要があります。

redirect_to @project, status: :see_other

status: :see_otherを付ければステータスコードは303になります。303であれば必ずGETでリダイレクトするため、本来の意図通りid=3のProjectページを表示してくれます。

こういうケースでは status: :see_other を付けなくても大丈夫(かもしれない)

以下のいずれかの条件に合致すればredirect_tostatus: :see_otherを付けなくても問題ないかもしれません。

  • button_toメソッドを使っている → PUT/PATCH/DELETEであってもブラウザはPOSTでフォームを送信する。POSTであれば、ステータスコードが302であってもGETでリダイレクトされる
  • turbo-railsのバージョンが1.3.0以上 → data-turbo-method="delete"が指定されていても、内部的にPOSTでフォームを送信するように切り替えてくれる

特に、turbo-railsのバージョンが1.3.0以上だった場合の恩恵は非常に大きいですね。turbo-rails 1.3.0のリリースは2022年9月なので、これ以降にrails newした場合はstatus: :see_otherを付けなくても大丈夫な可能性が高くなります。

【重要】でも、本当に万全を期すなら……

ただし、redirect_tostatus: :see_otherを付けない、というのは言い換えると、「リクエストが必ずPOSTで送られてくる前提でアプリケーションが作られている」ということです。

Webアプリケーションはその仕組み上、誰でもどこからでも自由にリクエストを送信できます。その気になればDELETEでリクエストを送ることも可能です(例: あえてturbo-railsを使わず、素のTurboがリクエストを送信する場合など)。status: :see_otherがない状態で、もし予期せずDELETEでリクエストが送られてきた場合は302、つまりDELETEでリダイレクトすることになり、大事なリソースが削除されてしまうリスクは残ったままになります。

というわけで、万全を期すならdestroyアクションのredirect_toは必ずstatus: :see_otherを付ける、とした方が安全です。

def destroy
  @task.destroy
  # 万が一DELETEでリクエストされた場合に備えてstatus: :see_otherを付けておく
  redirect_to @project, status: :see_other
end

また、同じことはupdateアクションにも言えます。なぜならPUT/PATCHでリクエストが送られてきた場合、302でリダイレクトするとPUT/PATCHでリダイレクト先にアクセスしてしまうためです。よって、updateアクションにもstatus: :see_otherを付けるべきです。

def update
  if @task.update(task_params)
    # 万が一PUT/PATCHでリクエストされた場合に備えてstatus: :see_otherを付けておく
    redirect_to @task, status: :see_other
  else
    render :edit, status: :unprocessable_entity
  end
end

Rails 7.1以降ならもう大丈夫?

実はバージョン7.1以降のRailsではscaffoldコマンド(またはrails g controllerコマンド)を実行すると最初からupdateアクションやdestroyアクションにstatus: :see_otherが付くようになっています。

よって、Rails 7.1以降を使っている場合は心配無用です!・・・と言いたいところですが、注意点が2つあります。

注意点1. :see_otherが付くのはコードを自動生成したときだけ

status: :see_otherが付くのはscaffoldコマンドやrails g controllerコマンドでコントローラのコードを生成したときだけです。

Rails 7.0以前に書いた既存のコードや、どこかからコピペしてきたcontrollerのコードについては、自分でstatus: :see_otherを付ける必要があります。

注意点2. jbuilderを使っているとdestroyにしか:see_otherが付かない

scaffoldコマンドやrails g controllerコマンドを使ったコードの自動生成は、jbuilder gemをインストールしている(Gemfile.lockにjbuilderが含まれている)とRails本体ではなく、jbuilder側の自動生成コードが使われます。

実は本記事の執筆時点(2025年2月)の最新バージョンであるjbuilder 2.13.0では、destroyアクションにしかstatus: :see_otherが付きません。そのため、updateアクションについては自分でstatus: :see_otherを付ける必要があります。

この問題についてはすでにupdateアクションにもstatus: :see_otherを付けるプルリクエストが作成されているため、そのうち解決されるかもしれません。

まとめ

というわけで本記事では「Railsのdestroyアクションにstatus: :see_otherを付けるべきか否か?」と題して、技術的な内容を深掘りしてみました。

長々と書いてきましたが、簡単に(雑に?)まとめると、

  • この記事が書かれた2021年当時は、destroyのredirect_toにはstatus: :see_otherを付けないと問題が起きる可能性があった
  • turbo-rails 1.3.0がリリースされた2022年9月以降はかなりリスクが低くなった
  • とはいえ、HTTPやブラウザの仕様を考えるとupdateやdestroyには常にstatus: :see_otherを付けた方が安心(つまり、status: :see_otherは付けるべき)
  • Rails 7.1以降ならscaffoldで生成したコードにstatus: :see_otherが付いている

ということになります。

Special thanks to: Hotwire.loveのみなさん

このトピックは先日開催されたHotwire.love Meetup vol.41で挙がった話題です。

この日は参加者全員でワイワイガヤガヤとコードやissueを眺めながら「status: :see_otherとは何なのか」を楽しく深掘りしました。参加者のみなさん、どうもありがとうございました!

Hotwire.love はHotwire関連の話題を楽しくおしゃべりするオンラインコミュニティです。初心者経験者を問わず、どなたでもお気軽にご参加ください!😄

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?