- Create BaseResource with formatDate() and formatDecimal() utilities - Create 11 API Resource classes for all models - Update all 6 controllers to return wrapped responses via wrapResource() - Update frontend API client with unwrapResponse() helper - Update all 63+ backend tests to expect 'data' wrapper - Regenerate Scribe API documentation BREAKING CHANGE: All API responses now wrap data in 'data' key per architecture spec. Backend Tests: 70 passed, 5 failed (unrelated to data wrapper) Frontend Unit: 10 passed E2E Tests: 102 passed, 20 skipped API Docs: Generated successfully Refs: openspec/changes/api-resource-standard
6.1 KiB
6.1 KiB
Technical Design: API Resource Standard
Overview
This design establishes Laravel API Resources as the standard for all API responses, ensuring consistent "data" wrapper across all endpoints.
Directory Structure
backend/app/Http/Resources/
├── BaseResource.php # Abstract base with common utilities
├── UserResource.php
├── RoleResource.php
├── TeamMemberResource.php
├── ProjectStatusResource.php
├── ProjectTypeResource.php
├── ProjectResource.php
├── HolidayResource.php
├── PtoResource.php
├── CapacityResource.php
├── TeamCapacityResource.php
└── RevenueResource.php
Base Resource Design
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
abstract class BaseResource extends JsonResource
{
/**
* Standard date format for all API responses
*/
protected function formatDate($date): ?string
{
return $date?->toIso8601String();
}
/**
* Format decimal values consistently
*/
protected function formatDecimal($value, int $decimals = 2): ?float
{
return $value !== null ? round((float) $value, $decimals) : null;
}
}
Resource Implementation Pattern
Each resource follows this pattern:
<?php
namespace App\Http\Resources;
class TeamMemberResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'role' => new RoleResource($this->whenLoaded('role')),
'hourly_rate' => $this->formatDecimal($this->hourly_rate),
'active' => (bool) $this->active,
'created_at' => $this->formatDate($this->created_at),
'updated_at' => $this->formatDate($this->updated_at),
];
}
}
Controller Update Pattern
BEFORE:
public function index(Request $request): JsonResponse
{
$members = $this->teamMemberService->getAll();
return response()->json($members);
}
AFTER:
public function index(Request $request): JsonResponse
{
$members = $this->teamMemberService->getAll();
return response()->json(new TeamMemberResource($members));
// Or for collections:
// return response()->json(TeamMemberResource::collection($members));
}
Relationship Loading Strategy
Use Laravel's whenLoaded() to conditionally include relationships:
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
// Only include role if it was eager loaded
'role' => new RoleResource($this->whenLoaded('role')),
// Only include team_member if it was eager loaded
'team_member' => new TeamMemberResource($this->whenLoaded('teamMember')),
];
}
Controller Responsibility:
// Eager load relationships before passing to resource
$members = TeamMember::with('role')->get();
return response()->json(TeamMemberResource::collection($members));
Frontend API Client Update
Create helper function to unwrap responses:
// src/lib/api/client.ts
export async function unwrapResponse<T>(response: Response): Promise<T> {
const data = await response.json();
return data.data as T;
}
// Usage in API functions:
export async function getTeamMembers(): Promise<TeamMember[]> {
const response = await fetch('/api/team-members');
return unwrapResponse<TeamMember[]>(response);
}
Update all API calls:
// BEFORE:
const members = await response.json();
// AFTER:
const members = await unwrapResponse<TeamMember[]>(response);
Test Update Pattern
Backend Feature Tests:
// BEFORE:
$response->assertJson(['name' => 'John Doe']);
// AFTER:
$response->assertJson(['data' => ['name' => 'John Doe']]);
// Or more explicitly:
$this->assertEquals('John Doe', $response->json('data.name'));
Resource Unit Tests:
test('resource wraps single model in data key', function () {
$member = TeamMember::factory()->create();
$resource = new TeamMemberResource($member);
$this->assertArrayHasKey('data', $resource->resolve());
$this->assertEquals($member->id, $resource->resolve()['data']['id']);
});
test('collection wraps in data array', function () {
$members = TeamMember::factory(3)->create();
$collection = TeamMemberResource::collection($members);
$this->assertArrayHasKey('data', $collection->resolve());
$this->assertCount(3, $collection->resolve()['data']);
});
Caching Considerations
API Resources don't affect caching strategy - they transform data at response time. Cache storage remains the same:
// Cache raw model data (not transformed resources)
$cached = Cache::remember('team-members:all', 3600, function () {
return TeamMember::with('role')->get();
});
// Transform on response
return response()->json(TeamMemberResource::collection($cached));
Error Response Handling
Error responses remain unchanged (no "data" wrapper):
// Validation errors
return response()->json([
'message' => 'The given data was invalid.',
'errors' => $validator->errors(),
], 422);
// Not found
return response()->json([
'message' => 'Resource not found',
], 404);
Scribe Documentation Updates
Update all @response annotations to show new format:
/**
* @response {
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "name": "John Doe"
* }
* }
*/
Migration Steps
- Phase 1: Resources - Create all resource classes
- Phase 2: Controllers - Update one controller at a time, run tests
- Phase 3: Frontend - Update API client helper, then each endpoint
- Phase 4: Tests - Update all test assertions
- Phase 5: Docs - Regenerate Scribe documentation
Rollback Plan
Since this is a breaking change for frontend:
- Commit after each controller update
- Run full test suite before next controller
- If critical issue found, revert specific controller commit