Bài 24 — REST API, Resource và Validation: TaskFlow mở giao diện JSON cho danh sách, chi tiết, tạo và hoàn thành task. Chúng ta tái sử dụng Action và Policy thay vì viết một bộ nghiệp vụ khác cho API.
1. Contract trước controller
| Method và đường dẫn | Kết quả |
|---|---|
| GET /api/v1/projects/{project}/tasks | 200, data và pagination links/meta |
| GET /api/v1/projects/{project}/tasks/{task} | 200, một resource trong data |
| POST /api/v1/projects/{project}/tasks | 201, data và Location |
| PATCH /api/v1/projects/{project}/tasks/{task}/complete | 200, trạng thái done; gọi lại trả 409 |
Đây là API phục vụ client cùng website dùng session hiện tại. Routes cố ý nằm trong web.php để giữ session và CSRF, dù URL bắt đầu bằng api/v1. Tên đường dẫn không tự biến authentication thành token. Chưa có API cấp project, sửa mọi field hoặc xóa task; Sanctum được tích hợp ở bài tiếp theo.
2. Resource là allowlist đầu ra
app/Http/Resources/TaskResource.php
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'project_id' => $this->project_id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_at' => $this->due_at?->toISOString(),
];
}
}TaskResource trả đúng id, project_id, title, description, status, priority và due_at. Không serialize toàn bộ model kèm relationship. Thêm một cột database sau này sẽ không tự xuất hiện trong response. due_at là chuỗi ISO khi có giá trị hoặc null; dữ liệu đầu vào hiện chưa cho client đặt deadline.
Resource không kiểm tra quyền thay Policy. Chỉ tạo Resource sau khi đã authorize. Chuỗi title/description trong JSON vẫn là dữ liệu, client phải render an toàn, không nhét trực tiếp vào innerHTML. Đây là JsonResource thông thường, không tuyên bố tuân thủ đặc tả JSON:API.
3. Form Request dùng chung với giao diện web
app/Http/Requests/StoreTaskRequest.php
<?php
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
$project = $this->route('project');
return $project instanceof Project && ($this->user()?->can('update', $project) ?? false);
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'priority' => ['required', Rule::in(['low', 'normal', 'high'])],
];
}
}authorize lấy project đã route-bind và yêu cầu quyền update. User khác nhận 403 ngay cả payload rỗng; owner có dữ liệu sai nhận 422. Rules thống nhất title, description và priority. Controller web ở bài 21 đã đổi sang dùng cùng StoreTaskRequest, tránh hai nơi validation lệch nhau.
validated chỉ cung cấp field có rule. DTO tiếp tục giữ invariant cho caller ngoài HTTP. Các field như status, owner_id hoặc assignee_id không được truyền vào Action; field thừa hiện bị bỏ qua, không phải mọi field lạ đều trả 422. Nếu muốn contract strict reject, cần bổ sung validation và test có chủ đích.
4. Controller mỏng, nghiệp vụ dùng lại
app/Http/Controllers/Api/TaskController.php
<?php
namespace App\Http\Controllers\Api;
use App\Actions\CompleteTask;
use App\Actions\CreateTask;
use App\Data\CreateTaskData;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Resources\TaskResource;
use App\Models\Project;
use App\Models\Task;
use App\Queries\TaskListQuery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class TaskController extends Controller
{
public function index(Request $request, Project $project, TaskListQuery $query)
{
Gate::authorize('view', $project);
return TaskResource::collection($query->paginate($project, $request->query()));
}
public function show(Project $project, Task $task): TaskResource
{
Gate::authorize('view', $project);
return new TaskResource($task);
}
public function store(StoreTaskRequest $request, Project $project, CreateTask $action)
{
$data = $request->validated();
$task = $action->handle($project, new CreateTaskData(
$data['title'], $data['description'] ?? null, $data['priority'],
));
return (new TaskResource($task))->response()->setStatusCode(201)
->header('Location', route('api.tasks.show', [$project, $task]));
}
public function complete(Project $project, Task $task, CompleteTask $action): TaskResource
{
Gate::authorize('update', $task);
$action->handle($task);
return new TaskResource($task);
}
}index authorize rồi dùng TaskListQuery từ bài 13, giữ filter/sort allowlist và per_page tối đa 50. show trả resource sau kiểm tra project. store gọi CreateTask với DTO và trả 201 cùng Location trỏ đến endpoint chi tiết. complete giữ nguyên luật chuyển trạng thái của bài 18.
POST tạo mới chưa có idempotency key: gửi lại có thể tạo task thứ hai. Client không tự retry mù sau timeout/500. PATCH complete hiện chọn trả 409 nếu task đã done, không coi lần lặp là thành công. Tính nhất quán của contract quan trọng hơn việc chọn tên HTTP method rồi suy ra hành vi không được triển khai.
5. Routing và ranh giới xác thực
routes/web.php
<?php
use App\Http\Controllers\TaskFlowOverviewController;
use App\Http\Controllers\TaskPreviewController;
use App\Http\Controllers\SessionController;
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\ProjectTaskController;
use App\Http\Middleware\AssignRequestId;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/taskflow', TaskFlowOverviewController::class)
->middleware(AssignRequestId::class)
->name('taskflow.overview');
Route::get('/tasks/create', [TaskPreviewController::class, 'create'])->name('tasks.create');
Route::post('/tasks/preview', [TaskPreviewController::class, 'preview'])
->middleware('throttle:task-preview')->name('tasks.preview');
Route::middleware('guest')->group(function () {
Route::get('/login', [SessionController::class, 'create'])->name('login');
Route::post('/login', [SessionController::class, 'store'])
->middleware('throttle:login')->name('login.store');
});
Route::view('/dashboard', 'dashboard')->middleware('auth')->name('dashboard');
Route::post('/logout', [SessionController::class, 'destroy'])->middleware('auth')->name('logout');
Route::middleware('auth')->scopeBindings()->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])->name('projects.index');
Route::post('/projects', [ProjectController::class, 'store'])->name('projects.store');
Route::get('/projects/{project}', [ProjectController::class, 'show'])->name('projects.show');
Route::post('/projects/{project}/tasks', [ProjectTaskController::class, 'store'])->name('project-tasks.store');
Route::patch('/projects/{project}/tasks/{task}/complete', [ProjectTaskController::class, 'complete'])
->name('project-tasks.complete');
});
// Same-origin session API: web middleware (including CSRF) remains active.
Route::prefix('api/v1/projects/{project}')->middleware('auth')->scopeBindings()->group(function () {
Route::get('/tasks', [\App\Http\Controllers\Api\TaskController::class, 'index'])->name('api.tasks.index');
Route::get('/tasks/{task}', [\App\Http\Controllers\Api\TaskController::class, 'show'])->name('api.tasks.show');
Route::post('/tasks', [\App\Http\Controllers\Api\TaskController::class, 'store'])->name('api.tasks.store');
Route::patch('/tasks/{task}/complete', [\App\Http\Controllers\Api\TaskController::class, 'complete'])
->name('api.tasks.complete');
});scopeBindings chặn task thuộc project khác bằng 404. auth yêu cầu phiên đăng nhập, Policy ngăn user khác truy cập và middleware CSRF vẫn hoạt động cho request ghi. Cookie session không phải bearer token. JSON client cùng origin cần gửi cookie và token fallback phù hợp; không exclude api/* khỏi CSRF chỉ vì request body là JSON.
Để xem danh sách, đăng nhập ở /login rồi mở GET endpoint của project mình đã tạo. Với request ghi, gửi Accept: application/json, Content-Type: application/json và token của phiên hiện tại qua X-CSRF-TOKEN khi cần fallback. Không copy session/token thật vào tài liệu hay chia sẻ lệnh có credential.
6. Test contract và test từ chối
tests/Feature/TaskApiTest.php
<?php
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class TaskApiTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
}
public function test_create_show_list_and_complete_use_explicit_resource_fields(): void
{
$project = Project::factory()->create();
$this->actingAs($project->owner);
$response = $this->postJson(route('api.tasks.store', $project), [
'title' => 'API task', 'priority' => 'high', 'status' => 'done', 'owner_id' => 999,
])->assertCreated()->assertJsonPath('data.status', 'todo');
$task = $project->tasks()->sole();
$response->assertHeader('Location', route('api.tasks.show', [$project, $task]));
$this->getJson(route('api.tasks.show', [$project, $task]))->assertOk()->assertExactJson([
'data' => ['id' => $task->id, 'project_id' => $project->id, 'title' => 'API task',
'description' => null, 'status' => 'todo', 'priority' => 'high', 'due_at' => null],
]);
$this->getJson(route('api.tasks.index', $project))->assertOk()
->assertJsonCount(1, 'data')->assertJsonStructure(['data', 'links', 'meta']);
$this->patchJson(route('api.tasks.complete', [$project, $task]))->assertOk()
->assertJsonPath('data.status', 'done');
$this->patchJson(route('api.tasks.complete', [$project, $task]))->assertStatus(409);
}
public function test_api_denies_guest_other_owner_and_wrong_parent(): void
{
$project = Project::factory()->create();
$task = Task::factory()->for($project)->create();
$this->getJson(route('api.tasks.index', $project))->assertUnauthorized();
$this->actingAs(User::factory()->create());
$this->getJson(route('api.tasks.show', [$project, $task]))->assertForbidden();
$this->postJson(route('api.tasks.store', $project), [])->assertForbidden();
$this->patchJson(route('api.tasks.complete', [$project, $task]))->assertForbidden();
$another = Project::factory()->for($project->owner, 'owner')->create();
$this->actingAs($project->owner)->getJson(route('api.tasks.show', [$another, $task]))->assertNotFound();
$this->assertSame('todo', $task->fresh()->status);
}
public function test_validation_and_csrf_are_not_disabled_for_json_routes(): void
{
$project = Project::factory()->create();
$this->actingAs($project->owner)->postJson(route('api.tasks.store', $project), [])
->assertUnprocessable()->assertJsonValidationErrors(['title', 'priority']);
$this->app->bind(\Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,
EnforcedApiCsrf::class);
$this->postJson(route('api.tasks.store', $project), ['title' => 'Blocked', 'priority' => 'normal'])
->assertStatus(419);
$this->assertDatabaseCount('tasks', 0);
}
}
class EnforcedApiCsrf extends \Illuminate\Foundation\Http\Middleware\PreventRequestForgery
{
protected function runningUnitTests()
{
return false;
}
}php artisan test --filter=TaskApiTest
php artisan test
Test assertExactJson khóa danh sách field chi tiết; test tạo kiểm tra 201/Location và status không bị payload ghi đè. Test danh sách xác nhận data/links/meta. Test khác kiểm tra guest 401, user khác 403, sai parent 404, validation 422, lặp complete 409 và thiếu CSRF 419 khi bật lại middleware thật trong test.
Suite pass 71 test, 319 assertions sau khi cả web/API dùng chung Form Request. Đây chưa phải kiểm thử token, CORS, hiệu năng hoặc schema OpenAPI. Bộ test cũng chưa chứng minh mọi tổ hợp pagination; bài query đã có test riêng cho phạm vi project và sort.
Bài tập: bổ sung test due_at có giá trị, per_page vượt 50, và client xử lý lỗi theo status mà không coi mọi lỗi là validation. Tham khảo Laravel API Resources.
Điều hướng: Bài 23 · Lộ trình. Tiếp theo: Sanctum và xác thực API.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.