簡単な仕様
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
ファイルを登録する。この部分を作るにあたって調べたこと
- ファイルアップロード – ファイルストレージ 6.x Laravel
- ファイルURL – ファイルストレージ 6.x Laravel
- ファイルアップロードのテスト – HTTPテスト 6.x Laravel
GET api/files/{file}
指定したファイル情報を取得する。この部分を作るにあたって調べたこと
- Illuminate\Http\Testing\File | Laravel API 。
getSize()
やstore()
を使った。 - 事前にファクトリクラスを用意したが、テストではフェイクファイルを作成してそれに依存するような形で DB レコードを用意する形となった。この状況ではファイルはテストが終われば自動的に消えるが、 DB レコードは基本的には消えない (インメモリ SQLite をデータベースとして指定しているため結果としては消えるけれども) 。ファクトリの中でフェイクファイルを生成しようかと思ったが、ファイルは消え、レコードは残る事態になりかねないため、ファクトリは使用しなかった。
GET api/files
ファイル情報の配列を取得する。この部分を作るにあたって調べたこと
ページネーションは無しとしました。サンプル程度のものである、ということと、実際に使う時は何かにファイルが数件紐づいている状態から扱うと考えられるためです。
PATCH api/files/{file}
指定したファイルを更新する。この部分を作るにあたって調べたこと
DELETE api/files/{file}
指定したファイルを削除する。この部分を作るにあたって調べたこと
REST API の DELETE メソッドの成功時のレスポンスは何を返せば良いのか、毎回悩みます。 結局、ステータスコードは 204 でレスポンスボディは何も無し、としました。
- 200 or 204 どっちを使うか – Qiita
- koel/SongController.php at master · koel/koel ← 様々な場面で参考にしている、音楽プレーヤーアプリのコード
完成したメイン部分のコード全体
ユニットテストと、コントローラーがメインです。ファクトリは結局使いませんでした。。。
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();
の前に生成したファイルが消えるという予想外な動きをしました。全てのテストメソッドでフェイクファイルを扱っていますので、 setup
で Storage::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 を試しているうちに、本投稿で作ったものに手が入りそう、と思っています。
以上です。