$errors]); } public static function validationFromFormErrors(FormErrors $errors): never { self::validationError($errors->toArray()); } public static function tooManyRequests(int $retryAfter = 60): never { $retryAfter = max(1, $retryAfter); self::send( self::buildErrorBody('rate_limit_exceeded', ['retry_after' => $retryAfter]), 429, 'rate_limit_exceeded', ['Retry-After: ' . $retryAfter] ); } /** * Transform a service result (['ok' => bool, ...] pattern) into an API response. */ public static function fromServiceResult(array $result, int $successStatus = 200): never { if ($result['ok'] ?? false) { $data = $result; unset($data['ok']); self::success($data, $successStatus); } $status = (int) ($result['status'] ?? 0); if ($status < 400) { $status = 422; } $error = (string) ($result['error'] ?? 'operation_failed'); $errors = $result['errors'] ?? []; $extra = []; if ($errors) { $extra['errors'] = $errors; } self::error($error, $status, $extra); } /** * Read JSON request body into an array. */ public static function readJsonBody(): array { $raw = file_get_contents('php://input'); if ($raw === false || $raw === '') { return []; } $data = json_decode($raw, true); return is_array($data) ? $data : []; } /** * Require specific HTTP method(s). */ public static function requireMethod(string ...$methods): void { $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); if (!in_array($method, $methods, true)) { self::methodNotAllowed(); } } /** * Require API authentication. */ public static function requireAuth(): void { if (!ApiAuth::isAuthenticated()) { self::unauthorized(); } } public static function requireAbility(string $ability, array $context = []): void { self::requireAuth(); $decision = self::authorizationService()->authorize($ability, [ 'actor_user_id' => ApiAuth::userId(), 'scoped_tenant_id' => ApiAuth::scopedTenantId(), ...$context, ]); if (!$decision->isAllowed()) { self::forbidden(); } } private static function send(?array $body, int $status, ?string $errorCode = null, array $headers = []): never { $requestId = self::requestId(); http_response_code($status); header('X-Request-Id: ' . $requestId); foreach ($headers as $headerLine) { header($headerLine); } if ($body === null) { self::finishSystemAuditReporter($status, $errorCode); self::apiAuditService()->finish($status, $errorCode); die(); } if (!array_key_exists('request_id', $body)) { $body['request_id'] = $requestId; } $json = json_encode($body, JSON_UNESCAPED_UNICODE); if (!is_string($json)) { $status = 500; http_response_code($status); $errorCode = $errorCode ?: 'serialization_error'; $fallbackBody = self::buildErrorBody('serialization_error'); $fallbackBody['request_id'] = $requestId; $json = json_encode($fallbackBody, JSON_UNESCAPED_UNICODE) ?: '{"ok":false,"error_code":"serialization_error","request_id":"unknown","details":{}}'; } header('Content-Type: application/json; charset=utf-8'); self::finishSystemAuditReporter($status, $errorCode); self::apiAuditService()->finish($status, $errorCode); die($json); } private static function requestId(): string { $requestId = RequestContext::currentId(); if (is_string($requestId) && trim($requestId) !== '') { return trim($requestId); } $requestId = self::apiAuditService()->currentRequestId(); if (is_string($requestId) && trim($requestId) !== '') { return trim($requestId); } return RequestContext::id(); } private static function buildErrorBody(string $errorCode, array $details = []): array { $normalizedErrorCode = trim($errorCode); if ($normalizedErrorCode === '') { $normalizedErrorCode = 'unknown_error'; } $normalizedDetails = self::normalizeDetails($details); return [ 'ok' => false, 'error_code' => $normalizedErrorCode, 'details' => $normalizedDetails, ]; } private static function normalizeDetails(array $details): array|\stdClass { if ($details === []) { return new \stdClass(); } if (array_is_list($details)) { return ['items' => $details]; } return $details; } private static function apiAuditService(): ApiAuditService { if (!is_callable(self::$apiAuditServiceResolver)) { throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiAuditService::class); } $service = (self::$apiAuditServiceResolver)(); if (!$service instanceof ApiAuditService) { throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiAuditService::class); } return $service; } private static function authorizationService(): AuthorizationService { if (!is_callable(self::$authorizationServiceResolver)) { throw new \RuntimeException('ApiResponse is not configured for dependency: ' . AuthorizationService::class); } $service = (self::$authorizationServiceResolver)(); if (!$service instanceof AuthorizationService) { throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . AuthorizationService::class); } return $service; } private static function systemAuditReporter(): ApiSystemAuditReporter { if (!is_callable(self::$apiSystemAuditReporterResolver)) { throw new \RuntimeException('ApiResponse is not configured for dependency: ' . ApiSystemAuditReporter::class); } $service = (self::$apiSystemAuditReporterResolver)(); if (!$service instanceof ApiSystemAuditReporter) { throw new \RuntimeException('ApiResponse resolver returned invalid dependency: ' . ApiSystemAuditReporter::class); } return $service; } private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void { try { self::systemAuditReporter()->finish($statusCode, $errorCode); } catch (\Throwable) { // fail-open } } }