feat(api): standardize agent-facing response envelopes

This commit is contained in:
Virgil 2026-03-30 05:52:06 +00:00
parent ca9b495884
commit ee3fba1e7a
8 changed files with 182 additions and 117 deletions

View file

@ -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,
);
}
}

View file

@ -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([

View file

@ -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();

View file

@ -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,
);
}
}

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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,
],
);
}
}

View file

@ -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);