21
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PHP8.4】PUTやPATCHでもリクエストボディを簡単に取得できるようになる

Posted at

PHPでは、POSTでリクエストを送るとリクエストボディが自動的にスーパーグローバル変数$_POSTに格納されます

これは非常に便利なのですが、注意点としてPOSTでないかぎり入ってきません。
たとえリクエストメソッドGETやPUTにリクエストボディを入れて送ったとしても$_POSTは空です。

そしてPHPには何気にリクエストボディをパースする関数がなく、自力でパースするのはたいへんに面倒で誤りも入りがちです。

この対応案としてスーパーグローバル変数$_PUTを追加したいみたいな話が出たこともあるのですが、それでは$_PATCHとか$_DELETEとかも必要なのかということになってあまり現実的ではないでしょう。

ということで、リクエストメソッドに関わらずリクエストボディを取得する関数request_parse_body()のRFCが提出されました。
既に受理されており、PHP8.4から利用可能になります。

以下は該当のRFC、RFC1867 for non-POST HTTP verbsの紹介です。

PHP RFC: RFC1867 for non-POST HTTP verbs

Proposal

multipart/form-dataコンテンツタイプはRFC1867で定義されています。
このコンテンツタイプは主に、ファイルを含むHTTPフォームで使用されています。
PHPは、このコンテンツタイプのパースをネイティブでサポートしていますが、しかしそれはPOSTリクエストに対してだけです。
具体的には、リクエストメソッドがPOSTかつコンテンツタイプがmultipart/form-dataである場合に、PHPはリクエストボディをパースして$_POST$_FILESスーパーグローバル変数に代入します。
この機能は裏側で自動的にトリガーされており、ユーザランドには公開されません。

RESTの出現により、PHPが対応していないPUTやPATCHなどのリクエストメソッドの利用が一般的になりました。
しかしこれらのリクエストメソッドを使用するには、リクエストボディを手動で解析しなければなりません。
ユーザランドによる実装は、データ量を考えるとパフォーマンスの点で適切ではないでしょう。
Issueには350以上のupvoteが投じられています。

このRFCでは、リクエストボディを取得する関数request_parse_bodyを追加し、既存のパース機能をユーザランドから使えるようにします。

/**
 * @param array<string, int>|null $options
 */
function request_parse_body(?array $options = null): array {}
 
// PUTリクエストだった場合$_POSTも$_FILESも入ってこない
var_dump($_POST);  // []
var_dump($_FILES); // []
 
[$_POST, $_FILES] = request_parse_body();
 
var_dump($_POST);  // [...]
var_dump($_FILES); // [...]

この関数は[0=>POST, 1=>FILES]の配列を返します。
必要に応じて、返り値でスーパーグローバル変数$_POSTおよび$_FILESを上書きできます。
これによって既存の$_POSTを再利用可能となります。

この関数はsapi_module.read_post()から入力を取得します。

Sanitization

自動呼び出しの場合と同じく、request_parse_body()は幾つかの原因でマルチパート解析に失敗します。

・入力サイズがpost_max_sizeを超えた。
・Content-Typeにboundaryが設定されていない。
・マルチパート数がmax_multipart_body_partsを超えた。
・POST入力数がmax_input_varsを超えた。
・送信ファイル数がmax_file_uploadsを超えた。
・ファイルにnameもfilenameも設定されていない。

解析に失敗すると警告が発生します。
また新しい例外RequestParseBodyExceptionが実装され、解析エラーを例外でキャッチできるようになります。

request_parse_body()は冪等ではないことに注意してください。
request_parse_body()を2回呼び出すことは安全ではありません。
これについては下記php://inputセクションで詳しく説明します。

Supported content types

このRFCが建てられた目的はmultipart/form-dataですが、同時にapplication/x-www-form-urlencodedコンテンツタイプもサポートします。
application/x-www-form-urlencodedの場合、$_FILES相当部分は空です。
サポートされていないコンテンツタイプにはInvalidArgumentExceptionが発生します。

php://input

リクエストコンテンツには通常、php://inputストリームでアクセスできます。
このストリームは入力をバッファリングしており、何度でも読み込むことが可能です。
リクエストメソッドがPOSTだった場合、PHPは制御をスクリプトに渡す前にリクエストボディ全体を読み取ってバッファリングします。
POST以外では、事前にリクエストボディを読み込むことはありません。
入力ストリームを読み込もうとしたときに初めてバッファリングされます。

このバッファリングメカニズムの唯一の例外がmultipart/form-dataであり、ストリームは空になります。
この理由として、パースした結果は$_POST$_FILESから利用できるため、ストリームを使う必要がないということが挙げられます。
これらのリクエストをバッファリングするということは、全てのリクエストボディをスーパーグローバル変数とストリームバッファの2個所に保存することになるため、時間とディスクスペースの負担が2倍になることを意味します。

同じ理由で、request_parse_body()はバッファリングを行いません。
この関数はsapi_module.read_post()を破壊的に消費するため、2回目の呼び出しはできないことになります。

$options parameter

グローバルではなくエンドポイント単位で入力制限をカスタマイズできます。
たとえばログイン者限定のフォームがあるとして、入力制限をpost_max_sizeupload_max_filesizeで緩和すると、DoS攻撃などのリスクが高まる可能性があります。
従って、特定のエンドポイントについてのみ、これらの制限を変更できる方が好ましいでしょう。

request_parse_body()は、第二引数$optionで次のini設定をオーバーライドするかのように動作します。

・max_file_uploads
・max_input_vars
・max_multipart_body_parts
・post_max_size
・upload_max_filesize

#[Route('/api/videos', methods: ['PUT'])]
public function index(): Response {
    [$post, $files] = request_parse_body(options: [
        'post_max_size' => '128M',
    ]);
 
    // ...
}

無効なキーや値を指定するとValueErrorが発生します。

Why not parse the content automatically?

POSTリクエストはapplication/x-www-form-urlencodedmultipart/form-dataを自動的にパースするのに、PUTやPATCHやその他の動詞では同じように動作しないのはなぜでしょうか。
主な理由はふたつあります。

一つ目は下位互換性です。
multipartのリクエストでは、リクエストボディはバッファリングされず消費されます。
マルチパートを手動で解析している既存コードは、入力ストリームが取得できなくなるため壊れてしまいます。

もうひとつの理由は、柔軟性が向上することです。
multipartを受け入れないエンドポイントは、リクエストを解析することなく早期に終了することがあるでしょう。
その場合にわざわざリクエストボディをパースしてバッファリングし、全く使用せずにファイルを削除するという手間を省くことができます。

なお、POSTリクエストでもこの利点を享受したい場合は、ini設定enable_post_data_readingで変更可能です。

Backwards incompatible changes

グローバル名前空間でrequest_parse_body()RequestParseBodyExceptionが予約されます。
他に後方互換性のない変更はありません。

RFC1867 refresher

RFC1867のおさらい。

RFC1867はmultipart/form-dataコンテンツタイプの定義です。
このコンテンツタイプは、主にファイルを含むHTTPリクエストを送信するために利用されます。
入力のキーと値のペアを送信する形式という意味では、application/x-www-form-urlencodedと似ています。
各入力は、他で使われていない任意の文字列で区切られます。
ファイルの場合、元のファイル名とコンテンツタイプも属性として渡されます。
以下に簡単な例を示します。

POST / HTTP/1.1
Host: localhost:9000
Content-Type: multipart/form-data; boundary=---------------------------84000087610663814162942123332

-----------------------------84000087610663814162942123332
Content-Disposition: form-data; name="post_field"

post content
-----------------------------84000087610663814162942123332
Content-Disposition: form-data; name="file_field"; filename="original_filename.txt"
Content-Type: text/plain

file content
-----------------------------84000087610663814162942123332--

PHPではスーパーグローバル変数$_POST$_FILESに格納されます。

var_dump($_POST);
array(1) {
  ["post_field"]=>
  string(9) "post data"
}

var_dump($_FILES);
array(1) {
  ["file_field"]=>
  array(6) {
    ["name"]=>
    string(21) "original_filename.txt"
    ["full_path"]=>
    string(21) "original_filename.txt"
    ["type"]=>
    string(10) "text/plain"
    ["tmp_name"]=>
    string(%d) "/tmp/sometmpfilename"
    ["error"]=>
    int(0)
    ["size"]=>
    int(12)
  }
}

RFC1867形式のリクエストは、リクエストメソッドがPOSTである場合は自動的に解析されます。
ファイル以外のリクエストボディは$_POSTに登録されます。
ファイルの場合、コンテンツはテンポラリファイルに保存され、テンポラリファイルのパスと関連情報が$_FILESに渡されます。
またリクエストの最後に、テンポラリファイルは自動的に削除されます。
これによって、サーバのディスク領域を埋めようとする攻撃を防いでいます。

Rejected ideas

拒否されたアイデア。

$input_stream parameter

このRFCには、かつてRoadRunnerなど特殊なSAPIが使用することを想定した引数$input_streamが存在しました。
しかし詳しく調べてみたところ、RoadRunnerやFrankenPHPでは不要でした。

この引数が必要となりそうなものは、WorkermanAdapterManなどのPHP製Webサーバに限られるようです。
しかしそれらにおいては、リクエストはストリームではなく文字列で処理されています。
文字列ではなくストリームを使うようになったら、この引数を再度検討する余地はあるでしょう。

Vote

投票期間は2023/01/22から2023/02/05、投票者の2/3の賛成で受理されます。
とか書かれてるんだけど、おそらく2024年の間違いです。

本RFCは賛成23反対1の賛成多数で受理されました。

便利関数request_parse_body()が、PHP8.4から使用可能になります。

感想

もっと正確かつ統一的にHTTPインターフェイスを扱おうというRFCがずっと前からあるのですが、こちらは放置で単純にリクエストボディを取得する関数になりました。
実際ユーザがやりたいことはリクエストの正しい解析なんかではなくリクエストを使って何かすることなので、妥当な方向性といえるでしょう。

この関数が役立つ場面はHTMLフォームではありません。
HTMLフォームではいまだにGET/POSTしか使えないし、今後も増える見込みはないので、特に関係ありません。

でもLaravelにはPUTもDELETEもOPTIONSもあるじゃん?と思うかもしれませんが、実際はこれPOSTしつつ隠しパラメータで_method=putとか送っているだけというなんちゃってPUTであり、実際はPOSTです。

この関数が必要となるのはHTMLフォーム以外からのリクエストであり、たとえばfetch APIはPUT/DELETEといった既存リクエストメソッドや、さらに勝手に決めたオレオレリクエストメソッドなんてものも送ることができます。
そういったエンドポイントからのリクエストを簡単に受け取れるようになる、たいへん便利な関数ですね。

とはいえ実際ユーザが直接この関数を触ることはあんまりなくて、フレームワークが用意した$request->method()みたいなインターフェイスを使えばこれまでと全く同じ使い方ができるよ、みたいな方向になるとは思いますが。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?