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:
2026-02-19 14:51:56 -05:00
parent 1592c5be8d
commit 47068dabce
49 changed files with 2426 additions and 809 deletions

View File

@@ -3,6 +3,9 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProjectResource;
use App\Http\Resources\ProjectStatusResource;
use App\Http\Resources\ProjectTypeResource;
use App\Models\Project;
use App\Models\ProjectStatus;
use App\Models\ProjectType;
@@ -41,21 +44,21 @@ class ProjectController extends Controller
* @queryParam status_id integer Filter by status ID. Example: 1
* @queryParam type_id integer Filter by type ID. Example: 2
*
* @response 200 [
* {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-001",
* "title": "Client Dashboard Redesign",
* "status_id": 1,
* "status": {"id": 1, "name": "Pre-sales"},
* "type_id": 2,
* "type": {"id": 2, "name": "Support"},
* "approved_estimate": "120.00",
* "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
* "created_at": "2024-01-15T10:00:00.000000Z",
* "updated_at": "2024-01-15T10:00:00.000000Z"
* }
* ]
* @response 200 {
* "data": [
* {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-001",
* "title": "Client Dashboard Redesign",
* "status": {"id": 1, "name": "Pre-sales"},
* "type": {"id": 2, "name": "Support"},
* "approved_estimate": "120.00",
* "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
* "created_at": "2024-01-15T10:00:00.000000Z",
* "updated_at": "2024-01-15T10:00:00.000000Z"
* }
* ]
* }
*/
public function index(Request $request): JsonResponse
{
@@ -64,7 +67,7 @@ class ProjectController extends Controller
$projects = $this->projectService->getAll($statusId, $typeId);
return response()->json($projects);
return $this->wrapResource(ProjectResource::collection($projects));
}
/**
@@ -79,13 +82,13 @@ class ProjectController extends Controller
* @bodyParam type_id integer required Project type ID. Example: 1
*
* @response 201 {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-001",
* "title": "Client Dashboard Redesign",
* "status_id": 1,
* "status": {"id": 1, "name": "Pre-sales"},
* "type_id": 1,
* "type": {"id": 1, "name": "Project"}
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-001",
* "title": "Client Dashboard Redesign",
* "status": {"id": 1, "name": "Pre-sales"},
* "type": {"id": 1, "name": "Project"}
* }
* }
* @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}
*/
@@ -94,7 +97,7 @@ class ProjectController extends Controller
try {
$project = $this->projectService->create($request->all());
return response()->json($project, 201);
return $this->wrapResource(new ProjectResource($project), 201);
} catch (ValidationException $e) {
return response()->json([
'message' => 'Validation failed',
@@ -113,13 +116,15 @@ class ProjectController extends Controller
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
*
* @response 200 {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-001",
* "title": "Client Dashboard Redesign",
* "status": {"id": 1, "name": "Pre-sales"},
* "type": {"id": 1, "name": "Project"},
* "approved_estimate": "120.00",
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-001",
* "title": "Client Dashboard Redesign",
* "status": {"id": 1, "name": "Pre-sales"},
* "type": {"id": 1, "name": "Project"},
* "approved_estimate": "120.00",
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
* }
* }
* @response 404 {"message":"Project not found"}
*/
@@ -133,7 +138,7 @@ class ProjectController extends Controller
], 404);
}
return response()->json($project);
return $this->wrapResource(new ProjectResource($project));
}
/**
@@ -150,10 +155,12 @@ class ProjectController extends Controller
* @bodyParam type_id integer Project type ID. Example: 2
*
* @response 200 {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-002",
* "title": "Updated Title",
* "type_id": 2
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "code": "PROJ-002",
* "title": "Updated Title",
* "type": {"id": 2, "name": "Support"}
* }
* }
* @response 404 {"message":"Project not found"}
* @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}
@@ -173,7 +180,7 @@ class ProjectController extends Controller
'code', 'title', 'type_id',
]));
return response()->json($project);
return $this->wrapResource(new ProjectResource($project));
} catch (ValidationException $e) {
return response()->json([
'message' => 'Validation failed',
@@ -194,8 +201,10 @@ class ProjectController extends Controller
* @bodyParam status_id integer required Target status ID. Example: 2
*
* @response 200 {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "status": {"id": 2, "name": "SOW Approval"}
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "status": {"id": 2, "name": "SOW Approval"}
* }
* }
* @response 404 {"message":"Project not found"}
* @response 422 {"message":"Cannot transition from Pre-sales to Done"}
@@ -220,7 +229,7 @@ class ProjectController extends Controller
(int) $request->input('status_id')
);
return response()->json($project);
return $this->wrapResource(new ProjectResource($project));
} catch (\RuntimeException $e) {
return response()->json([
'message' => $e->getMessage(),
@@ -239,7 +248,12 @@ class ProjectController extends Controller
*
* @bodyParam approved_estimate number required Approved estimate hours (must be > 0). Example: 120
*
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}
* @response 200 {
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "approved_estimate": "120.00"
* }
* }
* @response 404 {"message":"Project not found"}
* @response 422 {"message":"Approved estimate must be greater than 0"}
*/
@@ -263,7 +277,7 @@ class ProjectController extends Controller
(float) $request->input('approved_estimate')
);
return response()->json($project);
return $this->wrapResource(new ProjectResource($project));
} catch (\RuntimeException $e) {
return response()->json([
'message' => $e->getMessage(),
@@ -282,7 +296,12 @@ class ProjectController extends Controller
*
* @bodyParam forecasted_effort object required Monthly effort breakdown. Example: {"2024-02": 40, "2024-03": 60}
*
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}
* @response 200 {
* "data": {
* "id": "550e8400-e29b-41d4-a716-446655440000",
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
* }
* }
* @response 404 {"message":"Project not found"}
* @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"}
*/
@@ -319,17 +338,19 @@ class ProjectController extends Controller
*
* @authenticated
*
* @response 200 [
* {"id": 1, "name": "Project"},
* {"id": 2, "name": "Support"},
* {"id": 3, "name": "Engagement"}
* ]
* @response 200 {
* "data": [
* {"id": 1, "name": "Project"},
* {"id": 2, "name": "Support"},
* {"id": 3, "name": "Engagement"}
* ]
* }
*/
public function types(): JsonResponse
{
$types = ProjectType::orderBy('name')->get(['id', 'name']);
return response()->json($types);
return $this->wrapResource(ProjectTypeResource::collection($types));
}
/**
@@ -337,17 +358,19 @@ class ProjectController extends Controller
*
* @authenticated
*
* @response 200 [
* {"id": 1, "name": "Pre-sales", "order": 1},
* {"id": 2, "name": "SOW Approval", "order": 2},
* {"id": 3, "name": "Gathering Estimates", "order": 3}
* ]
* @response 200 {
* "data": [
* {"id": 1, "name": "Pre-sales", "order": 1},
* {"id": 2, "name": "SOW Approval", "order": 2},
* {"id": 3, "name": "Gathering Estimates", "order": 3}
* ]
* }
*/
public function statuses(): JsonResponse
{
$statuses = ProjectStatus::orderBy('order')->get(['id', 'name', 'order']);
return response()->json($statuses);
return $this->wrapResource(ProjectStatusResource::collection($statuses));
}
/**