Files
breadcrumb-the-shire/lib/Http/ApiResponse.php
2026-03-05 11:17:42 +01:00

292 lines
8.9 KiB
PHP

<?php
namespace MintyPHP\Http;
use MintyPHP\Http\Input\FormErrors;
use MintyPHP\Service\Access\AuthorizationService;
use MintyPHP\Service\Audit\ApiAuditService;
class ApiResponse
{
/** @var (callable(): ApiAuditService)|null */
private static $apiAuditServiceResolver = null;
/** @var (callable(): AuthorizationService)|null */
private static $authorizationServiceResolver = null;
/** @var (callable(): ApiSystemAuditReporter)|null */
private static $apiSystemAuditReporterResolver = null;
public static function configure(
callable $apiAuditServiceResolver,
callable $authorizationServiceResolver,
callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
self::$authorizationServiceResolver = $authorizationServiceResolver;
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
}
public static function success(array $data = [], int $status = 200): never
{
self::send($data, $status);
}
public static function created(array $data = []): never
{
self::success($data, 201);
}
public static function noContent(): never
{
self::send(null, 204);
}
public static function error(string $error, int $status = 400, array $extra = []): never
{
$body = self::buildErrorBody($error, $extra);
self::send($body, $status, $error);
}
public static function unauthorized(): never
{
self::error('unauthorized', 401);
}
public static function forbidden(string $detail = 'forbidden'): never
{
self::error($detail, 403);
}
public static function notFound(string $detail = 'not_found'): never
{
self::error($detail, 404);
}
public static function methodNotAllowed(): never
{
self::error('method_not_allowed', 405);
}
public static function validationError(array $errors): never
{
self::error('validation_error', 422, ['errors' => $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
}
}
}