Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
31 changes: 27 additions & 4 deletions src/Http/Middleware/LogApiRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]');
Expand All @@ -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');
}
}
39 changes: 39 additions & 0 deletions tests/Feature/LogApiRequestMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html', $log->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([
Expand Down
18 changes: 18 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
}
}
Loading