diff --git a/README.md b/README.md index eaa8880..b71e9cf 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ php artisan vendor:publish --provider=CodeTech\\ApiLogs\\Providers\\ApiLogServic Then adjust `config/api-logs.php`. `keys` are matched case-insensitively and recursively against the request payload, query string and response data; `headers` are matched against request header names. +Key redaction applies to JSON responses only. A response that is not JSON, such as an HTML error page, is stored as its raw body, unredacted. + ## Testing ``` diff --git a/src/Http/Middleware/LogApiRequest.php b/src/Http/Middleware/LogApiRequest.php index b60a4b4..bf53584 100644 --- a/src/Http/Middleware/LogApiRequest.php +++ b/src/Http/Middleware/LogApiRequest.php @@ -42,9 +42,6 @@ public function terminate(Request $request, $response): void ?? $request->server('REQUEST_TIME_FLOAT') ?? microtime(true); - $content = $response->getContent(); - $responseData = is_string($content) ? json_decode($content, true) : null; - $keys = config('api-logs.redact.keys', []); $headers = config('api-logs.redact.headers', []); $replacement = config('api-logs.redact.replacement', '[REDACTED]'); @@ -58,7 +55,33 @@ public function terminate(Request $request, $response): void 'ip' => $request->getClientIp(), 'request_data' => Redactor::redact($request->all(), $keys, $replacement), 'request_headers' => Redactor::redactHeaders($request->headers->all(), $headers, $replacement), - 'response_data' => is_array($responseData) ? Redactor::redact($responseData, $keys, $replacement) : $responseData, + 'response_data' => $this->responseData($response, $keys, $replacement), ]); } + + /** + * Get the response body to store: the decoded JSON value when the body + * is JSON, or the raw body as a UTF-8 string otherwise. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @param array $keys + * @param string $replacement + * @return mixed + */ + private function responseData($response, array $keys, string $replacement) + { + $content = $response->getContent(); + + if (! is_string($content)) { + return ''; + } + + $decoded = json_decode($content, true); + + if (is_array($decoded)) { + return Redactor::redact($decoded, $keys, $replacement); + } + + return $decoded ?? mb_scrub($content, 'UTF-8'); + } } diff --git a/tests/Feature/LogApiRequestMiddlewareTest.php b/tests/Feature/LogApiRequestMiddlewareTest.php index 9cab874..260c22c 100644 --- a/tests/Feature/LogApiRequestMiddlewareTest.php +++ b/tests/Feature/LogApiRequestMiddlewareTest.php @@ -100,6 +100,45 @@ public function test_user_without_the_trait_is_skipped(): void $this->assertSame(0, ApiLog::count()); } + public function test_html_response_is_logged_with_its_raw_body(): void + { + $this->actingAs($this->createUser()) + ->get('/api/forbidden', ['Accept' => '*/*']) + ->assertForbidden(); + + $log = ApiLog::first(); + + $this->assertIsString($log->response_data); + $this->assertStringContainsString('response_data); + } + + public function test_empty_response_is_logged_with_an_empty_body(): void + { + $this->actingAs($this->createUser()) + ->delete('/api/items') + ->assertNoContent(); + + $this->assertSame('', ApiLog::first()->response_data); + } + + public function test_streamed_response_is_logged_with_an_empty_body(): void + { + $this->actingAs($this->createUser()) + ->get('/api/export') + ->assertOk(); + + $this->assertSame('', ApiLog::first()->response_data); + } + + public function test_response_that_is_not_valid_utf8_is_logged_with_its_body_scrubbed(): void + { + $this->actingAs($this->createUser()) + ->get('/api/binary') + ->assertOk(); + + $this->assertSame('abc?', ApiLog::first()->response_data); + } + private function createUser(): User { return User::create([ diff --git a/tests/TestCase.php b/tests/TestCase.php index 97aad34..e9c1369 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -68,6 +68,24 @@ private function defineTestRoutes() 'access_token' => 'issued-token', ]); }); + + Route::get('/api/forbidden', function () { + abort(403); + }); + + Route::delete('/api/items', function () { + return response()->noContent(); + }); + + Route::get('/api/export', function () { + return response()->stream(function () { + echo 'a,b,c'; + }); + }); + + Route::get('/api/binary', function () { + return response("abc\xff"); + }); }); } }