概要
- リクエストクラスでprepareForValidationメソッドを使い、
multipart/form-data
のリクエストボディのjson decodeをしてカスタムルールでその値を使ったら何故かdecodeしていない値が返ってきてしまい詰まったのでまとめておく。
注意
今回紹介するコードは一部筆者以外の方が記載してくださったものを含みます!
この場を借りてご対応頂いた感謝を伝えさせてください!ありがとうございます!
詰まったときのコードたち
-
下記のような内容をmultipart/form-data指定してテストコードから
$this->post()
を使ってPOSTした。-
body
[ 'name' => 'テスト太郎', 'catchCopy' => 'テスト太郎のキャッチコピー', 'histories' => json_encode([ [ 'startYear' => 2010, 'endYear' => 2015, 'detail' => 'テスト太郎の経歴1', ] ]), ]
-
-
リクエストを受け取るリクエストクラス(
FooRequest.php
)でprepareForValidation()
をオーバーライドし、JSONをデコードしている。FooRequest.phpprotected function prepareForValidation(): void { if ($this->has('histories')) { $histories = json_decode(json: $this->input(key: 'histories'), associative: true); $this->merge(input: ['histories' => $histories]); } }
-
カスタムバリデーションルール(AfterStartPointRule)を下記のように定義した。
AfterStartPointRule.phppublic function validate(string $attribute, mixed $value, Closure $fail): void { preg_match(pattern: '/histories\.(\d+)\./', subject: $attribute, matches: $matches); $historyIndex = $matches[1]; $startYear = request()->input(key: "histories.$historyIndex.startYear"); $endYear = request()->input(key: "histories.$historyIndex.endYear"); if ( (!is_null(value: $endYear) && !is_null(value: $endMonth)) // 終了年月が両方nullでない場合 && ($endYear < $startYear) // 終了年が開始年より前の場合 ) { $fail('経歴の終了は経歴の開始より後にしてください。'); } }
処理の流れとしては下記を想定した。
- リクエストを受け取る。
- リクエストクラスでバリデーションチェックが行われる前にjson decodeを実施しデコード済みの値をリクエストの同キーに再度紐づける。
- カスタムバリデーションを使ってデコードした値のチェックを行う。
問題発生
AfterStartPointRuleの$startYear
と$endYear
がどうしてもnullになる。
原因追求
AfterStartPointRule内で下記コードをを書いて実行してみた。
dd(request()->input(key: "histories"));
結果を確認するとデコード前のJSONが返っている。
laravelのライフサイクル的に「リクエスト受け取り」 → 「デコード」 → 「バリデーションチェック」という順番で処理は必ず行われているはずである。
しかし、request()->input(キー名);
とするとデコード前のJSONが返る。これはおかしい。
リクエストクラスにて下記のように記載して実行した。
protected function prepareForValidation(): void
{
if ($this->has('histories')) {
$histories = json_decode(json: $this->input(key: 'histories'), associative: true);
$this->merge(input: ['histories' => $histories]);
dd($this->input(key: 'histories'));
}
}
この結果間違いなくJSONがデコードされ、配列が返された。
そのため
laravelのライフサイクル的に「リクエスト受け取り」 → 「デコード」 → 「バリデーションチェック」という順番で処理は必ず行われているはずである。
上記の裏付けは取れた。
原因
request()
は受け取ったリクエストの素のインスタンスを見ているらしい。
いくら受け取った後にデコードしてリクエストインスタンスにマージしてもrequest()
を使ってしまうと手を加える前のリクエストボディを見てしまう。そのためデコード前のJSONが取れてしまう。
解決方法
「デコードした後のリクエスト」をカスタムバリデーションルールに渡して使って貰えば問題は解決思想である。下記の様に対応した。
-
カスタムバリデーションルール(※より汎用的にするなら
private FooRequest $request
はprivate FooRequestの継承元のクラス $request
としたほうが良さそう。こうしないとカスタムバリデーションルールを他のリクエストクラスで使い回せない。)AfterStartPointRule.phppublic function __construct(private FooRequest $request) { } public function validate(string $attribute, mixed $value, Closure $fail): void { preg_match(pattern: '/histories\.(\d+)\./', subject: $attribute, matches: $matches); $historyIndex = $matches[1]; $startYear = $this->request->input(key: "histories.$historyIndex.startYear"); $endYear = $this->request->input(key: "histories.$historyIndex.endYear"); if ( (!is_null(value: $endYear) && !is_null(value: $endMonth)) // 終了年月が両方nullでない場合 && ($endYear < $startYear) // 終了年が開始年より前の場合 ) { $fail('経歴の終了は経歴の開始より後にしてください。'); } }
そもそもなんで配列状だとjson_decode()
が必要なの?
multipart/form-data
の場合、配列やオブジェクト等の複雑なデータ構造(入れ子)を直接扱う事ができない。
そのためリクエストを投げるときにはjson_encode()
を、受け取った後はjson_decode()
をしてリクエストボディの間だけ明示的に文字列に変換してあげる必要がある。
application/json
のときはリクエストボディが「ひとかたまりのJSON」なのでそのまま複雑なデータ構造を扱う事ができた。
multipart/form-data
は最も階層が浅いキーごとにパートと言われる個々の区切りの中で値を持つ。なんならパートごとに更にContent-Typeを設定することができる。単純な文字列や数値、ファイルを持つのには適しているが、複雑な構造はそのままの状態だと苦手らしい。