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
This commit is contained in:
269
openspec/changes/api-resource-standard/design.md
Normal file
269
openspec/changes/api-resource-standard/design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
## 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
|
||||
Reference in New Issue
Block a user