refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
297
core/Http/ApiResponse.php
Normal file
297
core/Http/ApiResponse.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Http\Input\FormErrors;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
|
||||
class ApiResponse
|
||||
{
|
||||
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): AuthorizationService)|null */
|
||||
private static $authorizationServiceResolver = null;
|
||||
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
|
||||
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::tryFinishAudit($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::tryFinishAudit($status, $errorCode);
|
||||
die($json);
|
||||
}
|
||||
|
||||
private static function requestId(): string
|
||||
{
|
||||
$requestId = RequestContext::currentId();
|
||||
if (is_string($requestId) && trim($requestId) !== '') {
|
||||
return trim($requestId);
|
||||
}
|
||||
|
||||
$requestId = self::tryGetAuditRequestId();
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
// Empty details must be stdClass so json_encode produces {} instead of [].
|
||||
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 tryFinishAudit(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryGetAuditRequestId(): ?string
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'currentRequestId')) {
|
||||
return $service->currentRequestId();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user