Vue.js と本気で戦ってみた ~ 第3章 : バリデーションとエラー制御

Vue.js と RESTful API でバリデーション

で Vue コンポーネントを分割し、ソースコードの見通しがよくなりました。
しかしまだ大事な機能を実装していません。
今回作った固定ページ管理は「タイトル」「スラッグ(URL に使う ID みたいなやつ)」「本文」の3つのパラメータからできていますが、すべて入力必須、さらにスラッグは一意でないといけません。
Laravel の API にバリデーションの機能を組み込むとともに、モーダルの編集フォームにエラー表示を追加していきましょう。

FormRequest の拡張

「Vue.js と本気で戦ってみた」という記事タイトルのくせして、ここから Laravel 側の作業。
まずバリデーションを行なう FormRequest という機能を拡張します。

>php artisan make:request AdminPageApiRequest

app/Http/Requests/AdminPageApiRequest.php というファイルが生まれました。
ここに固定ページのバリデーションを書いていきます。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;

use App\Page;

class AdminPageApiRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'title' => 'required|max:100',
            'slug' => 'required|max:20',
            'content' => 'required',
        ];
    }

    public function withValidator(Validator $validator)
    {
        $validator->after(function ($validator) {
            $page = Page::where('slug', $this->input('slug'))->first();
            if ( $page && $this->input('id') !== $page->id ) {
                $validator->errors()->add('slug', 'slug must be unique.');
            }
        });
    }

    protected function failedValidation(Validator $validator) {
        $response = response()->json([
            'status' => 400,
            'errors' => $validator->errors(),
        ], 400);
        throw new HttpResponseException($response);
    }
} // class AdminPageApiRequest

authorize() は認証が必要な場合に使うようです。今回の API はルーティングの時点で認証まわりを済ませているので、無条件で通すように設定。

rules()具体的なバリデーションルールを書いていきます。DB 定義に従って、title は必須+100文字以内、slug は必須+20文字以内、content は必須指定のみとしました。

また、withValidator() メソッドで slug のユニークチェックをします。パラメータとして渡ってきた idslug で、すでに登録されているデータがないことをチェック。すでに存在する場合はエラーメッセージを出します。

そして failedValidation() メソッドで、エラーがあったときの JSON レスポンスを定義します。ここでは 400 Bad Request を返すようにしました。

コントローラへの組み込み

こうしてできたバリデーションを API コントローラに組み込みましょう。

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\AdminPageApiRequest;
use Illuminate\Http\Exceptions\HttpResponseException;

use App\Page;

class PageController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $pages = Page::all();
        $result = [];
        foreach ($pages as $page) {
            $content = strip_tags($page->content);
            if ( mb_strlen($content) > 200 ) {
                $page->content =  mb_substr($content, 0, 200) . '…';
            }
            $result[$page->id] = $page;
        }
        return response($result);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \App\Http\Requests\AdminPageApiRequest  $request
     * @return \Illuminate\Http\Response
     */
    public function store(AdminPageApiRequest $request)
    {
        //
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show($id)
    {
        $page = Page::find($id);
        if ( ! $page ) {
            $response = response()->json([
                'status' => 'error',
                'message' => 'Page not found',
            ], 404);
            throw new HttpResponseException($response);
        }

        return response($page);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \App\Http\Requests\AdminPageApiRequest  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(AdminPageApiRequest $request, $id)
    {
        $page = Page::find($id);
        if ( ! $page ) {
            $response = response()->json([
                'status' => 'error',
                'message' => 'Page not found',
            ], 404);
            throw new HttpResponseException($response);
        }

        $page->title = $request->title;
        $page->slug = $request->slug;
        $page->content = $request->content;
        $page->save();

        $result = array(
            'status' => 'success',
            'message' => '',
        );
        return response($result);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy($id)
    {
        //
    }
}

引数のタイプヒンティングが Request だったのを AdminPageApiRequest に差し替えるだけ。これだけでバリデーションが動くようになります。
store() メソッドはまだ未実装ですが、ページの新規作成を作るときに実装する予定なので、今のうちにバリデーションを組み込んでおきます。

表示調整

さて、Vue に戻りましょう。
入力フォームでエラーを受け取りたいので、edit-page-modal コンポーネントにエラー処理を書いていきます。

<template>
    <div id="edit-page"
            class="modal fade"
            tabindex="-1"
            role="dialog"
            aria-labelledby="edit-page-label"
            aria-hidden="true">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <input type="hidden"
                            name="id"
                            id="page-id"
                            v-bind:value="id" />
                    <h5 class="modal-title" id="edit-page-label">Edit page</h5>
                    <button type="button"
                            class="close"
                            data-dismiss="modal"
                            aria-label="Close">
                        <span aria-hidden="true">×</span>
                    </button>
                </div>
                <form v-on:submit.prevent="save(id)">
                    <div class="modal-body">
                        <div class="form-group" v-bind:class="{ 'is-invalid': errors.slug }">
                            <label for="page-title">Title</label>
                            <input type="text"
                                    class="form-control"
                                    id="page-title"
                                    v-model="title"
                                    v-bind:class="{ 'is-invalid': errors.title }"
                                />
                            <label class="invalid-feedback"
                                    role="alert"
                                    for="page-title"
                                    v-if="errors.title"
                                    v-html="errors.title"></label>
                        </div>
                        <div class="form-group" v-bind:class="{ 'is-invalid': errors.slug }">
                            <label for="page-slug">Slug</label>
                            <input type="text"
                                    class="form-control"
                                    id="page-slug"
                                    v-model="slug"
                                    v-bind:class="{ 'is-invalid': errors.slug }"
                                />
                            <label class="invalid-feedback"
                                    role="alert"
                                    for="page-slug"
                                    v-if="errors.slug"
                                    v-html="errors.slug"></label>
                        </div>
                        <div class="form-group" v-bind:class="{ 'is-invalid': errors.slug }">
                            <label for="page-content">Content</label>
                            <textarea
                                    class="form-control"
                                    id="page-content"
                                    v-model="content"
                                    v-bind:class="{ 'is-invalid': errors.content }"
                                    rows="10"
                                ></textarea>
                            <label class="invalid-feedback"
                                    role="alert"
                                    for="page-content"
                                    v-if="errors.content"
                                    v-html="errors.content"></label>
                        </div>
                    </div>
                    <div class="modal-footer">
                        <button type="button"
                                class="btn btn-secondary"
                                data-dismiss="modal"
                            >Cancel</button>
                        <button type="submit"
                                class="btn btn-primary"
                            >Save</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</template>

<script>
    export default {
        name: "edit-page-modal",
        data: function () {
            return {
                urlShow: "",
                urlUpdate: "",
                id: 0,
                title: "",
                slug: "",
                content: "",
                errors: {
                    title: "",
                    slug: "",
                    content: "",
                }
            }
        },
        methods: {
            init: function (id, urlShow, urlUpdate) {
                console.log("modal::init");

                // initialize
                this.urlShow = urlShow;
                this.urlUpdate = urlUpdate;
                this.id = 0;
                this.title = "";
                this.slug = "";
                this.content = "";
                this.errors.title = "";
                this.errors.slug = "";
                this.errors.content = "";

                // get detail
                var url = this.urlShow;
                console.log("get", url);
                axios.get(url)
                .then(response => {
                    this.id = response.data.id;
                    this.title = response.data.title;
                    this.slug = response.data.slug;
                    this.content = response.data.content;
                })
                .catch(response => {
                    alert(response);
                });

                // show modal
                var el = "#" + this.$el.id;
                $(el).modal();
            },
            save: function () {
                console.log("modal::save", this.id);

                // update
                var url = this.urlUpdate;
                console.log("put", url);
                axios.put(url, {
                    id: this.id,
                    title: this.title,
                    slug: this.slug,
                    content: this.content
                })
                .then(response => {
                    // hide modal
                    var el = "#" + this.$el.id;
                    $(el).modal('hide');

                    // redraw list
                    this.$emit("redraw");
                })
                .catch(response => {
                    var status = response.response.status;
                    if (status == 400) {
                        var errors = response.response.data.errors;
                        this.errors.title = ( errors.title ? errors.title[0] : "" );
                        this.errors.slug = ( errors.slug ? errors.slug[0] : "" );
                        this.errors.content = ( errors.content ? errors.content[0] : "" );
                    }
                });
                return false;
            },
            redraw: function (id) {
                this.$emit("init");
            }
        }
    }
</script>

まず dataエラーメッセージを格納するプロパティを追加<し、init() メソッドで初期化します。
save() メソッドで 400 エラーが返ってきたらプロパティにエラーメッセージを格納。
このメッセージがトリガーとなって、<label class="invalid-feedback"> にエラーメッセージが表示されます。
ついでにエラーが見やすいよう、入力項目に is-invalid のクラスを指定します。

これで敢えてエラーを含むデータを送信すると……
Vue.js と RESTful API でバリデーション
Bootstrap を利用したエラー表示が完成です!

おわりに

Laravel を勉強し始めてから3ヶ月経ち、だいぶ慣れてきた感じがします。
FuelPHP のころはバリデーションはモデルに書いてたので、文化の違いというかカルチャーショックに似たものを感じました。Request オブジェクトの差し替えだけで動くんかい!!

肝心の Cities History Project は、現状と同等のものが完成したら Laravel バージョンに差し替える予定です。
それではまた!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です