Asial Blog

Recruit! Asialで一緒に働きませんか?

Laravel 5.4でWeb APIを作る

カテゴリ :
バックエンド(プログラミング)
タグ :
PHP
前回の記事では、Laravelでフロントエンド開発を行うための開発環境の作り方を解説しました。今回は、LaravelでWeb APIを作る方法を解説します。

データベースの準備



アプリケーションのデータはデータベースに保存するようにしたいので、セットアップを行います。
Laravelアプリケーションを作成すると、以下の値でデフォルトの接続情報が作成されます。


  • データベース: MySQL

  • データベース名: homestead

  • ユーザ名: homestead

  • パスワード: secret



ローカル開発環境にMySQLがインストール済みなら、上と同じ条件で新しいデータベースを作成するのが手軽です。
手元の環境にMySQLをインストールしたくない、といった場合には、開発用VMのLaravel Homesteadを利用するのが良いでしょう。

DBへの接続情報は、アプリケーションのルートディレクトリの「.env」という隠しファイルに記述します。

  1. $ cat .env
  2. APP_ENV=local
  3. APP_KEY=base64:sWS+TR6ESaDXCXGJKtk7twOtsE2lHVUqJdLUula6t3I=
  4. APP_DEBUG=true
  5. APP_LOG_LEVEL=debug
  6. APP_URL=http://localhost
  7. DB_CONNECTION=mysql
  8. DB_HOST=127.0.0.1
  9. DB_PORT=3306
  10. DB_DATABASE=homestead
  11. DB_USERNAME=homestead
  12. DB_PASSWORD=secret
  13. BROADCAST_DRIVER=log
  14. CACHE_DRIVER=file
  15. SESSION_DRIVER=file
  16. QUEUE_DRIVER=sync
  17. REDIS_HOST=127.0.0.1
  18. REDIS_PASSWORD=null
  19. REDIS_PORT=6379
  20. MAIL_DRIVER=smtp
  21. MAIL_HOST=mailtrap.io
  22. MAIL_PORT=2525
  23. MAIL_USERNAME=null
  24. MAIL_PASSWORD=null
  25. MAIL_ENCRYPTION=null
  26. PUSHER_APP_ID=
  27. PUSHER_APP_KEY=
  28. PUSHER_APP_SECRET=

データベース名等が異なる場合は、.envを適宜編集してください。

セットアップができたら、DBに接続できるか確認するため、「php artisan migrate:status」を実行します。なお、Homestead等のVMを使用している場合は、VM内で実行してください。
成功すれば、以下のような出力が得られるはずです。

  1. $ php artisan migrate:status
  2. +------+------------------------------------------------+
  3. | Ran? | Migration                                      |
  4. +------+------------------------------------------------+
  5. | N    | 2014_10_12_000000_create_users_table           |
  6. | N    | 2014_10_12_100000_create_password_resets_table |
  7. +------+------------------------------------------------+

もしもPDOException等のエラーが出てしまった場合は、.envに記述した接続情報が正しいか見直してください。

新しいテーブルの追加



はじめに、「artisan make:migration」を使って、マイグレーションファイルの雛形を作成します(ファイル名は実行した日時によって異なります)。

  1. $ php artisan make:migration create_items_table
  2. Created Migration: 2017_03_16_002633_create_items_table

作成できたら、以下の内容で置き換えます。

  1. <?php
  2.  
  3. use Illuminate\Support\Facades\Schema;
  4. use Illuminate\Database\Schema\Blueprint;
  5. use Illuminate\Database\Migrations\Migration;
  6.  
  7. class CreateItemsTable extends Migration
  8. {
  9.     /**
  10.      * Run the migrations.
  11.      *
  12.      * @return void
  13.      */
  14.     public function up()
  15.     {
  16.         Schema::create('items', function (Blueprint $table) {
  17.             $table->increments('id');
  18.             $table->unsignedInteger('user_id');
  19.             $table->text('content');
  20.             $table->boolean('checked')->default(false);
  21.             $table->timestamps();
  22.             $table->foreign('user_id')
  23.                 ->references('id')
  24.                 ->on('users');
  25.         });
  26.     }
  27.  
  28.     /**
  29.      * Reverse the migrations.
  30.      *
  31.      * @return void
  32.      */
  33.     public function down()
  34.     {
  35.         Schema::drop('items');
  36.     }
  37. }

簡単なToDoリストのテーブルで、user_idが持ち主、contentがタスクの内容、checkedが完了済みか否かを示します。
作成できたら「php artisan migrate」コマンドでテーブルを作成します。

  1. $ php artisan migrate
  2. Migrated: 2014_10_12_000000_create_users_table
  3. Migrated: 2014_10_12_100000_create_password_resets_table
  4. Migrated: 2017_03_16_002633_create_items_table

もし失敗してしまった場合は、「php artisan migrate:reset」コマンドで、全てのマイグレーションが実行される前の状態に戻しましょう。
また、リセットが上手くいっていない場合には、テーブルが消えずに残ってしまうことがあります。
その場合、データベースにログインしてDROP TABLEでテーブルを削除しましょう。

マイグレーションに成功すると、以下のテーブルが作成されます。

  1. mysql> desc items;
  2. +------------+------------------+------+-----+---------+----------------+
  3. | Field      | Type             | Null | Key | Default | Extra          |
  4. +------------+------------------+------+-----+---------+----------------+
  5. | id         | int(10) unsigned | NO   | PRI | NULL    | auto_increment |
  6. | user_id    | int(10) unsigned | NO   | MUL | NULL    |                |
  7. | content    | text             | NO   |     | NULL    |                |
  8. | checked    | tinyint(1)       | NO   |     | 0       |                |
  9. | created_at | timestamp        | YES  |     | NULL    |                |
  10. | updated_at | timestamp        | YES  |     | NULL    |                |
  11. +------------+------------------+------+-----+---------+----------------+

雛形の作成



APIを作成するためには、(1) itemsテーブルに対応するモデルクラスの作成 (2) Itemの操作を行うためのコントローラーの作成 が必要です。
以下のコマンドを実行すると、上記作業をコマンド一発で行なえます。

  1. $ php artisan make:model Item --controller --resource

make:modelコマンドに「--controller(または-c)」オプションを渡すと、モデルクラスに対応したコントローラーが作成されます。さらに、「--resource(または-r)」オプションを追加すると、「リソースコントローラー」が作成されます。リソースコントローラーでは、show()、edit()等のメソッドの引数にあらかじめモデルクラスが定義されています。また、後述するresourceルートと組み合わせると便利です。

  1. <?php
  2. // (省略)
  3.     /**
  4.      * Display the specified resource.
  5.      *
  6.      * @param  \App\Item  $item
  7.      * @return \Illuminate\Http\Response
  8.      */
  9.     public function show(Item $item)
  10.     {
  11.         //
  12.     }

初期データの登録



データベースへの初期データの登録には、Seederという仕組みを使うと便利です。
MySQLにログインしてINSERT文を発行してもいいのですが、ユーザを登録する際にpasswordをbcryptでハッシュ化する作業が必要だったりして、かえって面倒だったりします。Seederでデータを登録できるようにしておくと、テストの際にも利用しやすいので、オススメです。

database/seeds/DatabaseSeeder.phpを以下の内容に書き換えます。

  1. <?php
  2.  
  3. use Illuminate\Database\Seeder;
  4.  
  5. class DatabaseSeeder extends Seeder
  6. {
  7.     /**
  8.      * Run the database seeds.
  9.      *
  10.      * @return void
  11.      */
  12.     public function run()
  13.     {
  14.         $faker = \Faker\Factory::create();
  15.         
  16.         $user = new \App\User();
  17.         $user->name = $faker->name;
  18.         $user->email = $faker->unique()->safeEmail;
  19.         $user->password = bcrypt('password');
  20.         $user->remember_token = str_random(10);
  21.         $user->save();
  22.         
  23.         $item = new \App\Item();
  24.         $item->user_id = $user->id;
  25.         $item->content = $faker->text();
  26.         $item->save();
  27.     }
  28. }

本格的なデータ登録処理を行うならテーブルごとにSeederを作ると良いですが、ここではUserとItemを1つずつ登録したいだけなので、処理をベタ書きしています。
また、Fakerを使用して適当なダミーデータを生成しています。
DatabaseSeederは「php artisan db:seed」コマンドで実行できます。成功したら、以下のようなデータが登録されます(内容はランダムで変わります)。

  1. mysql> select * from users\G
  2. *************************** 1. row ***************************
  3.             id: 1
  4.           name: Prof. Macy Stanton
  5.          email: emarquardt@example.com
  6.       password: $2y$10$fTiwsn9d8VPL81XrTslB4OqT1qv5Si8qYoADECnmFC04AbxVfcEVO
  7. remember_token: ckzIlT20s1
  8.     created_at: 2017-03-16 01:48:48
  9.     updated_at: 2017-03-16 01:48:48
  10. 1 row in set (0.00 sec)
  11. mysql> select * from items\G
  12. *************************** 1. row ***************************
  13.         id: 1
  14.    user_id: 1
  15.    content: Qui voluptatem ea qui in. Alias incidunt ullam rem. Et sequi et et atque sequi sunt modi alias. Odit aut sed fugiat natus. Adipisci eum et omnis debitis.
  16.    checked: 0
  17. created_at: 2017-03-16 01:48:48
  18. updated_at: 2017-03-16 01:48:48
  19. 1 row in set (0.00 sec)

モデルの関連付け



テーブル同士の関係性(Relationships)は、「artisan make model」コマンドでは生成されないため、手書きする必要があります。

UserはItemを0個以上もつので、app/User.phpにitems()メソッドを追加して、hasMany()メソッドを呼び出します。

  1. <?php
  2.  
  3. namespace App;
  4.  
  5. use Illuminate\Notifications\Notifiable;
  6. use Illuminate\Foundation\Auth\User as Authenticatable;
  7.  
  8. class User extends Authenticatable
  9. {
  10.     use Notifiable;
  11.  
  12.     /**
  13.      * The attributes that are mass assignable.
  14.      *
  15.      * @var array
  16.      */
  17.     protected $fillable = [
  18.         'name', 'email', 'password',
  19.     ];
  20.  
  21.     /**
  22.      * The attributes that should be hidden for arrays.
  23.      *
  24.      * @var array
  25.      */
  26.     protected $hidden = [
  27.         'password', 'remember_token',
  28.     ];
  29.  
  30.     /**
  31.      * @return \Illuminate\Database\Eloquent\Relations\HasMany
  32.      */
  33.     public function items()
  34.     {
  35.         return $this->hasMany(Item::class);
  36.     }
  37. }

同様に、Itemは必ずいずれかのUserに属するので、app/Item.phpを以下のように編集します。

  1. <?php
  2.  
  3. namespace App;
  4.  
  5. use Illuminate\Database\Eloquent\Model;
  6.  
  7. class Item extends Model
  8. {
  9.     /**
  10.      * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
  11.      */
  12.     public function user()
  13.     {
  14.         return $this->belongsTo(User::class);
  15.     }
  16. }

ルーティング



コントローラーを呼び出すためのルーティングを定義します。
routes/api.php を以下のように変更します。

  1. <?php
  2.  
  3. use Illuminate\Http\Request;
  4.  
  5. /*
  6. |--------------------------------------------------------------------------
  7. | API Routes
  8. |--------------------------------------------------------------------------
  9. |
  10. | Here is where you can register API routes for your application. These
  11. | routes are loaded by the RouteServiceProvider within a group which
  12. | is assigned the "api" middleware group. Enjoy building your API!
  13. |
  14. */
  15.  
  16. // 認証は面倒なので一旦省略
  17. Route::resource('/items', 'ItemController', ['except' => ['create', 'edit']]);

APIにも認証が必要ですが、ここでは一旦省略しています(次回の記事で、OAuth 2.0を使った認証方法を紹介します)。
Route::resource()メソッドを使うと、先ほど作成したリソースコントローラーに対応するルートが定義されます。
ただし、新規作成画面(GET /RESOURCE/create)と編集画面(GET /RESOURCE/ID/edit)のルートは、APIには必要ありません。そのため、「 ['except' => ['create', 'edit']]」でcreateとeditのルートは除外しています。
ルートを定義したら、「php artisan route:list」で確認しておきましょう。



コントローラーの実装



ルーティングができたので、コントローラーを実装します。
まずは動作確認のため、app/Http/Controllers/ItemController.phpのindex()メソッドを以下の内容に書き換えます。

  1. <?php
  2. // (省略)
  3.     public function index()
  4.     {
  5.         return response(Item::all());
  6.     }

この状態で、 http://localhost:8000/api/items にアクセスすると、以下のようなJSONが返ってくるはずです。



機能テスト



コントローラーが動くようになったので、コントローラーの機能テストを作成しましょう。
「php artisan make:test ItemTest」で機能テスト(Feature Test)を作成できます。
作成したtests/Feature/ItemTest.phpを以下のように置き換えます。

  1. <?php
  2.  
  3. namespace Tests\Feature;
  4.  
  5. use App\Item;
  6. use Tests\TestCase;
  7. use Illuminate\Foundation\Testing\WithoutMiddleware;
  8. use Illuminate\Foundation\Testing\DatabaseMigrations;
  9. use Illuminate\Foundation\Testing\DatabaseTransactions;
  10.  
  11. class ItemTest extends TestCase
  12. {
  13.     use DatabaseMigrations;
  14.  
  15.     protected function setUp()
  16.     {
  17.         parent::setUp();
  18.         (new \DatabaseSeeder())->run(); // テストデータ登録
  19.     }
  20.  
  21.     public function testIndex()
  22.     {
  23.         $response = $this->get('/api/items');
  24.  
  25.         $response->assertStatus(200);
  26.         $this->assertCount(1, $response->json());
  27.     }
  28.  
  29.     public function testShow()
  30.     {
  31.         $response = $this->get('/api/items/1');
  32.  
  33.         $response->assertStatus(200);
  34.     }
  35.  
  36.     public function testStore()
  37.     {
  38.         $data = ['content' => 'ブログを書く'];
  39.         $response = $this->post('/api/items', $data);
  40.  
  41.         $response->assertStatus(201);
  42.         $response->assertJson($data);
  43.         $item = Item::query()->find($response->json()['id']);
  44.         $this->assertInstanceOf(Item::class, $item);
  45.     }
  46.  
  47.     public function testUpdateContent()
  48.     {
  49.         $data = ['content' => 'ブログを書く'];
  50.         $response = $this->patch('/api/items/1', $data);
  51.         
  52.         $response->assertStatus(200);
  53.         $response->assertJson($data);
  54.         $item = Item::query()->find(1);
  55.         $this->assertSame('ブログを書く', $item->content);
  56.     }
  57.  
  58.     public function testUpdateChecked()
  59.     {
  60.         $data = ['checked' => 1];
  61.         $response = $this->patch('/api/items/1', $data);
  62.  
  63.         $response->assertStatus(200);
  64.         $response->assertJson($data);
  65.         $item = Item::query()->find(1);
  66.         $this->assertEquals(true, $item->checked);
  67.     }
  68.  
  69.     public function testDelete()
  70.     {
  71.         $response = $this->delete('/api/items/1');
  72.  
  73.         $response->assertStatus(200);
  74.         $this->assertNull(Item::query()->find(1));
  75.     }
  76. }

はじめに、「use DatabaseMigrations;」することで、それぞれのテストの実行前にmigrate、実行後にmigrate:rollbackが実行されるようにしています。
これによって、あるテストで作成したデータが別のテストに影響することを避けられます。

テストデータの登録は手抜きをして、先ほど作成したSeederを使ってます。
テストケースはとりあえず正常系だけ書いてます。
やや特殊な点として、更新はcontentまたはcheckedのいずれか単独でも可能な仕様としています。

この状態でテストを実行してもいいのですが、接続先のデータベースが「homestead(デフォルトの場合)」となっているため、できればテスト用のデータベースを分けたいところです。

テスト環境のデータベースの切り換えは、.envなどでもできますが、phpunit.xmlに書くのが手軽でしょう。phpunit.xmlの<php>~</php>で囲まれた部分を、以下のように書き換えます(DB_DATABASEの定義を追加)。

  1.         <env name="APP_ENV" value="testing"/>
  2.         <env name="CACHE_DRIVER" value="array"/>
  3.         <env name="SESSION_DRIVER" value="array"/>
  4.         <env name="QUEUE_DRIVER" value="sync"/>
  5.         <env name="DB_DATABASE" value="test_homestead"/>

また、データベースは自動作成されないので、MySQLにログインして「CREATE DATABASE test_homestead」でテスト用のデータベースを作成しておきます。

用意ができたら、以下のように実行します(まだ実装が完了していないので、エラーになります)。

  1. $ ./vendor/bin/phpunit tests/Feature/ItemTest.php

バリデーション



ItemControllerで新しいItemを登録する機能は以下のように実装できます。
※ログインユーザーの取得処理については、ログイン機能実装後に作成します。

  1. <?php
  2. // 省略
  3.     public function store(Request $request)
  4.     {
  5.         $item = new Item();
  6.         // todo: ログインユーザのidが入るようにする
  7.         $item->user_id = \App\User::query()->first()->id;
  8.         $item->content = $request->input('content');
  9.         $item->save();
  10.         return response($item, 201);
  11.     }

現状では、入力値のバリデーションが全くありません。
contentにすごく長い文字列が入っていると、DB保存時にエラーが発生します。

Laravelでバリデーションを実装する方法はいくつかありますが、個人的にはFormRequestを使った方法がオススメです。
FormRequestは以下のコマンドで作成できます。

  1. $ php artisan make:request ItemStoreFormRequest

実行すると、app/Request/ItemFormStoreRequest.phpが追加されます。この中身を以下のように書き換えましょう。

  1. <?php
  2.  
  3. namespace App\Http\Requests;
  4.  
  5. use Illuminate\Foundation\Http\FormRequest;
  6.  
  7. class ItemStoreFormRequest extends FormRequest
  8. {
  9.     /**
  10.      * Determine if the user is authorized to make this request.
  11.      *
  12.      * @return bool
  13.      */
  14.     public function authorize()
  15.     {
  16.         return true; // todo: 認証実装
  17.     }
  18.  
  19.     /**
  20.      * Get the validation rules that apply to the request.
  21.      *
  22.      * @return array
  23.      */
  24.     public function rules()
  25.     {
  26.         return [
  27.             'content' => 'required|string|max:255',
  28.         ];
  29.     }
  30. }

contentというフィールドの値が、(1) nullまたは空文字列の場合 (2) 文字列ではない場合 (3) 255文字異常の長さの場合 にエラーが返るように設定しています。

次に、ItemControllerのstore()メソッドで使用するRequestクラスを置き換えます。

  1. <?php
  2. // 省略
  3. use App\Http\Requests\ItemStoreFormRequest;
  4. // 省略
  5.     public function store(ItemStoreFormRequest $request)

このようにすると、バリデーションはFormRequest、保存処理はコントローラー、という風に責務を分担できます。

同じように、ItemUpdateFormRequestも作成します。

  1. <?php
  2.  
  3. namespace App\Http\Requests;
  4.  
  5. use Illuminate\Foundation\Http\FormRequest;
  6.  
  7. class ItemUpdateFormRequest extends FormRequest
  8. {
  9.     /**
  10.      * Determine if the user is authorized to make this request.
  11.      *
  12.      * @return bool
  13.      */
  14.     public function authorize()
  15.     {
  16.         return true; // todo: 認証実装
  17.     }
  18.  
  19.     /**
  20.      * Get the validation rules that apply to the request.
  21.      *
  22.      * @return array
  23.      */
  24.     public function rules()
  25.     {
  26.         return [
  27.             'content' => 'string|max:255',
  28.             'checked' => 'boolean'
  29.         ];
  30.     }
  31. }

登録と更新では、バリデーションの条件が違うので、FormRequestクラスも別にしています。
具体的には、(1) contentは登録では必須だが、更新では必須ではない (2) checkedを変更できるのは更新の場合のみ という違いがあります。

最後に、コントローラーの全体像を掲載します。

  1. <?php
  2.  
  3. namespace App\Http\Controllers;
  4.  
  5. use App\Http\Requests\ItemStoreFormRequest;
  6. use App\Http\Requests\ItemUpdateFormRequest;
  7. use App\Item;
  8.  
  9. class ItemController extends Controller
  10. {
  11.     /**
  12.      * Display a listing of the resource.
  13.      *
  14.      * @return \Illuminate\Http\Response
  15.      */
  16.     public function index()
  17.     {
  18.         return response(Item::all());
  19.     }
  20.  
  21.     /**
  22.      * Store a newly created resource in storage.
  23.      *
  24.      * @param  \App\Http\Requests\ItemStoreFormRequest $request
  25.      * @return \Illuminate\Http\Response
  26.      */
  27.     public function store(ItemStoreFormRequest $request)
  28.     {
  29.         $item = new Item();
  30.         // todo: ログインユーザのidが入るようにする
  31.         $item->user_id = \App\User::query()->first()->id;
  32.         $item->content = $request->input('content');
  33.         $item->save();
  34.         return response($item, 201);
  35.     }
  36.  
  37.     /**
  38.      * Display the specified resource.
  39.      *
  40.      * @param  \App\Item $item
  41.      * @return \Illuminate\Http\Response
  42.      */
  43.     public function show(Item $item)
  44.     {
  45.         return response($item);
  46.     }
  47.  
  48.     /**
  49.      * Update the specified resource in storage.
  50.      *
  51.      * @param  \App\Http\Requests\ItemUpdateFormRequest $request
  52.      * @param  \App\Item                                $item
  53.      * @return \Illuminate\Http\Response
  54.      */
  55.     public function update(ItemUpdateFormRequest $request, Item $item)
  56.     {
  57.         if ($request->input('content')) {
  58.             $item->content = $request->input('content');
  59.         }
  60.         if ($request->input('checked')) {
  61.             $item->checked = $request->input('checked');
  62.         }
  63.  
  64.         $item->save();
  65.         return response($item);
  66.     }
  67.  
  68.     /**
  69.      * Remove the specified resource from storage.
  70.      *
  71.      * @param  \App\Item $item
  72.      * @return \Illuminate\Http\Response
  73.      */
  74.     public function destroy(Item $item)
  75.     {
  76.         $item->delete();
  77.         return response('{}'); // 返すものがないので空のJSONを返す
  78.     }
  79. }

これで一通りの実装ができたので、テストがすべて通るはずです。

  1. $ ./vendor/bin/phpunit tests/Feature/ItemTest.php
  2. PHPUnit 5.7.15 by Sebastian Bergmann and contributors.
  3. ......                                                              6 / 6 (100%)
  4. Time: 3.19 seconds, Memory: 8.00MB
  5. OK (6 tests, 14 assertions)

ソースコードの全体はGitHubでも公開しているので、参考にしてください。

ここまでで、Itemの取得・作成・更新・削除が行えるWeb APIができました。
次回は、このAPIを使うVue.jsアプリケーションを作っていきます。

参考



Laravel Homestead
Database: Migrations
Database: Seeding
Eloquent: Relationships
Routing
Validation