diff --git a/src/AlfaBank.php b/src/AlfaBank.php
index b0ce6cd..01f766a 100644
--- a/src/AlfaBank.php
+++ b/src/AlfaBank.php
@@ -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;
}
//
@@ -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);
}
//
}
diff --git a/src/Core/AlfaBankConnector.php b/src/Core/AlfaBankConnector.php
index a2e6626..039cee0 100644
--- a/src/Core/AlfaBankConnector.php
+++ b/src/Core/AlfaBankConnector.php
@@ -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
}
//
- //
- //
+ //
+ //
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,
- ];
- }
//
}
diff --git a/src/Http/Middleware/LoggingMiddleware.php b/src/Http/Middleware/LoggingMiddleware.php
deleted file mode 100644
index da2c82b..0000000
--- a/src/Http/Middleware/LoggingMiddleware.php
+++ /dev/null
@@ -1,75 +0,0 @@
-
- //
- public function __construct(protected LoggerInterface $logger) {}
- //
-
- //
- //
- 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()),
- ]);
- }
- //
-
- //
- //
- public function onResponse(Response $response): void
- {
- $this->logger->info("AlfaBank Response: {$response->status()}", [
- 'body' => $this->maskBody($response->json()),
- ]);
- }
- //
-
- //
- //
- protected function maskHeaders(array $headers): array
- {
- if (isset($headers['Authorization'])) {
- $headers['Authorization'] = 'Bearer *****';
- }
-
- return $headers;
- }
- //
-
- //
- //
- 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;
- }
- //
-}
-//
diff --git a/src/Http/Middleware/LoggingRequestMiddleware.php b/src/Http/Middleware/LoggingRequestMiddleware.php
index 6d28384..1533541 100644
--- a/src/Http/Middleware/LoggingRequestMiddleware.php
+++ b/src/Http/Middleware/LoggingRequestMiddleware.php
@@ -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
diff --git a/src/Http/Middleware/LoggingResponseMiddleware.php b/src/Http/Middleware/LoggingResponseMiddleware.php
index d091084..bb1917c 100644
--- a/src/Http/Middleware/LoggingResponseMiddleware.php
+++ b/src/Http/Middleware/LoggingResponseMiddleware.php
@@ -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
diff --git a/src/Laravel/AlfaBankServiceProvider.php b/src/Laravel/AlfaBankServiceProvider.php
index b849dca..98ca239 100644
--- a/src/Laravel/AlfaBankServiceProvider.php
+++ b/src/Laravel/AlfaBankServiceProvider.php
@@ -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;
//
@@ -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');
diff --git a/src/Laravel/Config/alfa-bank.php b/src/Laravel/Config/alfa-bank.php
index c5b3398..11d632f 100644
--- a/src/Laravel/Config/alfa-bank.php
+++ b/src/Laravel/Config/alfa-bank.php
@@ -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),
+ ],
];
//
diff --git a/src/Sbp/SbpConnector.php b/src/Sbp/SbpConnector.php
index b633bac..0dff45d 100644
--- a/src/Sbp/SbpConnector.php
+++ b/src/Sbp/SbpConnector.php
@@ -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,
- ];
- }
}
diff --git a/tests/Feature/LoggingTest.php b/tests/Feature/LoggingTest.php
new file mode 100644
index 0000000..bcc51e2
--- /dev/null
+++ b/tests/Feature/LoggingTest.php
@@ -0,0 +1,294 @@
+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');
+});