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 .env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,5 @@ SANCTUM_ACCESS_TOKEN_EXPIRATION=15
SANCTUM_REFRESH_TOKEN_EXPIRATION=43200
# Optional: Sanctum stateful domains for SPA authentication
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1,127.0.0.1:8000

API_LOGGER_ENABLED=true
138 changes: 138 additions & 0 deletions app/Http/Middleware/ApiLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class ApiLogger
{
public function handle(Request $request, Closure $next): Response
{
if (! config('api-logger.enabled')) {
$response = $next($request);
if (! ($response instanceof Response)) {
$content = is_string($response) ? $response : (is_array($response) ? (json_encode($response) ?: '') : '');
$response = new Response($content);
}

return $response;
}

// Get or generate a request ID for tracing
$requestId = $request->headers->get('X-Request-Id') ?? uniqid('req_', true);
$startTime = microtime(true);
$response = $next($request);
if (! ($response instanceof Response)) {
$content = is_string($response) ? $response : (is_array($response) ? (json_encode($response) ?: '') : '');
$response = new Response($content);
}
$endTime = microtime(true);

$user = Auth::user();
$userId = $user ? $user->id : null;
$ip = $request->ip();
$method = $request->method();
$uri = $request->getRequestUri();
/** @var array<string, array<int, string>|string> $rawHeaders */
$rawHeaders = $request->headers->all();
$headers = $this->maskHeaders($rawHeaders);

$rawBody = $request->all();
$body = $this->maskBody($this->castArrayKeysToString($rawBody));

$status = $response->getStatusCode();

$responseContent = $this->getResponseContent($response);
$responseBody = is_array($responseContent)
? $this->maskBody($this->castArrayKeysToString($responseContent))
: $responseContent;
$duration = round(($endTime - $startTime) * 1000, 2);

Log::info('API Request', [
'request_id' => $requestId,
'user_id' => $userId,
'ip' => $ip,
'method' => $method,
'uri' => $uri,
'headers' => $headers,
'body' => $body,
'response_status' => $status,
'response_body' => $responseBody,
'duration_ms' => $duration,
]);

return $response;
}

/**
* @param array<mixed, mixed> $array
* @return array<string, mixed>
*/
private function castArrayKeysToString(array $array): array
{
$result = [];
foreach ($array as $key => $value) {
$result[(string) $key] = $value;
}

return $result;
}

/**
* @param array<string, array<int, string|null>|string|null> $headers
* @return array<string, mixed>
*/
protected function maskHeaders(array $headers): array
{
$masked = [];
$maskKeys = (array) config('api-logger.masked_headers', []);
foreach ($headers as $key => $value) {
if (in_array(strtolower($key), $maskKeys, true)) {
$masked[$key] = '***MASKED***';
} else {
$masked[$key] = $value;
}
}

return $masked;
}

/**
* @param array<string, mixed> $body
* @return array<string, mixed>
*/
protected function maskBody(array $body): array
{
$maskKeys = (array) config('api-logger.masked_body_keys', []);
foreach ($body as $key => &$value) {
if (in_array(strtolower($key), $maskKeys, true)) {
$value = '***MASKED***';
}
}
unset($value);

return $body;
}

/**
* @param Response|string|array<string, mixed> $response
* @return array<string, mixed>|string
*/
protected function getResponseContent(Response|string|array $response): array|string
{
if ($response instanceof Response) {
$content = $response->getContent();
$json = is_string($content) ? json_decode($content, true) : null;

return is_array($json) ? $this->castArrayKeysToString($json) : (is_string($content) ? $content : '');
}

return is_array($response) ? $this->castArrayKeysToString($response) : $response;
}
}
1 change: 1 addition & 0 deletions bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
$middleware->alias([
'ability' => \App\Http\Middleware\CheckTokenAbility::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'api.logger' => \App\Http\Middleware\ApiLogger::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
Expand Down
21 changes: 21 additions & 0 deletions config/api-logger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

return [
// Enable or disable API logging
'enabled' => env('API_LOGGER_ENABLED', true),

// Header keys to mask in logs
'masked_headers' => [
'authorization',
'cookie',
'x-api-key',
],

// Body keys to mask in logs
'masked_body_keys' => [
'password',
'token',
'access_token',
'refresh_token',
],
];
2 changes: 1 addition & 1 deletion routes/api_v1.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->middleware(['throttle:api'])->group(function () {
Route::prefix('v1')->middleware(['throttle:api', 'api.logger'])->group(function () {
Route::get('/', function (Request $request) {
return 'Laravel Blog API V1 Root is working';
})->name('api.v1.status');
Expand Down
126 changes: 126 additions & 0 deletions tests/Feature/Middleware/ApiLoggerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace Tests\Feature\Middleware;

use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;

class ApiLoggerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Ensure logging is enabled for tests
Config::set('api-logger.enabled', true);
// Create a test route using the middleware
Route::middleware('api.logger')->post('/test-api-logger', function () {
return response()->json(['success' => true, 'token' => 'shouldbemasked']);
});
}

public function test_logs_request_and_response_with_masking()
{
Log::spy();
$payload = [
'username' => 'testuser',
'password' => 'supersecret',
];
$headers = [
'Authorization' => 'Bearer sometoken',
'X-Request-Id' => 'test-request-id-123',
];

$response = $this->postJson('/test-api-logger', $payload, $headers);
$response->assertOk();
$response->assertJson(['success' => true]);

Log::shouldHaveReceived('info')->withArgs(function ($message, $context) use ($headers) {
return $message === 'API Request'
&& $context['request_id'] === $headers['X-Request-Id']
&& $context['body']['password'] === '***MASKED***'
&& $context['headers']['authorization'] === '***MASKED***'
&& $context['response_body']['token'] === '***MASKED***';
})->once();
}

public function test_does_not_log_when_disabled()
{
Config::set('api-logger.enabled', false);
Log::spy();
$response = $this->postJson('/test-api-logger', []);
$response->assertOk();
Log::shouldNotHaveReceived('info');
}

public function test_logs_with_custom_masked_headers_and_body_keys()
{
Config::set('api-logger.masked_headers', ['authorization', 'x-custom-header']);
Config::set('api-logger.masked_body_keys', ['password', 'secret']);
Log::spy();
$payload = [
'username' => 'testuser',
'password' => 'supersecret',
'secret' => 'topsecret',
];
$headers = [
'Authorization' => 'Bearer sometoken',
'X-Custom-Header' => 'customvalue',
'X-Request-Id' => 'custom-id-456',
];
$response = $this->postJson('/test-api-logger', $payload, $headers);
$response->assertOk();
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) {
return $context['headers']['authorization'] === '***MASKED***'
&& $context['headers']['x-custom-header'] === '***MASKED***'
&& $context['body']['password'] === '***MASKED***'
&& $context['body']['secret'] === '***MASKED***';
})->once();
}

public function test_logs_all_context_fields()
{
Log::spy();
$payload = ['username' => 'testuser'];
$headers = [
'X-Request-Id' => 'all-fields-id',
];
$response = $this->postJson('/test-api-logger', $payload, $headers);
$response->assertOk();
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) use ($headers) {
return $context['request_id'] === $headers['X-Request-Id']
&& isset($context['ip'])
&& $context['method'] === 'POST'
&& str_contains($context['uri'], '/test-api-logger')
&& $context['response_status'] === 200
&& is_numeric($context['duration_ms']);
})->once();
}

public function test_logs_non_json_response()
{
Route::middleware('api.logger')->get('/test-non-json', function () {
return 'plain text response';
});
Log::spy();
$response = $this->get('/test-non-json');
$response->assertOk();
$response->assertSee('plain text response');
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) {
return $context['response_body'] === 'plain text response';
})->once();
}

public function test_logs_with_empty_headers_and_body()
{
Log::spy();
$response = $this->postJson('/test-api-logger', [], []);
$response->assertOk();
Log::shouldHaveReceived('info')->withArgs(function ($message, $context) {
return is_array($context['headers']) && is_array($context['body']);
})->once();
}
}