PHP
laravel
laravel5

Laravel5 チュートリアル ブログもどきを作る(9) カテゴリー追加・編集・削除APIを作成する

最近 Python にハマってしまい、あれやこれやしているうちに、前回記事を投稿してから、だいぶ日にちが経ってしまいました。
前回、管理画面のカテゴリ一覧画面を実装したので、今回はカテゴリーを追加・編集、削除するためのAPIを作成します。

リクエストクラスの編集

前回、ルーティングの追加は済ませたので、リクエストクラスから編集していきます。
カテゴリーの追加・編集、削除用にバリデートを追加します。app/Http/Requests/AdminBlogRequest.php を開いて下記のように編集します。

app/Http/Requests/AdminBlogRequest.php
<?php

namespace App\Http\Requests;

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

class AdminBlogRequest extends FormRequest
{
    ...中略...

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        // 現在実行しているアクション名を取得
        // アクション名により、どのルールを使うのか場合分けをしておく
        $action = $this->getCurrentAction();

        $rules['post'] = [
            'post_date' => 'required|date',                 // 必須・日付
            'title'     => 'required|string|max:255',       // 必須・文字列・最大値(255文字まで)
            'body'      => 'required|string|max:10000',     // 必須・文字列・最大値(10000文字まで)
        ];

        $rules['delete'] = [
            'article_id' => 'required|integer'              // 必須・整数
        ];

        $rules['editCategory'] = [
            'category_id'   => 'integer|min:1|nullable',    // 整数・最小値(1以上)
            'name'          => 'required|string|max:255',   // 必須・文字列・最大値(255文字まで)
            'display_order' => 'required|integer|min:1'     // 必須・整数・最小値(1以上)
        ];

        $rules['deleteCategory'] = [
            'category_id' => 'required|integer|min:1'       // 必須・整数・最小値(1以上)
        ];

        return array_get($rules, $action, []);
    }

    public function messages()
    {
        // 表示されるバリデートエラーメッセージを個別に編集したい場合は、ここに追加する
        // 項目名.ルール => メッセージという形式で書く
        // プレースホルダーを使うこともできる
        // 下記の例では :max の部分にそれぞれ設定した値(255, 10000)が入る
        return [
            'post_date.required'     => '日付は必須です',
            'post_date.date'         => '日付は日付形式で入力してください',
            'title.required'         => 'タイトルは必須です',
            'title.string'           => 'タイトルは文字列を入力してください',
            'title.max'              => 'タイトルは:max文字以内で入力してください',
            'body.required'          => '本文は必須です',
            'body.string'            => '本文は文字列を入力してください',
            'body.max'               => '本文は:max文字以内で入力してください',
            'category_id.required'   => 'カテゴリーIDは必須です',
            'category_id.integer'    => 'カテゴリーIDは整数でなければなりません',
            'category_id.min'        => 'カテゴリーIDは1以上でなければなりません',
            'name.required'          => 'カテゴリ名は必須です',
            'name.string'            => 'カテゴリ名は文字列を入力してください',
            'name.max'               => 'カテゴリ名は:max文字以内で入力してください',
            'display_order.required' => '表示順は必須です',
            'display_order.integer'  => '表示順は整数を入力してください',
            'display_order.min'      => '表示順は1以上を入力してください',
        ];
    }

    /**
     * オーバーライドしてAPIとして実行されているときのレスポンスを json にする
     *
     * @param Validator $validator
     */
    protected function failedValidation(Validator $validator)
    {
        $action = $this->getCurrentAction();

        // API以外のときは親クラスのメソッドを呼んで終わり
        if ($action == 'post' || $action == 'delete') {
            parent::failedValidation($validator);
        }

        $response['errors'] = $validator->errors()->toArray();
        throw new HttpResponseException(
            response()->json($response, 422)
        );
    }

    /**
     * 現在実行中のアクション名を返す
     *
     * @return mixed
     */
    public function getCurrentAction()
    {
        // 実行中のアクション名を取得
        // App\Http\Controllers\AdminBlogController@post のような返り値が返ってくるので @ で分割
        $route_action = Route::currentRouteAction();
        list(, $action) = explode('@', $route_action);
        return $action;
    }
}

editCategory, deleteCategory の各アクション分のバリデーションルールを追加し、追加したルールに対応するバリデートエラーメッセージを追加しました。

APIに対してフォームリクエストバリデーションを利用しますが、このままでは、バリデートエラーとなったときに、おかしな挙動になってしまうので、一工夫必要です。それは、親クラス FormRequestfailedValidation() をオーバーライドすることで、APIとして実行し、バリデートエラーとなったときに、JSON のレスポンスが返るようにしています。(API以外のアクセスのときは、親クラスの failedValidation() を呼ぶだけです)
※こちらの記事 【Laravel5】FormRequestのバリデーション結果をJSON APIで返す を参考にさせて頂きました。

コントローラーの編集

続いて、コントローラーにカテゴリー新規追加・編集、削除用のメソッドを追加します。app/Http/Controllers/AdminBlogController.php を開いて、下記2つのメソッドを追加します。

app/Http/Controllers/AdminBlogController.php
    /**
     * カテゴリ編集・新規作成API
     *
     * @param AdminBlogRequest $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function editCategory(AdminBlogRequest $request)
    {
        $input = $request->input();
        $category_id = $request->input('category_id');

        $category = $this->category->updateOrCreate(compact('category_id'), $input);

        // APIなので json のレスポンスを返す
        return response()->json($category);
    }

    /**
     * カテゴリ削除API
     *
     * @param AdminBlogRequest $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function deleteCategory(AdminBlogRequest $request)
    {
        $category_id = $request->input('category_id');
        $this->category->destroy($category_id);

        // APIなので json のレスポンスを返す
        return response()->json();
    }

記事の追加・編集、削除と似ているので、あまり問題無いと思います。エラーハンドリングは jQuery の方でやります。(本当は適切なステータスコードを返した方が良いのかもしれないですが)

View テンプレートの編集

カテゴリの追加・編集、削除は API で行うので、jQuery を利用したり Ajax で非同期通信したりするための準備を、あらかじめしておきます。親テンプレートの resources/views/admin_blog/app.blade.php を開いて下記のように編集します。

resources/views/admin_blog/app.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    {{--@yield ディレクティブは指定したセクションの内容を表示するために使用する--}}
    <title>@yield('title')</title>
    <link rel="stylesheet" href="{{ asset('/css/bootstrap.min.css') }}">
    <link rel="stylesheet" href="{{ asset('/css/blog.css') }}">

    {{--app.js を読み込めば jQuery や bootstrap.js が読み込まれる--}}
    <script src="{{ asset('/js/app.js') }}"></script>
    {{--API を叩くための準備として CSRF 用トークンを設定しておく--}}
    <meta name="csrf-token" content="{{ csrf_token() }}">
    {{--各ページで <head> タグ内に追加できるようにしておく--}}
    @yield('head')
</head>

<body>
@yield('body')
</body>
</html>

/js/app.js を読み込めば、jQuery や bootstrap.js が読み込まれます。これは アセットのコンパイル により用意されたものです。ここでは割愛しますが、詳しくは 日本語ドキュメント を参照してください。Laravel のバージョンによって異なる可能性もありますので、注意してください。そして、APIを叩く準備として、CSRF用のトークンをここで埋めておき、後で利用します。これで jQuery が使えるようになり、API を叩く準備も整いました。

続いてカテゴリー一覧画面のテンプレート resources/views/admin_blog/category.blade.php を開いて、下記のように編集します。

resources/views/admin_blog/category.blade.php
@extends('admin_blog.app')
@section('title', 'カテゴリ一覧')

@section('head')
    {{--jQuery は下記のファイルに記述し読み込むようにする--}}
    <script src="{{ asset('/js/category.js') }}"></script>
@endsection

@section('body')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                <h2>カテゴリ一覧</h2>

                <button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#categoryModal">
                    登録
                </button>
                <br>

                @if (count($list) > 0)
                    <br>

                    {{ $list->links() }}
                    <table class="table table-striped">
                        <tr>
                            <th width="120px">カテゴリ番号</th>
                            <th>カテゴリ名</th>
                            <th width="60px">表示順</th>
                            <th width="60px">編集</th>
                        </tr>

                        @foreach ($list as $category)
                            <tr data-category_id="{{ $category->category_id }}">
                                <td>
                                    <span class="category_id">{{ $category->category_id }}</span>
                                </td>
                                <td>
                                    <span class="name">{{ $category->name }}</span>
                                </td>
                                <td>
                                    <span class="display_order">{{ $category->display_order }}</span>
                                </td>
                                <td>
                                    <button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#categoryModal" data-category_id="{{ $category->category_id }}">編集</button>
                                </td>
                            </tr>
                        @endforeach
                    </table>
                @else
                    <br>
                    <p>カテゴリがありません。</p>
                @endif

            </div>
        </div>
    </div>

    <!-- モーダル・ダイアログ -->
    <div class="modal fade" id="categoryModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">

                <div class="modal-header">
                    <button type="button" class="close" data-dismiss="modal">
                        <span>×</span>
                    </button>
                    <h4 class="modal-title">カテゴリ編集</h4>
                </div>

                <div class="modal-body">
                    {{--API 通信結果表示部分--}}
                    <div id="api_result" class="hidden"></div>

                    <form class="form-horizontal">
                        <div class="form-group">
                            <label class="col-sm-2 control-label">カテゴリ名</label>
                            <div class="col-sm-10">
                                <input type="text" name="name" class="form-control" placeholder="カテゴリ名">
                            </div>
                        </div>
                        <div class="form-group">
                            <label class="col-sm-2 control-label">表示順</label>
                            <div class="col-sm-10">
                                <input type="text" name="display_order" size="20" class="form-control" placeholder="表示順">
                            </div>
                        </div>
                    </form>
                </div>

                <div class="modal-footer">
                    <button type="button" class="btn btn-default" data-dismiss="modal">閉じる</button>
                    <button type="button" id="category_delete" class="btn btn-danger">削除</button>
                    <button type="button" id="category_submit" class="btn btn-primary">保存</button>
                    <input type="hidden" name="category_id">
                </div>

            </div>
        </div>
    </div>
@endsection

カテゴリの新規追加・編集、削除フォームを bootstrap のモーダルを使って表示させています。(bootstrap モーダルの作り方については、Laravel とは関係が無いので説明を割愛します)

では、jQuery を使って API の呼び出しや、動的処理を実装していきます。public/js/category.js というファイルを新たに作成して、下記のように編集します。

public/js/category.js
$(function () {
    // メタタグに設定したトークンを使って、全リクエストヘッダにCSRFトークンを追加
    $.ajaxSetup({
        headers: {
            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
        }
    });

    // 登録もしくは編集ボタン押下時
    $('[data-toggle=modal]').on('click', function() {
        var target_category_id = $(this).attr('data-category_id');

        // 既存カテゴリーならフォームに値をセット
        if (target_category_id) {
            var target_object = $('tr[data-category_id=' + target_category_id + ']');
            var target_value = {
                category_id   : target_category_id,
                name          : target_object.find('span.name').text(),
                display_order : target_object.find('span.display_order').text()
            };
            $('input[name=category_id]').val(target_value.category_id);
            $('input[name=name]').val(target_value.name);
            $('input[name=display_order]').val(target_value.display_order);
        }
    });

    // モーダルが閉じられるとき、アラートを消し、フォームを空にしておく
    $('#categoryModal').on('hidden.bs.modal', function() {
        $('#api_result').html('').removeClass().addClass('hidden');
        $('input[name=category_id]').val(null);
        $('input[name=name]').val(null);
        $('input[name=display_order]').val(null);
    });

    // 保存ボタン押下時
    $('#category_submit').on('click', function() {
        var category_id = $('input[name=category_id]').val();
        category_id = (category_id) ? category_id : null;
        var data = {
            category_id   : category_id,
            name          : $('input[name=name]').val(),
            display_order : $('input[name=display_order]').val()
        };

        // APIを呼び出してDBに保存
        $.ajax({
            type : 'POST',
            url : 'category/edit',
            data : data

        }).done(function(data) {
            // 正常時 結果表示
            $('#api_result').html('<span>正常に処理が完了しました</span>')
                .removeClass()
                .addClass('alert alert-success show');
            // 少しせこいが、リロードして変更が反映された画面を表示する
            location.reload();

        }).fail(function(data) {
            // エラー時 エラーメッセージ生成
            var error_message = '';
            $.each(data.responseJSON.errors, function(element, message_array) {
                $.each(message_array, function(index, message) {
                    error_message += message + '<br>';
                })
            });

            // エラーメッセージ表示
            $('#api_result').html('<span>' + error_message + '</span>')
                .removeClass()
                .addClass('alert alert-danger show');
        });
    });

    // 削除ボタン押下時
    $('#category_delete').on('click', function() {
        var data = {
            category_id : $('input[name=category_id]').val()
        };

        // APIを呼び出して削除
        $.ajax({
            type : 'POST',
            url : 'category/delete',
            data : data

        }).done(function(data) {
            // 正常時 結果表示
            $('#api_result').html('<span>削除しました</span>')
                .removeClass()
                .addClass('alert alert-success show');
            // リロード
            location.reload();

        }).fail(function(data) {
            // エラー時 エラーメッセージ表示
            $('#api_result').html('<span>削除に失敗しました</span>')
                .removeClass()
                .addClass('alert alert-danger show');
        });
    });
});

ほとんど Laravel とは関係の無い記述なので、詳細は割愛しますが、冒頭の $.ajaxSetup() で、meta タグに設定しておいた CSRF 用トークンを取得して設定しているのがポイントです。こうしておかないと POST で API を呼び出すときに、エラーが返ってきてしまいます。
また、バリデートエラーになった場合は、先ほど JSON でレスポンスが返るように実装したので、それを基にエラーメッセージを作成して表示させています。
ここまでできたら、実際にフォーム(モーダル)から、カテゴリの追加・編集、削除が出来ることを確認してください。

記事投稿フォームでカテゴリーを選択できるようにする

カテゴリーの追加・編集、削除ができるようになったので、記事を新規投稿・編集するときに、設定したカテゴリーを記事投稿フォームから選択できるようにしていきます。では、app/Http/Controllers/AdminBlogController.php を開いて form() メソッドを下記のように編集します。

app/Http/Controllers/AdminBlogController.php
    /**
     * ブログ記事入力フォーム
     *
     * @param  int $article_id 記事ID
     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
     */
    public function form(int $article_id = null)
    {
        $article = $this->article->find($article_id);

        // 記事データがあれば toArray メソッドで配列にしておき、フォーマットした post_date を入れる
        $input = [];
        if ($article) {
            $input = $article->toArray();
            $input['post_date'] = $article->post_date_text;
        } else {
            $article_id = null;
        }

        $input = array_merge($input, old());

        // カテゴリーの取得
        // pluck メソッドを使って引数に指定した項目で配列を生成する
        $category_list = $this->category->getCategoryList()->pluck('name', 'category_id');

        return view('admin_blog.form', compact('input', 'article_id', 'category_list'));
    }

カテゴリーのリストを取得して、View テンプレートに渡す処理を追加しました。カテゴリーのリストを取得する際に、コレクションの pluck メソッドを利用しました。このメソッドは、コレクションの中から、第一引数に指定した値を全て取り出し、配列にして返すメソッドです。今回はカテゴリー名を取り出し、配列として取得します。第二引数は、取り出した値のキーとして利用したい項目で、今回は category_id を指定しました。つまり、下記のような配列を取得することになります。

Array
        (
            [5] => 日々のこと
            [6] => 仕事
            [7] => 雑談
        )

これは、セレクトボックス等を作るときに、非常に便利ですし、pluck メソッド自体、非常に役に立つので、覚えておいて損は無いと思います。詳しくは日本語ドキュメントを参照してください。

では続いて、フォームを表示するテンプレートを編集します。resources/views/admin_blog/form.blade.php を開いて、フォーム部分を下記のように編集します。

resources/views/admin_blog/form.blade.php
               <form method="POST" action="{{ route('admin_post') }}">
                    <div class="form-group">
                        <label>日付</label>
                        {{--{{$variable or 'Default'}} は {{isset($variable) ? $variable : 'Default'}} と同じ意味で、変数があるかどうかわからないときに便利です--}}
                        <input class="form-control" name="post_date" size="20" value="{{ $input['post_date'] or null }}" placeholder="日付を入力して下さい。">
                    </div>

                    <div class="form-group">
                        <label>カテゴリー</label>
                        <select class="form-control" name="category_id">
                            @foreach ($category_list as $category_id => $category_name)
                                @php
                                    $input_category_id = array_get($input, 'category_id');
                                    $selected = ($category_id == $input_category_id) ? ' selected' : null;
                                @endphp
                                <option value="{{ $category_id }}"{{$selected}}>{{ $category_name }}</option>
                            @endforeach
                        </select>
                    </div>

                ...(中略)...

                </form>

カテゴリーのセレクトボックスを作っている部分を、フォームに追加しました。セレクトボックスの "selected" を付ける部分は、<option> タグの中に処理を書くと、一行が長くなってしまうので、予め @php ディレクティブを利用して、PHPコードを埋め込んで処理をしておき、その結果を <option> タグ内に埋めるという方法にしています。

最後に、app/Models/Article.phpを編集します。

app/Models/Article.php
class Article extends Model
{
    ...中略...

    // 「複数代入」を利用するときに指定する。追加・編集可能なカラム名のみを指定する。
    // $guarded プロパティを利用すると、逆に、追加・編集不可能なカラムを指定できる。
    protected $fillable = ['category_id', 'post_date', 'title', 'body'];

$fillable に category_id を追加しました。これを追加しておかないと、セレクトボックスで指定したカテゴリーを受け取ってもらえません。
ここまでできたら、記事投稿フォームを表示して、登録したカテゴリーがセレクトボックスで選択できること、記事が投稿できることを確認してください。

長々と続けてきたこのチュートリアルも、次回で最終回です。次回は、フロント側のカテゴリ別の表示を実装します。

参考資料

Laravel 日本語ドキュメント

プログラムソース(github)

その他