diff --git a/src/php/src/Api/Concerns/HasApiResponses.php b/src/php/src/Api/Concerns/HasApiResponses.php index 3ab973b..4a47c75 100644 --- a/src/php/src/Api/Concerns/HasApiResponses.php +++ b/src/php/src/Api/Concerns/HasApiResponses.php @@ -11,15 +11,33 @@ use Illuminate\Http\JsonResponse; */ trait HasApiResponses { + /** + * Return a standard error response. + */ + protected function errorResponse( + string $errorCode, + string $message, + array $meta = [], + int $status = 400, + ): JsonResponse { + return response()->json(array_merge([ + 'success' => false, + 'error' => $errorCode, + 'message' => $message, + 'error_code' => $errorCode, + ], $meta), $status); + } + /** * Return a no workspace response. */ protected function noWorkspaceResponse(): JsonResponse { - return response()->json([ - 'error' => 'no_workspace', - 'message' => 'No workspace found. Please select a workspace first.', - ], 404); + return $this->errorResponse( + errorCode: 'no_workspace', + message: 'No workspace found. Please select a workspace first.', + status: 404, + ); } /** @@ -27,10 +45,14 @@ trait HasApiResponses */ protected function notFoundResponse(string $resource = 'Resource'): JsonResponse { - return response()->json([ - 'error' => 'not_found', - 'message' => "{$resource} not found.", - ], 404); + return $this->errorResponse( + errorCode: 'not_found', + message: "{$resource} not found.", + meta: [ + 'resource' => $resource, + ], + status: 404, + ); } /** @@ -38,12 +60,15 @@ trait HasApiResponses */ protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse { - return response()->json([ - 'error' => 'feature_limit_reached', - 'message' => $message ?? 'You have reached your limit for this feature.', - 'feature' => $feature, - 'upgrade_url' => route('hub.usage'), - ], 403); + return $this->errorResponse( + errorCode: 'feature_limit_reached', + message: $message ?? 'You have reached your limit for this feature.', + meta: [ + 'feature' => $feature, + 'upgrade_url' => route('hub.usage'), + ], + status: 403, + ); } /** @@ -51,10 +76,20 @@ trait HasApiResponses */ protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse { - return response()->json([ - 'error' => 'access_denied', - 'message' => $message, - ], 403); + return $this->forbiddenResponse($message, status: 403); + } + + /** + * Return a forbidden response. + */ + protected function forbiddenResponse(string $message, array $meta = [], int $status = 403): JsonResponse + { + return $this->errorResponse( + errorCode: 'forbidden', + message: $message, + meta: $meta, + status: $status, + ); } /** @@ -63,6 +98,7 @@ trait HasApiResponses protected function successResponse(string $message, array $data = []): JsonResponse { return response()->json(array_merge([ + 'success' => true, 'message' => $message, ], $data)); } @@ -73,6 +109,7 @@ trait HasApiResponses protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse { return response()->json([ + 'success' => true, 'message' => $message, 'data' => $resource, ], 201); @@ -81,13 +118,16 @@ trait HasApiResponses /** * Return a validation error response. */ - protected function validationErrorResponse(array $errors): JsonResponse + protected function validationErrorResponse(array $errors, int $status = 422): JsonResponse { - return response()->json([ - 'error' => 'validation_failed', - 'message' => 'The given data was invalid.', - 'errors' => $errors, - ], 422); + return $this->errorResponse( + errorCode: 'validation_failed', + message: 'The given data was invalid.', + meta: [ + 'errors' => $errors, + ], + status: $status, + ); } /** @@ -97,10 +137,11 @@ trait HasApiResponses */ protected function invalidStatusResponse(string $message): JsonResponse { - return response()->json([ - 'error' => 'invalid_status', - 'message' => $message, - ], 422); + return $this->errorResponse( + errorCode: 'invalid_status', + message: $message, + status: 422, + ); } /** @@ -110,15 +151,13 @@ trait HasApiResponses */ protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse { - $response = [ - 'error' => 'provider_error', - 'message' => $message, - ]; - - if ($provider !== null) { - $response['provider'] = $provider; - } - - return response()->json($response, 400); + return $this->errorResponse( + errorCode: 'provider_error', + message: $message, + meta: array_filter([ + 'provider' => $provider, + ]), + status: 400, + ); } } diff --git a/src/php/src/Api/Controllers/Api/WebhookSecretController.php b/src/php/src/Api/Controllers/Api/WebhookSecretController.php index 5dee8c1..e5284be 100644 --- a/src/php/src/Api/Controllers/Api/WebhookSecretController.php +++ b/src/php/src/Api/Controllers/Api/WebhookSecretController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Api\Controllers\Api; +use Core\Api\Concerns\HasApiResponses; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -16,6 +17,8 @@ use Core\Social\Models\Webhook; */ class WebhookSecretController extends Controller { + use HasApiResponses; + public function __construct( protected WebhookSecretRotationService $rotationService ) {} @@ -28,7 +31,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $webhook = Webhook::where('workspace_id', $workspace->id) @@ -36,7 +39,7 @@ class WebhookSecretController extends Controller ->first(); if (! $webhook) { - return response()->json(['error' => 'Webhook not found'], 404); + return $this->notFoundResponse('Webhook'); } $validated = $request->validate([ @@ -66,7 +69,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) @@ -74,7 +77,7 @@ class WebhookSecretController extends Controller ->first(); if (! $endpoint) { - return response()->json(['error' => 'Webhook endpoint not found'], 404); + return $this->notFoundResponse('Webhook endpoint'); } $validated = $request->validate([ @@ -104,7 +107,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $webhook = Webhook::where('workspace_id', $workspace->id) @@ -112,7 +115,7 @@ class WebhookSecretController extends Controller ->first(); if (! $webhook) { - return response()->json(['error' => 'Webhook not found'], 404); + return $this->notFoundResponse('Webhook'); } return response()->json([ @@ -128,7 +131,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) @@ -136,7 +139,7 @@ class WebhookSecretController extends Controller ->first(); if (! $endpoint) { - return response()->json(['error' => 'Webhook endpoint not found'], 404); + return $this->notFoundResponse('Webhook endpoint'); } return response()->json([ @@ -152,7 +155,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $webhook = Webhook::where('workspace_id', $workspace->id) @@ -160,7 +163,7 @@ class WebhookSecretController extends Controller ->first(); if (! $webhook) { - return response()->json(['error' => 'Webhook not found'], 404); + return $this->notFoundResponse('Webhook'); } $this->rotationService->invalidatePreviousSecret($webhook); @@ -179,7 +182,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) @@ -187,7 +190,7 @@ class WebhookSecretController extends Controller ->first(); if (! $endpoint) { - return response()->json(['error' => 'Webhook endpoint not found'], 404); + return $this->notFoundResponse('Webhook endpoint'); } $this->rotationService->invalidatePreviousSecret($endpoint); @@ -206,7 +209,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $webhook = Webhook::where('workspace_id', $workspace->id) @@ -214,7 +217,7 @@ class WebhookSecretController extends Controller ->first(); if (! $webhook) { - return response()->json(['error' => 'Webhook not found'], 404); + return $this->notFoundResponse('Webhook'); } $validated = $request->validate([ @@ -240,7 +243,7 @@ class WebhookSecretController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) @@ -248,7 +251,7 @@ class WebhookSecretController extends Controller ->first(); if (! $endpoint) { - return response()->json(['error' => 'Webhook endpoint not found'], 404); + return $this->notFoundResponse('Webhook endpoint'); } $validated = $request->validate([ diff --git a/src/php/src/Api/Controllers/Api/WebhookTemplateController.php b/src/php/src/Api/Controllers/Api/WebhookTemplateController.php index d9f20eb..9078fed 100644 --- a/src/php/src/Api/Controllers/Api/WebhookTemplateController.php +++ b/src/php/src/Api/Controllers/Api/WebhookTemplateController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Api\Controllers\Api; +use Core\Api\Concerns\HasApiResponses; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -17,6 +18,8 @@ use Core\Api\Services\WebhookTemplateService; */ class WebhookTemplateController extends Controller { + use HasApiResponses; + public function __construct( protected WebhookTemplateService $templateService ) {} @@ -29,7 +32,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $query = WebhookPayloadTemplate::where('workspace_id', $workspace->id) @@ -61,7 +64,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) @@ -69,7 +72,7 @@ class WebhookTemplateController extends Controller ->first(); if (! $template) { - return response()->json(['error' => 'Template not found'], 404); + return $this->notFoundResponse('Template'); } return response()->json([ @@ -85,7 +88,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $validated = $request->validate([ @@ -102,10 +105,9 @@ class WebhookTemplateController extends Controller $validation = $this->templateService->validateTemplate($validated['template'], $format); if (! $validation['valid']) { - return response()->json([ - 'error' => 'Invalid template', - 'errors' => $validation['errors'], - ], 422); + return $this->validationErrorResponse([ + 'template' => $validation['errors'], + ]); } $template = WebhookPayloadTemplate::create([ @@ -133,7 +135,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) @@ -141,7 +143,7 @@ class WebhookTemplateController extends Controller ->first(); if (! $template) { - return response()->json(['error' => 'Template not found'], 404); + return $this->notFoundResponse('Template'); } $validated = $request->validate([ @@ -159,10 +161,9 @@ class WebhookTemplateController extends Controller $validation = $this->templateService->validateTemplate($validated['template'], $format); if (! $validation['valid']) { - return response()->json([ - 'error' => 'Invalid template', - 'errors' => $validation['errors'], - ], 422); + return $this->validationErrorResponse([ + 'template' => $validation['errors'], + ]); } } @@ -186,7 +187,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) @@ -194,12 +195,12 @@ class WebhookTemplateController extends Controller ->first(); if (! $template) { - return response()->json(['error' => 'Template not found'], 404); + return $this->notFoundResponse('Template'); } // Don't allow deleting builtin templates if ($template->isBuiltin()) { - return response()->json(['error' => 'Built-in templates cannot be deleted'], 403); + return $this->forbiddenResponse('Built-in templates cannot be deleted'); } $template->delete(); @@ -255,7 +256,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) @@ -263,7 +264,7 @@ class WebhookTemplateController extends Controller ->first(); if (! $template) { - return response()->json(['error' => 'Template not found'], 404); + return $this->notFoundResponse('Template'); } $newName = $request->input('name', $template->name.' (copy)'); @@ -282,7 +283,7 @@ class WebhookTemplateController extends Controller $workspace = $request->user()?->defaultHostWorkspace(); if (! $workspace) { - return response()->json(['error' => 'Workspace not found'], 404); + return $this->noWorkspaceResponse(); } $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) @@ -290,7 +291,7 @@ class WebhookTemplateController extends Controller ->first(); if (! $template) { - return response()->json(['error' => 'Template not found'], 404); + return $this->notFoundResponse('Template'); } $template->setAsDefault(); diff --git a/src/php/src/Api/Controllers/McpApiController.php b/src/php/src/Api/Controllers/McpApiController.php index 828e85b..8070188 100644 --- a/src/php/src/Api/Controllers/McpApiController.php +++ b/src/php/src/Api/Controllers/McpApiController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Core\Api\Controllers; use Core\Front\Controller; +use Core\Api\Concerns\HasApiResponses; use Core\Api\Models\ApiKey; use Core\Mod\Mcp\Models\McpApiRequest; use Core\Mod\Mcp\Models\McpToolCall; @@ -23,6 +24,8 @@ use Symfony\Component\Yaml\Yaml; */ class McpApiController extends Controller { + use HasApiResponses; + /** * List all available MCP servers. * @@ -53,7 +56,7 @@ class McpApiController extends Controller $server = $this->loadServerFull($id); if (! $server) { - return response()->json(['error' => 'Server not found'], 404); + return $this->notFoundResponse('Server'); } return response()->json($server); @@ -72,7 +75,7 @@ class McpApiController extends Controller $server = $this->loadServerFull($id); if (! $server) { - return response()->json(['error' => 'Server not found'], 404); + return $this->notFoundResponse('Server'); } $tools = $server['tools'] ?? []; @@ -129,13 +132,13 @@ class McpApiController extends Controller $server = $this->loadServerFull($validated['server']); if (! $server) { - return response()->json(['error' => 'Server not found'], 404); + return $this->notFoundResponse('Server'); } // Verify tool exists in server definition $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); if (! $toolDef) { - return response()->json(['error' => 'Tool not found'], 404); + return $this->notFoundResponse('Tool'); } // Version resolution @@ -178,15 +181,17 @@ class McpApiController extends Controller ); if (! empty($validationErrors)) { - return response()->json([ - 'success' => false, - 'error' => 'Validation failed', - 'error_code' => 'VALIDATION_ERROR', - 'validation_errors' => $validationErrors, - 'server' => $validated['server'], - 'tool' => $validated['tool'], - 'version' => $toolVersion?->version ?? 'unversioned', - ], 422); + return $this->errorResponse( + errorCode: 'VALIDATION_ERROR', + message: 'Validation failed', + meta: [ + 'validation_errors' => $validationErrors, + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? 'unversioned', + ], + status: 422, + ); } } @@ -343,13 +348,13 @@ class McpApiController extends Controller { $serverConfig = $this->loadServerFull($server); if (! $serverConfig) { - return response()->json(['error' => 'Server not found'], 404); + return $this->notFoundResponse('Server'); } // Verify tool exists in server definition $toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool); if (! $toolDef) { - return response()->json(['error' => 'Tool not found'], 404); + return $this->notFoundResponse('Tool'); } $versionService = app(ToolVersionService::class); @@ -374,7 +379,7 @@ class McpApiController extends Controller $toolVersion = $versionService->getToolAtVersion($server, $tool, $version); if (! $toolVersion) { - return response()->json(['error' => 'Version not found'], 404); + return $this->notFoundResponse('Version'); } $response = response()->json($toolVersion->toApiArray()); @@ -399,7 +404,9 @@ class McpApiController extends Controller { // Parse URI format: server://resource/path if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) { - return response()->json(['error' => 'Invalid resource URI format'], 400); + return $this->validationErrorResponse([ + 'uri' => ['Invalid resource URI format. Expected pattern server://resource/path'], + ], 400); } $serverId = $matches[1]; @@ -407,7 +414,7 @@ class McpApiController extends Controller $server = $this->loadServerFull($serverId); if (! $server) { - return response()->json(['error' => 'Server not found'], 404); + return $this->notFoundResponse('Server'); } try { @@ -418,10 +425,14 @@ class McpApiController extends Controller 'content' => $result, ]); } catch (\Throwable $e) { - return response()->json([ - 'error' => $e->getMessage(), - 'uri' => $uri, - ], 500); + return $this->errorResponse( + errorCode: 'resource_read_error', + message: $e->getMessage(), + meta: [ + 'uri' => $uri, + ], + status: 500, + ); } } diff --git a/src/php/src/Api/Exceptions/RateLimitExceededException.php b/src/php/src/Api/Exceptions/RateLimitExceededException.php index cad4e41..30b3b0e 100644 --- a/src/php/src/Api/Exceptions/RateLimitExceededException.php +++ b/src/php/src/Api/Exceptions/RateLimitExceededException.php @@ -36,7 +36,9 @@ class RateLimitExceededException extends HttpException public function render(): JsonResponse { return response()->json([ + 'success' => false, 'error' => 'rate_limit_exceeded', + 'error_code' => 'rate_limit_exceeded', 'message' => $this->getMessage(), 'retry_after' => $this->rateLimitResult->retryAfter, 'limit' => $this->rateLimitResult->limit, diff --git a/src/php/src/Api/Middleware/AuthenticateApiKey.php b/src/php/src/Api/Middleware/AuthenticateApiKey.php index 40b6fe9..839ed4e 100644 --- a/src/php/src/Api/Middleware/AuthenticateApiKey.php +++ b/src/php/src/Api/Middleware/AuthenticateApiKey.php @@ -6,6 +6,7 @@ namespace Core\Api\Middleware; use Core\Api\Models\ApiKey; use Core\Api\Services\IpRestrictionService; +use Core\Api\Concerns\HasApiResponses; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -24,6 +25,8 @@ use Symfony\Component\HttpFoundation\Response; */ class AuthenticateApiKey { + use HasApiResponses; + public function handle(Request $request, Closure $next, ?string $scope = null): Response { $token = $request->bearerToken(); @@ -117,10 +120,11 @@ class AuthenticateApiKey */ protected function unauthorized(string $message): Response { - return response()->json([ - 'error' => 'unauthorized', - 'message' => $message, - ], 401); + return $this->errorResponse( + errorCode: 'unauthorized', + message: $message, + status: 401, + ); } /** @@ -128,9 +132,6 @@ class AuthenticateApiKey */ protected function forbidden(string $message): Response { - return response()->json([ - 'error' => 'forbidden', - 'message' => $message, - ], 403); + return $this->forbiddenResponse($message, status: 403); } } diff --git a/src/php/src/Api/Middleware/CheckApiScope.php b/src/php/src/Api/Middleware/CheckApiScope.php index 32aeec0..614bb9c 100644 --- a/src/php/src/Api/Middleware/CheckApiScope.php +++ b/src/php/src/Api/Middleware/CheckApiScope.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Core\Api\Middleware; use Core\Api\Models\ApiKey; +use Core\Api\Concerns\HasApiResponses; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response; */ class CheckApiScope { + use HasApiResponses; + public function handle(Request $request, Closure $next, string ...$scopes): Response { $apiKey = $request->attributes->get('api_key'); @@ -38,12 +41,13 @@ class CheckApiScope // Check all required scopes foreach ($scopes as $scope) { if (! $apiKey->hasScope($scope)) { - return response()->json([ - 'error' => 'forbidden', - 'message' => "API key missing required scope: {$scope}", - 'required_scopes' => $scopes, - 'key_scopes' => $apiKey->scopes, - ], 403); + return $this->forbiddenResponse( + message: "API key missing required scope: {$scope}", + meta: [ + 'required_scopes' => $scopes, + 'key_scopes' => $apiKey->scopes, + ], + ); } } diff --git a/src/php/src/Api/Middleware/EnforceApiScope.php b/src/php/src/Api/Middleware/EnforceApiScope.php index 958e3fa..9b83495 100644 --- a/src/php/src/Api/Middleware/EnforceApiScope.php +++ b/src/php/src/Api/Middleware/EnforceApiScope.php @@ -6,6 +6,7 @@ namespace Core\Api\Middleware; use Closure; use Core\Api\Models\ApiKey; +use Core\Api\Concerns\HasApiResponses; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response; */ class EnforceApiScope { + use HasApiResponses; + /** * HTTP method to required scope mapping. */ @@ -52,12 +55,13 @@ class EnforceApiScope $requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ; if (! $apiKey->hasScope($requiredScope)) { - return response()->json([ - 'error' => 'forbidden', - 'message' => "API key missing required scope: {$requiredScope}", - 'detail' => "{$method} requests require '{$requiredScope}' scope", - 'key_scopes' => $apiKey->scopes, - ], 403); + return $this->forbiddenResponse( + message: "API key missing required scope: {$requiredScope}", + meta: [ + 'detail' => "{$method} requests require '{$requiredScope}' scope", + 'key_scopes' => $apiKey->scopes, + ], + ); } return $next($request);