- 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
270 lines
6.1 KiB
Markdown
270 lines
6.1 KiB
Markdown
## 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
|
|
<?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
|
|
<?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:**
|
|
```php
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$members = $this->teamMemberService->getAll();
|
|
return response()->json($members);
|
|
}
|
|
```
|
|
|
|
**AFTER:**
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
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:**
|
|
```php
|
|
// 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:**
|
|
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// BEFORE:
|
|
const members = await response.json();
|
|
|
|
// AFTER:
|
|
const members = await unwrapResponse<TeamMember[]>(response);
|
|
```
|
|
|
|
---
|
|
|
|
### Test Update Pattern
|
|
|
|
**Backend Feature Tests:**
|
|
```php
|
|
// 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:**
|
|
```php
|
|
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:
|
|
|
|
```php
|
|
// 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):
|
|
|
|
```php
|
|
// 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:
|
|
|
|
```php
|
|
/**
|
|
* @response {
|
|
* "data": {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "name": "John Doe"
|
|
* }
|
|
* }
|
|
*/
|
|
```
|
|
|
|
---
|
|
|
|
### Migration Steps
|
|
|
|
1. **Phase 1: Resources** - Create all resource classes
|
|
2. **Phase 2: Controllers** - Update one controller at a time, run tests
|
|
3. **Phase 3: Frontend** - Update API client helper, then each endpoint
|
|
4. **Phase 4: Tests** - Update all test assertions
|
|
5. **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
|