Files
breadcrumb-the-shire/lib/Http/RequestContext.php
2026-03-06 00:44:52 +01:00

234 lines
6.7 KiB
PHP

<?php
namespace MintyPHP\Http;
use MintyPHP\Repository\Support\RepoQuery;
// Holds per-request metadata (id, channel, method, path, ip) as a static singleton.
// Must be initialized once at the entry point (web, api, cli) via start().
final class RequestContext
{
/** @var array<string, mixed>|null */
private static ?array $context = null;
public static function start(array $overrides = []): void
{
if (self::$context === null) {
self::$context = self::buildDefaultContext();
}
self::applyOverrides($overrides);
}
public static function ensureStarted(): void
{
if (self::$context !== null) {
return;
}
self::$context = self::buildDefaultContext();
}
// Returns the request ID, generating one if missing. Use this when you need a guaranteed value.
public static function id(): string
{
self::ensureStarted();
$requestId = trim((string) (self::$context['request_id'] ?? ''));
if ($requestId === '') {
$requestId = RepoQuery::uuidV4();
self::$context['request_id'] = $requestId;
}
return $requestId;
}
// Returns the ID only if already set — safe to call before start() (e.g. in error handlers).
public static function currentId(): ?string
{
if (self::$context === null) {
return null;
}
$requestId = trim((string) (self::$context['request_id'] ?? ''));
return $requestId !== '' ? $requestId : null;
}
public static function setChannel(string $channel): void
{
self::ensureStarted();
$normalized = self::normalizeChannel($channel);
if ($normalized !== '') {
self::$context['channel'] = $normalized;
}
}
public static function setPath(string $path): void
{
self::ensureStarted();
$path = trim($path);
if ($path === '') {
return;
}
self::$context['path'] = substr($path, 0, 255);
}
public static function context(): array
{
self::ensureStarted();
return self::$context ?? [];
}
public static function requestHeaderValue(): string
{
return self::id();
}
// HMAC when a key is configured (e.g. hashing IPs for audit logs) — plain SHA256 as fallback.
public static function hashValue(string $value): string
{
$secret = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : '';
if ($secret === '') {
return hash('sha256', $value);
}
return hash_hmac('sha256', $value, $secret);
}
public static function resetForTests(): void
{
self::$context = null;
}
/**
* @return array<string, mixed>
*/
private static function buildDefaultContext(): array
{
$method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')));
if ($method === '') {
$method = PHP_SAPI === 'cli' ? 'CLI' : 'GET';
}
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$path = parse_url($uri, PHP_URL_PATH);
$path = is_string($path) ? trim($path) : '';
if ($path === '' && PHP_SAPI === 'cli') {
$path = (string) ($_SERVER['argv'][0] ?? 'cli');
}
$requestId = self::requestIdFromHeader();
if ($requestId === null) {
$requestId = RepoQuery::uuidV4();
}
return [
'request_id' => $requestId,
'channel' => self::detectChannel($path),
'method' => substr($method, 0, 8),
'path' => substr($path, 0, 255),
'ip' => self::normalizeIp((string) ($_SERVER['REMOTE_ADDR'] ?? '')),
'user_agent' => self::normalizeUserAgent((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')),
];
}
private static function applyOverrides(array $overrides): void
{
foreach ($overrides as $key => $value) {
if (!is_string($key)) {
continue;
}
if ($key === 'request_id') {
$requestId = trim((string) $value);
if (self::isValidUuid($requestId)) {
self::$context['request_id'] = $requestId;
}
continue;
}
if ($key === 'channel') {
$channel = self::normalizeChannel((string) $value);
if ($channel !== '') {
self::$context['channel'] = $channel;
}
continue;
}
if ($key === 'method') {
$method = strtoupper(trim((string) $value));
if ($method !== '') {
self::$context['method'] = substr($method, 0, 8);
}
continue;
}
if ($key === 'path') {
$path = trim((string) $value);
if ($path !== '') {
self::$context['path'] = substr($path, 0, 255);
}
continue;
}
}
}
private static function detectChannel(string $path): string
{
if (PHP_SAPI === 'cli') {
return 'cli';
}
if (defined('MINTY_API_REQUEST')) {
return 'api';
}
$normalizedPath = ltrim(strtolower(trim($path)), '/');
if (str_starts_with($normalizedPath, 'api/')) {
return 'api';
}
return 'web';
}
private static function normalizeChannel(string $channel): string
{
$channel = strtolower(trim($channel));
return in_array($channel, ['web', 'api', 'scheduler', 'cli'], true) ? $channel : '';
}
// Accept a caller-supplied request ID for cross-service trace correlation.
// Only trusted if it's a valid UUID format — arbitrary strings are ignored.
private static function requestIdFromHeader(): ?string
{
$header = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''));
if (self::isValidUuid($header)) {
return strtolower($header);
}
return null;
}
private static function isValidUuid(string $value): bool
{
return preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i', $value) === 1;
}
private static function normalizeIp(string $ip): string
{
$ip = trim($ip);
if ($ip === '') {
return '';
}
return substr($ip, 0, 45);
}
private static function normalizeUserAgent(string $userAgent): string
{
$userAgent = trim($userAgent);
if ($userAgent === '') {
return '';
}
return substr($userAgent, 0, 255);
}
}