Files
headroom/openspec/changes/api-resource-standard/design.md
Santhosh Janardhanan 47068dabce feat(api): Implement API Resource Standard compliance
- 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
2026-02-19 14:51:56 -05:00

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