アプリの外からフォームリクエストを使うと旨みがない話

  • 3
    Like
  • 0
    Comment

はじめに

以下のような状況だった。
・アプリ外のLPにユーザ登録フォームがある。
・登録処理はアプリ内で行う。
・バリデーションもアプリ内で行う。
・Ajax使う

初めからこれ前提で設計できていればよかったのですが、
ほぼ作り切った状態でこの仕様に変更になったので無駄に修正範囲が大きかった。

その迷走っぷりを備忘録として残しておきたいと思う。

アプリ内であれば何の問題もなかった

この仕様になる前は普通にPOSTでsubmitして、フォームリクエストでバリデーションしてた。
エラーがあったら{{ $errors->first('email') }}みたいな感じで表示する。
エラーがなければそのままコントローラ内の処理に移って登録処理が走り完了画面を返す。
何の問題もなかった。

登録フォーム

test.php
<form method="post" action="{{ route('test_post') }}">
    {{ csrf_field() }}
    <input type="text" name="email">
    @if($errors->has('email'))
        {{ $errors->first('email') }}
    @endif
    <input type="submit" value="登録">
</form>

ルーティング

routes.php
Route::post('/test', 'TestController@postTest')->name('test_post');

コントローラー

TestController.php
public function postTest(TestRequest $req) {

    /**
     * 登録処理
     */

    return view('complete');
}

バリデーション

TestRequest.php
public function rules() {
    return [
        'email' => 'required|email|max:100', 
    ];
}

まあシンプルに省略して書くとこんな感じ。

発端はビューの移動

登録フォームがアプリ外部に出てしまった。
さて、何が起こったか。

Laravel製関数への依存

CSRF

{{ csrf_field() }}

当然使えない。
そしてTokenMismatchExceptionが発生する。

解決策としてはミドルウェアの$exceptにURIを記述すればそこだけCSRFのチェックが行われなくなる。
それか最初からミドルウェア適用しないようにルーティング書き換える。

VerifyCsrfToken.php
protected $except = [
    '/test',
];

エラーが表示できない

@if($errors->has('email'))
    {{ $errors->first('email') }}
@endif

フォームリクエストは発生したエラーをグローバルな変数$errorsに保存してくれる。
これを$_SESSIONから探して表示しろということか?頭が痛くなる。。。
全くフォームリクエストの恩恵を受けられていない。

元の入力値も表示できない

<input type="text" name="email" @if(!is_null(old('email'))) value="{{ old('email') }}">

oldが使えないからこれもセッションから引っ張り出せというのか。

何一つフレームワークの恩恵を受けられていない。
Laravel使ってるのに全然モダンじゃない。

バリデーションはJSでやればいいじゃん

必須や文字数ならJSでさばけるが、メールアドレスの重複チェックなどDBへの問い合わせを含むものがある。
リレーションが多くデータの整合性チェックも多い。
ので、どうしてもアプリにAPIを用意しなきゃいけない。

で、

JSによるチェック→OK→submit→(アプリ側)DB問い合わせバリデーション→NG→エラー表示

みたいな2段階のチェックはユーザビリティが悪いのでなし。
すべて1回で完結させたい。

まずAjaxにしよう

アプリ側で書いたバリデーションを活用できるし、全部まとめてチェックできる。
Ajax一択だ。

登録フォームにはファイルのアップロードもある。
アップロードしたファイルをエラー発生時にも保持しておきたいという要望が同時に来た。

これを実現するためにもAjaxはちょうどいい。

フォームリクエストでJSONを返す

responseメソッドをオーバーライドすればレスポンスの形式を変えられる。
エラーが発生したときにはこのレスポンスが返され、エラーがなければコントローラに記述したレスポンスが返される。

TestRequest.php
public function response(array $errors) {
    $headers = [
        'Access-Control-Allow-Origin' =>' *',
    ];

    $response["status"] = "NG+";
    $response["message"] = $errors;

    return \Response::json($response,200,$headers);
}

Ajaxでリクエストを投げるように変更

test.php
<input type="button" value="登録" onclick="validation()">
test.js
function validation() {
    $.ajax({
        type:"post",
        url:"ルートURL/test",
        data:{
            "email":$('input[name="email"]').val(), 
        },
        success:function(data){
            if(data.status == 'NG+') {
                if(data.message['email'] != null) {
                    $('input[name="email"]').after("<span class='m-error'>" + data.message['email']['0'] + "</span>");
                }
            }
        }
    });
}

ふとここで気づく。

エラーの時はこれでいい。
処理が通った後はどうなる?

コントローラが返すべきもの

TestController.php
public function postTest(TestRequest $req) {

    /**
     * 登録処理
     */

    return response()->json(['status' => 'ok']);
}

こうするとjsでキャッチできるが、、、
受け取ったところでどうする。

フォームリクエストはバリデーションとサブミット後の処理が一続きになっている。
Ajaxで呼んで処理が通ったところで登録完了ページにどう遷移すればいい?

test.js
success:function(data){
    if(data.status == 'ok') {
        window.location.href = './complete.html';
    }
}

ださすぎる。
URL叩けば完了画面が見れてしまう。
モダンじゃない。

そして問題はもう一つある。
確認画面や確認ダイアログをはさめないことだ。

ボタンを押した瞬間にバリデーション処理が走り、エラーがなければ確認ダイアログを表示。
確認ダイアログのOKを押して登録する、という2段階を実現するためにはバリデーションと登録のAPIを
分離しなければならない。

さもなければ確認ダイアログのOKを押した後にバリデーションが行われ、エラーがあればエラーが表示されるというフローになってしまう。
またもやユーザビリティが悪い。

解決方法

formタグのactionと、Ajaxのurlを別のAPIにする。

routes.php
// 登録フォーム表示用
Route::get('/test', 'TestController@getTest');
// 登録フォームsubmit用
Route::post('/test', 'TestController@postTest')->name('test_post');
// 登録フォームバリデーション用
Route::post('/test/validate', 'TestController@validateTest')->name('test_validate');

まずはバリデーションを行わせる。
バリデーションをパスしたらコントローラ内のレスポンスが返されるので
そこで判別してformを改めてsubmitする。

test.js
function validation() {
    $.ajax({
        type:"post",
        url:"ルートURL/test/validate",
        data:{
            "email":$('input[name="email"]').val(), 
        },
        success:function(data){
            if(data.status == 'ok') {
                $('form').submit();
            } else {
                if(data.message['email'] != null) {
                    $('input[name="email"]').after("<span class='m-error'>" + data.message['email']['0'] + "</span>");
                }
            }
        }
    });
}

formタグにはsubmit用のURLを指定しているのでバリデーションは行われず登録処理が行われる。

test.php
<form method="post" action="{{ route('test_post') }}">

</form>

後はコントローラーで完了画面をレスポンスとして返せばよい。

TestController.php
public function postTest(Request $req) {
    /**
     * 登録処理
     */

    return response(view('complete'), 200)->header('Content-Type', 'text/html');
}

登録フォームのgetとpostのURLを同じ/testにしておくことによって、submit後に再読み込みをしても
登録フォームの画面が新しく開かれる事になるので、完了画面を隠蔽しておける。

何が悪だったのか

仕様変更やそもそもの設計はまあそうなのだが、他にいい手はなかったのか。

フォームリクエストが悪かと言われれば、そうとも言い切れない。

フォームリクエストのメリットはいくつかある。
コントローラをきれいに保てること。
エラーの表示やリダイレクトの処理を自分で書かなくてよいこと。
バリデーションとその後の処理がシームレスに繋がっていること。
こちらで特に考えなくてもよしなにやってくれる。

しかしアプリ外部からこの仕組みを呼ぶと、つながりが中途半端になり逆にややこしくなる。

jsonでエラーを返す仕組みが用意されているということは、Ajaxを想定してのことなのだろうが、
アプリ外部から使うのにはちょっと無理があるかなと思った。

結論

仕様が悪い。