Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.12% covered (success)
96.12%
124 / 129
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiLoggingMiddleware
96.09% covered (success)
96.09%
123 / 128
75.00% covered (warning)
75.00%
6 / 8
36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 process
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 recordLog
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
11
 buildDetail
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 resolveRequestId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 captureRequestBody
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 resolveClientIp
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 resolveIntAttribute
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Middleware;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\Domain\Model\ApiLogDetail;
12use App\Core\Api\Domain\Model\ApiLogEntry;
13use App\Core\Api\Domain\Model\ApiLoggingMode;
14use App\Core\Api\Domain\Repository\ApiLoggerInterface;
15use App\Core\Api\Domain\Service\PayloadSanitizer;
16use DateTimeImmutable;
17use Psr\Http\Message\ResponseInterface;
18use Psr\Http\Message\ServerRequestInterface;
19use Psr\Http\Server\MiddlewareInterface;
20use Psr\Http\Server\RequestHandlerInterface;
21use Throwable;
22
23/**
24 * REST & HTMX API Logging and Performance Monitoring Middleware (OWASP / NIST AU-3 / AU-12).
25 *
26 * Intercepts API requests, measures execution time, peak memory usage, status codes,
27 * and records access logs adhering to Standard, Full, or Smart-Hybrid logging modes.
28 *
29 * @package App\Core\Api\Middleware
30 */
31final readonly class ApiLoggingMiddleware implements MiddlewareInterface
32{
33    /**
34     * ApiLoggingMiddleware constructor.
35     *
36     * @param ApiLoggerInterface $apiLogger        API Logger repository.
37     * @param PayloadSanitizer   $payloadSanitizer Sensitive data redactor.
38     * @param ApiLoggingMode     $mode             Logging mode (Standard, Full, Smart-Hybrid).
39     * @param float              $slowThresholdMs  Execution time threshold in milliseconds for slow request capture.
40     */
41    public function __construct(
42        private ApiLoggerInterface $apiLogger,
43        private PayloadSanitizer   $payloadSanitizer,
44        private ApiLoggingMode     $mode = ApiLoggingMode::SMART_HYBRID,
45        private float              $slowThresholdMs = 500.0,
46    ) {
47    }
48
49    /**
50     * Processes request, measures latency and writes audit log entry.
51     *
52     * @param ServerRequestInterface  $request Server request.
53     * @param RequestHandlerInterface $handler Request handler.
54     * @return ResponseInterface Handled response.
55     * @throws Throwable Re-throws exception after logging.
56     */
57    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
58    {
59        $startTime = hrtime(true);
60        $requestId = $this->resolveRequestId($request);
61        $rawRequestBody = $this->captureRequestBody($request);
62
63        $response = null;
64        $caughtException = null;
65        $statusCode = 0;
66
67        try {
68            $response = $handler->handle($request);
69            $statusCode = $response->getStatusCode();
70            return $response;
71        } catch (Throwable $e) {
72            $caughtException = $e;
73            $statusCode = $e->getCode() >= 400 && $e->getCode() <= 599 ? (int) $e->getCode() : 500;
74            throw $e;
75        } finally {
76            $durationMs = (hrtime(true) - $startTime) / 1e6;
77            $memoryPeak = memory_get_peak_usage(true);
78
79            $this->recordLog(
80                $request,
81                $response,
82                $rawRequestBody,
83                $requestId,
84                [
85                    'status'      => $statusCode,
86                    'duration_ms' => $durationMs,
87                    'memory_peak' => $memoryPeak,
88                    'exception'   => $caughtException,
89                ]
90            );
91        }
92    }
93
94    /**
95     * Compiles and persists log entry and optional payload details.
96     *
97     * @param ServerRequestInterface $request Incoming request.
98     * @param ResponseInterface|null $response Returned response if any.
99     * @param string $rawRequestBody Captured request payload.
100     * @param string $requestId Request trace ID.
101     * @param array<string, mixed> $stats Execution status, duration, memory, and exception.
102     * @return void
103     */
104    private function recordLog(
105        ServerRequestInterface $request,
106        ?ResponseInterface     $response,
107        string                 $rawRequestBody,
108        string                 $requestId,
109        array                  $stats,
110    ): void {
111        $statusCode = (int) ($stats['status'] ?? 0);
112        $durationMs = (float) ($stats['duration_ms'] ?? 0.0);
113        $memoryPeak = (int) ($stats['memory_peak'] ?? 0);
114        /** @var Throwable|null $exception */
115        $exception  = $stats['exception'] ?? null;
116
117        $isSlow = $durationMs >= $this->slowThresholdMs;
118        $isError = $statusCode >= 400 || $statusCode === 0;
119        $isAborted = $statusCode === 0 || $statusCode === 499 || $statusCode === 504;
120
121        $logLevel = match (true) {
122            $statusCode >= 500 || ($exception !== null) => 'error',
123            $statusCode >= 400 || $isSlow                => 'warning',
124            default                                      => 'info',
125        };
126
127        $routePattern = (string) ($request->getAttribute('route_pattern')
128            ?? $request->getAttribute('_route_pattern')
129            ?? $request->getUri()->getPath());
130
131        $ipAddress = $this->resolveClientIp($request);
132        $userAgent = $request->getHeaderLine('User-Agent');
133        $responseBytes = $response !== null ? (int) ($response->getBody()->getSize() ?? 0) : 0;
134
135        $userId = $this->resolveIntAttribute($request, ['user_id', 'actor_user_id', 'current_user_id']);
136        $apiKeyId = $this->resolveIntAttribute($request, ['api_key_id']);
137        $clientInstanceId = $this->resolveIntAttribute($request, ['client_instance_id', 'instance_id']);
138
139        $shouldCapture = $this->mode->shouldCapturePayload($statusCode, $durationMs, $this->slowThresholdMs);
140
141        $entry = new ApiLogEntry(
142            requestId:        $requestId,
143            httpMethod:       $request->getMethod(),
144            routePattern:     $routePattern,
145            requestUri:       $this->payloadSanitizer->sanitizeUri((string) $request->getUri()),
146            statusCode:       $statusCode,
147            durationMs:       $durationMs,
148            memoryPeakBytes:  $memoryPeak,
149            responseBytes:    $responseBytes,
150            ipAddress:        $ipAddress,
151            userAgent:        $userAgent,
152            logLevel:         $logLevel,
153            isSlow:           $isSlow,
154            isError:          $isError,
155            isAborted:        $isAborted,
156            hasPayloadDump:   $shouldCapture,
157            createdAt:        new DateTimeImmutable(),
158            clientInstanceId: $clientInstanceId,
159            userId:           $userId,
160            apiKeyId:         $apiKeyId,
161        );
162
163        $detail = null;
164        if ($shouldCapture) {
165            $detail = $this->buildDetail(
166                $request,
167                $response,
168                $rawRequestBody,
169                $requestId,
170                $exception
171            );
172        }
173
174        $this->apiLogger->logRequest($entry, $detail);
175    }
176
177    /**
178     * Builds sanitized detail dump for full or error logging.
179     *
180     * @param ServerRequestInterface $request        Incoming request.
181     * @param ResponseInterface|null $response       Returned response.
182     * @param string                 $rawRequestBody Captured request body.
183     * @param string                 $requestId      Request trace ID.
184     * @param Throwable|null         $exception      Exception if thrown.
185     * @return ApiLogDetail Sanitized detail value object.
186     */
187    private function buildDetail(
188        ServerRequestInterface $request,
189        ?ResponseInterface     $response,
190        string                 $rawRequestBody,
191        string                 $requestId,
192        ?Throwable             $exception,
193    ): ApiLogDetail {
194        $sanitizedReqHeaders = $this->payloadSanitizer->sanitizeHeaders($request->getHeaders());
195        $sanitizedReqPayload = $this->payloadSanitizer->sanitizePayload($rawRequestBody);
196
197        $sanitizedResHeaders = $response !== null
198            ? $this->payloadSanitizer->sanitizeHeaders($response->getHeaders())
199            : [];
200
201        $rawResponseBody = '';
202        if ($response !== null) {
203            $stream = $response->getBody();
204            if ($stream->isSeekable()) {
205                $rawResponseBody = (string) $stream;
206                $stream->rewind();
207            }
208        }
209        $sanitizedResPayload = $this->payloadSanitizer->sanitizePayload($rawResponseBody);
210
211        return new ApiLogDetail(
212            requestId:        $requestId,
213            requestHeaders:   (string) json_encode($sanitizedReqHeaders, JSON_UNESCAPED_SLASHES),
214            requestPayload:   $sanitizedReqPayload,
215            responseHeaders:  (string) json_encode($sanitizedResHeaders, JSON_UNESCAPED_SLASHES),
216            responsePayload:  $sanitizedResPayload,
217            exceptionClass:   $exception !== null ? $exception::class : null,
218            exceptionMessage: $exception?->getMessage(),
219            exceptionTrace:   $exception?->getTraceAsString(),
220        );
221    }
222
223    /**
224     * Extracts request correlation ID.
225     *
226     * @param ServerRequestInterface $request Incoming HTTP request.
227     * @return string Validated or generated UUID string.
228     */
229    private function resolveRequestId(ServerRequestInterface $request): string
230    {
231        $id = $request->getAttribute(RequestIdMiddleware::ATTRIBUTE_NAME)
232            ?? $request->getHeaderLine(RequestIdMiddleware::HEADER_NAME);
233
234        if (is_string($id) && $id !== '') {
235            return $id;
236        }
237
238        return bin2hex(random_bytes(16));
239    }
240
241    /**
242     * Safely reads request stream body and rewinds stream pointer.
243     *
244     * @param ServerRequestInterface $request Incoming request.
245     * @return string Raw request body string.
246     */
247    private function captureRequestBody(ServerRequestInterface $request): string
248    {
249        $stream = $request->getBody();
250        if (!$stream->isReadable()) {
251            return '';
252        }
253
254        $content = (string) $stream;
255        if ($stream->isSeekable()) {
256            $stream->rewind();
257        }
258
259        return $content;
260    }
261
262    /**
263     * Resolves client IP address with proxy header validation.
264     *
265     * @param ServerRequestInterface $request Request instance.
266     * @return string Remote client IP address.
267     */
268    private function resolveClientIp(ServerRequestInterface $request): string
269    {
270        $serverParams = $request->getServerParams();
271        $headers = ['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'];
272
273        foreach ($headers as $h) {
274            if (!empty($serverParams[$h])) {
275                $ips = explode(',', (string) $serverParams[$h]);
276                $ip = trim($ips[0]);
277                if (filter_var($ip, FILTER_VALIDATE_IP) !== false) {
278                    return $ip;
279                }
280            }
281        }
282
283        return '127.0.0.1';
284    }
285
286    /**
287     * Resolves integer attribute from request attributes.
288     *
289     * @param ServerRequestInterface $request Request instance.
290     * @param list<string>          $keys    Attribute key names in order of priority.
291     * @return int|null Resolved integer value or null.
292     */
293    private function resolveIntAttribute(ServerRequestInterface $request, array $keys): ?int
294    {
295        foreach ($keys as $k) {
296            $val = $request->getAttribute($k);
297            if ($val !== null && is_numeric($val) && (int) $val > 0) {
298                return (int) $val;
299            }
300        }
301
302        return null;
303    }
304}