Compare commits

..

No commits in common. "feat/standardise-error-responses" and "dev" have entirely different histories.

5 changed files with 90 additions and 170 deletions

View file

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Concerns;
use Illuminate\Http\JsonResponse;
/**
* Standardised API response helpers for php-tenant controllers.
*
* All error responses follow the format:
* ```json
* {
* "success": false,
* "error": "Human-readable message",
* "code": "MACHINE_READABLE_CODE"
* }
* ```
*
* All success responses include `"success": true`.
*
* @see https://forge.lthn.ai/core/php-tenant/issues/20
*/
trait HasStandardApiResponses
{
/**
* Return a standardised error response.
*/
protected function errorResponse(string $message, string $code, int $status = 400, array $extra = []): JsonResponse
{
return response()->json(array_merge([
'success' => false,
'error' => $message,
'code' => $code,
], $extra), $status);
}
/**
* Return a standardised success response.
*/
protected function successResponse(array $data = [], int $status = 200): JsonResponse
{
return response()->json(array_merge([
'success' => true,
], $data), $status);
}
/**
* Return a not found error response.
*/
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
{
return $this->errorResponse("{$resource} not found", 'NOT_FOUND', 404);
}
/**
* Return an unauthenticated error response.
*/
protected function unauthenticatedResponse(): JsonResponse
{
return $this->errorResponse('Unauthenticated', 'UNAUTHENTICATED', 401);
}
/**
* Return an access denied error response.
*/
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
{
return $this->errorResponse($message, 'ACCESS_DENIED', 403);
}
/**
* Return a no workspace error response.
*/
protected function noWorkspaceResponse(): JsonResponse
{
return $this->errorResponse(
'No workspace found. Please select a workspace first.',
'NO_WORKSPACE',
404
);
}
/**
* Return a validation / unprocessable entity error response.
*/
protected function unprocessableResponse(string $message, string $code = 'UNPROCESSABLE_ENTITY', array $extra = []): JsonResponse
{
return $this->errorResponse($message, $code, 422, $extra);
}
}

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Core\Tenant\Controllers\Api; namespace Core\Tenant\Controllers\Api;
use Core\Rules\SafeWebhookUrl; use Core\Rules\SafeWebhookUrl;
use Core\Tenant\Concerns\HasStandardApiResponses;
use Core\Tenant\Exceptions\InvalidWebhookUrlException; use Core\Tenant\Exceptions\InvalidWebhookUrlException;
use Core\Tenant\Models\EntitlementWebhook; use Core\Tenant\Models\EntitlementWebhook;
use Core\Tenant\Models\EntitlementWebhookDelivery; use Core\Tenant\Models\EntitlementWebhookDelivery;
@ -26,8 +25,6 @@ use Illuminate\Validation\Rule;
*/ */
class EntitlementWebhookController extends Controller class EntitlementWebhookController extends Controller
{ {
use HasStandardApiResponses;
public function __construct( public function __construct(
protected EntitlementWebhookService $webhookService protected EntitlementWebhookService $webhookService
) {} ) {}
@ -74,16 +71,16 @@ class EntitlementWebhookController extends Controller
metadata: $validated['metadata'] ?? [] metadata: $validated['metadata'] ?? []
); );
return $this->successResponse([ return response()->json([
'message' => __('Webhook created successfully'), 'message' => __('Webhook created successfully'),
'webhook' => $webhook, 'webhook' => $webhook,
'secret' => $webhook->secret, // Return secret on creation only 'secret' => $webhook->secret, // Return secret on creation only
], 201); ], 201);
} catch (InvalidWebhookUrlException $e) { } catch (InvalidWebhookUrlException $e) {
return $this->unprocessableResponse( return response()->json([
$e->getMessage(), 'message' => $e->getMessage(),
'INVALID_WEBHOOK_URL' 'error' => 'invalid_webhook_url',
); ], 422);
} }
} }
@ -97,7 +94,7 @@ class EntitlementWebhookController extends Controller
$webhook->loadCount('deliveries'); $webhook->loadCount('deliveries');
$webhook->load(['deliveries' => fn ($q) => $q->latest('created_at')->limit(10)]); $webhook->load(['deliveries' => fn ($q) => $q->latest('created_at')->limit(10)]);
return $this->successResponse([ return response()->json([
'webhook' => $webhook, 'webhook' => $webhook,
'available_events' => $this->webhookService->getAvailableEvents(), 'available_events' => $this->webhookService->getAvailableEvents(),
]); ]);
@ -123,15 +120,15 @@ class EntitlementWebhookController extends Controller
try { try {
$webhook = $this->webhookService->update($webhook, $validated); $webhook = $this->webhookService->update($webhook, $validated);
return $this->successResponse([ return response()->json([
'message' => __('Webhook updated successfully'), 'message' => __('Webhook updated successfully'),
'webhook' => $webhook, 'webhook' => $webhook,
]); ]);
} catch (InvalidWebhookUrlException $e) { } catch (InvalidWebhookUrlException $e) {
return $this->unprocessableResponse( return response()->json([
$e->getMessage(), 'message' => $e->getMessage(),
'INVALID_WEBHOOK_URL' 'error' => 'invalid_webhook_url',
); ], 422);
} }
} }
@ -144,7 +141,7 @@ class EntitlementWebhookController extends Controller
$this->webhookService->unregister($webhook); $this->webhookService->unregister($webhook);
return $this->successResponse([ return response()->json([
'message' => __('Webhook deleted successfully'), 'message' => __('Webhook deleted successfully'),
]); ]);
} }
@ -158,7 +155,7 @@ class EntitlementWebhookController extends Controller
$secret = $webhook->regenerateSecret(); $secret = $webhook->regenerateSecret();
return $this->successResponse([ return response()->json([
'message' => __('Secret regenerated successfully'), 'message' => __('Secret regenerated successfully'),
'secret' => $secret, 'secret' => $secret,
]); ]);
@ -177,17 +174,18 @@ class EntitlementWebhookController extends Controller
try { try {
$delivery = $this->webhookService->testWebhook($webhook); $delivery = $this->webhookService->testWebhook($webhook);
return $this->successResponse([ return response()->json([
'message' => $delivery->isSucceeded() 'message' => $delivery->isSucceeded()
? __('Test webhook sent successfully') ? __('Test webhook sent successfully')
: __('Test webhook failed'), : __('Test webhook failed'),
'delivery' => $delivery, 'delivery' => $delivery,
]); ]);
} catch (InvalidWebhookUrlException $e) { } catch (InvalidWebhookUrlException $e) {
return $this->unprocessableResponse( return response()->json([
$e->getMessage(), 'message' => $e->getMessage(),
'INVALID_WEBHOOK_URL' 'error' => 'invalid_webhook_url',
); 'reason' => $e->reason,
], 422);
} }
} }
@ -200,7 +198,7 @@ class EntitlementWebhookController extends Controller
$this->webhookService->resetCircuitBreaker($webhook); $this->webhookService->resetCircuitBreaker($webhook);
return $this->successResponse([ return response()->json([
'message' => __('Webhook re-enabled successfully'), 'message' => __('Webhook re-enabled successfully'),
'webhook' => $webhook->refresh(), 'webhook' => $webhook->refresh(),
]); ]);
@ -228,15 +226,14 @@ class EntitlementWebhookController extends Controller
$this->authorizeWebhook($request, $delivery->webhook); $this->authorizeWebhook($request, $delivery->webhook);
if ($delivery->isSucceeded()) { if ($delivery->isSucceeded()) {
return $this->unprocessableResponse( return response()->json([
__('Cannot retry a successful delivery'), 'message' => __('Cannot retry a successful delivery'),
'DELIVERY_ALREADY_SUCCEEDED' ], 422);
);
} }
$delivery = $this->webhookService->retryDelivery($delivery); $delivery = $this->webhookService->retryDelivery($delivery);
return $this->successResponse([ return response()->json([
'message' => $delivery->isSucceeded() 'message' => $delivery->isSucceeded()
? __('Delivery retried successfully') ? __('Delivery retried successfully')
: __('Delivery retry failed'), : __('Delivery retry failed'),

View file

@ -6,7 +6,6 @@ namespace Core\Tenant\Controllers;
use Core\Api\RateLimit\RateLimit; use Core\Api\RateLimit\RateLimit;
use Core\Front\Controller; use Core\Front\Controller;
use Core\Tenant\Concerns\HasStandardApiResponses;
use Core\Tenant\Models\EntitlementLog; use Core\Tenant\Models\EntitlementLog;
use Core\Tenant\Models\Package; use Core\Tenant\Models\Package;
use Core\Tenant\Models\User; use Core\Tenant\Models\User;
@ -44,8 +43,6 @@ use Illuminate\Support\Str;
#[RateLimit(limit: 60, window: 60, key: 'entitlement-api')] #[RateLimit(limit: 60, window: 60, key: 'entitlement-api')]
class EntitlementApiController extends Controller class EntitlementApiController extends Controller
{ {
use HasStandardApiResponses;
public function __construct( public function __construct(
protected EntitlementService $entitlements protected EntitlementService $entitlements
) {} ) {}
@ -92,11 +89,10 @@ class EntitlementApiController extends Controller
$package = Package::where('code', $validated['product_code'])->first(); $package = Package::where('code', $validated['product_code'])->first();
if (! $package) { if (! $package) {
return $this->errorResponse( return response()->json([
"Package '{$validated['product_code']}' not found", 'success' => false,
'PACKAGE_NOT_FOUND', 'error' => "Package '{$validated['product_code']}' not found",
404 ], 404);
);
} }
// Get or create the user's primary workspace // Get or create the user's primary workspace
@ -137,7 +133,8 @@ class EntitlementApiController extends Controller
] ]
); );
return $this->successResponse([ return response()->json([
'success' => true,
'entitlement_id' => $workspacePackage->id, 'entitlement_id' => $workspacePackage->id,
'workspace_id' => $workspace->id, 'workspace_id' => $workspace->id,
'workspace_slug' => $workspace->slug, 'workspace_slug' => $workspace->slug,
@ -154,7 +151,10 @@ class EntitlementApiController extends Controller
$workspacePackage = WorkspacePackage::find($id); $workspacePackage = WorkspacePackage::find($id);
if (! $workspacePackage) { if (! $workspacePackage) {
return $this->notFoundResponse('Entitlement'); return response()->json([
'success' => false,
'error' => 'Entitlement not found',
], 404);
} }
$workspace = $workspacePackage->workspace; $workspace = $workspacePackage->workspace;
@ -170,7 +170,8 @@ class EntitlementApiController extends Controller
$this->entitlements->invalidateCache($workspace); $this->entitlements->invalidateCache($workspace);
return $this->successResponse([ return response()->json([
'success' => true,
'entitlement_id' => $workspacePackage->id, 'entitlement_id' => $workspacePackage->id,
'status' => $workspacePackage->fresh()->status, 'status' => $workspacePackage->fresh()->status,
]); ]);
@ -184,7 +185,10 @@ class EntitlementApiController extends Controller
$workspacePackage = WorkspacePackage::find($id); $workspacePackage = WorkspacePackage::find($id);
if (! $workspacePackage) { if (! $workspacePackage) {
return $this->notFoundResponse('Entitlement'); return response()->json([
'success' => false,
'error' => 'Entitlement not found',
], 404);
} }
$workspace = $workspacePackage->workspace; $workspace = $workspacePackage->workspace;
@ -199,7 +203,8 @@ class EntitlementApiController extends Controller
$this->entitlements->invalidateCache($workspace); $this->entitlements->invalidateCache($workspace);
return $this->successResponse([ return response()->json([
'success' => true,
'entitlement_id' => $workspacePackage->id, 'entitlement_id' => $workspacePackage->id,
'status' => $workspacePackage->fresh()->status, 'status' => $workspacePackage->fresh()->status,
]); ]);
@ -213,7 +218,10 @@ class EntitlementApiController extends Controller
$workspacePackage = WorkspacePackage::find($id); $workspacePackage = WorkspacePackage::find($id);
if (! $workspacePackage) { if (! $workspacePackage) {
return $this->notFoundResponse('Entitlement'); return response()->json([
'success' => false,
'error' => 'Entitlement not found',
], 404);
} }
$workspace = $workspacePackage->workspace; $workspace = $workspacePackage->workspace;
@ -229,7 +237,8 @@ class EntitlementApiController extends Controller
$this->entitlements->invalidateCache($workspace); $this->entitlements->invalidateCache($workspace);
return $this->successResponse([ return response()->json([
'success' => true,
'entitlement_id' => $workspacePackage->id, 'entitlement_id' => $workspacePackage->id,
'status' => $workspacePackage->fresh()->status, 'status' => $workspacePackage->fresh()->status,
]); ]);
@ -248,7 +257,10 @@ class EntitlementApiController extends Controller
$workspacePackage = WorkspacePackage::find($id); $workspacePackage = WorkspacePackage::find($id);
if (! $workspacePackage) { if (! $workspacePackage) {
return $this->notFoundResponse('Entitlement'); return response()->json([
'success' => false,
'error' => 'Entitlement not found',
], 404);
} }
$workspace = $workspacePackage->workspace; $workspace = $workspacePackage->workspace;
@ -279,7 +291,8 @@ class EntitlementApiController extends Controller
$this->entitlements->invalidateCache($workspace); $this->entitlements->invalidateCache($workspace);
return $this->successResponse([ return response()->json([
'success' => true,
'entitlement_id' => $workspacePackage->id, 'entitlement_id' => $workspacePackage->id,
'status' => $workspacePackage->fresh()->status, 'status' => $workspacePackage->fresh()->status,
'expires_at' => $workspacePackage->fresh()->expires_at?->toIso8601String(), 'expires_at' => $workspacePackage->fresh()->expires_at?->toIso8601String(),
@ -294,10 +307,14 @@ class EntitlementApiController extends Controller
$workspacePackage = WorkspacePackage::with(['package', 'workspace'])->find($id); $workspacePackage = WorkspacePackage::with(['package', 'workspace'])->find($id);
if (! $workspacePackage) { if (! $workspacePackage) {
return $this->notFoundResponse('Entitlement'); return response()->json([
'success' => false,
'error' => 'Entitlement not found',
], 404);
} }
return $this->successResponse([ return response()->json([
'success' => true,
'entitlement' => [ 'entitlement' => [
'id' => $workspacePackage->id, 'id' => $workspacePackage->id,
'workspace_id' => $workspacePackage->workspace_id, 'workspace_id' => $workspacePackage->workspace_id,
@ -339,20 +356,22 @@ class EntitlementApiController extends Controller
$user = User::where('email', $validated['email'])->first(); $user = User::where('email', $validated['email'])->first();
if (! $user) { if (! $user) {
return $this->errorResponse('User not found', 'USER_NOT_FOUND', 404, [ return response()->json([
'allowed' => false, 'allowed' => false,
'reason' => 'User not found',
'feature_code' => $validated['feature'], 'feature_code' => $validated['feature'],
]); ], 404);
} }
// Get user's primary workspace // Get user's primary workspace
$workspace = $user->defaultHostWorkspace(); $workspace = $user->defaultHostWorkspace();
if (! $workspace) { if (! $workspace) {
return $this->errorResponse('No workspace found for user', 'NO_WORKSPACE', 404, [ return response()->json([
'allowed' => false, 'allowed' => false,
'reason' => 'No workspace found for user',
'feature_code' => $validated['feature'], 'feature_code' => $validated['feature'],
]); ], 404);
} }
// Check entitlement // Check entitlement
@ -392,14 +411,20 @@ class EntitlementApiController extends Controller
$user = User::where('email', $validated['email'])->first(); $user = User::where('email', $validated['email'])->first();
if (! $user) { if (! $user) {
return $this->errorResponse('User not found', 'USER_NOT_FOUND', 404); return response()->json([
'success' => false,
'error' => 'User not found',
], 404);
} }
// Get user's primary workspace // Get user's primary workspace
$workspace = $user->defaultHostWorkspace(); $workspace = $user->defaultHostWorkspace();
if (! $workspace) { if (! $workspace) {
return $this->errorResponse('No workspace found for user', 'NO_WORKSPACE', 404); return response()->json([
'success' => false,
'error' => 'No workspace found for user',
], 404);
} }
// Record usage // Record usage
@ -411,7 +436,8 @@ class EntitlementApiController extends Controller
$validated['metadata'] ?? null $validated['metadata'] ?? null
); );
return $this->successResponse([ return response()->json([
'success' => true,
'usage_record_id' => $record->id, 'usage_record_id' => $record->id,
'feature_code' => $validated['feature'], 'feature_code' => $validated['feature'],
'quantity' => $validated['quantity'] ?? 1, 'quantity' => $validated['quantity'] ?? 1,
@ -474,13 +500,17 @@ class EntitlementApiController extends Controller
$user = $request->user(); $user = $request->user();
if (! $user) { if (! $user) {
return $this->unauthenticatedResponse(); return response()->json([
'error' => 'Unauthenticated',
], 401);
} }
$workspace = $user->defaultHostWorkspace(); $workspace = $user->defaultHostWorkspace();
if (! $workspace) { if (! $workspace) {
return $this->errorResponse('No workspace found', 'NO_WORKSPACE', 404); return response()->json([
'error' => 'No workspace found',
], 404);
} }
return $this->summary($request, $workspace); return $this->summary($request, $workspace);

View file

@ -5,13 +5,13 @@ declare(strict_types=1);
namespace Core\Tenant\Controllers; namespace Core\Tenant\Controllers;
use Core\Front\Controller; use Core\Front\Controller;
use Core\Tenant\Concerns\HasStandardApiResponses;
use Core\Tenant\Models\User; use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace; use Core\Tenant\Models\Workspace;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Mod\Api\Controllers\Concerns\HasApiResponses;
use Mod\Api\Controllers\Concerns\ResolvesWorkspace; use Mod\Api\Controllers\Concerns\ResolvesWorkspace;
use Mod\Api\Resources\PaginatedCollection; use Mod\Api\Resources\PaginatedCollection;
use Mod\Api\Resources\WorkspaceResource; use Mod\Api\Resources\WorkspaceResource;
@ -24,7 +24,7 @@ use Mod\Api\Resources\WorkspaceResource;
*/ */
class WorkspaceController extends Controller class WorkspaceController extends Controller
{ {
use HasStandardApiResponses; use HasApiResponses;
use ResolvesWorkspace; use ResolvesWorkspace;
/** /**
@ -219,10 +219,10 @@ class WorkspaceController extends Controller
// Prevent deleting user's only workspace // Prevent deleting user's only workspace
$workspaceCount = $user->workspaces()->count(); $workspaceCount = $user->workspaces()->count();
if ($workspaceCount <= 1) { if ($workspaceCount <= 1) {
return $this->unprocessableResponse( return response()->json([
'You cannot delete your only workspace.', 'error' => 'cannot_delete',
'CANNOT_DELETE_LAST_WORKSPACE' 'message' => 'You cannot delete your only workspace.',
); ], 422);
} }
$workspace->delete(); $workspace->delete();

View file

@ -118,10 +118,8 @@ describe('Cross-App Entitlement API', function () {
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false,
'error' => 'User not found',
'code' => 'USER_NOT_FOUND',
'allowed' => false, 'allowed' => false,
'reason' => 'User not found',
]); ]);
}); });
@ -133,10 +131,8 @@ describe('Cross-App Entitlement API', function () {
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false,
'error' => 'No workspace found for user',
'code' => 'NO_WORKSPACE',
'allowed' => false, 'allowed' => false,
'reason' => 'No workspace found for user',
]); ]);
}); });
@ -270,7 +266,6 @@ describe('Cross-App Entitlement API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'User not found', 'error' => 'User not found',
'code' => 'USER_NOT_FOUND',
]); ]);
}); });
@ -287,7 +282,6 @@ describe('Cross-App Entitlement API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'No workspace found for user', 'error' => 'No workspace found for user',
'code' => 'NO_WORKSPACE',
]); ]);
}); });
@ -362,9 +356,7 @@ describe('Cross-App Entitlement API', function () {
$response->assertStatus(404) $response->assertStatus(404)
->assertJson([ ->assertJson([
'success' => false,
'error' => 'No workspace found', 'error' => 'No workspace found',
'code' => 'NO_WORKSPACE',
]); ]);
}); });
@ -514,7 +506,6 @@ describe('Blesta Provisioning API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => "Package 'nonexistent-package' not found", 'error' => "Package 'nonexistent-package' not found",
'code' => 'PACKAGE_NOT_FOUND',
]); ]);
}); });
@ -643,7 +634,6 @@ describe('Blesta Provisioning API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'Entitlement not found', 'error' => 'Entitlement not found',
'code' => 'NOT_FOUND',
]); ]);
}); });
@ -708,7 +698,6 @@ describe('Blesta Provisioning API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'Entitlement not found', 'error' => 'Entitlement not found',
'code' => 'NOT_FOUND',
]); ]);
}); });
@ -793,7 +782,6 @@ describe('Blesta Provisioning API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'Entitlement not found', 'error' => 'Entitlement not found',
'code' => 'NOT_FOUND',
]); ]);
}); });
@ -864,7 +852,6 @@ describe('Blesta Provisioning API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'Entitlement not found', 'error' => 'Entitlement not found',
'code' => 'NOT_FOUND',
]); ]);
}); });
@ -948,7 +935,6 @@ describe('Blesta Provisioning API', function () {
->assertJson([ ->assertJson([
'success' => false, 'success' => false,
'error' => 'Entitlement not found', 'error' => 'Entitlement not found',
'code' => 'NOT_FOUND',
]); ]);
}); });
@ -1077,7 +1063,6 @@ describe('Error Response Format', function () {
->assertJsonStructure([ ->assertJsonStructure([
'success', 'success',
'error', 'error',
'code',
]); ]);
}); });