1 Commits

Author SHA1 Message Date
Anatoliy
d93b1569f5 refactor(logging) 2026-06-09 16:11:15 +03:00
9 changed files with 408 additions and 110 deletions

View File

@@ -12,6 +12,7 @@ use Apoloz\AlfaBank\Enums\Environment;
use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest;
use Apoloz\AlfaBank\Sbp\SbpConnector;
use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
use Psr\Log\LoggerInterface;
class AlfaBank
{
@@ -43,7 +44,8 @@ class AlfaBank
?string $clientSecret = null,
?string $redirectUri = null,
?string $signingCertificatePath = null,
?string $signingPrivateKeyPath = null
?string $signingPrivateKeyPath = null,
?LoggerInterface $logger = null,
): AlfaBankConnector {
$config = new AlfaConfig(
baseUrl: $environment->value,
@@ -62,7 +64,13 @@ class AlfaBank
$signer = new OpenSslCmsSigner($signerCert, $signerKey);
return new AlfaBankConnector($config, $signer);
$connector = new AlfaBankConnector($config, $signer);
if ($logger) {
$connector->withLogging($logger);
}
return $connector;
}
// </SECTION>
@@ -75,7 +83,8 @@ class AlfaBank
Environment $environment = Environment::SANDBOX,
?string $caBundlePath = null,
?string $signingCertificatePath = null,
?string $signingPrivateKeyPath = null
?string $signingPrivateKeyPath = null,
?LoggerInterface $logger = null,
): SbpConnector {
$baseUrl = $environment === Environment::PRODUCTION
? Environment::SBP_PRODUCTION->value
@@ -90,7 +99,7 @@ class AlfaBank
signingPrivateKeyPath: $signingPrivateKeyPath
);
return new SbpConnector($config, $keyNameAlias);
return new SbpConnector($config, $keyNameAlias, logger: $logger);
}
// </SECTION>
}

View File

@@ -15,6 +15,7 @@ use Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware;
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator;
use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Http\Connector;
use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
@@ -37,10 +38,13 @@ class AlfaBankConnector extends Connector implements HasPagination
public function __construct(
protected AlfaConfig $alfaConfig,
protected ?SignerInterface $signer = null
protected ?SignerInterface $signer = null,
protected bool $loggingEnabled = true,
protected ?LoggerInterface $logger = null,
) {
$this->tries = 3;
$this->retryInterval = 500;
$this->logger = $this->logger ?? new NullLogger;
}
public function resolveBaseUrl(): string
@@ -50,7 +54,12 @@ class AlfaBankConnector extends Connector implements HasPagination
public function boot(PendingRequest $pendingRequest): void
{
//
$pendingRequest->middleware()->onRequest(new RequestIdMiddleware);
if ($this->loggingEnabled) {
$pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger));
$pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger));
}
}
public function paginate(Request $request): OffsetPaginator
@@ -101,12 +110,19 @@ class AlfaBankConnector extends Connector implements HasPagination
}
// </SECTION>
// <SECTION name="logging_mutator" intent="Allow attaching a PSR-3 logger to the connector for debugging.">
// <BELIEF_STATE "Logging should be an optional, opt-in feature. This method provides a fluent interface to attach a logging middleware, which will automatically sanitize sensitive data before output.">
// <SECTION name="logging_mutator" intent="Attach a PSR-3 logger to the connector for debugging.">
// <BELIEF_STATE "Logging is enabled by default with a NullLogger (silent). Pass a real logger to start seeing output, or disable entirely via withoutLogging().">
public function withLogging(LoggerInterface $logger): static
{
$this->middleware()->onRequest(new LoggingRequestMiddleware($logger));
$this->middleware()->onResponse(new LoggingResponseMiddleware($logger));
$this->logger = $logger;
$this->loggingEnabled = true;
return $this;
}
public function withoutLogging(): static
{
$this->loggingEnabled = false;
return $this;
}
@@ -159,12 +175,5 @@ class AlfaBankConnector extends Connector implements HasPagination
// OAuth токен запросы требуют application/x-www-form-urlencoded
];
}
protected function defaultMiddleware(): array
{
return [
new RequestIdMiddleware,
];
}
// </SECTION>
}

View File

@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Http\Middleware;
use Psr\Log\LoggerInterface;
use Saloon\Contracts\RequestMiddleware;
use Saloon\Contracts\ResponseMiddleware;
use Saloon\Http\PendingRequest;
use Saloon\Http\Response;
class LoggingMiddleware implements RequestMiddleware, ResponseMiddleware
{
// <SECTION name="constructor_definition" intent="Initialize the middleware with a logger instance.">
// <BELIEF_STATE "The constructor injects a `LoggerInterface` instance, making the logging functionality easily testable and configurable. This adheres to dependency inversion principles, allowing the middleware to log messages without being tightly coupled to a specific logging implementation.">
public function __construct(protected LoggerInterface $logger) {}
// </SECTION>
// <SECTION name="on_request_method" intent="Log outgoing API requests.">
// <BELIEF_STATE "The `onRequest` method intercepts each outgoing HTTP request. It logs the request method and endpoint, along with masked headers and body content. Masking of sensitive information like authorization headers and payment data is performed by `maskHeaders` and `maskBody` methods, ensuring that debugging logs do not compromise security.">
public function onRequest(PendingRequest $request): void
{
$this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [
'headers' => $this->maskHeaders($request->headers()->all()),
'body' => $this->maskBody($request->body()?->all()),
]);
}
// </SECTION>
// <SECTION name="on_response_method" intent="Log incoming API responses.">
// <BELIEF_STATE "The `onResponse` method intercepts each incoming HTTP response. It logs the response status and the masked response body. This provides valuable information for debugging API integrations and understanding the server's responses, all while maintaining the privacy of potentially sensitive data within the response payload.">
public function onResponse(Response $response): void
{
$this->logger->info("AlfaBank Response: {$response->status()}", [
'body' => $this->maskBody($response->json()),
]);
}
// </SECTION>
// <SECTION name="mask_headers_method" intent="Mask sensitive information in HTTP headers.">
// <BELIEF_STATE "This protected helper method iterates through the request headers and replaces the `Authorization` header's value with a generic placeholder. This prevents sensitive tokens from being inadvertently exposed in logs, which is a crucial security measure.">
protected function maskHeaders(array $headers): array
{
if (isset($headers['Authorization'])) {
$headers['Authorization'] = 'Bearer *****';
}
return $headers;
}
// </SECTION>
// <SECTION name="mask_body_method" intent="Mask sensitive information in request/response bodies.">
// <BELIEF_STATE "This protected recursive helper method inspects the request or response body for predefined sensitive keys (e.g., `pan`, `client_secret`). If a sensitive key is found, its value is replaced with a mask. The method recursively processes nested arrays, ensuring that sensitive data is masked even within complex JSON structures, thereby enhancing log security.">
protected function maskBody(mixed $body): mixed
{
if (! is_array($body)) {
return $body;
}
$sensitiveKeys = ['pan', 'cvc', 'digestSignatures', 'client_secret', 'refresh_token', 'access_token'];
foreach ($body as $key => $value) {
if (in_array($key, $sensitiveKeys, true)) {
$body[$key] = '*****';
} elseif (is_array($value)) {
$body[$key] = $this->maskBody($value);
}
}
return $body;
}
// </SECTION>
}
// </SECTION>

View File

@@ -14,10 +14,14 @@ class LoggingRequestMiddleware implements RequestMiddleware
public function __invoke(PendingRequest $request): void
{
$this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [
'headers' => $this->maskHeaders($request->headers()->all()),
'body' => $this->maskBody($request->body()?->all()),
]);
$this->logger->info(
"AlfaBank Request: {$request->getMethod()->value} {$request->getUrl()}",
[
'host' => $request->getConnector()->resolveBaseUrl(),
'headers' => $this->maskHeaders($request->headers()->all()),
'body' => $this->maskBody($request->body()?->all()),
]
);
}
protected function maskHeaders(array $headers): array

View File

@@ -14,9 +14,21 @@ class LoggingResponseMiddleware implements ResponseMiddleware
public function __invoke(Response $response): void
{
$this->logger->info("AlfaBank Response: {$response->status()}", [
'body' => $this->maskBody($response->json()),
]);
$pendingRequest = $response->getPendingRequest();
try {
$body = $this->maskBody($response->json());
} catch (\JsonException) {
$body = $response->body();
}
$this->logger->info(
"AlfaBank Response: {$response->status()} {$pendingRequest->getUrl()}",
[
'host' => $pendingRequest->getConnector()->resolveBaseUrl(),
'body' => $body,
]
);
}
protected function maskBody(mixed $body): mixed

View File

@@ -24,6 +24,7 @@ use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
use Apoloz\AlfaBank\Services\PhpseclibCardEncryptor;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;
// </SECTION>
@@ -68,10 +69,16 @@ class AlfaBankServiceProvider extends ServiceProvider
});
$this->app->singleton(AlfaBankConnector::class, function ($app) {
return new AlfaBankConnector(
$loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true;
$connector = new AlfaBankConnector(
alfaConfig: $app->make('alfa.baas.config'),
signer: $app->make(SignerInterface::class)
signer: $app->make(SignerInterface::class),
loggingEnabled: $loggingEnabled,
logger: $loggingEnabled ? $app->make(LoggerInterface::class) : null,
);
return $connector;
});
$this->app->alias(AlfaBankConnector::class, 'alfa.baas');
@@ -90,10 +97,13 @@ class AlfaBankServiceProvider extends ServiceProvider
$this->app->singleton(SbpConnector::class, function ($app) {
$config = $app['config']['alfa-bank.sbp'];
$loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true;
return new SbpConnector(
alfaConfig: $app->make('alfa.sbp.config'),
keyNameAlias: $config['key_name_alias'] ?? null
keyNameAlias: $config['key_name_alias'] ?? null,
loggingEnabled: $loggingEnabled,
logger: $loggingEnabled ? $app->make(LoggerInterface::class) : null,
);
});
$this->app->alias(SbpConnector::class, 'alfa.sbp');

View File

@@ -60,5 +60,18 @@ return [
'encryption' => [
'card_public_key' => env('ALFA_CARD_PUBLIC_KEY'),
],
/*
|--------------------------------------------------------------------------
| Logging
|--------------------------------------------------------------------------
|
| By default, all requests and responses are logged using the Laravel logger.
| Set ALFA_LOGGING_ENABLED=false to disable.
|
*/
'logging' => [
'enabled' => env('ALFA_LOGGING_ENABLED', true),
],
];
// </SECTION>

View File

@@ -5,10 +5,14 @@ declare(strict_types=1);
namespace Apoloz\AlfaBank\Sbp;
use Apoloz\AlfaBank\Core\AlfaConfig;
use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware;
use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware;
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator;
use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling;
use Apoloz\AlfaBank\Utils\CmsSigner;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Http\Connector;
use Saloon\Http\PendingRequest;
use Saloon\Http\Request;
@@ -27,10 +31,13 @@ class SbpConnector extends Connector implements HasPagination
public function __construct(
protected AlfaConfig $alfaConfig,
protected ?string $keyNameAlias = null
protected ?string $keyNameAlias = null,
protected bool $loggingEnabled = true,
protected ?LoggerInterface $logger = null,
) {
$this->tries = 3;
$this->retryInterval = 500;
$this->logger = $this->logger ?? new NullLogger;
}
public function resolveBaseUrl(): string
@@ -64,6 +71,13 @@ class SbpConnector extends Connector implements HasPagination
public function boot(PendingRequest $pendingRequest): void
{
$pendingRequest->middleware()->onRequest(new RequestIdMiddleware);
if ($this->loggingEnabled) {
$pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger));
$pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger));
}
$bodyRepo = $pendingRequest->body();
if (! $bodyRepo) {
return;
@@ -107,6 +121,21 @@ class SbpConnector extends Connector implements HasPagination
return $this;
}
public function withLogging(LoggerInterface $logger): static
{
$this->logger = $logger;
$this->loggingEnabled = true;
return $this;
}
public function withoutLogging(): static
{
$this->loggingEnabled = false;
return $this;
}
public function paginate(Request $request): OffsetPaginator
{
return new AlfaOffsetPaginator($this, $request);
@@ -133,11 +162,4 @@ class SbpConnector extends Connector implements HasPagination
'Accept' => 'application/json',
];
}
protected function defaultMiddleware(): array
{
return [
new RequestIdMiddleware,
];
}
}

View File

@@ -0,0 +1,294 @@
<?php
use Apoloz\AlfaBank\Core\AlfaBankConnector;
use Apoloz\AlfaBank\Core\AlfaConfig;
use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware;
use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest;
use Apoloz\AlfaBank\Sbp\SbpConnector;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
function createLoggingConfig(): AlfaConfig
{
$cert = tempnam(sys_get_temp_dir(), 'cert');
$key = tempnam(sys_get_temp_dir(), 'key');
return new AlfaConfig(
baseUrl: 'https://test.alfabank.ru',
certificatePath: $cert,
privateKeyPath: $key
);
}
test('AlfaBankConnector logs request with host by default', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->exactly(2))
->method('info')
->with(
$this->logicalOr(
$this->stringContains('AlfaBank Request'),
$this->stringContains('AlfaBank Response')
),
$this->callback(function (array $context): bool {
return isset($context['host']) && $context['host'] === 'https://test.alfabank.ru';
})
);
$mockClient = new MockClient([
'*' => MockResponse::make(['accounts' => []], 200),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
$connector->withMockClient($mockClient);
$connector->send(new GetAccountsRequest);
});
test('AlfaBankConnector logs response with host by default', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$calls = [];
$logger->expects($this->exactly(2))
->method('info')
->willReturnCallback(function (string $message, array $context) use (&$calls): void {
$calls[] = $message;
});
$mockClient = new MockClient([
'*' => MockResponse::make(['accounts' => []], 200),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
$connector->withMockClient($mockClient);
$connector->send(new GetAccountsRequest);
expect($calls)->toHaveCount(2);
expect($calls[0])->toContain('AlfaBank Request');
expect($calls[1])->toContain('AlfaBank Response');
});
test('AlfaBankConnector does not log when loggingEnabled is false', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->never())->method('info');
$mockClient = new MockClient([
'*' => MockResponse::make(['accounts' => []], 200),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: false, logger: $logger);
$connector->withMockClient($mockClient);
$connector->send(new GetAccountsRequest);
});
test('withoutLogging disables logging', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->never())->method('info');
$mockClient = new MockClient([
'*' => MockResponse::make(['accounts' => []], 200),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
$connector->withoutLogging();
$connector->withMockClient($mockClient);
$connector->send(new GetAccountsRequest);
});
test('withLogging re-enables logging after withoutLogging', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->exactly(2))->method('info');
$mockClient = new MockClient([
'*' => MockResponse::make(['accounts' => []], 200),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: false, logger: $logger);
$connector->withLogging($logger);
$connector->withMockClient($mockClient);
$connector->send(new GetAccountsRequest);
});
test('SbpConnector logs request with host by default', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->exactly(2))
->method('info')
->with(
$this->logicalOr(
$this->stringContains('AlfaBank Request'),
$this->stringContains('AlfaBank Response')
),
$this->callback(function (array $context): bool {
return isset($context['host']) && $context['host'] === 'https://test.alfabank.ru';
})
);
$mockClient = new MockClient([
'*' => MockResponse::make([], 200),
]);
$config = createLoggingConfig();
$connector = new SbpConnector($config, 'alias', loggingEnabled: true, logger: $logger);
$connector->withMockClient($mockClient);
$connector->send(new class extends \Saloon\Http\Request
{
protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET;
public function resolveEndpoint(): string
{
return '/test';
}
});
});
test('SbpConnector withoutLogging disables logging', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->never())->method('info');
$mockClient = new MockClient([
'*' => MockResponse::make([], 200),
]);
$config = createLoggingConfig();
$connector = new SbpConnector($config, 'alias', loggingEnabled: true, logger: $logger);
$connector->withoutLogging();
$connector->withMockClient($mockClient);
$connector->send(new class extends \Saloon\Http\Request
{
protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET;
public function resolveEndpoint(): string
{
return '/test';
}
});
});
test('LoggingRequestMiddleware masks sensitive headers', function (): void {
$middleware = new LoggingRequestMiddleware(
$this->createMock(LoggerInterface::class)
);
$reflection = new ReflectionMethod($middleware, 'maskHeaders');
$result = $reflection->invoke($middleware, [
'Authorization' => 'Bearer secret-token',
'Accept' => 'application/json',
]);
expect($result['Authorization'])->toBe('Bearer *****');
expect($result['Accept'])->toBe('application/json');
});
test('LoggingRequestMiddleware masks sensitive body fields', function (): void {
$middleware = new LoggingRequestMiddleware(
$this->createMock(LoggerInterface::class)
);
$reflection = new ReflectionMethod($middleware, 'maskBody');
$result = $reflection->invoke($middleware, [
'pan' => '4276123456789012',
'cvc' => '123',
'amount' => 1000,
'nested' => [
'client_secret' => 'super-secret',
],
]);
expect($result['pan'])->toBe('*****');
expect($result['cvc'])->toBe('*****');
expect($result['amount'])->toBe(1000);
expect($result['nested']['client_secret'])->toBe('*****');
});
test('AlfaBankConnector without logger uses NullLogger silently', function (): void {
$mockClient = new MockClient([
'*' => MockResponse::make(['accounts' => []], 200),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: true);
$connector->withMockClient($mockClient);
expect($connector->send(new GetAccountsRequest))->status()->toBe(200);
});
test('logs non-JSON response body without throwing', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$loggedBody = null;
$logger->expects($this->exactly(2))
->method('info')
->willReturnCallback(function (string $message, array $context) use (&$loggedBody): void {
if (str_contains($message, 'Response')) {
$loggedBody = $context['body'];
}
});
$mockClient = new MockClient([
'*' => MockResponse::make('eyJhbGciOiJIUzI1NiJ9...', 200, ['Content-Type' => 'application/jwt']),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
$connector->withMockClient($mockClient);
$response = $connector->send(new GetAccountsRequest);
expect($response->status())->toBe(200);
expect($loggedBody)->toBe('eyJhbGciOiJIUzI1NiJ9...');
});
test('logs plain text response without throwing', function (): void {
/** @var LoggerInterface&MockObject */
$logger = $this->createMock(LoggerInterface::class);
$loggedBody = null;
$logger->expects($this->exactly(2))
->method('info')
->willReturnCallback(function (string $message, array $context) use (&$loggedBody): void {
if (str_contains($message, 'Response')) {
$loggedBody = $context['body'];
}
});
$mockClient = new MockClient([
'*' => MockResponse::make('Internal Server Error', 200, ['Content-Type' => 'text/plain']),
]);
$config = createLoggingConfig();
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
$connector->withMockClient($mockClient);
$response = $connector->send(new GetAccountsRequest);
expect($response->status())->toBe(200);
expect($loggedBody)->toBe('Internal Server Error');
});