From faea523afc30a9fa5ed2b08d929b5a14ba3c00b1 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Thu, 9 May 2024 13:01:58 +0500 Subject: [PATCH] feat: logging support for requests and responses (#58) This commit adds the configurable logging support via psr/log/LoggerInterface. It adds the ability to log requests and responses for any API call. It also adds the unit tests that cover all aspects of this new feature --- .phan/config.php | 3 +- README.md | 54 ++- composer.json | 3 +- composer.lock | 138 +++--- src/Client.php | 14 +- src/ClientBuilder.php | 17 +- src/Logger/ApiLogger.php | 115 +++++ .../BaseHttpLoggingConfiguration.php | 95 ++++ .../Configuration/LoggingConfiguration.php | 70 +++ .../Configuration/RequestConfiguration.php | 46 ++ .../Configuration/ResponseConfiguration.php | 9 + src/Logger/ConsoleLogger.php | 45 ++ src/Logger/LoggerConstants.php | 33 ++ src/Logger/NullApiLogger.php | 26 ++ tests/LoggerTest.php | 423 ++++++++++++++++++ tests/Mocking/Logger/LogEntry.php | 36 ++ tests/Mocking/Logger/MockLogger.php | 47 ++ tests/Mocking/Logger/MockPrinter.php | 16 + tests/Mocking/MockHelper.php | 70 +++ 19 files changed, 1166 insertions(+), 94 deletions(-) create mode 100644 src/Logger/ApiLogger.php create mode 100644 src/Logger/Configuration/BaseHttpLoggingConfiguration.php create mode 100644 src/Logger/Configuration/LoggingConfiguration.php create mode 100644 src/Logger/Configuration/RequestConfiguration.php create mode 100644 src/Logger/Configuration/ResponseConfiguration.php create mode 100644 src/Logger/ConsoleLogger.php create mode 100644 src/Logger/LoggerConstants.php create mode 100644 src/Logger/NullApiLogger.php create mode 100644 tests/LoggerTest.php create mode 100644 tests/Mocking/Logger/LogEntry.php create mode 100644 tests/Mocking/Logger/MockLogger.php create mode 100644 tests/Mocking/Logger/MockPrinter.php diff --git a/.phan/config.php b/.phan/config.php index feabba4..bbba420 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -30,7 +30,8 @@ $vendor_dir . '/apimatic/core-interfaces', $vendor_dir . '/apimatic/jsonmapper', $vendor_dir . '/phpunit/phpunit', - $vendor_dir . '/php-jsonpointer/php-jsonpointer' + $vendor_dir . '/php-jsonpointer/php-jsonpointer', + $vendor_dir . '/psr/log' ], // A directory list that defines files that will be excluded diff --git a/README.md b/README.md index 15aaa8b..856afa0 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,18 @@ composer require "apimatic/core" ``` ## Request -| Name | Description | -|------------------------------------------------------------------------------|-----------------------------------------------------------------------| -| [`AdditionalFormParams`](src/Request/Parameters/AdditionalFormParams.php) | Used to add additional form params to a request | -| [`AdditionalHeaderParams`](src/Request/Parameters/AdditionalHeaderParams.php)| Used to add additional header params to a request | -| [`AdditionalQueryParams`](src/Request/Parameters/AdditionalQueryParams.php) | Used to add additional query params to a request | -| [`BodyParam`](src/Request/Parameters/BodyParam.php) | Body parameter class | -| [`FormParam`](src/Request/Parameters/FormParam.php) | Form parameter class | -| [`HeaderParam`](src/Request/Parameters/HeaderParam.php) | Header parameter class | -| [`QueryParam`](src/Request/Parameters/QueryParam.php) | Query parameter class | -| [`TemplateParam`](src/Request/Parameters/TemplateParam.php) | Template parameter class | -| [`RequestBuilder`](src/Request/RequestBuilder.php) | Used to instantiate a new Request object with the properties provided | -| [`Request`](src/Request/Request.php) | Request class for an API call | +| Name | Description | +|-------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| [`AdditionalFormParams`](src/Request/Parameters/AdditionalFormParams.php) | Used to add additional form params to a request | +| [`AdditionalHeaderParams`](src/Request/Parameters/AdditionalHeaderParams.php) | Used to add additional header params to a request | +| [`AdditionalQueryParams`](src/Request/Parameters/AdditionalQueryParams.php) | Used to add additional query params to a request | +| [`BodyParam`](src/Request/Parameters/BodyParam.php) | Body parameter class | +| [`FormParam`](src/Request/Parameters/FormParam.php) | Form parameter class | +| [`HeaderParam`](src/Request/Parameters/HeaderParam.php) | Header parameter class | +| [`QueryParam`](src/Request/Parameters/QueryParam.php) | Query parameter class | +| [`TemplateParam`](src/Request/Parameters/TemplateParam.php) | Template parameter class | +| [`RequestBuilder`](src/Request/RequestBuilder.php) | Used to instantiate a new Request object with the properties provided | +| [`Request`](src/Request/Request.php) | Request class for an API call | ## Response | Name | Description | @@ -51,16 +51,28 @@ composer require "apimatic/core" | [`ResponseHandler`](src/Response/ResponseHandler.php) | Response handler for an API call that holds all the above response handling features | | [`Context`](src/Response/Context.php) | Holds the current context i.e. the current request, response and other needed details | +## Logger +| Name | Description | +|---------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| [`ApiLogger`](src/Logger/ApiLogger.php) | Provides implementation for logging API requests and responses | +| [`NullApiLogger`](src/Logger/NullApiLogger.php) | Provides the default implementation for ApiLogger when no logging configuration is provided | +| [`ConsoleLogger`](src/Logger/ConsoleLogger.php) | A LoggerInterface implementation that log messages on console | +| [`LoggerConstants`](src/Logger/LoggerConstants.php) | Holds constants like NON_SENSITIVE_HEADERS, etc. | +| [`BaseHttpLoggingConfiguration`](src/Logger/Configuration/BaseHttpLoggingConfiguration.php) | Common configurations shared by request and response logging configurations | +| [`LoggingConfiguration`](src/Logger/Configuration/LoggingConfiguration.php) | Provides client's logging configurations | +| [`RequestConfiguration`](src/Logger/Configuration/RequestConfiguration.php) | Provides request's logging configurations | +| [`ResponseConfiguration`](src/Logger/Configuration/ResponseConfiguration.php) | Provides response's logging configurations | + ## TestCase -| Name | Description | -|--------------------------------------------------------------------------------------|------------------------------------------------------------------------------| -| [`KeysAndValuesBodyMatcher`](src/TestCase/BodyMatchers/KeysAndValuesBodyMatcher.php) | Matches actual and expected body, considering both the keys and values | -| [`KeysBodyMatcher`](src/TestCase/BodyMatchers/KeysBodyMatcher.php) | Matches actual and expected body, considering just the keys | -| [`NativeBodyMatcher`](src/TestCase/BodyMatchers/NativeBodyMatcher.php) | A body matcher for native values like string, int etc | -| [`RawBodyMatcher`](src/TestCase/BodyMatchers/RawBodyMatcher.php) | Exactly matches the body received to expected body | -| [`HeadersMatcher`](src/TestCase/HeadersMatcher.php) | Matches the headers received and the headers expected | -| [`StatusCodeMatcher`](src/TestCase/StatusCodeMatcher.php) | Matches the HTTP status codes received to the expected ones | -| [`CoreTestCase`](core-lib-php/src/TestCase/CoreTestCase.php) | Main class for a test case that performs assertions w/ all the above matchers| +| Name | Description | +|--------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| [`KeysAndValuesBodyMatcher`](src/TestCase/BodyMatchers/KeysAndValuesBodyMatcher.php) | Matches actual and expected body, considering both the keys and values | +| [`KeysBodyMatcher`](src/TestCase/BodyMatchers/KeysBodyMatcher.php) | Matches actual and expected body, considering just the keys | +| [`NativeBodyMatcher`](src/TestCase/BodyMatchers/NativeBodyMatcher.php) | A body matcher for native values like string, int etc | +| [`RawBodyMatcher`](src/TestCase/BodyMatchers/RawBodyMatcher.php) | Exactly matches the body received to expected body | +| [`HeadersMatcher`](src/TestCase/HeadersMatcher.php) | Matches the headers received and the headers expected | +| [`StatusCodeMatcher`](src/TestCase/StatusCodeMatcher.php) | Matches the HTTP status codes received to the expected ones | +| [`CoreTestCase`](core-lib-php/src/TestCase/CoreTestCase.php) | Main class for a test case that performs assertions w/ all the above matchers | [packagist-url]: https://packagist.org/packages/apimatic/core diff --git a/composer.json b/composer.json index 9a3d819..9e37ee8 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "ext-curl": "*", "ext-dom": "*", "ext-libxml": "*", - "apimatic/core-interfaces": "~0.1.4", + "psr/log": "^1.1.4 || ^2.0.0 || ^3.0.0", + "apimatic/core-interfaces": "~0.1.5", "apimatic/jsonmapper": "^3.1.1", "php-jsonpointer/php-jsonpointer": "^3.0.2" }, diff --git a/composer.lock b/composer.lock index 51ca92d..efb6251 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b0c18c36311074253e7dd2534394a86d", + "content-hash": "9fdb3ec1706ae57fb5bdfd790d73983f", "packages": [ { "name": "apimatic/core-interfaces", - "version": "0.1.4", + "version": "0.1.5", "source": { "type": "git", "url": "https://github.com/apimatic/core-interfaces-php.git", - "reference": "a030736e675b5522a5c1183ebfb1b9b101eb1181" + "reference": "b4f1bffc8be79584836f70af33c65e097eec155c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/a030736e675b5522a5c1183ebfb1b9b101eb1181", - "reference": "a030736e675b5522a5c1183ebfb1b9b101eb1181", + "url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/b4f1bffc8be79584836f70af33c65e097eec155c", + "reference": "b4f1bffc8be79584836f70af33c65e097eec155c", "shasum": "" }, "require": { @@ -45,9 +45,9 @@ ], "support": { "issues": "https://github.com/apimatic/core-interfaces-php/issues", - "source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.4" + "source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.5" }, - "time": "2024-05-04T04:33:59+00:00" + "time": "2024-05-09T06:32:07+00:00" }, { "name": "apimatic/jsonmapper", @@ -158,6 +158,56 @@ "source": "https://github.com/raphaelstolt/php-jsonpointer/tree/master" }, "time": "2016-08-29T08:51:01+00:00" + }, + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" } ], "packages-dev": [ @@ -315,16 +365,16 @@ }, { "name": "composer/xdebug-handler", - "version": "3.0.4", + "version": "3.0.5", "source": { "type": "git", "url": "https://github.com/composer/xdebug-handler.git", - "reference": "4f988f8fdf580d53bdb2d1278fe93d1ed5462255" + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/4f988f8fdf580d53bdb2d1278fe93d1ed5462255", - "reference": "4f988f8fdf580d53bdb2d1278fe93d1ed5462255", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", "shasum": "" }, "require": { @@ -361,7 +411,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/3.0.4" + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" }, "funding": [ { @@ -377,7 +427,7 @@ "type": "tidelift" } ], - "time": "2024-03-26T18:29:49+00:00" + "time": "2024-05-06T16:37:16+00:00" }, { "name": "doctrine/deprecations", @@ -1128,16 +1178,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.28.0", + "version": "1.29.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "cd06d6b1a1b3c75b0b83f97577869fd85a3cd4fb" + "reference": "536889f2b340489d328f5ffb7b02bb6b183ddedc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/cd06d6b1a1b3c75b0b83f97577869fd85a3cd4fb", - "reference": "cd06d6b1a1b3c75b0b83f97577869fd85a3cd4fb", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/536889f2b340489d328f5ffb7b02bb6b183ddedc", + "reference": "536889f2b340489d328f5ffb7b02bb6b183ddedc", "shasum": "" }, "require": { @@ -1169,9 +1219,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.28.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.29.0" }, - "time": "2024-04-03T18:51:33+00:00" + "time": "2024-05-06T12:04:23+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1648,56 +1698,6 @@ }, "time": "2021-11-05T16:47:00+00:00" }, - { - "name": "psr/log", - "version": "3.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", - "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.0" - }, - "time": "2021-07-14T16:46:02+00:00" - }, { "name": "sabre/event", "version": "5.1.4", diff --git a/src/Client.php b/src/Client.php index 55d2577..77c8dfe 100644 --- a/src/Client.php +++ b/src/Client.php @@ -13,6 +13,7 @@ use Core\Types\Sdk\CoreCallback; use Core\Utils\JsonHelper; use CoreInterfaces\Core\Authentication\AuthInterface; +use CoreInterfaces\Core\Logger\ApiLoggerInterface; use CoreInterfaces\Core\Request\ParamInterface; use CoreInterfaces\Http\HttpClientInterface; use CoreInterfaces\Sdk\ConverterInterface; @@ -46,6 +47,7 @@ public static function getJsonHelper(Client $client = null): JsonHelper private $globalRuntimeConfig; private $globalErrors; private $apiCallback; + private $apiLogger; /** * @param HttpClientInterface $httpClient @@ -58,6 +60,7 @@ public static function getJsonHelper(Client $client = null): JsonHelper * @param ParamInterface[] $globalRuntimeConfig * @param array $globalErrors * @param CoreCallback|null $apiCallback + * @param ApiLoggerInterface $apiLogger */ public function __construct( HttpClientInterface $httpClient, @@ -69,7 +72,8 @@ public function __construct( array $globalConfig, array $globalRuntimeConfig, array $globalErrors, - ?CoreCallback $apiCallback + ?CoreCallback $apiCallback, + ApiLoggerInterface $apiLogger ) { $this->httpClient = $httpClient; self::$converter = $converter; @@ -83,6 +87,7 @@ public function __construct( $this->globalRuntimeConfig = $globalRuntimeConfig; $this->globalErrors = $globalErrors; $this->apiCallback = $apiCallback; + $this->apiLogger = $apiLogger; } public function getGlobalRequest(?string $server = null): Request @@ -106,6 +111,11 @@ public function getHttpClient(): HttpClientInterface return $this->httpClient; } + public function getApiLogger(): ApiLoggerInterface + { + return $this->apiLogger; + } + public function validateAuth(Auth $auth): Auth { $auth->withAuthManagers($this->authManagers)->validate(self::getJsonHelper($this)); @@ -128,6 +138,7 @@ public function beforeRequest(Request $request) if (isset($this->apiCallback)) { $this->apiCallback->callOnBeforeWithConversion($request, self::getConverter($this)); } + $this->apiLogger->logRequest($request); } public function afterResponse(Context $context) @@ -135,5 +146,6 @@ public function afterResponse(Context $context) if (isset($this->apiCallback)) { $this->apiCallback->callOnAfterWithConversion($context, self::getConverter($this)); } + $this->apiLogger->logResponse($context->getResponse()); } } diff --git a/src/ClientBuilder.php b/src/ClientBuilder.php index 24a7a6a..531f96d 100644 --- a/src/ClientBuilder.php +++ b/src/ClientBuilder.php @@ -4,6 +4,9 @@ namespace Core; +use Core\Logger\ApiLogger; +use Core\Logger\Configuration\LoggingConfiguration; +use Core\Logger\NullApiLogger; use Core\Request\Parameters\HeaderParam; use Core\Response\Types\ErrorType; use Core\Types\Sdk\CoreCallback; @@ -66,6 +69,11 @@ public static function init(HttpClientInterface $httpClient): self */ private $apiCallback; + /** + * @var LoggingConfiguration|null + */ + private $loggingConfig; + /** * @var string|null */ @@ -131,6 +139,12 @@ public function apiCallback($apiCallback): self return $this; } + public function loggingConfiguration(?LoggingConfiguration $loggingConfig): self + { + $this->loggingConfig = $loggingConfig; + return $this; + } + /** * @param ParamInterface[] $globalParams * @return $this @@ -203,7 +217,8 @@ public function build(): Client $this->globalConfig, $this->globalRuntimeConfig, $this->globalErrors, - $this->apiCallback + $this->apiCallback, + is_null($this->loggingConfig) ? new NullApiLogger() : new ApiLogger($this->loggingConfig) ); } } diff --git a/src/Logger/ApiLogger.php b/src/Logger/ApiLogger.php new file mode 100644 index 0000000..ab5926d --- /dev/null +++ b/src/Logger/ApiLogger.php @@ -0,0 +1,115 @@ +config = $config; + } + + /** + * @inheritDoc + */ + public function logRequest(RequestInterface $request): void + { + $contentType = $this->getHeaderValue(LoggerConstants::CONTENT_TYPE_HEADER, $request->getHeaders()); + + $this->config->logMessage( + 'Request {' . LoggerConstants::METHOD . '} {' . LoggerConstants::URL . + '} {' . LoggerConstants::CONTENT_TYPE . '}', + [ + LoggerConstants::METHOD => $request->getHttpMethod(), + LoggerConstants::URL => $this->getRequestUrl($request), + LoggerConstants::CONTENT_TYPE => $contentType + ] + ); + + if ($this->config->getRequestConfig()->shouldLogHeaders()) { + $headers = $this->config->getRequestConfig()->getLoggableHeaders( + $request->getHeaders(), + $this->config->shouldMaskSensitiveHeaders() + ); + $this->config->logMessage( + 'Request Headers {' . LoggerConstants::HEADERS . '}', + [LoggerConstants::HEADERS => $headers] + ); + } + + if ($this->config->getRequestConfig()->shouldLogBody()) { + $body = $request->getParameters(); + if (empty($body)) { + $body = $request->getBody(); + } + $this->config->logMessage( + 'Request Body {' . LoggerConstants::BODY . '}', + [LoggerConstants::BODY => $body] + ); + } + } + + /** + * @inheritDoc + */ + public function logResponse(ResponseInterface $response): void + { + $contentLength = $this->getHeaderValue(LoggerConstants::CONTENT_LENGTH_HEADER, $response->getHeaders()); + $contentType = $this->getHeaderValue(LoggerConstants::CONTENT_TYPE_HEADER, $response->getHeaders()); + + $this->config->logMessage( + 'Response {' . LoggerConstants::STATUS_CODE . '} {' . LoggerConstants::CONTENT_LENGTH . + '} {' . LoggerConstants::CONTENT_TYPE . '}', + [ + LoggerConstants::STATUS_CODE => $response->getStatusCode(), + LoggerConstants::CONTENT_LENGTH => $contentLength, + LoggerConstants::CONTENT_TYPE => $contentType + ] + ); + + if ($this->config->getResponseConfig()->shouldLogHeaders()) { + $headers = $this->config->getResponseConfig()->getLoggableHeaders( + $response->getHeaders(), + $this->config->shouldMaskSensitiveHeaders() + ); + $this->config->logMessage( + 'Response Headers {' . LoggerConstants::HEADERS . '}', + [LoggerConstants::HEADERS => $headers] + ); + } + + if ($this->config->getResponseConfig()->shouldLogBody()) { + $this->config->logMessage( + 'Response Body {' . LoggerConstants::BODY . '}', + [LoggerConstants::BODY => $response->getRawBody()] + ); + } + } + + private function getHeaderValue(string $key, array $headers): ?string + { + $key = strtolower($key); + foreach ($headers as $k => $value) { + if (strtolower($k) === $key) { + return $value; + } + } + return null; + } + + private function getRequestUrl(RequestInterface $request): string + { + $queryUrl = $request->getQueryUrl(); + if ($this->config->getRequestConfig()->shouldIncludeQueryInPath()) { + return $queryUrl; + } + return explode("?", $queryUrl)[0]; + } +} diff --git a/src/Logger/Configuration/BaseHttpLoggingConfiguration.php b/src/Logger/Configuration/BaseHttpLoggingConfiguration.php new file mode 100644 index 0000000..797f120 --- /dev/null +++ b/src/Logger/Configuration/BaseHttpLoggingConfiguration.php @@ -0,0 +1,95 @@ +logBody = $logBody; + $this->logHeaders = $logHeaders; + $this->headersToInclude = array_map('strtolower', $headersToInclude); + $this->headersToExclude = empty($headersToInclude) ? array_map('strtolower', $headersToExclude) : []; + $this->headersToUnmask = array_merge( + array_map('strtolower', LoggerConstants::NON_SENSITIVE_HEADERS), + array_map('strtolower', $headersToUnmask) + ); + } + + /** + * Indicates whether to log the body. + */ + public function shouldLogBody(): bool + { + return $this->logBody; + } + + /** + * Indicates whether to log the headers. + */ + public function shouldLogHeaders(): bool + { + return $this->logHeaders; + } + + /** + * Select the headers from the list of provided headers for logging. + * + * @param string[] $headers + * @param bool $maskSensitiveHeaders + * + * @return string[] + */ + public function getLoggableHeaders(array $headers, bool $maskSensitiveHeaders): array + { + $sensitiveHeaders = []; + $filteredHeaders = array_filter($headers, function ($key) use ($maskSensitiveHeaders, &$sensitiveHeaders) { + $lowerCaseKey = strtolower(strval($key)); + if ($maskSensitiveHeaders && $this->isSensitiveHeader($lowerCaseKey)) { + $sensitiveHeaders[$key] = '**Redacted**'; + } + if ( + (empty($this->headersToInclude) || in_array($lowerCaseKey, $this->headersToInclude, true)) && + (empty($this->headersToExclude) || !in_array($lowerCaseKey, $this->headersToExclude, true)) + ) { + return true; + } + unset($sensitiveHeaders[$key]); + return false; + }, ARRAY_FILTER_USE_KEY); + + return array_merge($filteredHeaders, $sensitiveHeaders); + } + + private function isSensitiveHeader($headerKey): bool + { + if (in_array($headerKey, $this->headersToUnmask, true)) { + return false; + } + return true; + } +} diff --git a/src/Logger/Configuration/LoggingConfiguration.php b/src/Logger/Configuration/LoggingConfiguration.php new file mode 100644 index 0000000..f63765b --- /dev/null +++ b/src/Logger/Configuration/LoggingConfiguration.php @@ -0,0 +1,70 @@ +logger = $logger ?? new ConsoleLogger('printf'); + $this->level = $level; + $this->maskSensitiveHeaders = $maskSensitiveHeaders; + $this->requestConfig = $requestConfig; + $this->responseConfig = $responseConfig; + } + + /** + * Log the given message using the context array. This function uses the + * LogLevel and Logger instance set via constructor of this class. + */ + public function logMessage(string $message, array $context): void + { + $this->logger->log($this->level, $message, $context); + } + + /** + * Indicates whether sensitive headers should be masked in logs. + * + * @return bool True if sensitive headers should be masked, false otherwise. + */ + public function shouldMaskSensitiveHeaders(): bool + { + return $this->maskSensitiveHeaders; + } + + /** + * Gets the request configuration for logging. + * + * @return RequestConfiguration The request configuration. + */ + public function getRequestConfig(): RequestConfiguration + { + return $this->requestConfig; + } + + /** + * Gets the response configuration for logging. + * + * @return ResponseConfiguration The response configuration. + */ + public function getResponseConfig(): ResponseConfiguration + { + return $this->responseConfig; + } +} diff --git a/src/Logger/Configuration/RequestConfiguration.php b/src/Logger/Configuration/RequestConfiguration.php new file mode 100644 index 0000000..1de6711 --- /dev/null +++ b/src/Logger/Configuration/RequestConfiguration.php @@ -0,0 +1,46 @@ +includeQueryInPath = $includeQueryInPath; + } + + /** + * Indicates whether to include query parameters in the logged path. + */ + public function shouldIncludeQueryInPath(): bool + { + return $this->includeQueryInPath; + } +} diff --git a/src/Logger/Configuration/ResponseConfiguration.php b/src/Logger/Configuration/ResponseConfiguration.php new file mode 100644 index 0000000..8153f29 --- /dev/null +++ b/src/Logger/Configuration/ResponseConfiguration.php @@ -0,0 +1,9 @@ +printer = $printer; + } + + /** + * @inheritDoc + */ + public function log($level, $message, array $context = []): void + { + if (!in_array($level, LoggerConstants::ALLOWED_LOG_LEVELS, true)) { + throw new InvalidArgumentException( + "Invalid LogLevel: $level. See Psr\Log\LogLevel.php for possible values of log levels." + ); + } + Closure::fromCallable($this->printer)("%s: %s\n", $level, str_replace( + array_map(function ($key) { + return '{' . $key . '}'; + }, array_keys($context)), + array_map(function ($value) { + return CoreHelper::serialize($value); + }, $context), + $message + )); + } +} diff --git a/src/Logger/LoggerConstants.php b/src/Logger/LoggerConstants.php new file mode 100644 index 0000000..4e0763e --- /dev/null +++ b/src/Logger/LoggerConstants.php @@ -0,0 +1,33 @@ + 'my-content-type', + 'HeaderA' => 'value A', + 'HeaderB' => 'value B', + 'Expires' => '2345ms' + ]; + private const REDACTED_VALUE = '**Redacted**'; + + public function testLogLevels() + { + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::INFO)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::DEBUG)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::NOTICE)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::ERROR)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::EMERGENCY)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::ALERT)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::CRITICAL)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::WARNING)); + MockHelper::getMockLogger()->assertLastEntries($this->logAndGetEntry(LogLevel::INFO)); + } + + public function testConsoleLogger() + { + $printer = new MockPrinter(); + $consoleLogger = new ConsoleLogger([$printer, 'printMessage']); + + $this->logAndGetEntry(LogLevel::INFO, $consoleLogger, '{key1}-{key2}', [ + 'key1' => 'valA', + 'key2' => 'valB' + ]); + + $this->assertEquals(["%s: %s\n", LogLevel::INFO, 'valA-valB'], $printer->args); + } + + public function testConsoleLoggerFailure() + { + $level = '__unknown__'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "Invalid LogLevel: $level. See Psr\Log\LogLevel.php for possible values of log levels." + ); + + $printer = new MockPrinter(); + $consoleLogger = new ConsoleLogger([$printer, 'printMessage']); + + $this->logAndGetEntry($level, $consoleLogger); + } + + private function logAndGetEntry( + string $level, + LoggerInterface $logger = null, + string $message = 'someMessage', + array $context = [] + ): LogEntry { + $logEntry = new LogEntry($level, $message, $context); + MockHelper::getLoggingConfiguration($level, null, null, null, $logger) + ->logMessage($logEntry->message, $logEntry->context); + return $logEntry; + } + + public function testDefaultLoggingConfiguration() + { + $apiLogger = MockHelper::getClient()->getApiLogger(); + $this->assertInstanceOf(ApiLoggerInterface::class, $apiLogger); + + $request = new Request(self::TEST_URL); + $response = MockHelper::getClient()->getHttpClient()->execute($request); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => self::TEST_URL, + 'contentType' => null + ]) + ); + $apiLogger->logResponse($response); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::RESPONSE_FORMAT, [ + 'statusCode' => 200, + 'contentLength' => null, + 'contentType' => Format::JSON + ]) + ); + } + + public function testLoggingRequestShouldIncludeInQuery() + { + $requestParams = MockHelper::getClient()->validateParameters([ + QueryParam::init('key', 'value') + ]); + $request = new Request(self::TEST_URL, MockHelper::getClient(), $requestParams); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + null, + null, + MockHelper::getRequestLoggingConfiguration(true) + )); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => 'https://some/path?key=value', + 'contentType' => null + ]) + ); + } + + public function testLoggingRequestContentType() + { + $requestParams = MockHelper::getClient()->validateParameters([ + HeaderParam::init('Content-Type', self::TEST_HEADERS['Content-Type']) + ]); + $request = new Request(self::TEST_URL, MockHelper::getClient(), $requestParams); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration()); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => self::TEST_URL, + 'contentType' => self::TEST_HEADERS['Content-Type'] + ]) + ); + } + + public function testLoggingRequestFileAsBody() + { + $requestParams = MockHelper::getClient()->validateParameters([ + BodyParam::init(MockHelper::getFileWrapper()), + ]); + $request = new Request(self::TEST_URL, MockHelper::getClient(), $requestParams); + $request->setBodyFormat(Format::JSON, [CoreHelper::class, 'serialize']); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + null, + null, + MockHelper::getRequestLoggingConfiguration( + false, + true + ) + )); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => self::TEST_URL, + 'contentType' => 'application/octet-stream' + ]), + new LogEntry(LogLevel::INFO, self::REQUEST_BODY_FORMAT, [ + 'body' => 'This test file is created to test CoreFileWrapper functionality' + ]) + ); + } + + public function testLoggingRequestBody() + { + $requestParams = MockHelper::getClient()->validateParameters([ + BodyParam::init([ + 'key' => 'value' + ]), + ]); + $request = new Request(self::TEST_URL, MockHelper::getClient(), $requestParams); + $request->setBodyFormat(Format::JSON, [CoreHelper::class, 'serialize']); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + null, + null, + MockHelper::getRequestLoggingConfiguration( + false, + true + ) + )); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => self::TEST_URL, + 'contentType' => Format::JSON + ]), + new LogEntry(LogLevel::INFO, self::REQUEST_BODY_FORMAT, [ + 'body' => '{"key":"value"}' + ]) + ); + } + + public function testLoggingRequestFormParams() + { + $requestParams = MockHelper::getClient()->validateParameters([ + FormParam::init('key', 'value') + ]); + $request = new Request(self::TEST_URL, MockHelper::getClient(), $requestParams); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + null, + null, + MockHelper::getRequestLoggingConfiguration( + false, + true + ) + )); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => self::TEST_URL, + 'contentType' => null + ]), + new LogEntry(LogLevel::INFO, self::REQUEST_BODY_FORMAT, [ + 'body' => [ + 'key' => 'value' + ] + ]) + ); + } + + public function testLoggingRequestHeaders() + { + $requestParams = MockHelper::getClient()->validateParameters([ + HeaderParam::init('Content-Type', self::TEST_HEADERS['Content-Type']), + HeaderParam::init('HeaderA', self::TEST_HEADERS['HeaderA']), + HeaderParam::init('HeaderB', self::TEST_HEADERS['HeaderB']), + HeaderParam::init('Expires', self::TEST_HEADERS['Expires']) + ]); + $request = new Request(self::TEST_URL, MockHelper::getClient(), $requestParams); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + null, + null, + MockHelper::getRequestLoggingConfiguration( + false, + false, + true + ) + )); + $apiLogger->logRequest($request); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::REQUEST_FORMAT, [ + 'method' => 'Get', + 'url' => self::TEST_URL, + 'contentType' => self::TEST_HEADERS['Content-Type'] + ]), + new LogEntry(LogLevel::INFO, self::REQUEST_HEADERS_FORMAT, [ + 'headers' => [ + 'Content-Type' => self::TEST_HEADERS['Content-Type'], + 'HeaderA' => self::REDACTED_VALUE, + 'HeaderB' => self::REDACTED_VALUE, + 'Expires' => self::TEST_HEADERS['Expires'], + 'key5' => self::REDACTED_VALUE + ] + ]) + ); + } + + public function testLoggingResponseBody() + { + $response = new MockResponse(); + $response->setStatusCode(200); + $response->setBody([ + 'key' => 'value' + ]); + $response->setHeaders([ + 'content-type' => Format::JSON, + 'content-length' => '45' + ]); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + null, + null, + null, + MockHelper::getResponseLoggingConfiguration(true) + )); + $apiLogger->logResponse($response); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry(LogLevel::INFO, self::RESPONSE_FORMAT, [ + 'statusCode' => 200, + 'contentLength' => '45', + 'contentType' => Format::JSON + ]), + new LogEntry(LogLevel::INFO, self::RESPONSE_BODY_FORMAT, [ + 'body' => '{"key":"value"}' + ]) + ); + } + + public function testLoggingResponseHeaders() + { + $response = new MockResponse(); + $response->setStatusCode(400); + $response->setHeaders(self::TEST_HEADERS); + $apiLogger = new ApiLogger(MockHelper::getLoggingConfiguration( + LogLevel::ERROR, + null, + null, + MockHelper::getResponseLoggingConfiguration( + false, + true + ) + )); + $apiLogger->logResponse($response); + MockHelper::getMockLogger()->assertLastEntries( + new LogEntry('error', self::RESPONSE_FORMAT, [ + 'statusCode' => 400, + 'contentLength' => null, + 'contentType' => self::TEST_HEADERS['Content-Type'] + ]), + new LogEntry('error', self::RESPONSE_HEADERS_FORMAT, [ + 'headers' => [ + 'Content-Type' => self::TEST_HEADERS['Content-Type'], + 'HeaderA' => self::REDACTED_VALUE, + 'HeaderB' => self::REDACTED_VALUE, + 'Expires' => self::TEST_HEADERS['Expires'] + ] + ]) + ); + } + + public function testLoggableHeaders() + { + $responseConfig = MockHelper::getResponseLoggingConfiguration(false, true); + $expectedHeaders = [ + 'Content-Type' => self::TEST_HEADERS['Content-Type'], + 'HeaderA' => self::REDACTED_VALUE, + 'HeaderB' => self::REDACTED_VALUE, + 'Expires' => self::TEST_HEADERS['Expires'] + ]; + $this->assertEquals($expectedHeaders, $responseConfig->getLoggableHeaders(self::TEST_HEADERS, true)); + } + + public function testAllUnMaskedLoggableHeaders() + { + $responseConfig = MockHelper::getResponseLoggingConfiguration(false, true); + $this->assertEquals(self::TEST_HEADERS, $responseConfig->getLoggableHeaders(self::TEST_HEADERS, false)); + } + + public function testIncludedLoggableHeaders() + { + $responseConfig = MockHelper::getResponseLoggingConfiguration( + false, + true, + ['HeaderB', 'Expires'] + ); + $expectedHeaders = [ + 'HeaderB' => self::REDACTED_VALUE, + 'Expires' => self::TEST_HEADERS['Expires'] + ]; + $this->assertEquals($expectedHeaders, $responseConfig->getLoggableHeaders(self::TEST_HEADERS, true)); + } + + public function testExcludedLoggableHeaders() + { + $responseConfig = MockHelper::getResponseLoggingConfiguration( + false, + true, + [], + ['HeaderB', 'Expires'] + ); + $expectedHeaders = [ + 'HeaderA' => self::REDACTED_VALUE, + 'Content-Type' => self::TEST_HEADERS['Content-Type'], + ]; + $this->assertEquals($expectedHeaders, $responseConfig->getLoggableHeaders(self::TEST_HEADERS, true)); + } + + public function testIncludeAndExcludeLoggableHeaders() + { + $responseConfig = MockHelper::getResponseLoggingConfiguration( + false, + true, + ['HEADERB', 'EXPIRES'], + ['EXPIRES'] + ); + $expectedHeaders = [ + 'HeaderB' => self::REDACTED_VALUE, + 'Expires' => self::TEST_HEADERS['Expires'] + ]; + // If both include and exclude headers are provided then only includeHeaders will work + $this->assertEquals($expectedHeaders, $responseConfig->getLoggableHeaders(self::TEST_HEADERS, true)); + } + + public function testUnMaskedLoggableHeaders() + { + $responseConfig = MockHelper::getResponseLoggingConfiguration( + false, + true, + [], + [], + ['HeaderB'] + ); + $expectedHeaders = [ + 'Content-Type' => self::TEST_HEADERS['Content-Type'], + 'HeaderA' => self::REDACTED_VALUE, + 'HeaderB' => self::TEST_HEADERS['HeaderB'], + 'Expires' => self::TEST_HEADERS['Expires'] + ]; + $this->assertEquals($expectedHeaders, $responseConfig->getLoggableHeaders(self::TEST_HEADERS, true)); + } +} diff --git a/tests/Mocking/Logger/LogEntry.php b/tests/Mocking/Logger/LogEntry.php new file mode 100644 index 0000000..d0406c5 --- /dev/null +++ b/tests/Mocking/Logger/LogEntry.php @@ -0,0 +1,36 @@ +level = $level; + $this->message = $message; + $this->context = $context; + } + + public function checkEquals(LogEntry $other): void + { + Assert::assertEquals( + [ + $this->level, + $this->message, + $this->context + ], + [ + $other->level, + $other->message, + $other->context + ], + 'LogEntry did not match' + ); + } +} diff --git a/tests/Mocking/Logger/MockLogger.php b/tests/Mocking/Logger/MockLogger.php new file mode 100644 index 0000000..44c3aa0 --- /dev/null +++ b/tests/Mocking/Logger/MockLogger.php @@ -0,0 +1,47 @@ +logEntries[] = new LogEntry($level, $message, $context); + } + + /** + * Returns the count of entries logged via this logger. + */ + public function countEntries(): int + { + return count($this->logEntries); + } + + /** + * Assert if the given log entries are same as the last added log entries. + */ + public function assertLastEntries(LogEntry ...$logEntries): void + { + Assert::assertGreaterThanOrEqual( + count($logEntries), + count($this->logEntries), + 'Number of expected log entries are greater then actual log entries' + ); + $reversedActual = array_reverse($this->logEntries); + + foreach (array_reverse($logEntries) as $index => $entry) { + $entry->checkEquals($reversedActual[$index]); + } + } +} diff --git a/tests/Mocking/Logger/MockPrinter.php b/tests/Mocking/Logger/MockPrinter.php new file mode 100644 index 0000000..1ba8bff --- /dev/null +++ b/tests/Mocking/Logger/MockPrinter.php @@ -0,0 +1,16 @@ +args = $args; + } +} diff --git a/tests/Mocking/MockHelper.php b/tests/Mocking/MockHelper.php index 541d3d7..70bdbc6 100644 --- a/tests/Mocking/MockHelper.php +++ b/tests/Mocking/MockHelper.php @@ -5,6 +5,9 @@ use Core\ApiCall; use Core\Client; use Core\ClientBuilder; +use Core\Logger\Configuration\LoggingConfiguration; +use Core\Logger\Configuration\RequestConfiguration; +use Core\Logger\Configuration\ResponseConfiguration; use Core\Request\Parameters\AdditionalHeaderParams; use Core\Request\Parameters\HeaderParam; use Core\Request\Parameters\TemplateParam; @@ -13,6 +16,7 @@ use Core\Tests\Mocking\Authentication\FormAuthManager; use Core\Tests\Mocking\Authentication\HeaderAuthManager; use Core\Tests\Mocking\Authentication\QueryAuthManager; +use Core\Tests\Mocking\Logger\MockLogger; use Core\Tests\Mocking\Other\MockChild1; use Core\Tests\Mocking\Other\MockChild2; use Core\Tests\Mocking\Other\MockChild3; @@ -24,6 +28,8 @@ use Core\Tests\Mocking\Types\MockFileWrapper; use Core\Types\CallbackCatcher; use Core\Utils\JsonHelper; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; class MockHelper { @@ -57,12 +63,18 @@ class MockHelper */ private static $urlFileWrapper; + /** + * @var MockLogger + */ + private static $logger; + public static function getClient(): Client { if (!isset(self::$client)) { $clientBuilder = ClientBuilder::init(new MockHttpClient()) ->converter(new MockConverter()) ->apiCallback(self::getCallbackCatcher()) + ->loggingConfiguration(self::getLoggingConfiguration()) ->serverUrls([ 'Server1' => 'http://my/path:3000/{one}', 'Server2' => 'https://my/path/{two}' @@ -164,4 +176,62 @@ public static function getFileWrapperFromUrl(): MockFileWrapper } return self::$urlFileWrapper; } + + public static function getMockLogger(): MockLogger + { + if (!isset(self::$logger)) { + self::$logger = new MockLogger(); + } + return self::$logger; + } + + public static function getLoggingConfiguration( + ?string $logLevel = null, + ?bool $maskSensitiveHeaders = null, + ?RequestConfiguration $requestConfig = null, + ?ResponseConfiguration $responseConfig = null, + ?LoggerInterface $logger = null + ): LoggingConfiguration { + return new LoggingConfiguration( + $logger ?? self::getMockLogger(), + $logLevel ?? LogLevel::INFO, + $maskSensitiveHeaders ?? true, + $requestConfig ?? self::getRequestLoggingConfiguration(), + $responseConfig ?? self::getResponseLoggingConfiguration() + ); + } + + public static function getRequestLoggingConfiguration( + bool $includeQueryInPath = false, + bool $logBody = false, + bool $logHeaders = false, + array $headersToInclude = [], + array $headersToExclude = [], + array $headersToUnmask = [] + ): RequestConfiguration { + return new RequestConfiguration( + $includeQueryInPath, + $logBody, + $logHeaders, + $headersToInclude, + $headersToExclude, + $headersToUnmask + ); + } + + public static function getResponseLoggingConfiguration( + bool $logBody = false, + bool $logHeaders = false, + array $headersToInclude = [], + array $headersToExclude = [], + array $headersToUnmask = [] + ): ResponseConfiguration { + return new ResponseConfiguration( + $logBody, + $logHeaders, + $headersToInclude, + $headersToExclude, + $headersToUnmask + ); + } }