カテゴリー
コンピューター

Laravel 6 。ファイルを扱うシンプルな CRUD API の例を作った (今後 Dropzon.js を試すために)

簡単な仕様

  • GET api/files ファイル情報の配列を取得する。
  • GET api/files/{file} 指定したファイル情報を取得する。
  • POST api/files ファイルを登録する。
  • PATCH api/files/{file} 指定したファイルを更新する。
  • DELETE api/files/{file} 指定したファイルを削除する。

ファイル登録時などのレスポンスは、最初、 URL だけでいいや、 DB で管理したく無いし、と思っていました。 しかし、 How to show files already stored on server – FAQ · Wiki · Matias Meno / Dropzone · GitLab を読むと name, size, url が Dropzone.js では必要なようです。 なので、面倒ではありますが DB でこれらを管理しようと思います。。。 こうなると、例えば POST 時のレスポンスは次のようになるかと思います。

{
  "name": "file.jpg",
  "size": 12345,
  "url": "http://localhost/storage/files/rXVtdVkqRvR7G0PYZUtupJNPmM9mPhdqrvkHI4Ly.jpg"
}

作っていく

ファイル生成や設定やコード数の少ない変更など

ファイルは DB ではなく一般公開するディレクトリへ保存する予定なため、 ファイルストレージ 6.x Laravel にしたがってシンボリックリンクを貼ります。

php artisan storage:link

今回は常に一般公開しますので、デフォルトのファイルシステムを public へと変更します。

$ git diff laravel/config/filesystems.php
diff --git a/laravel/config/filesystems.php b/laravel/config/filesystems.php
index 220c010..cb5be7e 100644
--- a/laravel/config/filesystems.php
+++ b/laravel/config/filesystems.php
@@ -13,7 +13,7 @@ return [
     |
     */

-    'default' => env('FILESYSTEM_DRIVER', 'local'),
+    'default' => env('FILESYSTEM_DRIVER', 'public'),

     /*
     |--------------------------------------------------------------------------
$

コントローラーを作りました。中身はあとで書いていきます。

php artisan make:controller Api/FileController --api --force

ルーティングをしました。 APIリソースルート – コントローラ 6.x Laravel です。

$ git diff laravel/routes/api.php
diff --git a/laravel/routes/api.php b/laravel/routes/api.php
index c641ca5..5265cb4 100644
--- a/laravel/routes/api.php
+++ b/laravel/routes/api.php
@@ -16,3 +16,5 @@ use Illuminate\Http\Request;
 Route::middleware('auth:api')->get('/user', function (Request $request) {
     return $request->user();
 });
+
+Route::apiResource('files', 'Api\FileController');
$

モデルと、マイグレーションファイルを生成します。

php artisan make:model --migration Models/File

また、レスポンスは API リソースファイルとした方が良さそうと感じましたので作っておきます。

php artisan make:resource File

そして、 POST 、 GET や PUT 時のレスポンスは data でラップされていない構造で受け取りたいので、 データラップ – Eloquent: APIリソース 6.x Laravel の通りに修正しました。

$ git diff laravel/app/Providers/AppServiceProvider.php
diff --git a/laravel/app/Providers/AppServiceProvider.php b/laravel/app/Providers/AppServiceProvider.php
index ee8ca5b..8104fb8 100644
--- a/laravel/app/Providers/AppServiceProvider.php
+++ b/laravel/app/Providers/AppServiceProvider.php
@@ -2,6 +2,7 @@

 namespace App\Providers;

+use Illuminate\Http\Resources\Json\Resource;
 use Illuminate\Support\ServiceProvider;

 class AppServiceProvider extends ServiceProvider
@@ -23,6 +24,6 @@ class AppServiceProvider extends ServiceProvider
      */
     public function boot()
     {
-        //
+        Resource::withoutWrapping();
     }
 }
$

あと、テストとそのためのファクトリも作っておきます (ファクトリは結局使いませんでした) 。

php artisan make:test FileControllerTest
php artisan make:factory FileFactory --model=Models/File
# ↑ファクトリは結局使わなかったので、生成不要だった。

POST api/files ファイルを登録する。この部分を作るにあたって調べたこと

GET api/files/{file} 指定したファイル情報を取得する。この部分を作るにあたって調べたこと

  • Illuminate\Http\Testing\File | Laravel APIgetSize()store() を使った。
  • 事前にファクトリクラスを用意したが、テストではフェイクファイルを作成してそれに依存するような形で DB レコードを用意する形となった。この状況ではファイルはテストが終われば自動的に消えるが、 DB レコードは基本的には消えない (インメモリ SQLite をデータベースとして指定しているため結果としては消えるけれども) 。ファクトリの中でフェイクファイルを生成しようかと思ったが、ファイルは消え、レコードは残る事態になりかねないため、ファクトリは使用しなかった。

GET api/files ファイル情報の配列を取得する。この部分を作るにあたって調べたこと

ページネーションは無しとしました。サンプル程度のものである、ということと、実際に使う時は何かにファイルが数件紐づいている状態から扱うと考えられるためです。

PATCH api/files/{file} 指定したファイルを更新する。この部分を作るにあたって調べたこと

DELETE api/files/{file} 指定したファイルを削除する。この部分を作るにあたって調べたこと

REST API の DELETE メソッドの成功時のレスポンスは何を返せば良いのか、毎回悩みます。 結局、ステータスコードは 204 でレスポンスボディは何も無し、としました。

完成したメイン部分のコード全体

ユニットテストと、コントローラーがメインです。ファクトリは結局使いませんでした。。。

app/Http/Controllers/Api/FileController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\File as FileResource;
use App\Models\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;

class FileController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return AnonymousResourceCollection
     */
    public function index(): AnonymousResourceCollection
    {
        return FileResource::collection(File::all());
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return FileResource
     */
    public function store(Request $request): FileResource
    {
        $file = $request->file('file');
        $path = $file->store('files');
        $model = File::create([
            'name' => $file->getClientOriginalName(),
            'size' => $file->getSize(),
            'path' => $path,
        ]);

        return new FileResource($model);
    }

    /**
     * Display the specified resource.
     *
     * @param  File  $file
     * @return FileResource
     */
    public function show(File $file): FileResource
    {
        return new FileResource($file);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  File  $file
     * @return FileResource
     */
    public function update(Request $request, File $file): FileResource
    {
        $filePathToBeDeleted = $file->path;

        $uploadFile = $request->file('file');
        $path = $uploadFile->store('files');
        $file->fill([
            'name' => $uploadFile->getClientOriginalName(),
            'size' => $uploadFile->getSize(),
            'path' => $path,
        ])->save();

        // 更新前のファイルを削除する
        Storage::delete($filePathToBeDeleted);

        return new FileResource($file);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  File  $file
     * @return JsonResponse
     */
    public function destroy(File $file): JsonResponse
    {
        $filePathToBeDeleted = $file->path;

        $file->delete();

        // レコードに紐づいていたファイルを削除
        Storage::delete($filePathToBeDeleted);

        return response()->json([], Response::HTTP_NO_CONTENT);
    }
}

app/Http/Resources/File.php

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class File extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'size' => $this->size,
            'url' => $this->url,
        ];
    }
}

app/Models/File.php

<?php

namespace App\Models;

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

class File extends Model
{
    /**
     * 複数代入する属性
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'size',
        'path',
    ];

    /**
     * ネイティブなタイプへキャストする属性
     *
     * @var array
     */
    protected $casts = [
        'size' => 'integer',
    ];

    /**
     * URL を取得します。
     *
     * @return string
     */
    public function getUrlAttribute(): string
    {
        return Storage::url($this->path);
    }
}

database/migrations/2021_05_04_222600_create_files_table.php

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateFilesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('files', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('name');
            $table->integer('size');
            $table->string('path');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('files');
    }
}

tests/Feature/FileControllerTest.php 。最初、 Storage::fake();createFile メソッドに書いていました。けれども、テストメソッドの中で複数回 Storage::fake(); を実行すると、 2 回目の Storage::fake(); の前に生成したファイルが消えるという予想外な動きをしました。全てのテストメソッドでフェイクファイルを扱っていますので、 setupStorage::fake(); を実行するようにしました。

今にして思えば、createFile メソッドを setup メソッドで 2 回ほど実行しておいて、各テストメソッドでは事前に作成指定おいたフェイクファイルとそれに紐づくレコードを利用する、というようにすることで、各テストメソッドでの準備にあたるコードをもっと減らせたのではないかと思います。

<?php

namespace Tests\Feature;

use App\Models\File;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class FileControllerTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();
        $this->seed();

        Storage::fake();
    }

    /**
     * テスト用フェイクファイルと紐づく DB レコードを生成します。
     *
     * @param string $fileName
     */
    private function createFile(string $fileName): File
    {
        $file = UploadedFile::fake()->image($fileName)->size(1000);
        $fileSize = $file->getSize();
        $path = $file->store('files');
        // テスト後に DB レコードに紐づくファイルは消えてしまうこと、DB カラムはすべてファイル由来であること、を考慮し、ファクトリは使用しなかった。
        $record = File::create([
            'name' => $fileName,
            'size' => $fileSize,
            'path'=> $path,
        ]);

        return $record;
    }

    /**
     * @test
     * @return void
     */
    public function 全てのファイルを取得できること(): void
    {
        $fileName1 = 'file1.jpg';
        $file1 = $this->createFile($fileName1);
        $fileName2 = 'file2.jpg';
        $file2 = $this->createFile($fileName2);

        $response = $this->get("/api/files");

        // 確認。レスポンス
        $response
            ->assertStatus(200)
            ->assertExactJson([
                [
                    'id' => $file1->id,
                    'name' => $fileName1,
                    'size' => $file1->size,
                    'url' => $file1->url,
                ],
                [
                    'id' => $file2->id,
                    'name' => $fileName2,
                    'size' => $file2->size,
                    'url' => $file2->url,
                ],
            ]);
    }

    /**
     * @test
     * @return void
     */
    public function ファイルを登録できること(): void
    {
        $fileName = 'file.jpg';
        $file = UploadedFile::fake()->image($fileName)->size(1000);

        $response = $this->post('/api/files', [
            'file' => $file,
        ]);

        // 確認。レスポンス
        $record = File::first();
        $response
            ->assertStatus(201)
            ->assertJson([
                'name' => $fileName,
                'size' => $file->getSize(),
                'url' => $record->url,
            ]);

        // 確認。データベース
        $this->assertDatabaseHas('files', [
            'name' => $fileName,
            'size' => $file->getSize(),
        ]);

        // 確認。ファイル保存
        Storage::assertExists('files/' . $file->hashName());
    }

    /**
     * @test
     * @return void
     */
    public function 指定ファイルを取得できること(): void
    {
        $fileName = 'file.jpg';
        $file = $this->createFile($fileName);
        $fileId = $file->id;

        $response = $this->get("/api/files/{$fileId}");

        // 確認。レスポンス
        $response
            ->assertStatus(200)
            ->assertExactJson([
                'id' => $fileId,
                'name' => $fileName,
                'size' => $file->size,
                'url' => $file->url,
            ]);
    }

    /**
     * @test
     * @return void
     */
    public function 指定ファイルを更新できること(): void
    {
        // 更新される対象
        $targetFileName = 'targetFile.jpg';
        $targetFile = $this->createFile($targetFileName);
        $targetFileId = $targetFile->id;

        // アップロードするファイル
        $uploadFileName = 'uploadFile.jpg';
        $uploadFile = UploadedFile::fake()->image($uploadFileName)->size(2000);

        $response = $this->patch("/api/files/{$targetFileId}", [
            'file' => $uploadFile,
        ]);

        // 確認。レスポンス
        $uploadFileRecord = $targetFile->fresh();
        $response
            ->assertStatus(200)
            ->assertJson([
                'id' => $targetFileId,
                'name' => $uploadFileName,
                'size' => $uploadFileRecord->size,
                'url' => $uploadFileRecord->url,
            ]);

        // 確認。データベース
        $this->assertDatabaseHas('files', [
            'name' => $uploadFileName,
            'size' => $uploadFile->getSize(),
        ]);

        // 確認。ファイル保存
        Storage::assertExists('files/' . $uploadFile->hashName());
        Storage::assertMissing($targetFile->path);
    }

    /**
     * @test
     * @return void
     */
    public function 指定ファイルを削除できること(): void
    {
        // 削除される対象
        $targetFileName = 'targetFile.jpg';
        $targetFile = $this->createFile($targetFileName);
        $targetFileId = $targetFile->id;

        $response = $this->delete("/api/files/{$targetFileId}");

        // 確認。レスポンス
        $response
            ->assertStatus(204);

        // 確認。データベース
        $this->assertDatabaseMissing('files', [
            'name' => $targetFileName,
            'size' => $targetFile->size,
        ]);

        // 確認。ファイル保存
        Storage::assertMissing($targetFile->path);
    }
}

おわりに

なぜこんなことをやっているかというと、 Vue.js 用の Dropzone ライブラリを軽く試したメモ – oki2a24 の続きで Dropzon.js の使い方を調べたく、そのために、ファイルをアップロードできるサーバを用意する必要があり、無いので、本投稿で作っているのでした。

ようやく用意できました。

ただ、 Dropzon.js を試しているうちに、本投稿で作ったものに手が入りそう、と思っています。

以上です。

コメントを残す