簡潔に書いてきます。チュートリアルでは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
$ 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
70行目 'timezone' => 'Asia/Tokyo',
83行目 'locale' => 'ja',
####.env
サーバーはMySQLを利用します。
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 を追加でインストールします。
$ npm install
$ npm install -D vue
####Laravel Mix
ここは少し変わります。
参考:https://readouble.com/laravel/7.x/ja/mix.html
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/
にて画面が表示されます。
###画面(HTML)を返す
####ルーティング
<?php
// APIのURL以外のリクエストに対してはindexテンプレートを返す
// 画面遷移はフロントエンドのVueRouterが制御する
Route::get('/{any?}', fn() => view('index'))->where('any', '.+');
####テンプレート
<!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
import Vue from 'vue'
new Vue({
el: '#app',
template: '<h1>Hello world</h1>'
})
####フロントエンドのビルド
コマンドでnpm run watch
を2回実行すると、下記の画面が表示されます。
###Vue Router
####インストール
$ npm install -D vue-router
####ルートコンポーネント
<template>
<div>
<main>
<div class="container">
<RouterView />
</div>
</main>
</div>
</template>
###ページコンポーネント
<template>
<h1>Photo List</h1>
</template>
<template>
<h1>Login</h1>
</template>
####ルーティング
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
import Vue from 'vue'
// ルーティングの定義をインポートする
import router from './router'
// ルートコンポーネントをインポートする
import App from './App.vue'
new Vue({
el: '#app',
router, // ルーティングの定義を読み込む
components: { App }, // ルートコンポーネントの使用を宣言する
template: '<App />' // ルートコンポーネントを描画する
})
####history モード
const router = new VueRouter({
mode: 'history', // ★ 追加
routes
})
ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-3
##(4) 認証API
###API 用のルート
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('web') // ★ 'api' → 'web' に変更
->namespace($this->namespace)
->group(base_path('routes/api.php'));
}
###テストの準備
####インメモリの SQLite を用いる
config/database.php の connections に以下の接続情報を追加します。
'sqlite_testing' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
phpunit.xml に DB 接続の設定を追記します。ここは少し変わります。
<php>
<server name="APP_ENV" value="testing"/>
<server name="DB_CONNECTION" value="sqlite_testing"/> <!-- ★ 追加 -->
<!-- 以下略 -->
###会員登録 API
####テストコード
$ php artisan make:test RegisterApiTest
<?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 に下記を追記します。
<?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');
####コントローラー
use Illuminate\Http\Request; // ★ 追加
class RegisterController extends Controller
{
/* 中略 */
// ★ メソッド追加
protected function registered(Request $request, $user)
{
return $user;
}
}
####テスト実施
$ 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
####テストコード
$ php artisan make:test LoginApiTest
<?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 に下記を追記します。
// ログイン
Route::post('/login', 'Auth\LoginController@login')->name('login');
####コントローラー
use Illuminate\Http\Request; // ★ 追加
class LoginController extends Controller
{
/* 中略 */
// ★ メソッド追加
protected function authenticated(Request $request, $user)
{
return $user;
}
}
####テスト実施
$ php artisan test --testdox
#実行結果
Login Api (Tests\Feature\LoginApi)
✔ Should 登録済みのユーザーを認証して返却する
###ログアウト API
####テストコード
$ php artisan make:test LogoutApiTest
<?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 に下記を追記します。
// ログアウト
Route::post('/logout', 'Auth\LoginController@logout')->name('logout');
####コントローラー
LoginController.php に下記のメソッドを追記します。
protected function loggedOut(Request $request)
{
// セッションを再生成する
$request->session()->regenerate();
return response()->json();
}
####テスト実施
$ php artisan test --testdox
#実行結果
Logout Api (Tests\Feature\LogoutApi)
✔ Should 認証済みのユーザーをログアウトさせる
ここまでは以下から確認できます。
https://github.com/neneta0921/vuesplash/tree/ch-4
##(5) 認証ページ
###ヘッダーとフッター
####ヘッダーコンポーネント
<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>
####フッターコンポーネント
<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
<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>
ブラウザでヘッダーとフッターが表示されます。
###タブ機能を実装する
####タブ UI を追加する
<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」がタブで切り替わることを確認できます。
####選択状態のスタイルを変える
<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>
###フォームを実装する
最終的に以下のようになります。
<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 の導入
####インストール
$ npm install --save-dev vuex
####ストアの作成と読み込み
const state = {}
const getters = {}
const mutations = {}
const actions = {}
export default {
namespaced: true,
state,
getters,
mutations,
actions
}
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
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 対策の実装
#####クッキーを取り出す関数
/**
* クッキーの値を取得する
* @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 の設定
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 の先頭行に以下の記述を追加します。
import './bootstrap'
###会員登録
####マイグレーション
$ php artisan migrate
####ストアの実装
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 メソッドを以下の通り編集します。
async register () {
// authストアのresigterアクションを呼び出す
await this.$store.dispatch('auth/register', this.registerForm)
// トップページに移動する
this.$router.push('/')
}
###ログイン
####ストアの実装
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 login () {
// authストアのloginアクションを呼び出す
await this.$store.dispatch('auth/login', this.loginForm)
// トップページに移動する
this.$router.push('/')
},
###ログアウト
####ストアの実装
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)
}
}
####コンポーネントの実装
<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
###ステートの値による要素の出し分け
####ゲッターを追加する
const getters = {
check: state => !! state.user,
username: state => state.user ? state.user.name : ''
}
####ナビゲーションバー
<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>
####フッター
<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
$ php artisan make:test UserApiTest
<?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());
}
}
####実装
// ログインユーザー
Route::get('/user', fn() => Auth::user())->name('user');
$ php artisan test --testdox
#実行結果
User Api (Tests\Feature\UserApi)
✔ Should ログイン中のユーザーを返却する
✔ Should ログインされていない場合は空文字を返却する
###起動時にログインチェック
####ストアにアクションを追加
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)
}
}
####ログインチェックしてからアプリを生成する
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()
####ミドルウェア
public function handle($request, Closure $next, $guard = null)
{
if (Auth::guard($guard)->check()) {
return redirect()->route('user'); // ★ 変更
}
return $next($request);
}
###ナビゲーションガード
####ルート定義にナビゲーションガードを追加
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) エラーハンドリング
最終的なコードは以下のようになります。
###システムエラーページ
<template>
<p>システムエラーが発生しました。</p>
</template>
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
###レスポンスコード定義
/**
* クッキーの値を取得する
* @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 ストア
const state = {
code: null
}
const mutations = {
setCode (state, code) {
state.code = code
}
}
export default {
namespaced: true,
state,
mutations
}
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 ストア
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
}
###ページコンポーネント
<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>
###ルートコンポーネント
<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
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
)
###ログアウト
<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にファイルを保管します。
###テストコード
$ php artisan make:test PhotoSubmitApiTest
<?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 は利用しないので設定はしません。
####マイグレーション
$ 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 テーブル
public function up()
{
Schema::create('photos', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('filename');
$table->timestamps();
});
}
#####likes テーブル
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 テーブル
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');
});
}
#####マイグレーション実行
$ php artisan migrate
####モデル
#####Photo
$ php artisan make:model Photo
<?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
/**
* リレーションシップ - photosテーブル
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function photos()
{
return $this->hasMany('App\Photo');
}
####ルーティング
// 写真投稿
Route::post('/photos', 'PhotoController@create')->name('photo.create');
####フォームリクエスト
$ php artisan make:request StorePhoto
<?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'
];
}
}
####コントローラー
$ php artisan make:controller PhotoController
Localに保存するので、チュートリアルとは少し違います。
<?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);
}
}
####テストの実行
$ 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) 写真投稿フォーム
最終的に以下のようになります。
###フォームコンポーネント
<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>
###ナビゲーションバーコンポーネント
<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>
###投稿完了後のページ遷移
####遷移先ページ作成
<template>
<h1>Photo Detail</h1>
</template>
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
###ローディング
####ローダーコンポーネント作成
<template>
<div class="loader">
<p class="loading__text">
<slot>Loading...</slot>
</p>
<div class="loader__item loader__item--heart"><div></div></div>
</div>
</template>
###サクセスメッセージ
####メッセージストア作成
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
}
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
####メッセージコンポーネント
<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>
####ルートコンポーネント
<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
###テストコード
####ファクトリ
$ php artisan make:factory PhotoFactory
<?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(),
];
});
####テストケース
$ php artisan make:test PhotoListApiTest
<?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 の実装
####ルーティング
// 写真一覧
Route::get('/photos', 'PhotoController@index')->name('photo.index');
####Photo モデル
/**
* リレーションシップ - 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 モデル
protected $visible = [
'name',
];
####コントローラー
最終的に以下のようになります。クラウドを利用していないので、チュートリアルとは少し異なります。
<?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);
}
}
####テスト実行
$ 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']);
がエラーの原因のようです。あとで修正します。
###ダウンロードリンク
####ルート定義
// 写真ダウンロード
Route::get('/photos/{photo}/download', 'PhotoController@download');
チュートリアルはここまでですので、先程のエラーを解消します。
###エラー解消のために試したこと
app\Photo.php
に以下のようにします。
参考:https://readouble.com/laravel/7.x/ja/filesystem.html?header
<?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コマンドを使用します。
$ 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 コンポーネント
####写真の表示
<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 コンポーネント
<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>
###ページネーション
####ルート定義
{
path: '/',
component: PhotoList,
props: route => {
const page = route.query.page
return { page: /^[1-9][0-9]*$/.test(page) ? page * 1 : 1 }
}
}
####Pagination コンポーネント
<template>
<div class="pagination">
<RouterLink
v-if="! isFirstPage"
:to="`/?page=${currentPage - 1}`"
class="button"
>« prev</RouterLink>
<RouterLink
v-if="! isLastPage"
:to="`/?page=${currentPage + 1}`"
class="button"
>next »</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 コンポーネント
<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ページあたりの項目数を制御する
protected $perPage = 15;
####ページ遷移時にページ先頭を表示
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
下記のようにファイルを修正します。
/**
* 写真ダウンロード
* @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'でダウンロードできるようになりました。
また、モデルも以下のように変更しました。
<?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');
}
}
<?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度テスト実行すると、成功しました。
$ php artisan test --testdox
#実行結果
Photo List Api (Tests\Feature\PhotoListApi)
✔ Should 正しい構造のJSONを返却する
フォト一覧がまだうまく表示されていませんが、次に進みます。
ここまでは以下で確認できます。
https://github.com/neneta0921/vuesplash/pull/new/ch-12
##(13) 写真詳細ページ
###Web API
####テスト
$ php artisan make:test PhotoDetailApiTest
<?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,
],
]);
}
}
####ルート定義
// 写真詳細
Route::get('/photos/{id}', 'PhotoController@show')->name('photo.show');
####コントローラー
public function __construct()
{
// 認証が必要
$this->middleware('auth')->except(['index', 'download', 'show']);
}
/**
* 写真詳細
* @param string $id
* @return Photo
*/
public function show(string $id)
{
$photo = Photo::where('id', $id)->with(['owner'])->first();
return $photo ?? abort(404);
}
####テスト実行
$ php artisan test --testdox
#実行結果
Photo Detail Api (Tests\Feature\PhotoDetailApi)
✔ Should 正しい構造のJSONを返却する
###フロントエンド
####PhotoDetail コンポーネント
<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
{
"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
####テスト
#####ファクトリ
$ php artisan make:factory CommentFactory
<?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,
];
});
#####写真詳細取得テストケース
<?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(),
]);
}
}
#####コメント投稿テストケース
$ php artisan make:factory CommentFactory
<?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);
}
}
####ルート定義
// コメント
Route::post('/photos/{photo}/comments', 'PhotoController@addComment')->name('photo.comment');
####モデルクラス
#####Comment
$ php artisan make:model Comment
<?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
/**
* リレーションシップ - commentsテーブル
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function comments()
{
return $this->hasMany('App\Comment')->orderBy('id', 'desc');
}
/** JSONに含める属性 */
protected $visible = [
'id', 'owner', 'url', 'comments',
];
####フォームリクエスト
$ php artisan make:request StoreComment
<?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',
];
}
}
####コントローラー
#####コメント投稿
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);
}
}
テスト実行
$ 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.
コメント投稿のテストケースがエラーが起きて失敗しました。気にせず次に進みます。
#####写真詳細
/**
* 写真詳細
* @param string $id
* @return Photo
*/
public function show(string $id)
{
$photo = Photo::where('id', $id)
->with(['owner', 'comments.author'])->first();
return $photo ?? abort(404);
}
テスト実行
$ php artisan test --testdox
#実行結果
Add Comment Api (Tests\Feature\AddCommentApi)
✘ Should コメントを追加できる
Photo Detail Api (Tests\Feature\PhotoDetailApi)
✘ Should 正しい構造のJSONを返却する
またもやテストケースがエラーが起きて失敗しましたが、気にせず次に進みます。
###フロントエンド
####コメントを投稿する
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))
コメントテーブルを確認します。
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ファイルを作成します。
$ php artisan make:migration comments_table_add_column
Migrationに変更したい定義を記述します。
public function up()
{
// comment テーブル定義を変更する
Schema::table('comments', function (Blueprint $table){
// 新規カラムを追加する
$table->text('content');
});
}
作成したMigrationを実行してテーブル定義を変更します。
$ php artisan migrate
これでもう1度テストを実行します。
$ 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
####テストコード
$ php artisan make:test LikeApiTest
<?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());
}
}
####ルート定義
// いいね
Route::put('/photos/{id}/like', 'PhotoController@like')->name('photo.like');
// いいね解除
Route::delete('/photos/{id}/like', 'PhotoController@unlike');
####モデルクラス
/**
* リレーションシップ - usersテーブル
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function likes()
{
return $this->belongsToMany('App\User', 'likes')->withTimestamps();
}
####コントローラー
/**
* いいね
* @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];
}
####テスト実行
$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は最終的に以下のようになります。
<?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();
}
}
####コントローラー
#####写真一覧
メソッドを下記のように変更します。
public function index()
{
$photos = Photo::with(['owner', 'likes'])
->orderBy(Photo::CREATED_AT, 'desc')->paginate();
return $photos;
}
#####写真詳細
メソッドを下記のように変更します。
public function show(string $id)
{
$photo = Photo::where('id', $id)
->with(['owner', 'comments.author', 'likes'])->first();
return $photo ?? abort(404);
}
####テスト実行
$php artisan test --testdox
#実行結果
Photo Detail Api (Tests\Feature\PhotoDetailApi)
✔ Should 正しい構造のJSONを返却する
Photo List Api (Tests\Feature\PhotoListApi)
✔ Should 正しい構造のJSONを返却する
###写真一覧ページ
####Photo コンポーネント
<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 コンポーネント
<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 コンポーネント
<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
// トークンリフレッシュ
Route::get('/reflesh-token', function (Illuminate\Http\Request $request) {
$request->session()->regenerateToken();
return response()->json();
});
####レスポンスコード定義
export const UNAUTHORIZED = 419
####ルートコンポーネント
<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 エラー
####レスポンスコード定義
export const NOT_FOUND = 404
####エラーページ作成
<template>
<p>お探しのページは見つかりませんでした。</p>
</template>
import NotFound from './pages/errors/NotFound.vue'
/* 中略 */
const routes = [
/* 中略 */
{
path: '*',
component: NotFound
}
]
####ルートコンポーネント
<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
あとで誤字脱字や修正加筆予定です。