11
12

More than 3 years have passed since last update.

Vue + Vue Router + Vuex + Laravel 7.20.0で写真共有アプリを作ろう

Last updated at Posted at 2020-07-25

最新版のLarabel 7.20.0で作っていきます。
202007191203.png

簡潔に書いてきます。チュートリアルではDockerで環境構築してAWS S3 に画像をアップロードしていますが、ここではXamppで環境構築しLocalに画像をアップロードしていく点が違います。

作業環境

  • OS:Windows 10 HOME ver.2004
  • Node:12.18.2
  • npm:6.14.6
  • Vue.js:
  • Vue Router:
  • Vuex:
  • PHP:7.4.6
  • Laravel:7.20.0

(1) イントロダクション

説明のみでコードはないので、エラーもありませんでした。

(2) アプリケーションの設計

説明のみでコードはないので、エラーもありませんでした。

(3) SPA開発環境とVue Router

Laravel プロジェクトを作成する

Docker で開発環境構築

環境構築はXampp で行いました。Laravelのインストールとログインに利用するコントローラーもインストールします。
参考:https://readouble.com/laravel/7.x/ja/authentication.html

Terminal
$ cd C:\xampp\htdocs
$ composer create-project laravel/laravel vuesplash --prefer-dist
$ composer require laravel/ui
$ php artisan ui vue --auth

今回フロントはVueで作成するので、以下のフォルダとファイルを削除します。

  • views/auth
  • views/layouts
  • views/home.blade.php

Laravel の初期設定

app.php

config/app.php
70行目 'timezone' => 'Asia/Tokyo',
83行目 'locale' => 'ja',

.env

サーバーはMySQLを利用します。

.env
APP_NAME=Vuesplash

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=vuesplash //データベース名
DB_USERNAME=root //ユーザー名
DB_PASSWORD= //パスワード

EditorConfig

.editorconfig ファイルが存在しないので飛ばします。

フロントエンドの準備

JavaScript

パッケージとVue.js を追加でインストールします。

Terminal
$ npm install
$ npm install -D vue

Laravel Mix

ここは少し変わります。
参考:https://readouble.com/laravel/7.x/ja/mix.html

webpack.mix.js
const mix = require('laravel-mix');

mix.js('resources/js/app.js', 'public/js')
    .sass('resources/sass/app.scss', 'public/css')
    .version();

mix.browserSync({
    files: [
      "resources/views/**/*.blade.php",
      "public/**/*.*"
    ],
    proxy: 'http://127.0.0.1:8000'
});

コマンドでphp artisan serveを実行してLaravelを起動して、別のコマンドでnpm run watchを実行するとブラウザ自動的に立ち上がり、http://localhost:3000/にて画面が表示されます。
202007172101.png

画面(HTML)を返す

ルーティング

routes/web.php
<?php

// APIのURL以外のリクエストに対してはindexテンプレートを返す
// 画面遷移はフロントエンドのVueRouterが制御する
Route::get('/{any?}', fn() => view('index'))->where('any', '.+');

テンプレート

resources/views/index.blade.php
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ config('app.name') }}</title>

  <!-- Scripts -->
  <script src="{{ mix('js/app.js') }}" defer></script>

  <!-- Fonts -->
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Merriweather|Roboto:400">
  <link rel="stylesheet" href="https://unpkg.com/ionicons@4.2.2/dist/css/ionicons.min.css">

  <!-- Styles -->
  <link rel="stylesheet" href="https://hypertext-candy.s3-ap-northeast-1.amazonaws.com/posts/vue-laravel-tutorial/app.css">
</head>
<body>
  <div id="app"></div>
</body>
</html>

JavaScript

resources/js/app.js
import Vue from 'vue'

new Vue({
  el: '#app',
  template: '<h1>Hello world</h1>'
})

フロントエンドのビルド

コマンドでnpm run watchを2回実行すると、下記の画面が表示されます。
202007172109.png

Vue Router

インストール

Terminal
$ npm install -D vue-router

ルートコンポーネント

resources/js/App.vue
<template>
  <div>
    <main>
      <div class="container">
        <RouterView />
      </div>
    </main>
  </div>
</template>

ページコンポーネント

resources/js/pages/PhotoList.vue
<template>
  <h1>Photo List</h1>
</template>
resources/js/pages/Login.vue
<template>
  <h1>Login</h1>
</template>

ルーティング

resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// ページコンポーネントをインポートする
import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'

// VueRouterプラグインを使用する
// これによって<RouterView />コンポーネントなどを使うことができる
Vue.use(VueRouter)

// パスとコンポーネントのマッピング
const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/login',
    component: Login
  }
]

// VueRouterインスタンスを作成する
const router = new VueRouter({
  routes
})

// VueRouterインスタンスをエクスポートする
// app.jsでインポートするため
export default router
config/app.php
import Vue from 'vue'
// ルーティングの定義をインポートする
import router from './router'
// ルートコンポーネントをインポートする
import App from './App.vue'

new Vue({
  el: '#app',
  router, // ルーティングの定義を読み込む
  components: { App }, // ルートコンポーネントの使用を宣言する
  template: '<App />' // ルートコンポーネントを描画する
})

history モード

resources/js/router.js
const router = new VueRouter({
  mode: 'history', // ★ 追加
  routes
})

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-3

(4) 認証API

API 用のルート

app/Providers/RouteServiceProvider.php
protected function mapApiRoutes()
{
    Route::prefix('api')
         ->middleware('web') // ★ 'api' → 'web' に変更
         ->namespace($this->namespace)
         ->group(base_path('routes/api.php'));
}

テストの準備

インメモリの SQLite を用いる

config/database.php の connections に以下の接続情報を追加します。

config/database.php
'sqlite_testing' => [
    'driver' => 'sqlite',
    'database' => ':memory:',
    'prefix' => '',
],

phpunit.xml に DB 接続の設定を追記します。ここは少し変わります。

phpunit.xml
<php>
    <server name="APP_ENV" value="testing"/>
    <server name="DB_CONNECTION" value="sqlite_testing"/> <!-- ★ 追加 -->
    <!-- 以下略 -->

会員登録 API

テストコード

Terminal
$ php artisan make:test RegisterApiTest
tests/Feature/RegisterApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class RegisterApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_新しいユーザーを作成して返却する()
    {
        $data = [
            'name' => 'vuesplash user',
            'email' => 'dummy@email.com',
            'password' => 'test1234',
            'password_confirmation' => 'test1234',
        ];

        $response = $this->json('POST', route('register'), $data);

        $user = User::first();
        $this->assertEquals($data['name'], $user->name);

        $response
            ->assertStatus(201)
            ->assertJson(['name' => $user->name]);
    }
}

ルート定義

routes/api.php に下記を追記します。

routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

// 会員登録
Route::post('/register', 'Auth\RegisterController@register')->name('register');

コントローラー

app/Http/Controllers/Auth/RegisterController.php
use Illuminate\Http\Request; // ★ 追加

class RegisterController extends Controller
{
    /* 中略 */

    // ★ メソッド追加
    protected function registered(Request $request, $user)
    {
        return $user;
    }
}

テスト実施

Terminal
$ php artisan test --testdox

#実行結果
Warning: TTY mode is not supported on Windows platform.
PHPUnit 8.5.8 by Sebastian Bergmann and contributors.

Example (Tests\Unit\Example)
 ✔ Basic test

Example (Tests\Feature\Example)
 ✔ Basic test

Register Api (Tests\Feature\RegisterApi)
 ✔ Should 新しいユーザーを作成して返却する

Time: 12.17 seconds, Memory: 24.00 MB

OK (3 tests, 5 assertions)

ログイン API

テストコード

Terminal
$ php artisan make:test LoginApiTest
tests/Feature/LoginApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class LoginApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_登録済みのユーザーを認証して返却する()
    {
        $response = $this->json('POST', route('login'), [
            'email' => $this->user->email,
            'password' => 'password',
        ]);

        $response
            ->assertStatus(200)
            ->assertJson(['name' => $this->user->name]);

        $this->assertAuthenticatedAs($this->user);
    }
}

ルート定義

routes/api.php に下記を追記します。

routes/api.php
// ログイン
Route::post('/login', 'Auth\LoginController@login')->name('login');

コントローラー

app/Http/Controllers/Auth/LoginController.php
use Illuminate\Http\Request; // ★ 追加

class LoginController extends Controller
{
    /* 中略 */

    // ★ メソッド追加
    protected function authenticated(Request $request, $user)
    {
        return $user;
    }
}

テスト実施

Terminal
$ php artisan test --testdox

#実行結果
Login Api (Tests\Feature\LoginApi)
 ✔ Should 登録済みのユーザーを認証して返却する

ログアウト API

テストコード

Terminal
$ php artisan make:test LogoutApiTest
tests/Feature/LogoutApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class LogoutApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_認証済みのユーザーをログアウトさせる()
    {
        $response = $this->actingAs($this->user)
                         ->json('POST', route('logout'));

        $response->assertStatus(200);
        $this->assertGuest();
    }
}

ルート定義

routes/api.php に下記を追記します。

routes/api.php
// ログアウト
Route::post('/logout', 'Auth\LoginController@logout')->name('logout');

コントローラー

LoginController.php に下記のメソッドを追記します。

app/Http/Controllers/Auth/LoginController.php
protected function loggedOut(Request $request)
{
    // セッションを再生成する
    $request->session()->regenerate();

    return response()->json();
}

テスト実施

Terminal
$ php artisan test --testdox

#実行結果
Logout Api (Tests\Feature\LogoutApi)
 ✔ Should 認証済みのユーザーをログアウトさせる

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-4

(5) 認証ページ

ヘッダーとフッター

ヘッダーコンポーネント

resources/js/components/Navbar.vue
<template>
  <nav class="navbar">
    <RouterLink class="navbar__brand" to="/">
      Vuesplash
    </RouterLink>
    <div class="navbar__menu">
      <div class="navbar__item">
        <button class="button">
          <i class="icon ion-md-add"></i>
          Submit a photo
        </button>
      </div>
      <span class="navbar__item">
        username
      </span>
      <div class="navbar__item">
        <RouterLink class="button button--link" to="/login">
          Login / Register
        </RouterLink>
      </div>
    </div>
  </nav>
</template>

フッターコンポーネント

resources/js/components/Footer.vue
<template>
  <footer class="footer">
    <button class="button button--link">Logout</button>
    <RouterLink class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

App.vue

resources/js/App.vue
<template>
  <div>
    <header>
      <Navbar />
    </header>
    <main>
      <div class="container">
        <RouterView />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script>
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'

export default {
  components: {
    Navbar,
    Footer
  }
}
</script>

ブラウザでヘッダーとフッターが表示されます。
202007180944.png

タブ機能を実装する

タブ UI を追加する

resources/js/pages/Login.vue
<template>
  <div class="container--small">
    <ul class="tab">
      <li
        class="tab__item"
        @click="tab = 1"
      >Login</li>
      <li
        class="tab__item"
        @click="tab = 2"
      >Register</li>
    </ul>
    <div class="panel" v-show="tab === 1">Login Form</div>
    <div class="panel" v-show="tab === 2">Register Form</div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      tab: 1
    }
  }
}
</script>

ブラウザで「Login Form」と「Register Form」がタブで切り替わることを確認できます。
202007180949.png

選択状態のスタイルを変える

resources/js/pages/Login.vue
<ul class="tab">
  <li
    class="tab__item"
    :class="{'tab__item--active': tab === 1 }"
    @click="tab = 1"
  >Login</li>
  <li
    class="tab__item"
    :class="{'tab__item--active': tab === 2 }"
    @click="tab = 2"
  >Register</li>
</ul>

フォームを実装する

最終的に以下のようになります。

resources/js/pages/Login.vue
<template>
  <div class="container--small">
    <ul class="tab">
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 1 }"
        @click="tab = 1"
      >Login</li>
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 2 }"
        @click="tab = 2"
      >Register</li>
    </ul>
    <div class="panel" v-show="tab === 1">
      <form class="form" @submit.prevent="login">
        <label for="login-email">Email</label>
        <input type="text" class="form__item" id="login-email" v-model="loginForm.email">
        <label for="login-password">Password</label>
        <input type="password" class="form__item" id="login-password" v-model="loginForm.password">
        <div class="form__button">
          <button type="submit" class="button button--inverse">login</button>
        </div>
      </form>
    </div>
    <div class="panel" v-show="tab === 2">
      <form class="form" @submit.prevent="register">
        <label for="username">Name</label>
        <input type="text" class="form__item" id="username" v-model="registerForm.name">
        <label for="email">Email</label>
        <input type="text" class="form__item" id="email" v-model="registerForm.email">
        <label for="password">Password</label>
        <input type="password" class="form__item" id="password" v-model="registerForm.password">
        <label for="password-confirmation">Password (confirm)</label>
        <input type="password" class="form__item" id="password-confirmation" v-model="registerForm.password_confirmation">
        <div class="form__button">
          <button type="submit" class="button button--inverse">register</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      tab: 1,
      loginForm: {
        email: '',
        password: ''
      },
      registerForm: {
        name: '',
        email: '',
        password: '',
        password_confirmation: ''
      }
    }
  },
  methods: {
    login () {
      console.log(this.loginForm)
    },
    register () {
      console.log(this.registerForm)
    }
  }
}
</script>

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-5

(6) 認証機能とVuex

Vuex の導入

インストール

Terminal
$ npm install --save-dev vuex

ストアの作成と読み込み

resources/js/store/auth.js
const state = {}

const getters = {}

const mutations = {}

const actions = {}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}
resources/js/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth
  }
})

export default store
resources/js/app.js
import Vue from 'vue'
import router from './router'
import store from './store' // ★ 追加
import App from './App.vue'

new Vue({
  el: '#app',
  router,
  store, // ★ 追加
  components: { App },
  template: '<App />'
})

CSRF 対策

CSRF 対策の実装

クッキーを取り出す関数
resources/js/util.js
/**
 * クッキーの値を取得する
 * @param {String} searchKey 検索するキー
 * @returns {String} キーに対応する値
 */
export function getCookieValue (searchKey) {
  if (typeof searchKey === 'undefined') {
    return ''
  }

  let val = ''

  document.cookie.split(';').forEach(cookie => {
    const [key, value] = cookie.split('=')
    if (key === searchKey) {
      return val = value
    }
  })

  return val
}
Axios の設定
resources/js/bootstrap.js
import { getCookieValue } from './util'

window.axios = require('axios')

// Ajaxリクエストであることを示すヘッダーを付与する
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'

window.axios.interceptors.request.use(config => {
  // クッキーからトークンを取り出してヘッダーに添付する
  config.headers['X-XSRF-TOKEN'] = getCookieValue('XSRF-TOKEN')

  return config
})

app.js の先頭行に以下の記述を追加します。

resources/js/app.js
import './bootstrap'

会員登録

マイグレーション

Terminal
$ php artisan migrate

ストアの実装

resources/js/store/auth.js
const state = {
  user: null
}

const getters = {}

const mutations = {
  setUser (state, user) {
    state.user = user
  }
}

const actions = {
  async register (context, data) {
    const response = await axios.post('/api/register', data)
    context.commit('setUser', response.data)
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

コンポーネントの実装

ログインページを表す Login.vue の register メソッドを以下の通り編集します。

resources/js/pages/Login.vue
async register () {
  // authストアのresigterアクションを呼び出す
  await this.$store.dispatch('auth/register', this.registerForm)

  // トップページに移動する
  this.$router.push('/')
}

ログイン

ストアの実装

resources/js/store/auth.js
const actions = {
  async register (context, data) {
    const response = await axios.post('/api/register', data)
    context.commit('setUser', response.data)
  },
  async login (context, data) {
    const response = await axios.post('/api/login', data)
    context.commit('setUser', response.data)
  }
}

コンポーネントの実装

resources/js/pages/Login.vue
async login () {
  // authストアのloginアクションを呼び出す
  await this.$store.dispatch('auth/login', this.loginForm)

  // トップページに移動する
  this.$router.push('/')
},

ログアウト

ストアの実装

resources/js/store/auth.js
const actions = {
  async register (context, data) {
    const response = await axios.post('/api/register', data)
    context.commit('setUser', response.data)
  },
  async login (context, data) {
    const response = await axios.post('/api/login', data)
    context.commit('setUser', response.data)
  },
  async logout (context) {
    const response = await axios.post('/api/logout')
    context.commit('setUser', null)
  }
}

コンポーネントの実装

resources/js/components/Footer.vue
<template>
  <footer class="footer">
    <button class="button button--link" @click="logout">Logout</button>
    <RouterLink class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

<script>
export default {
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')

      this.$router.push('/login')
    }
  }
}
</script>

ここまでは以下より確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-6

(7) 認証機能とVuex Part.2

ステートの値による要素の出し分け

ゲッターを追加する

resources/js/store/auth.js
const getters = {
  check: state => !! state.user,
  username: state => state.user ? state.user.name : ''
}

ナビゲーションバー

resources/js/components/Navbar.vue
<template>
  <nav class="navbar">
    <RouterLink class="navbar__brand" to="/">
      Vuesplash
    </RouterLink>
    <div class="navbar__menu">
      <div v-if="isLogin" class="navbar__item">
        <button class="button">
          <i class="icon ion-md-add"></i>
          Submit a photo
        </button>
      </div>
      <span v-if="isLogin" class="navbar__item">
        username
      </span>
      <div v-else class="navbar__item">
        <RouterLink class="button button--link" to="/login">
          Login / Register
        </RouterLink>
      </div>
    </div>
  </nav>
</template>

<script>
export default {
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    },
    username () {
      return this.$store.getters['auth/username']
    }
  }
}
</script>

フッター

resources/js/components/Footer.vue
<template>
  <footer class="footer">
    <button v-if="isLogin" class="button button--link" @click="logout">
      Logout
    </button>
    <RouterLink v-else class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

<script>
export default {
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    }
  },
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')

      this.$router.push('/login')
    }
  }
}
</script>

認証状態を維持する

ユーザー取得 API

Terminal
$ php artisan make:test UserApiTest
tests/Feature/UserApiTest.php
<?php

namespace Tests\Feature;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class UserApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_ログイン中のユーザーを返却する()
    {
        $response = $this->actingAs($this->user)->json('GET', route('user'));

        $response
            ->assertStatus(200)
            ->assertJson([
                'name' => $this->user->name,
            ]);
    }

    /**
     * @test
     */
    public function should_ログインされていない場合は空文字を返却する()
    {
        $response = $this->json('GET', route('user'));

        $response->assertStatus(200);
        $this->assertEquals("", $response->content());
    }
}

実装

routes/api.php
// ログインユーザー
Route::get('/user', fn() => Auth::user())->name('user');
Terminal
$ php artisan test --testdox

#実行結果
User Api (Tests\Feature\UserApi)
 ✔ Should ログイン中のユーザーを返却する
 ✔ Should ログインされていない場合は空文字を返却する

起動時にログインチェック

ストアにアクションを追加

resources/js/store/auth.js
const actions = {
  async register (context, data) {/* 中略 */},
  async login (context, data) {/* 中略 */},
  async logout (context) {/* 中略 */},
  async currentUser (context) {
    const response = await axios.get('/api/user')
    const user = response.data || null
    context.commit('setUser', user)
  }
}

ログインチェックしてからアプリを生成する

resources/js/app.js
import './bootstrap'
import Vue from 'vue'
import router from './router'
import store from './store'
import App from './App.vue'

const createApp = async () => {
  await store.dispatch('auth/currentUser')

  new Vue({
    el: '#app',
    router,
    store,
    components: { App },
    template: '<App />'
  })
}

createApp()

ミドルウェア

app/Http/Middleware/RedirectIfAuthenticated.php
public function handle($request, Closure $next, $guard = null)
{
    if (Auth::guard($guard)->check()) {
        return redirect()->route('user'); // ★ 変更
    }

    return $next($request);
}

ナビゲーションガード

ルート定義にナビゲーションガードを追加

resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'

import store from './store' // ★ 追加

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/login',
    component: Login,
    beforeEnter (to, from, next) {  // ★ 追加
      if (store.getters['auth/check']) {
        next('/')
      } else {
        next()
      }
    }
  }
]

const router = new VueRouter({
  mode: 'history',
  routes
})

export default router

ここまでは以下より確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-7

(8) エラーハンドリング

最終的なコードは以下のようになります。

システムエラーページ

resources/js/pages/errors/System.vue
<template>
  <p>システムエラーが発生しました。</p>
</template>
resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// ページコンポーネントをインポートする
import PhotoList from './pages/PhotoList.vue'
import Login from './pages/Login.vue'
import SystemError from './pages/errors/System.vue'

import store from './store'

// VueRouterプラグインを使用する
// これによって<RouterView />コンポーネントなどを使うことができる
Vue.use(VueRouter)

// パスとコンポーネントのマッピング
const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/login',
    component: Login,
    beforeEnter (to, from, next) {
      if (store.getters['auth/check']) {
        next('/')
      } else {
        next()
      }
    }
  },
  {
    path: '/500',
    component: SystemError
  }
]

// VueRouterインスタンスを作成する
const router = new VueRouter({
  mode: 'history',
  routes
})

// VueRouterインスタンスをエクスポートする
// app.jsでインポートするため
export default router

レスポンスコード定義

resources/js/util.js
/**
 * クッキーの値を取得する
 * @param {String} searchKey 検索するキー
 * @returns {String} キーに対応する値
 */
export function getCookieValue (searchKey) {
  if (typeof searchKey === 'undefined') {
    return ''
  }

  let val = ''

  document.cookie.split(';').forEach(cookie => {
    const [key, value] = cookie.split('=')
    if (key === searchKey) {
      return val = value
    }
  })

  return val
}

export const OK = 200
export const CREATED = 201
export const INTERNAL_SERVER_ERROR = 500
export const UNPROCESSABLE_ENTITY = 422

error ストア

resources/js/store/error.js
const state = {
  code: null
}

const mutations = {
  setCode (state, code) {
    state.code = code
  }
}

export default {
  namespaced: true,
  state,
  mutations
}
resources/js/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'
import error from './error'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth,
    error
  }
})

export default store

auth ストア

resources/js/store/auth.js
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'

const state = {
  user: null,
  apiStatus: null,
  loginErrorMessages: null,
  registerErrorMessages: null
}

const getters = {
  check: state => !! state.user,
  username: state => state.user ? state.user.name : ''
}

const mutations = {
  setUser (state, user) {
    state.user = user
  },
  setApiStatus (state, status) {
    state.apiStatus = status
  },
  setLoginErrorMessages (state, messages) {
    state.loginErrorMessages = messages
  },
  setRegisterErrorMessages (state, messages) {
    state.registerErrorMessages = messages
  }
}

const actions = {
  // 会員登録
  async register (context, data) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/register', data)

    if (response.status === CREATED) {
      context.commit('setApiStatus', true)
      context.commit('setUser', response.data)
      return false
    }

    context.commit('setApiStatus', false)
    if (response.status === UNPROCESSABLE_ENTITY) {
      context.commit('setRegisterErrorMessages', response.data.errors)
    } else {
      context.commit('error/setCode', response.status, { root: true })
    }
  },

  // ログイン
  async login (context, data) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/login', data)

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', response.data)
      return false
    }

    context.commit('setApiStatus', false)
    if (response.status === UNPROCESSABLE_ENTITY) {
      context.commit('setLoginErrorMessages', response.data.errors)
    } else {
      context.commit('error/setCode', response.status, { root: true })
    }
  },

  // ログアウト
  async logout (context) {
    context.commit('setApiStatus', null)
    const response = await axios.post('/api/logout')

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', null)
      return false
    }

    context.commit('setApiStatus', false)
    context.commit('error/setCode', response.status, { root: true })
  },

  // ログインユーザーチェック
  async currentUser (context) {
    context.commit('setApiStatus', null)
    const response = await axios.get('/api/user')
    const user = response.data || null

    if (response.status === OK) {
      context.commit('setApiStatus', true)
      context.commit('setUser', user)
      return false
    }

    context.commit('setApiStatus', false)
    context.commit('error/setCode', response.status, { root: true })
  }
}

export default {
  namespaced: true,
  state,
  getters,
  mutations,
  actions
}

ページコンポーネント

resources/js/pages/Login.vue
<template>
  <div class="container--small">
    <ul class="tab">
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 1 }"
        @click="tab = 1"
      >Login</li>
      <li
        class="tab__item"
        :class="{'tab__item--active': tab === 2 }"
        @click="tab = 2"
      >Register</li>
    </ul>
    <div class="panel" v-show="tab === 1">
      <form class="form" @submit.prevent="login">
        <div v-if="loginErrors" class="errors">
          <ul v-if="loginErrors.email">
            <li v-for="msg in loginErrors.email" :key="msg">{{ msg }}</li>
          </ul>
          <ul v-if="loginErrors.password">
            <li v-for="msg in loginErrors.password" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <label for="login-email">Email</label>
        <input type="text" class="form__item" id="login-email" v-model="loginForm.email">
        <label for="login-password">Password</label>
        <input type="password" class="form__item" id="login-password" v-model="loginForm.password">
        <div class="form__button">
          <button type="submit" class="button button--inverse">login</button>
        </div>
      </form>
    </div>
    <div class="panel" v-show="tab === 2">
      <form class="form" @submit.prevent="register">
        <div v-if="registerErrors" class="errors">
          <ul v-if="registerErrors.name">
            <li v-for="msg in registerErrors.name" :key="msg">{{ msg }}</li>
          </ul>
          <ul v-if="registerErrors.email">
            <li v-for="msg in registerErrors.email" :key="msg">{{ msg }}</li>
          </ul>
          <ul v-if="registerErrors.password">
            <li v-for="msg in registerErrors.password" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <label for="username">Name</label>
        <input type="text" class="form__item" id="username" v-model="registerForm.name">
        <label for="email">Email</label>
        <input type="text" class="form__item" id="email" v-model="registerForm.email">
        <label for="password">Password</label>
        <input type="password" class="form__item" id="password" v-model="registerForm.password">
        <label for="password-confirmation">Password (confirm)</label>
        <input type="password" class="form__item" id="password-confirmation" v-model="registerForm.password_confirmation">
        <div class="form__button">
          <button type="submit" class="button button--inverse">register</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  data () {
    return {
      tab: 1,
      loginForm: {
        email: '',
        password: ''
      },
      registerForm: {
        name: '',
        email: '',
        password: '',
        password_confirmation: ''
      }
    }
  },
  computed: mapState({
    apiStatus: state => state.auth.apiStatus,
    loginErrors: state => state.auth.loginErrorMessages,
    registerErrors: state => state.auth.registerErrorMessages
  }),
  methods: {
    async login () {
      // authストアのloginアクションを呼び出す
      await this.$store.dispatch('auth/login', this.loginForm)
      if (this.apiStatus) {
        // トップページに移動する
        this.$router.push('/')
      }
    },
    async register () {
      // authストアのresigterアクションを呼び出す
      await this.$store.dispatch('auth/register', this.registerForm)
      if (this.apiStatus) {
        // トップページに移動する
        this.$router.push('/')
      }
    },
    clearError () {
      this.$store.commit('auth/setLoginErrorMessages', null)
      this.$store.commit('auth/setRegisterErrorMessages', null)
    }
  },
  created () {
    this.clearError()
  }
}
</script>

ルートコンポーネント

resources/js/App.vue
<template>
  <div>
    <header>
      <Navbar />
    </header>
    <main>
      <div class="container">
        <RouterView />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script>
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { INTERNAL_SERVER_ERROR } from './util'
export default {
  components: {
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

bootstrap

resources/js/bootstrap.js
import { getCookieValue } from './util'

window.axios = require('axios')

// Ajaxリクエストであることを示すヘッダーを付与する
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'

window.axios.interceptors.request.use(config => {
  // クッキーからトークンを取り出してヘッダーに添付する
  config.headers['X-XSRF-TOKEN'] = getCookieValue('XSRF-TOKEN')

  return config
})

window.axios.interceptors.response.use(
  response => response,
  error => error.response || error
)

ログアウト

resources/jscomponents/Footer.vue
<template>
  <footer class="footer">
    <button v-if="isLogin" class="button button--link" @click="logout">
      Logout
    </button>
    <RouterLink v-else class="button button--link" to="/login">
      Login / Register
    </RouterLink>
  </footer>
</template>

<script>
import { mapState, mapGetters } from 'vuex'
export default {
  computed: {
    ...mapState({
      apiStatus: state => state.auth.apiStatus
    }),
    ...mapGetters({
      isLogin: 'auth/check'
    })
  },
  methods: {
    async logout () {
      await this.$store.dispatch('auth/logout')
      if (this.apiStatus) {
        this.$router.push('/login')
      }
    }
  }
}
</script>

ここまでは下記にまとめてあります。
https://github.com/neneta0921/vuesplash/tree/ch-8

(9) 写真投稿API

AWS S3

AWS S3 は利用せずにLocalにファイルを保管します。

テストコード

Terminal
$ php artisan make:test PhotoSubmitApiTest
tests/Feature/PhotoSubmitApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class PhotoSubmitApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_ファイルをアップロードできる()
    {
        // S3ではなくテスト用のストレージを使用する
        // → storage/framework/testing
        Storage::fake('s3');

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.create'), [
                // ダミーファイルを作成して送信している
                'photo' => UploadedFile::fake()->image('photo.jpg'),
            ]);

        // レスポンスが201(CREATED)であること
        $response->assertStatus(201);

        $photo = Photo::first();

        // 写真のIDが12桁のランダムな文字列であること
        $this->assertRegExp('/^[0-9a-zA-Z-_]{12}$/', $photo->id);

        // DBに挿入されたファイル名のファイルがストレージに保存されていること
        Storage::cloud()->assertExists($photo->filename);
    }

    /**
     * @test
     */
    public function should_データベースエラーの場合はファイルを保存しない()
    {
        // 乱暴だがこれでDBエラーを起こす
        Schema::drop('photos');

        Storage::fake('s3');

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.create'), [
                'photo' => UploadedFile::fake()->image('photo.jpg'),
            ]);

        // レスポンスが500(INTERNAL SERVER ERROR)であること
        $response->assertStatus(500);

        // ストレージにファイルが保存されていないこと
        $this->assertEquals(0, count(Storage::cloud()->files()));
    }

    /**
     * @test
     */
    public function should_ファイル保存エラーの場合はDBへの挿入はしない()
    {
        // ストレージをモックして保存時にエラーを起こさせる
        Storage::shouldReceive('cloud')
            ->once()
            ->andReturnNull();

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.create'), [
                'photo' => UploadedFile::fake()->image('photo.jpg'),
            ]);

        // レスポンスが500(INTERNAL SERVER ERROR)であること
        $response->assertStatus(500);

        // データベースに何も挿入されていないこと
        $this->assertEmpty(Photo::all());
    }
}

API の実装

.env

AWS S3 は利用しないので設定はしません。

マイグレーション

Terminal
$ php artisan make:migration create_photos_table --create=photos
$ php artisan make:migration create_likes_table --create=likes
$ php artisan make:migration create_comments_table --create=comments

以下はチュートリアルとは少し異なります。
参考:https://readouble.com/laravel/7.x/ja/migrations.html

photos テーブル
XXXX_XX_XX_XXXXXX_create_photos_table.php
    public function up()
    {
        Schema::create('photos', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained();
            $table->string('filename');
            $table->timestamps();
        });
    }
likes テーブル
XXXX_XX_XX_XXXXXX_create_likes_table.php
    public function up()
    {
        Schema::create('likes', function (Blueprint $table) {
            $table->id();
            $table->string('photo_id');
            $table->foreignId('user_id')->constrained();
            $table->timestamps();

            $table->foreign('photo_id')->references('id')->on('photos');
        });
    }
comments テーブル
XXXX_XX_XX_XXXXXX_create_comments_table.php
    public function up()
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->string('photo_id');
            $table->foreignId('user_id')->constrained();
            $table->text('content');
            $table->timestamps();

            $table->foreign('photo_id')->references('id')->on('photos');
        });
    }
マイグレーション実行
Terminal
$ php artisan migrate

モデル

Photo
Terminal
$ php artisan make:model Photo
app/Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }
}
User
app/User.php
/**
 * リレーションシップ - photosテーブル
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function photos()
{
    return $this->hasMany('App\Photo');
}

ルーティング

routes/api.php
// 写真投稿
Route::post('/photos', 'PhotoController@create')->name('photo.create');

フォームリクエスト

Terminal
$ php artisan make:request StorePhoto
app/Http/Requests/StorePhoto.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StorePhoto 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 [
            'photo' => 'required|file|mimes:jpg,jpeg,png,gif'
        ];
    }
}

コントローラー

Terminal
$ php artisan make:controller PhotoController

Localに保存するので、チュートリアルとは少し違います。

app/Http/Controllers/PhotoController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StorePhoto;
use App\Photo;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class PhotoController extends Controller
{
    public function __construct()
    {
        // 認証が必要
        $this->middleware('auth');
    }

    /**
     * 写真投稿
     * @param StorePhoto $request
     * @return \Illuminate\Http\Response
     */
    public function create(StorePhoto $request)
    {
        // 投稿写真の拡張子を取得する
        $extension = $request->photo->extension();

        $photo = new Photo();

        // インスタンス生成時に割り振られたランダムなID値と
        // 本来の拡張子を組み合わせてファイル名とする
        $photo->filename = $photo->id . '.' . $extension;

        // storage/app/public配下に保存する
        //Storage::putFileAs('photos', $photo->filename, 'public');
        $request->photo->storeAs('photos', $photo->filename, 'public');

        // データベースエラー時にファイル削除を行うため
        // トランザクションを利用する
        DB::beginTransaction();

        try {
            Auth::user()->photos()->save($photo);
            DB::commit();
        } catch (\Exception $exception) {
            DB::rollBack();
            // DBとの不整合を避けるためアップロードしたファイルを削除
            Storage::disk()->delete($photo->filename);
            throw $exception;
        }

        // リソースの新規作成なので
        // レスポンスコードは201(CREATED)を返却する
        return response($photo, 201);
    }
}

テストの実行

Terminal
$ php artisan test --testdox

#実行結果
Photo Submit Api (Tests\Feature\PhotoSubmitApi)
 ✘ Should ファイルをアップロードできる
   │
   │ Expected status code 201 but received 405.
   │ Failed asserting that 201 is identical to 405.
   │
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:185
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoSubmitApiTest.php:42
   │

 ✘ Should データベースエラーの場合はファイルを保存しない
   │
   │ Expected status code 500 but received 405.
   │ Failed asserting that 500 is identical to 405.
   │
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:185
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoSubmitApiTest.php:69
   │

 ✘ Should ファイル保存エラーの場合はDBへの挿入はしない
   │
   │ Expected status code 500 but received 422.
   │ Failed asserting that 500 is identical to 422.
   │
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Testing\TestResponse.php:185
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoSubmitApiTest.php:91
   │

FAILURES!

今回はエラーが出るようにテストしているので、これでOKです。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-10

(10) 写真投稿フォーム

最終的に以下のようになります。

フォームコンポーネント

resources/js/components/PhotoForm.vue
<template>
  <div v-show="value" class="photo-form">
    <h2 class="title">Submit a photo</h2>
    <div v-show="loading" class="panel">
      <Loader>Sending your photo...</Loader>
    </div>
    <form v-show="! loading" class="form" @submit.prevent="submit">
      <div class="errors" v-if="errors">
        <ul v-if="errors.photo">
          <li v-for="msg in errors.photo" :key="msg">{{ msg }}</li>
        </ul>
      </div>
      <input class="form__item" type="file" @change="onFileChange">
      <output class="form__output" v-if="preview">
        <img :src="preview" alt="">
      </output>
      <div class="form__button">
        <button type="submit" class="button button--inverse">submit</button>
      </div>
    </form>
  </div>
</template>

<script>
import { CREATED, UNPROCESSABLE_ENTITY } from '../util'
import Loader from './Loader.vue'
export default {
  components: {
    Loader
  },
  props: {
    value: {
      type: Boolean,
      required: true
    }
  },
  data () {
    return {
      loading: false,
      preview: null,
      photo: null,
      errors: null
    }
  },
  methods: {
    // フォームでファイルが選択されたら実行される
    onFileChange (event) {
      // 何も選択されていなかったら処理中断
      if (event.target.files.length === 0) {
        this.reset()
        return false
      }
      // ファイルが画像ではなかったら処理中断
      if (! event.target.files[0].type.match('image.*')) {
        this.reset()
        return false
      }
      // FileReaderクラスのインスタンスを取得
      const reader = new FileReader()
      // ファイルを読み込み終わったタイミングで実行する処理
      reader.onload = e => {
        // previewに読み込み結果(データURL)を代入する
        // previewに値が入ると<output>につけたv-ifがtrueと判定される
        // また<output>内部の<img>のsrc属性はpreviewの値を参照しているので
        // 結果として画像が表示される
        this.preview = e.target.result
      }
      // ファイルを読み込む
      // 読み込まれたファイルはデータURL形式で受け取れる(上記onload参照)
      reader.readAsDataURL(event.target.files[0])
      this.photo = event.target.files[0]
    },
    // 入力欄の値とプレビュー表示をクリアするメソッド
    reset () {
      this.preview = ''
      this.photo = null
      this.$el.querySelector('input[type="file"]').value = null
    },
    async submit () {
      this.loading = true
      const formData = new FormData()
      formData.append('photo', this.photo)
      const response = await axios.post('/api/photos', formData)
      this.loading = false
      if (response.status === UNPROCESSABLE_ENTITY) {
        this.errors = response.data.errors
        return false
      }
      this.reset()
      this.$emit('input', false)
      if (response.status !== CREATED) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      // メッセージ登録
      this.$store.commit('message/setContent', {
        content: '写真が投稿されました!',
        timeout: 6000
      })
      this.$router.push(`/photos/${response.data.id}`)
    }
  }
}
</script>

ナビゲーションバーコンポーネント

resources/js/components/Navbar.vue
<template>
  <nav class="navbar">
    <RouterLink class="navbar__brand" to="/">
      Vuesplash
    </RouterLink>
    <div class="navbar__menu">
      <div v-if="isLogin" class="navbar__item">
        <button class="button" @click="showForm = ! showForm">
          <i class="icon ion-md-add"></i>
          Submit a photo
        </button>
      </div>
      <span v-if="isLogin" class="navbar__item">
        {{ username }}
      </span>
      <div v-else class="navbar__item">
        <RouterLink class="button button--link" to="/login">
          Login / Register
        </RouterLink>
      </div>
    </div>
    <PhotoForm v-model="showForm" />
  </nav>
</template>

<script>
import PhotoForm from './PhotoForm.vue'
export default {
  components: {
    PhotoForm
  },
  data () {
    return {
      showForm: false
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    },
    username () {
      return this.$store.getters['auth/username']
    }
  }
}
</script>

投稿完了後のページ遷移

遷移先ページ作成

resources/js/pages/PhotoDetail.vue
<template>
  <h1>Photo Detail</h1>
</template>
resources/js/router.js
import Vue from 'vue'
import VueRouter from 'vue-router'

// ページコンポーネントをインポートする
import PhotoList from './pages/PhotoList.vue'
import PhotoDetail from './pages/PhotoDetail.vue'
import Login from './pages/Login.vue'
import SystemError from './pages/errors/System.vue'

import store from './store'

// VueRouterプラグインを使用する
// これによって<RouterView />コンポーネントなどを使うことができる
Vue.use(VueRouter)

// パスとコンポーネントのマッピング
const routes = [
  {
    path: '/',
    component: PhotoList
  },
  {
    path: '/photos/:id',
    component: PhotoDetail,
    props: true
  },
  {
    path: '/login',
    component: Login,
    beforeEnter (to, from, next) {
      if (store.getters['auth/check']) {
        next('/')
      } else {
        next()
      }
    }
  },
  {
    path: '/500',
    component: SystemError
  }
]

// VueRouterインスタンスを作成する
const router = new VueRouter({
  mode: 'history',
  routes
})

// VueRouterインスタンスをエクスポートする
// app.jsでインポートするため
export default router

ローディング

ローダーコンポーネント作成

resources/js/components/Loader.vue
<template>
  <div class="loader">
    <p class="loading__text">
      <slot>Loading...</slot>
    </p>
    <div class="loader__item loader__item--heart"><div></div></div>
  </div>
</template>

サクセスメッセージ

メッセージストア作成

resources/js/store/message.js
const state = {
  content: ''
}

const mutations = {
  setContent (state, { content, timeout }) {
    state.content = content

    if (typeof timeout === 'undefined') {
      timeout = 3000
    }

    setTimeout(() => (state.content = ''), timeout)
  }
}

export default {
  namespaced: true,
  state,
  mutations
}
resources/js/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import auth from './auth'
import error from './error'
import message from './message'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    auth,
    error,
    message
  }
})

export default store

メッセージコンポーネント

resources/js/components/Message.vue
<template>
  <div class="message" v-show="message">
    {{ message }}
  </div>
</template>

<script>
  import { mapState } from 'vuex'

  export default {
    computed: {
      ...mapState({
        message: state => state.message.content
      })
    }
  }
</script>

ルートコンポーネント

resources/js/App.vue
<template>
  <div>
    <header>
      <Navbar />
    </header>
    <main>
      <div class="container">
        <Message /> <!-- ★ 追加 -->
        <RouterView />
      </div>
    </main>
    <Footer />
  </div>
</template>

<script>
import Message from './components/Message.vue' // ★ 追加
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { INTERNAL_SERVER_ERROR } from './util'
export default {
  components: {
    Message, // ★ 追加
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-10

(11) 写真一覧取得API

テストコード

ファクトリ

Terminal
$ php artisan make:factory PhotoFactory
database/factories/PhotoFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use Faker\Generator as Faker;
use Illuminate\Support\Str;

$factory->define(App\Photo::class, function (Faker $faker) {
    return [
        'id' => Str::random(12),
        'user_id' => fn() => factory(App\User::class)->create()->id,
        'filename' => Str::random(12) . '.jpg',
        'created_at' => $faker->dateTime(),
        'updated_at' => $faker->dateTime(),
    ];
});

テストケース

Terminal
$ php artisan make:test PhotoListApiTest
tests/Feature/PhotoListApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;

class PhotoListApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        // 5つの写真データを生成する
        factory(Photo::class, 5)->create();

        $response = $this->json('GET', route('photo.index'));

        // 生成した写真データを作成日降順で取得
        $photos = Photo::with(['owner'])->orderBy('created_at', 'desc')->get();

        // data項目の期待値
        $expected_data = $photos->map(function ($photo) {
            return [
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
            ];
        })
        ->all();

        $response->assertStatus(200)
            // レスポンスJSONのdata項目に含まれる要素が5つであること
            ->assertJsonCount(5, 'data')
            // レスポンスJSONのdata項目が期待値と合致すること
            ->assertJsonFragment([
                "data" => $expected_data,
            ]);
    }
}

API の実装

ルーティング

routes/api.php
// 写真一覧
Route::get('/photos', 'PhotoController@index')->name('photo.index');

Photo モデル

app/Photo.php
/**
 * リレーションシップ - usersテーブル
 * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
 */
public function owner()
{
    return $this->belongsTo('App\User', 'user_id', 'id', 'users');
}

/**
 * アクセサ - url
 * @return string
 */
public function getUrlAttribute()
{
    return Storage::cloud()->url($this->attributes['filename']);
}

/** JSONに含める属性 */
protected $appends = [
    'url',
];

User モデル

app/User.php
protected $visible = [
    'name',
];

コントローラー

最終的に以下のようになります。クラウドを利用していないので、チュートリアルとは少し異なります。

app/Http/Controllers/Auth/PhotoController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StorePhoto;
use App\Photo;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class PhotoController extends Controller
{
    public function __construct()
    {
        // 認証が必要
        $this->middleware('auth')->except(['index', 'download']);
    }

    /**
     * 写真投稿
     * @param StorePhoto $request
     * @return \Illuminate\Http\Response
     */
    public function create(StorePhoto $request)
    {
        // 投稿写真の拡張子を取得する
        $extension = $request->photo->extension();

        $photo = new Photo();

        // インスタンス生成時に割り振られたランダムなID値と
        // 本来の拡張子を組み合わせてファイル名とする
        $photo->filename = $photo->id . '.' . $extension;

        // storage/app/public配下に保存する
        //Storage::putFileAs('photos', $photo->filename, 'public');
        $request->photo->storeAs('photos', $photo->filename, 'public');

        // データベースエラー時にファイル削除を行うため
        // トランザクションを利用する
        DB::beginTransaction();

        try {
            Auth::user()->photos()->save($photo);
            DB::commit();
        } catch (\Exception $exception) {
            DB::rollBack();
            // DBとの不整合を避けるためアップロードしたファイルを削除
            Storage::disk()->delete($photo->filename);
            throw $exception;
        }

        // リソースの新規作成なので
        // レスポンスコードは201(CREATED)を返却する
        return response($photo, 201);
    }

    /**
     * 写真一覧
     */
    public function index()
    {
        $photos = Photo::with(['owner'])
            ->orderBy(Photo::CREATED_AT, 'desc')->paginate();

        return $photos;
    }

    /**
     * 写真ダウンロード
     * @param Photo $photo
     * @return \Illuminate\Http\Response
     */
    public function download(Photo $photo)
    {
        // 写真の存在チェック
        if (! Storage::exists($photo->filename)) {
            abort(404);
        }

        $disposition = 'attachment; filename="' . $photo->filename . '"';
        $headers = [
            'Content-Type' => 'application/octet-stream',
            'Content-Disposition' => $disposition,
        ];

        return response(Storage::get($photo->filename), 200, $headers);
    }
}

テスト実行

Terminal
$ php artisan test --testdox

# 実行結果
Photo List Api (Tests\Feature\PhotoListApi)
 ✘ Should 正しい構造のJSONを返却する
   │
   │ Error: Class 'App\Storage' not found
   │
   │ C:\xampp\htdocs\vuesplash\app\Photo.php:70
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:473
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:1442
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:386
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Concerns\HasAttributes.php:365
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Model.php:1633
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoListApiTest.php:34
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Support\Collection.php:638
   │ C:\xampp\htdocs\vuesplash\vendor\laravel\framework\src\Illuminate\Database\Eloquent\Collection.php:277
   │ C:\xampp\htdocs\vuesplash\tests\Feature\PhotoListApiTest.php:39
   │

ここでエラーが出ました。
app\Photo.phpの70行目、return Storage::cloud()->url($this->attributes['filename']);がエラーの原因のようです。あとで修正します。

ダウンロードリンク

ルート定義

routes/web.php
// 写真ダウンロード
Route::get('/photos/{photo}/download', 'PhotoController@download');

チュートリアルはここまでですので、先程のエラーを解消します。

エラー解消のために試したこと

app\Photo.phpに以下のようにします。
参考:https://readouble.com/laravel/7.x/ja/filesystem.html?header

app\Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage; // ★追加

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function owner()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }

    /**
     * アクセサ - url
     * @return string
     */
    public function getUrlAttribute()
    {
        return Storage::url($this->attributes['filename']); // ★変更
    }

    /** JSONに含める属性 */
    protected $appends = [
      'url',
    ];
}

次にシンボリックリンクを張るには、storage:link Artisanコマンドを使用します。

Terminal
$ php artisan storage:link

LaravelではStorage/app/public内に保存しますが、画像を読み込むフォルダはpublic/storageファルダになります。
保存:storage/app/public
読込:public/storage
これを解消するために、ファイルにパスを書いて保存してから、上記のコマンドを実行します。

しかし、'/photos/DBに存在する写真ID/download'にアクセスしても404エラーが出ていて、デバックもしづらいので次に進みます。

ここまでは下記で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-11

(12) 写真一覧ページ

Photo コンポーネント

写真の表示

resources/js/components/Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <img
        class="photo__image"
        :src="item.url"
        :alt="`Photo by ${item.owner.name}`"
      >
    </figure>
    <RouterLink
      class="photo__overlay"
      :to="`/photos/${item.id}`"
      :title="`View the photo by ${item.owner.name}`"
    >
      <div class="photo__controls">
        <button
          class="photo__action photo__action--like"
          title="Like photo"
        >
          <i class="icon ion-md-heart"></i>12
        </button>
        <a
          class="photo__action"
          title="Download photo"
          @click.stop
          :href="`/photos/${item.id}/download`"
        >
          <i class="icon ion-md-arrow-round-down"></i>
        </a>
      </div>
      <div class="photo__username">
        {{ item.owner.name }}
      </div>
    </RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true
    }
  }
}
</script>

PhotoList コンポーネント

resources/js/pages/PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
      />
    </div>
  </div>
</template>

<script>
import { OK } from '../util'
import Photo from '../components/Photo.vue'

export default {
  components: {
    Photo
  },
  data () {
    return {
      photos: []
    }
  },
  methods: {
    async fetchPhotos () {
      const response = await axios.get('/api/photos')

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photos = response.data.data
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}
</script>

ページネーション

ルート定義

resources/js/router.js
{
  path: '/',
  component: PhotoList,
  props: route => {
    const page = route.query.page
    return { page: /^[1-9][0-9]*$/.test(page) ? page * 1 : 1 }
  }
}

Pagination コンポーネント

resources/js/components/Pagination.vue
<template>
  <div class="pagination">
    <RouterLink
      v-if="! isFirstPage"
      :to="`/?page=${currentPage - 1}`"
      class="button"
    >&laquo; prev</RouterLink>
    <RouterLink
      v-if="! isLastPage"
      :to="`/?page=${currentPage + 1}`"
      class="button"
    >next &raquo;</RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    currentPage: {
      type: Number,
      required: true
    },
    lastPage: {
      type: Number,
      required: true
    }
  },
  computed: {
    isFirstPage () {
      return this.currentPage === 1
    },
    isLastPage () {
      return this.currentPage === this.lastPage
    }
  }
}
</script>

PhotoList コンポーネント

resources/js/pages/PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
      />
    </div>
    <Pagination :current-page="currentPage" :last-page="lastPage" />
  </div>
</template>

<script>
import { OK } from '../util'
import Photo from '../components/Photo.vue'
import Pagination from '../components/Pagination.vue'

export default {
  components: {
    Photo,
    Pagination
  },
  props: {
    page: {
      type: Number,
      required: false,
      default: 1
    }
  },
  data () {
    return {
      photos: [],
      currentPage: 0,
      lastPage: 0
    }
  },
  methods: {
    async fetchPhotos () {
      const response = await axios.get(`/api/photos/?page=${this.page}`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photos = response.data.data
      this.currentPage = response.data.current_page
      this.lastPage = response.data.last_page
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}
</script>

1ページあたりの項目数を制御する

app/Photo.php
protected $perPage = 15;

ページ遷移時にページ先頭を表示

resources/js/router.js
const router = new VueRouter({
  mode: 'history',
  scrollBehavior () {
    return { x: 0, y: 0 }
  },
  routes
})

チュートリアルはここまでです。

前章のエラーを解決する

具体的なファイルのパスを確認します。
ファイルのパスは下記のようになっているのが正しい状態です。
※`E2V4x-Qusyka@はランダムに生成されたファイル名です。

保存:storage/app/public/photos/E2V4x-Qusyka.jpeg
読込:public/storage/photos/E2V4x-Qusyka.jpeg

しかし、現状のパスは以下のようになっていて、/photosが抜けています。

読込:public/storage/E2V4x-Qusyka.jpeg

下記のようにファイルを修正します。

app/Http/Controllers/Auth/PhotoController.php
/**
 * 写真ダウンロード
 * @param Photo $photo
 * @return \Illuminate\Http\Response
 */
public function download(Photo $photo)
{
    // 写真の存在チェック
    //if (! Storage::exists($photo->filename)) {
    //    abort(404);
    //}
    $filePath = 'public/photos/' . $photo->filename;
    $mimeType = Storage::mimeType($filePath);
    $disposition = 'attachment; filename="' . $photo->filename . '"';
    $headers = [
        'Content-Type' => $mimeType,
        'Content-Disposition' => $disposition,
    ];
    return Storage::download($filePath, $photo->filename, $headers);
}

これで'public/photos/E2V4x-Qusyka.jpeg'でダウンロードできるようになりました。
また、モデルも以下のように変更しました。

app/User.php
<?php

namespace App;

use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /** JSONに含める属性 */
    protected $visible = [
        'name',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    /**
     * リレーションシップ - photosテーブル
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function photos()
    {
        return $this->hasMany('App\Photo');
    }
}
app/Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** JSONに含める属性 */
    protected $visible = [
        'id', 'owner', 'url',
    ];

    /** JSONに含める属性 */
    protected $appends = [
        'url',
    ];

    /** 1ページあたりのアイテム数 */
    protected $perPage = 15;

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function owner()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }

    /**
     * アクセサ - url
     * @return string
     */
    public function getUrlAttribute()
    {
        return Storage::url($this->attributes['filename']);
    }
}

これでもう1度テスト実行すると、成功しました。

Terminal
$ php artisan test --testdox

#実行結果
Photo List Api (Tests\Feature\PhotoListApi)
 ✔ Should 正しい構造のJSONを返却する

フォト一覧がまだうまく表示されていませんが、次に進みます。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-12

(13) 写真詳細ページ

Web API

テスト

Terminal
$ php artisan make:test PhotoDetailApiTest
tests/Feature/PhotoDetailApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PhotoDetailApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        factory(Photo::class)->create();
        $photo = Photo::first();

        $response = $this->json('GET', route('photo.show', [
            'id' => $photo->id,
        ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
            ]);
    }
}

ルート定義

routes/api.php
// 写真詳細
Route::get('/photos/{id}', 'PhotoController@show')->name('photo.show');

コントローラー

app/Http/Controllers/Auth/PhotoController.php
public function __construct()
{
    // 認証が必要
    $this->middleware('auth')->except(['index', 'download', 'show']);
}
app/Http/Controllers/Auth/PhotoController.php
/**
 * 写真詳細
 * @param string $id
 * @return Photo
 */
public function show(string $id)
{
    $photo = Photo::where('id', $id)->with(['owner'])->first();

    return $photo ?? abort(404);
}

テスト実行

Terminal
$ php artisan test --testdox

#実行結果
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✔ Should 正しい構造のJSONを返却する

フロントエンド

PhotoDetail コンポーネント

resources/js/PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button class="button button--like" title="Like photo">
        <i class="icon ion-md-heart"></i>12
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
    </div>
  </div>
</template>

<script>
import { OK } from '../util'

export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null,
      fullWidth: false
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo = response.data
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>

チュートリアルはここまでです。

写真を表示させる

具体的なAPIを確認してみます。E2V4x-Qusykaは環境によって変わります。
http://localhost:3000/api/photos/E2V4x-Qusyka

api.json
{
  "id":"E2V4x-Qusyka",
  "url":"\/storage\/E2V4x-Qusyka.jpeg",
  "owner":{
    "name":"\u30c6\u30b9\u30c8\u3093"
  }
}

すると上記のようなjsonファイルが取得できました。
urlが本来ならばstorage/photos/E2V4x-Qusyka.jpegとなるべきところが、/storage/E2V4x-Qusyka.jpegとなっているので、画像が読み込めていないことが分かります。
Photoモデルのurlを取得する部分は以下のように変更します。

/**
 * アクセサ - url
 * @return string
 */
public function getUrlAttribute()
{
    return Storage::url('photos/' . $this->attributes['filename']);
}

これでstorage/photos/E2V4x-Qusyka.jpegとなり、画像が表示されるようになりました。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-13

(14) コメント投稿機能

Web API

テスト

ファクトリ
Terminal
$ php artisan make:factory CommentFactory
database/factories/CommentFactory.php
<?php

/** @var \Illuminate\Database\Eloquent\Factory $factory */

use App\Model;
use Faker\Generator as Faker;

$factory->define(Model::class, function (Faker $faker) {
    return [
        'content' => substr($faker->text, 0, 500),
        'user_id' => fn() => factory(App\User::class)->create()->id,
    ];
});
写真詳細取得テストケース
tests/Feature/PhotoDetailApiTest.php
<?php

namespace Tests\Feature;

use App\Comment;
use App\Photo;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class PhotoDetailApiTest extends TestCase
{
    use RefreshDatabase;

    /**
     * @test
     */
    public function should_正しい構造のJSONを返却する()
    {
        factory(Photo::class)->create()->each(function ($photo) {
            $photo->comments()->saveMany(factory(Comment::class, 3)->make());
        });
        $photo = Photo::first();

        $response = $this->json('GET', route('photo.show', [
            'id' => $photo->id,
        ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'id' => $photo->id,
                'url' => $photo->url,
                'owner' => [
                    'name' => $photo->owner->name,
                ],
                'comments' => $photo->comments
                    ->sortByDesc('id')
                    ->map(function ($comment) {
                        return [
                            'author' => [
                                'name' => $comment->author->name,
                            ],
                            'content' => $comment->content,
                        ];
                    })
                    ->all(),
            ]);
    }
}
コメント投稿テストケース
Terminal
$ php artisan make:factory CommentFactory
tests/Feature/AddCommentApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class AddCommentApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        // テストユーザー作成
        $this->user = factory(User::class)->create();
    }

    /**
     * @test
     */
    public function should_コメントを追加できる()
    {
        factory(Photo::class)->create();
        $photo = Photo::first();

        $content = 'sample content';

        $response = $this->actingAs($this->user)
            ->json('POST', route('photo.comment', [
                'photo' => $photo->id,
            ]), compact('content'));

        $comments = $photo->comments()->get();

        $response->assertStatus(201)
            // JSONフォーマットが期待通りであること
            ->assertJsonFragment([
                "author" => [
                    "name" => $this->user->name,
                ],
                "content" => $content,
            ]);

        // DBにコメントが1件登録されていること
        $this->assertEquals(1, $comments->count());
        // 内容がAPIでリクエストしたものであること
        $this->assertEquals($content, $comments[0]->content);
    }
}

ルート定義

routes/api.php
// コメント
Route::post('/photos/{photo}/comments', 'PhotoController@addComment')->name('photo.comment');

モデルクラス

Comment
Terminal
$ php artisan make:model Comment
app/Comment.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
    /** JSONに含める属性 */
    protected $visible = [
        'author', 'content',
    ];

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function author()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }
}
Photo
app/Photo.php
/**
 * リレーションシップ - commentsテーブル
 * @return \Illuminate\Database\Eloquent\Relations\HasMany
 */
public function comments()
{
    return $this->hasMany('App\Comment')->orderBy('id', 'desc');
}
app/Photo.php
/** JSONに含める属性 */
protected $visible = [
    'id', 'owner', 'url', 'comments',
];

フォームリクエスト

Terminal
$ php artisan make:request StoreComment
app/Http/Requests/StoreComment.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreComment 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 [
            'content' => 'required|max:500',
        ];
    }
}

コントローラー

コメント投稿
app/Http/Controllers/Auth/PhotoController.php
use App\Comment;
use App\Http\Requests\StoreComment;

class PhotoController extends Controller
{
    /* 中略 */

    /**
     * コメント投稿
     * @param Photo $photo
     * @param StoreComment $request
     * @return \Illuminate\Http\Response
     */
    public function addComment(Photo $photo, StoreComment $request)
    {
        $comment = new Comment();
        $comment->content = $request->get('content');
        $comment->user_id = Auth::user()->id;
        $photo->comments()->save($comment);

        // authorリレーションをロードするためにコメントを取得しなおす
        $new_comment = Comment::where('id', $comment->id)->with('author')->first();

        return response($new_comment, 201);
    }
}

テスト実行

Terminal
$ php artisan test --testdox

#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
 ✘ Should コメントを追加できる
   │
   │ Expected status code 201 but received 500.
   │ Failed asserting that 201 is identical to 500.

コメント投稿のテストケースがエラーが起きて失敗しました。気にせず次に進みます。

写真詳細
app/Http/Controllers/Auth/PhotoController.php
/**
 * 写真詳細
 * @param string $id
 * @return Photo
 */
public function show(string $id)
{
    $photo = Photo::where('id', $id)
        ->with(['owner', 'comments.author'])->first();

    return $photo ?? abort(404);
}

テスト実行

Terminal
$ php artisan test --testdox

#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
 ✘ Should コメントを追加できる
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✘ Should 正しい構造のJSONを返却する

またもやテストケースがエラーが起きて失敗しましたが、気にせず次に進みます。

フロントエンド

コメントを投稿する

PhotoDetail.vueは最終的に以下のようになります。

resources/js/PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button class="button button--like" title="Like photo">
        <i class="icon ion-md-heart"></i>12
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
      <ul v-if="photo.comments.length > 0" class="photo-detail__comments">
        <li
          v-for="comment in photo.comments"
          :key="comment.content"
          class="photo-detail__commentItem"
        >
          <p class="photo-detail__commentBody">
            {{ comment.content }}
          </p>
          <p class="photo-detail__commentInfo">
            {{ comment.author.name }}
          </p>
        </li>
      </ul>
      <p v-else>No comments yet.</p>
      <form v-if="isLogin" @submit.prevent="addComment" class="form">
        <div v-if="commentErrors" class="errors">
          <ul v-if="commentErrors.content">
            <li v-for="msg in commentErrors.content" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <textarea class="form__item" v-model="commentContent"></textarea>
        <div class="form__button">
          <button type="submit" class="button button--inverse">submit comment</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'
export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null,
      fullWidth: false,
      commentContent: '',
      commentErrors: null
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo = response.data
    },
    async addComment () {
      const response = await axios.post(`/api/photos/${this.id}/comments`, {
        content: this.commentContent
      })
      // バリデーションエラー
      if (response.status === UNPROCESSABLE_ENTITY) {
        this.commentErrors = response.data.errors
        return false
      }
      this.commentContent = ''
      // エラーメッセージをクリア
      this.commentErrors = null
      // その他のエラー
      if (response.status !== CREATED) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo.comments = [
        response.data,
        ...this.photo.comments
      ]
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>

エラー修正編

試しに「乾杯!」とコメントしたところ、以下のエラーが出ました。

SQLSTATE[42S22]: Column not found: 1054 Unknown column 'content' in 'field list' (SQL: insert into `comments` (`content`, `user_id`, `photo_id`, `updated_at`, `created_at`) values (乾杯!, 1, E2V4x-Qusyka, 2020-07-19 09:49:28, 2020-07-19 09:49:28))

コメントテーブルを確認します。

XXXX_XX_XX_XXXXXX_create_comments_table.php
    public function up()
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->string('photo_id');
            $table->foreignId('user_id')->constrained();
            $table->timestamps();

            $table->foreign('photo_id')->references('id')->on('photos');
        });
    }

コメントを保存するテーブルが抜けていたので追加します。まずはMigrationファイルを作成します。

Terminal
$ php artisan make:migration comments_table_add_column

Migrationに変更したい定義を記述します。

XXXX_XX_XX_XXXXXX_create_comments_table_add_column.php
    public function up()
    {
        // comment テーブル定義を変更する
        Schema::table('comments', function (Blueprint $table){

            // 新規カラムを追加する
            $table->text('content');
        });
    }

作成したMigrationを実行してテーブル定義を変更します。

Terminal
$ php artisan migrate

これでもう1度テストを実行します。

Terminal
$ php artisan test --testdox

#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
 ✘ Should コメントを追加できる
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✘ Should 正しい構造のJSONを返却する

テストではまたもやエラーが出ましたが、画面上はコメントができるようになりましたのでよしとします。

※phpMyAdminからテーブルを削除するとテストも成功したので、箇条書きで簡単な手順を残しておきます。

  • migrationsテーブルに入り、XXXX_XX_XX_XXXXXX_create_comments_tableとXXXX_XX_XX_XXXXXX_create_comments_table_add_columnを削除し、更にcommentsテーブル自体も削除します。
  • XXXX_XX_XX_XXXXXX_create_comments_table_add_column.phpのファイルも削除します。
  • XXXX_XX_XX_XXXXXX_create_comments_table.phpに$table->text('content');を適切な行に挿入します。
  • これでphp artisan migrateを実行すると、新しくcommentsテーブルを作ることができます。
  • php artisan test --testdoxを実行すると、テストがうまくいきました。

ここまでは以下にファイルがあります。
https://github.com/neneta0921/vuesplash/pull/new/ch-14

(15) いいね機能

いいね機能 API

テストコード

Terminal
$ php artisan make:test LikeApiTest
tests/Feature/LikeApiTest.php
<?php

namespace Tests\Feature;

use App\Photo;
use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;

class LikeApiTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        $this->user = factory(User::class)->create();

        factory(Photo::class)->create();
        $this->photo = Photo::first();
    }

    /**
     * @test
     */
    public function should_いいねを追加できる()
    {
        $response = $this->actingAs($this->user)
            ->json('PUT', route('photo.like', [
                'id' => $this->photo->id,
            ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'photo_id' => $this->photo->id,
            ]);

        $this->assertEquals(1, $this->photo->likes()->count());
    }

    /**
     * @test
     */
    public function should_2回同じ写真にいいねしても1個しかいいねがつかない()
    {
        $param = ['id' => $this->photo->id];
        $this->actingAs($this->user)->json('PUT', route('photo.like', $param));
        $this->actingAs($this->user)->json('PUT', route('photo.like', $param));

        $this->assertEquals(1, $this->photo->likes()->count());
    }

    /**
     * @test
     */
    public function should_いいねを解除できる()
    {
        $this->photo->likes()->attach($this->user->id);

        $response = $this->actingAs($this->user)
            ->json('DELETE', route('photo.like', [
                'id' => $this->photo->id,
            ]));

        $response->assertStatus(200)
            ->assertJsonFragment([
                'photo_id' => $this->photo->id,
            ]);

        $this->assertEquals(0, $this->photo->likes()->count());
    }
}

ルート定義

routes/api.php
// いいね
Route::put('/photos/{id}/like', 'PhotoController@like')->name('photo.like');

// いいね解除
Route::delete('/photos/{id}/like', 'PhotoController@unlike');

モデルクラス

app/Photo.php
/**
 * リレーションシップ - usersテーブル
 * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
 */
public function likes()
{
    return $this->belongsToMany('App\User', 'likes')->withTimestamps();
}

コントローラー

app/Http/Controllers/PhotoController.php
/**
 * いいね
 * @param string $id
 * @return array
 */
public function like(string $id)
{
    $photo = Photo::where('id', $id)->with('likes')->first();

    if (! $photo) {
        abort(404);
    }

    $photo->likes()->detach(Auth::user()->id);
    $photo->likes()->attach(Auth::user()->id);

    return ["photo_id" => $id];
}

/**
 * いいね解除
 * @param string $id
 * @return array
 */
public function unlike(string $id)
{
    $photo = Photo::where('id', $id)->with('likes')->first();

    if (! $photo) {
        abort(404);
    }

    $photo->likes()->detach(Auth::user()->id);

    return ["photo_id" => $id];
}

テスト実行

Terminal
$php artisan test --testdox

#実行結果
Like Api (Tests\Feature\LikeApi)
 ✔ Should いいねを追加できる
 ✔ Should 2回同じ写真にいいねしても1個しかいいねがつかない
 ✔ Should いいねを解除できる

テストが成功したので次に進みます。

写真一覧・詳細取得 API

テストコード

PhotoListApiTest および PhotoDetailApiTest のテストメソッド内で、 assertJsonFragment の引数の期待値配列に以下の2項目を追加します。

'liked_by_user' => false,
'likes_count' => 0,

モデルクラス

app/Photo.phpは最終的に以下のようになります。

app/Photo.php
<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth; // ★ 追記
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;

class Photo extends Model
{
    /** プライマリキーの型 */
    protected $keyType = 'string';

    /** JSONに含める属性 */
    protected $visible = [
        'id', 'owner', 'url', 'comments', 'likes_count', 'liked_by_user',
    ];

    /** JSONに含める属性 */
    protected $appends = [
        'url', 'likes_count', 'liked_by_user',
    ];

    /** 1ページあたりのアイテム数 */
    protected $perPage = 15;

    /** IDの桁数 */
    const ID_LENGTH = 12;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        if (! Arr::get($this->attributes, 'id')) {
            $this->setId();
        }
    }

    /**
     * ランダムなID値をid属性に代入する
     */
    private function setId()
    {
        $this->attributes['id'] = $this->getRandomId();
    }

    /**
     * ランダムなID値を生成する
     * @return string
     */
    private function getRandomId()
    {
        $characters = array_merge(
            range(0, 9), range('a', 'z'),
            range('A', 'Z'), ['-', '_']
        );

        $length = count($characters);

        $id = "";

        for ($i = 0; $i < self::ID_LENGTH; $i++) {
            $id .= $characters[random_int(0, $length - 1)];
        }

        return $id;
    }

    /**
     * アクセサ - url
     * @return string
     */
    public function getUrlAttribute()
    {
        return Storage::url('photos/' . $this->attributes['filename']);
    }

    /**
     * アクセサ - likes_count
     * @return int
     */
    public function getLikesCountAttribute()
    {
        return $this->likes->count();
    }

    /**
     * アクセサ - liked_by_user
     * @return boolean
     */
    public function getLikedByUserAttribute()
    {
        if (Auth::guest()) {
            return false;
        }

        return $this->likes->contains(function ($user) {
            return $user->id === Auth::user()->id;
        });
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function owner()
    {
        return $this->belongsTo('App\User', 'user_id', 'id', 'users');
    }

    /**
     * リレーションシップ - commentsテーブル
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function comments()
    {
        return $this->hasMany('App\Comment')->orderBy('id', 'desc');
    }

    /**
     * リレーションシップ - usersテーブル
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
     */
    public function likes()
    {
        return $this->belongsToMany('App\User', 'likes')->withTimestamps();
    }
}

コントローラー

写真一覧

メソッドを下記のように変更します。

app/Http/Controllers/PhotoController.php
public function index()
{
    $photos = Photo::with(['owner', 'likes'])
        ->orderBy(Photo::CREATED_AT, 'desc')->paginate();

    return $photos;
}
写真詳細

メソッドを下記のように変更します。

app/Http/Controllers/PhotoController.php
public function show(string $id)
{
    $photo = Photo::where('id', $id)
        ->with(['owner', 'comments.author', 'likes'])->first();

    return $photo ?? abort(404);
}

テスト実行

Terminal
$php artisan test --testdox

#実行結果
Photo Detail Api (Tests\Feature\PhotoDetailApi)
 ✔ Should 正しい構造のJSONを返却する

Photo List Api (Tests\Feature\PhotoListApi)
 ✔ Should 正しい構造のJSONを返却する

写真一覧ページ

Photo コンポーネント

resources/js/componets/Photo.vue
<template>
  <div class="photo">
    <figure class="photo__wrapper">
      <img
        class="photo__image"
        :src="item.url"
        :alt="`Photo by ${item.owner.name}`"
      >
    </figure>
    <RouterLink
      class="photo__overlay"
      :to="`/photos/${item.id}`"
      :title="`View the photo by ${item.owner.name}`"
    >
      <div class="photo__controls">
        <button
          class="photo__action photo__action--like"
          :class="{ 'photo__action--liked': item.liked_by_user }"
          title="Like photo"
          @click.prevent="like"
        >
          <i class="icon ion-md-heart"></i>{{ item.likes_count }}
        </button>
        <a
          class="photo__action"
          title="Download photo"
          @click.stop
          :href="`/photos/${item.id}/download`"
        >
          <i class="icon ion-md-arrow-round-down"></i>
        </a>
      </div>
      <div class="photo__username">
        {{ item.owner.name }}
      </div>
    </RouterLink>
  </div>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true
    }
  },
  methods: {
    like () {
      this.$emit('like', {
        id: this.item.id,
        liked: this.item.liked_by_user
      })
    }
  }
}
</script>

PhotoList コンポーネント

resources/js/pages/PhotoList.vue
<template>
  <div class="photo-list">
    <div class="grid">
      <Photo
        class="grid__item"
        v-for="photo in photos"
        :key="photo.id"
        :item="photo"
        @like="onLikeClick"
      />
    </div>
    <Pagination :current-page="currentPage" :last-page="lastPage" />
  </div>
</template>

<script>
import { OK } from '../util'
import Photo from '../components/Photo.vue'
import Pagination from '../components/Pagination.vue'
export default {
  components: {
    Photo,
    Pagination
  },
  props: {
    page: {
      type: Number,
      required: false,
      default: 1
    }
  },
  data () {
    return {
      photos: [],
      currentPage: 0,
      lastPage: 0
    }
  },
  methods: {
    async fetchPhotos () {
      const response = await axios.get(`/api/photos/?page=${this.page}`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photos = response.data.data
      this.currentPage = response.data.current_page
      this.lastPage = response.data.last_page
    },
    onLikeClick ({ id, liked }) {
      if (! this.$store.getters['auth/check']) {
        alert('いいね機能を使うにはログインしてください。')
        return false
      }
      if (liked) {
        this.unlike(id)
      } else {
        this.like(id)
      }
    },
    async like (id) {
      const response = await axios.put(`/api/photos/${id}/like`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photos = this.photos.map(photo => {
        if (photo.id === response.data.photo_id) {
          photo.likes_count += 1
          photo.liked_by_user = true
        }
        return photo
      })
    },
    async unlike (id) {
      const response = await axios.delete(`/api/photos/${id}/like`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photos = this.photos.map(photo => {
        if (photo.id === response.data.photo_id) {
          photo.likes_count -= 1
          photo.liked_by_user = false
        }
        return photo
      })
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhotos()
      },
      immediate: true
    }
  }
}
</script>

写真詳細ページ

PhotoDetail コンポーネント

resources/js/pages/PhotoDetail.vue
<template>
  <div
    v-if="photo"
    class="photo-detail"
    :class="{ 'photo-detail--column': fullWidth }"
  >
    <figure
      class="photo-detail__pane photo-detail__image"
      @click="fullWidth = ! fullWidth"
    >
      <img :src="photo.url" alt="">
      <figcaption>Posted by {{ photo.owner.name }}</figcaption>
    </figure>
    <div class="photo-detail__pane">
      <button
        class="button button--like"
        :class="{ 'button--liked': photo.liked_by_user }"
        title="Like photo"
        @click="onLikeClick"
      >
        <i class="icon ion-md-heart"></i>{{ photo.likes_count }}
      </button>
      <a
        :href="`/photos/${photo.id}/download`"
        class="button"
        title="Download photo"
      >
        <i class="icon ion-md-arrow-round-down"></i>Download
      </a>
      <h2 class="photo-detail__title">
        <i class="icon ion-md-chatboxes"></i>Comments
      </h2>
      <ul v-if="photo.comments.length > 0" class="photo-detail__comments">
        <li
          v-for="comment in photo.comments"
          :key="comment.content"
          class="photo-detail__commentItem"
        >
          <p class="photo-detail__commentBody">
            {{ comment.content }}
          </p>
          <p class="photo-detail__commentInfo">
            {{ comment.author.name }}
          </p>
        </li>
      </ul>
      <p v-else>No comments yet.</p>
      <form v-if="isLogin" @submit.prevent="addComment" class="form">
        <div v-if="commentErrors" class="errors">
          <ul v-if="commentErrors.content">
            <li v-for="msg in commentErrors.content" :key="msg">{{ msg }}</li>
          </ul>
        </div>
        <textarea class="form__item" v-model="commentContent"></textarea>
        <div class="form__button">
          <button type="submit" class="button button--inverse">submit comment</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script>
import { OK, CREATED, UNPROCESSABLE_ENTITY } from '../util'
export default {
  props: {
    id: {
      type: String,
      required: true
    }
  },
  data () {
    return {
      photo: null,
      fullWidth: false,
      commentContent: '',
      commentErrors: null
    }
  },
  computed: {
    isLogin () {
      return this.$store.getters['auth/check']
    }
  },
  methods: {
    async fetchPhoto () {
      const response = await axios.get(`/api/photos/${this.id}`)
      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo = response.data
    },
    async addComment () {
      const response = await axios.post(`/api/photos/${this.id}/comments`, {
        content: this.commentContent
      })
      // バリデーションエラー
      if (response.status === UNPROCESSABLE_ENTITY) {
        this.commentErrors = response.data.errors
        return false
      }
      this.commentContent = ''
      // エラーメッセージをクリア
      this.commentErrors = null
      // その他のエラー
      if (response.status !== CREATED) {
        this.$store.commit('error/setCode', response.status)
        return false
      }
      this.photo.comments = [
        response.data,
        ...this.photo.comments
      ]
    },
    onLikeClick () {
      if (! this.isLogin) {
        alert('いいね機能を使うにはログインしてください。')
        return false
      }

      if (this.photo.liked_by_user) {
        this.unlike()
      } else {
        this.like()
      }
    },
    async like () {
      const response = await axios.put(`/api/photos/${this.id}/like`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo.likes_count = this.photo.likes_count + 1
      this.photo.liked_by_user = true
    },
    async unlike () {
      const response = await axios.delete(`/api/photos/${this.id}/like`)

      if (response.status !== OK) {
        this.$store.commit('error/setCode', response.status)
        return false
      }

      this.photo.likes_count = this.photo.likes_count - 1
      this.photo.liked_by_user = false
    }
  },
  watch: {
    $route: {
      async handler () {
        await this.fetchPhoto()
      },
      immediate: true
    }
  }
}
</script>

ここまでのソースコードは以下に置いてあります。
https://github.com/neneta0921/vuesplash/pull/new/ch-15

(16) エラーハンドリング Part.2

認証エラー

トークンリフレッシュ API

routes/api.php
// トークンリフレッシュ
Route::get('/reflesh-token', function (Illuminate\Http\Request $request) {
    $request->session()->regenerateToken();

    return response()->json();
});

レスポンスコード定義

resources/js/util.js
export const UNAUTHORIZED = 419

ルートコンポーネント

resources/js/App.vue
<script>
import Message from './components/Message.vue'
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { UNAUTHORIZED, INTERNAL_SERVER_ERROR } from './util'

export default {
  components: {
    Message,
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      async handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        } else if (val === UNAUTHORIZED) {
          // トークンをリフレッシュ
          await axios.get('/api/refresh-token')
          // ストアのuserをクリア
          this.$store.commit('auth/setUser', null)
          // ログイン画面へ
          this.$router.push('/login')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

404 エラー

レスポンスコード定義

resources/js/util.js
export const NOT_FOUND = 404

エラーページ作成

resources/js/pages/errors/NotFound.vue
<template>
  <p>お探しのページは見つかりませんでした。</p>
</template>
resources/js/route.js
import NotFound from './pages/errors/NotFound.vue'

/* 中略 */

const routes = [
  /* 中略 */
  {
    path: '*',
    component: NotFound
  }
]

ルートコンポーネント

resources/js/App.vue
<script>
import Message from './components/Message.vue'
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
import { NOT_FOUND, UNAUTHORIZED, INTERNAL_SERVER_ERROR } from './util'

export default {
  components: {
    Message,
    Navbar,
    Footer
  },
  computed: {
    errorCode () {
      return this.$store.state.error.code
    }
  },
  watch: {
    errorCode: {
      async handler (val) {
        if (val === INTERNAL_SERVER_ERROR) {
          this.$router.push('/500')
        } else if (val === UNAUTHORIZED) {
          // トークンをリフレッシュ
          await axios.get('/api/refresh-token')
          // ストアのuserをクリア
          this.$store.commit('auth/setUser', null)
          // ログイン画面へ
          this.$router.push('/login')
        } else if (val === NOT_FOUND) {
          this.$router.push('/not-found')
        }
      },
      immediate: true
    },
    $route () {
      this.$store.commit('error/setCode', null)
    }
  }
}
</script>

以上で終了です。
お疲れさまでした。
https://github.com/neneta0921/vuesplash/pull/new/ch-16

あとで誤字脱字や修正加筆予定です。

11
12
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
11
12