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

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

  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