From 65e7614c0afd0ef824e9209a295e56372cc9c799 Mon Sep 17 00:00:00 2001 From: Dick van der Heiden Date: Mon, 28 Oct 2024 08:33:25 +0100 Subject: [PATCH] Initial commit --- .github/ISSUE_TEMPLATE/bug_report.md | 27 +++ .github/ISSUE_TEMPLATE/feature_request.md | 15 ++ .github/PULL_REQUEST_TEMPLATE.md | 3 + .github/workflows/ci.yml | 31 +++ .gitignore | 8 + LICENSE | 21 ++ README.md | 196 ++++++++++++++++++ composer.json | 50 +++++ config/request-response-log.php | 40 ++++ ...10_26_100000_create_request_logs_table.php | 31 +++ ...0_26_200000_create_response_logs_table.php | 32 +++ ecs.php | 20 ++ phpstan.neon.dist | 13 ++ phpunit.xml | 35 ++++ src/Contracts/Factory.php | 10 + src/Enums/RequestFlow.php | 9 + src/Factories/PsrRequestLogFactory.php | 103 +++++++++ src/Factories/PsrResponseLogFactory.php | 89 ++++++++ src/Factories/SymfonyRequestLogFactory.php | 89 ++++++++ src/Factories/SymfonyResponseLogFactory.php | 85 ++++++++ src/Helpers/Sanitizer.php | 66 ++++++ src/ManualRequestResponseLogger.php | 50 +++++ .../ApplicationRequestResponseLogger.php | 30 +++ src/Models/LogModel.php | 30 +++ src/Models/RequestLog.php | 81 ++++++++ src/Models/ResponseLog.php | 56 +++++ src/RequestResponseLogServiceProvider.php | 25 +++ src/RequestResponseLogger.php | 42 ++++ tests/Factories/PsrRequestLogFactoryTest.php | 46 ++++ tests/Factories/PsrResponseLogFactoryTest.php | 66 ++++++ .../SymfonyRequestLogFactoryTest.php | 45 ++++ .../SymfonyResponseLogFactoryTest.php | 66 ++++++ tests/Helpers/SanitizerTest.php | 102 +++++++++ tests/ManualRequestResponseLoggerTest.php | 80 +++++++ .../ApplicationRequestResponseLoggerTest.php | 38 ++++ tests/Models/LogModelTest.php | 65 ++++++ tests/Models/RequestLogTest.php | 59 ++++++ tests/Models/ResponseLogTest.php | 46 ++++ tests/RequestResponseLoggerTest.php | 44 ++++ tests/TestCase.php | 23 ++ 40 files changed, 1967 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/request-response-log.php create mode 100644 database/migrations/2024_10_26_100000_create_request_logs_table.php create mode 100644 database/migrations/2024_10_26_200000_create_response_logs_table.php create mode 100644 ecs.php create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml create mode 100644 src/Contracts/Factory.php create mode 100644 src/Enums/RequestFlow.php create mode 100644 src/Factories/PsrRequestLogFactory.php create mode 100644 src/Factories/PsrResponseLogFactory.php create mode 100644 src/Factories/SymfonyRequestLogFactory.php create mode 100644 src/Factories/SymfonyResponseLogFactory.php create mode 100644 src/Helpers/Sanitizer.php create mode 100644 src/ManualRequestResponseLogger.php create mode 100644 src/Middleware/ApplicationRequestResponseLogger.php create mode 100644 src/Models/LogModel.php create mode 100644 src/Models/RequestLog.php create mode 100644 src/Models/ResponseLog.php create mode 100644 src/RequestResponseLogServiceProvider.php create mode 100644 src/RequestResponseLogger.php create mode 100644 tests/Factories/PsrRequestLogFactoryTest.php create mode 100644 tests/Factories/PsrResponseLogFactoryTest.php create mode 100644 tests/Factories/SymfonyRequestLogFactoryTest.php create mode 100644 tests/Factories/SymfonyResponseLogFactoryTest.php create mode 100644 tests/Helpers/SanitizerTest.php create mode 100644 tests/ManualRequestResponseLoggerTest.php create mode 100644 tests/Middleware/ApplicationRequestResponseLoggerTest.php create mode 100644 tests/Models/LogModelTest.php create mode 100644 tests/Models/RequestLogTest.php create mode 100644 tests/Models/ResponseLogTest.php create mode 100644 tests/RequestResponseLoggerTest.php create mode 100644 tests/TestCase.php diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5f1b338 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve the package +title: "" +labels: bug +assignees: dvdheiden +--- + +## Describe the bug + +A clear and concise description of what the bug is. + +## Reproduction + +Steps to reproduce the behavior. + +## Expected behavior + +A clear and concise description of what you expected to happen. + +## Actual behavior + +Describe the behavior as it is right now. + +## Additional information + +Anything else you want to provide. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..03d7347 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest an idea for this package +title: "" +labels: feature +assignees: dvdheiden +--- + +## Goal + +A clear and concise description of which "problem" you want to get solved. Ex. I think this could be easier when... + +## Additional information + +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e7e36af --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +# Changes + +Provide a summary of your changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5585fc4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: RequestResponseLog + +on: [ push ] + +jobs: + tests: + runs-on: ubuntu-latest + + name: Tests + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, intl + + - name: Install composer dependencies + run: composer install --no-ansi --no-interaction --no-scripts --no-suggest --no-progress + + - name: Execute static analysis + run: composer analyse + + - name: Execute tests via PHPUnit + run: composer test:no-coverage + + - name: Check code style + run: composer code-style:check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8835a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/vendor +composer.lock +/build +.phpunit.result.cache +.idea +.phpunit.cache +.DS_Store +.phpactor.json \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cf91f71 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Dick van der Heiden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7caae4f --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# RequestResponseLog + +This package for Laravel applications provides a middleware for logging HTTP requests and responses, both incoming to, +or outgoing from your Laravel application. It offers: +- Middleware to be used with the Laravel HTTP client, Guzzle and Saloon. +- Middleware to log requests made to your application and its response. +- Functionality to manual logs requests and responses. + +It's originally created to log API requests to other services and to log incoming webhooks, but can be used for any +request and response logging. + +## Requirements + +This package requires Laravel 10+ and PHP 8.3+. + +## Installation + +You can install the package via composer: + +```bash +composer require goedemiddag/request-response-log +``` + +The package will automatically register itself in Laravel, but you need to run the migrations: + +```bash +php artisan migrate +``` + +You are now ready to use the package. + +## Usage + +This package provides two middleware solutions: +- Logging HTTP requests and responses, to be used with the Laravel HTTP client, Guzzle and Saloon. +- Logging Requests and Responses from your application. + +### HTTP Logger + +#### Laravel HTTP client + +When using the Laravel HTTP client: + +```php +use Goedemiddag\RequestResponseLog\RequestResponseLogger; +use Illuminate\Support\Facades\Http; + +Http::withMiddleware(RequestResponseLogger::middleware('vendor')) +``` + +#### Guzzle + +When initializing the client: + +```php +use Goedemiddag\RequestResponseLog\RequestResponseLogger; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Handler\CurlHandler; + +$stack = new HandlerStack(); +$stack->setHandler(new CurlHandler()); +$stack->push(RequestResponseLogger::middleware('vendor')); + +$client = new Client(['handler' => $stack]); +``` + +#### Saloon + +In the constructor of the connector: + +```php +use Goedemiddag\RequestResponseLog\RequestResponseLogger; +use Saloon\Http\Connector; + +class YourConnector extends Connector +{ + public function __construct() + { + $this + ->sender() + ->addMiddleware(RequestResponseLogger::middleware('vendor')); + } +} +``` + +### Application logger + +The package provides a middleware for your Laravel application called `ApplicationRequestResponseLogger`. This will log +the incoming request and the response your application generates. This is originally use for logging incoming webhooks, +but feel free to use it for anything you like. Just register the middleware to a group or apply it to a route +individually: + +```php +Route::post('/webhook') + ->uses([WebhookController::class, 'handle']) + ->middleware([ApplicationRequestResponseLogger::class]); +``` + +### Manual logger + +The package provides a logger for manual logging of requests and responses. This can be useful when you want to log +requests and responses that do not support the middleware or any other use case you can come up with. + +```php +use Goedemiddag\RequestResponseLog\Enums\RequestFlow; +use Goedemiddag\RequestResponseLog\ManualRequestResponseLogger; + +$requestLog = ManualRequestResponseLogger::fromRequest( + vendor: 'vendor', + request: $request, + flow: RequestFlow::Incoming, +); + +// TODO your code here + +ManualRequestResponseLogger::fromResponse( + requestLog: $requestLog, + response: $response, +); +``` + +## Configuration + +The package provides a configuration file that allows you to configure the package to your needs. You can change the +table names to your likings, change the database connection (so you can store the logs somewhere else than your default +database), determine which fields should be masked (even per vendor), etc. The configuration file can be published by +running: + +```bash +php artisan vendor:publish --provider="Goedemiddag\RequestResponseLog\RequestResponseLogServiceProvider" --tag="config" +``` + +The configuration file will be published to `config/request-response-log.php`. The configuration file contains comments +to explain more about the options. + +The migrations are loaded automatically and can't be published, as everything you can configure about it, is in the +configuration. + +## Cleaning up + +This package uses the default model prune functionality from Laravel to clean up old logs. You can configure the amount +of days to keep logs in the configuration file. To clean up the logs automatically, schedule the model prune command +and pass the model you want to prune: + +### Laravel 11 + +Add the following to your `routes/console.php` file: + +```php +use Goedemiddag\RequestResponseLog\Models\RequestLog; +use Illuminate\Support\Facades\Schedule; + +Schedule::command('model:prune', ['--model' => [RequestLog::class]])->daily(); +``` + +## Laravel 10 + +Add the following to your `app/Console/Kernel.php` file: + +```php +use Goedemiddag\RequestResponseLog\Models\RequestLog; + +$schedule + ->command('model:prune', ['--model' => [RequestLog::class]]) + ->daily(); +``` + +## Contributing + +Found a bug or want to add a new feature? Great! There are also many other ways to make meaningful contributions such +as reviewing outstanding pull requests and writing documentation. Even opening an issue for a bug you found is +appreciated. + +When you create a pull request, make sure it is tested, following the code standard (run `composer code-style:fix` to +take care of that for you) and please create one pull request per feature. In exchange, you will be credited as +contributor. + +### Testing + +To run the tests, you can use the following command: + +```bash +composer test +``` + +### Security + +If you discover any security related issues in this or other packages of Goedemiddag, please email dev@goedemiddag.nl +instead of using the issue tracker. + +# About Goedemiddag + +[Goedemiddag!](https://www.goedemiddag.nl) is a digital web-agency based in Delft, the Netherlands. We are a team of +professionals who are passionate about the craft of building digital solutions that make a difference for its users. +See our [GitHub organisation](https://github.com/goedemiddag) for more package. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1a937f2 --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "goedemiddag/request-response-log", + "description": "Package for logging (external) requests and the responses", + "homepage": "https://github.com/goedemiddag/request-response-log", + "license": "MIT", + "authors": [ + { + "name": "Dick van der Heiden", + "email": "dick@goedemiddag.nl" + } + ], + "require": { + "php": "^8.3", + "laravel/framework": "^10.0|^11.0" + }, + "require-dev": { + "larastan/larastan": "^2.9", + "orchestra/testbench": "^9.5", + "phpunit/phpunit": "^11.4", + "roave/security-advisories": "dev-latest", + "symplify/easy-coding-standard": "^12.3" + }, + "autoload": { + "psr-4": { + "Goedemiddag\\RequestResponseLog\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Goedemiddag\\RequestResponseLog\\Tests\\": "tests" + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "code-style:check": "vendor/bin/ecs", + "code-style:fix": "vendor/bin/ecs --fix", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/phpunit", + "test:no-coverage": "vendor/bin/phpunit --no-coverage" + }, + "extra": { + "laravel": { + "providers": [ + "Goedemiddag\\RequestResponseLog\\RequestResponseLogServiceProvider" + ] + } + } +} diff --git a/config/request-response-log.php b/config/request-response-log.php new file mode 100644 index 0000000..119075f --- /dev/null +++ b/config/request-response-log.php @@ -0,0 +1,40 @@ + 30, + + 'security' => [ + // For security reason we should not log sensitive fields. + 'sensitive_fields' => [ + 'password', + 'access_token', + 'refresh_token', + 'token', + 'apipassword', + 'client_secret', + 'two_factor_secret', + 'cookie', + 'authorization', + 'php-auth-user', + 'php-auth-pw', + 'php-auth-digest', + ], + + 'sensitive_fields_per_vendor' => [], + ], + + 'database' => [ + // The database connection to use for storing the request and response logs, this can be any connection defined + // in `config/database.php` and different from the application's main database connection. Defaults to the + // application's main database connection. + 'connection' => env('DB_CONNECTION', 'sqlite'), + + // The table name where to model should store the request logs + 'request_log_table' => 'request_logs', + + // The table name where to model should store the response logs + 'response_log_table' => 'response_logs', + ], +]; diff --git a/database/migrations/2024_10_26_100000_create_request_logs_table.php b/database/migrations/2024_10_26_100000_create_request_logs_table.php new file mode 100644 index 0000000..9046b8d --- /dev/null +++ b/database/migrations/2024_10_26_100000_create_request_logs_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('flow'); + $table->string('vendor'); + $table->string('method')->default('GET'); + $table->json('headers')->nullable(); + $table->string('base_uri'); + $table->string('path')->default('/'); + $table->json('query_parameters')->nullable(); + $table->json('body')->nullable(); + $table->string('request_identifier')->nullable()->unique(); + $table->timestamps(); + + $table->index(['flow', 'vendor', 'path']); + }); + } +}; diff --git a/database/migrations/2024_10_26_200000_create_response_logs_table.php b/database/migrations/2024_10_26_200000_create_response_logs_table.php new file mode 100644 index 0000000..ec09487 --- /dev/null +++ b/database/migrations/2024_10_26_200000_create_response_logs_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->uuid('request_log_id'); + $table->boolean('success')->default(false); + $table->unsignedSmallInteger('status_code')->nullable(); + $table->string('reason_phrase')->nullable(); + $table->json('headers')->nullable(); + $table->json('body')->nullable(); + $table->timestamps(); + + $table + ->foreign('request_log_id') + ->references('id') + ->on('request_logs') + ->cascadeOnDelete(); + }); + } +}; diff --git a/ecs.php b/ecs.php new file mode 100644 index 0000000..f2dfba6 --- /dev/null +++ b/ecs.php @@ -0,0 +1,20 @@ +withPaths([ + __DIR__.'/config', + __DIR__.'/database', + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withSkip([ + SingleLineEmptyBodyFixer::class, + MethodArgumentSpaceFixer::class, + ]) + ->withPhpCsFixerSets(perCS20: true); diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..c9f09a1 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + + paths: + - config/ + - database/ + - src/ + - tests/ + + # Level 9 is the highest level + level: 5 \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..fa7e7c3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,35 @@ + + + + + + + + + + tests + + + + + src + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Contracts/Factory.php b/src/Contracts/Factory.php new file mode 100644 index 0000000..f38269e --- /dev/null +++ b/src/Contracts/Factory.php @@ -0,0 +1,10 @@ +request + ->getMethod(); + } + + private function getHeaders(): array + { + return Sanitizer::filterSensitiveData( + array: $this + ->request + ->getHeaders(), + vendor: $this->vendor, + ); + } + + private function getBaseUri(): string + { + $uri = $this + ->request + ->getUri(); + + return Str::of($uri->getScheme()) + ->append('://') + ->append($uri->getHost()) + ->toString(); + } + + private function getPath(): string + { + return $this + ->request + ->getUri() + ->getPath(); + } + + private function getQueryParameters(): array + { + $queryString = $this + ->request + ->getUri() + ->getQuery(); + + return Sanitizer::filterSensitiveData( + array: HeaderUtils::parseQuery($queryString), + vendor: $this->vendor, + ); + } + + private function getBody(): array|string + { + return Sanitizer::sanitizeBody( + body: $this + ->request + ->getBody() + ->getContents(), + vendor: $this->vendor, + ); + } + + private function getRequestIdentifier(): string + { + return $this->requestIdentifier ?? Str::uuid()->toString(); + } + + public function build(): RequestLog + { + return new RequestLog([ + 'flow' => $this->flow, + 'vendor' => $this->vendor, + 'method' => $this->getMethod(), + 'headers' => $this->getHeaders(), + 'base_uri' => $this->getBaseUri(), + 'path' => $this->getPath(), + 'query_parameters' => $this->getQueryParameters(), + 'body' => $this->getBody(), + 'request_identifier' => $this->getRequestIdentifier(), + ]); + } +} diff --git a/src/Factories/PsrResponseLogFactory.php b/src/Factories/PsrResponseLogFactory.php new file mode 100644 index 0000000..23f277b --- /dev/null +++ b/src/Factories/PsrResponseLogFactory.php @@ -0,0 +1,89 @@ +requestLog + ->id; + } + + private function indicateSuccess(): bool + { + return $this + ->response + ->getStatusCode() < 300; + } + + private function getStatusCode(): int + { + return $this + ->response + ->getStatusCode(); + } + + private function getReasonPhrase(): string + { + return $this + ->response + ->getReasonPhrase(); + } + + private function getHeaders(): array + { + $headers = $this + ->response + ->getHeaders(); + + return Sanitizer::filterSensitiveData( + array: $headers, + vendor: $this + ->requestLog + ->vendor, + ); + } + + private function getBody(): array|string + { + $contents = $this + ->response + ->getBody() + ->getContents(); + + return ctype_print($contents) + ? Sanitizer::sanitizeBody( + body: $contents, + vendor: $this + ->requestLog + ->vendor, + ) + : '--binary--'; + } + + public function build(): ResponseLog + { + return new ResponseLog([ + 'request_log_id' => $this->getRequestLogId(), + 'success' => $this->indicateSuccess(), + 'status_code' => $this->getStatusCode(), + 'reason_phrase' => $this->getReasonPhrase(), + 'headers' => $this->getHeaders(), + 'body' => $this->getBody(), + ]); + } +} diff --git a/src/Factories/SymfonyRequestLogFactory.php b/src/Factories/SymfonyRequestLogFactory.php new file mode 100644 index 0000000..add361e --- /dev/null +++ b/src/Factories/SymfonyRequestLogFactory.php @@ -0,0 +1,89 @@ +request + ->getMethod(); + } + + private function getHeaders(): array + { + return Sanitizer::filterSensitiveData( + array: $this + ->request + ->headers + ->all(), + vendor: $this->vendor, + ); + } + + private function getBaseUri(): string + { + return $this + ->request + ->getSchemeAndHttpHost(); + } + + private function getPath(): string + { + return $this + ->request + ->getPathInfo(); + } + + private function getQueryParameters(): array + { + return Sanitizer::filterSensitiveData( + array: $this + ->request + ->query + ->all(), + vendor: $this->vendor, + ); + } + + private function getBody(): array + { + return Sanitizer::filterSensitiveData( + array: $this + ->request + ->getPayload() + ->all(), + vendor: $this->vendor, + ); + } + + public function build(): RequestLog + { + return new RequestLog([ + 'flow' => $this->flow, + 'vendor' => $this->vendor, + 'method' => $this->getMethod(), + 'headers' => $this->getHeaders(), + 'base_uri' => $this->getBaseUri(), + 'path' => $this->getPath(), + 'query_parameters' => $this->getQueryParameters(), + 'body' => $this->getBody(), + 'request_identifier' => $this->requestIdentifier, + ]); + } +} diff --git a/src/Factories/SymfonyResponseLogFactory.php b/src/Factories/SymfonyResponseLogFactory.php new file mode 100644 index 0000000..ed96a95 --- /dev/null +++ b/src/Factories/SymfonyResponseLogFactory.php @@ -0,0 +1,85 @@ +requestLog + ->id; + } + + private function indicateSuccess(): bool + { + return $this + ->response + ->isSuccessful(); + } + + private function getStatusCode(): int + { + return $this + ->response + ->getStatusCode(); + } + + private function getReasonPhrase(): string + { + return Response::$statusTexts[$this->response->getStatusCode()] ?? ''; + } + + private function getHeaders(): array + { + return Sanitizer::filterSensitiveData( + array: $this + ->response + ->headers + ->all(), + vendor: $this + ->requestLog + ->vendor, + ); + } + + private function getBody(): array|string + { + $contents = $this + ->response + ->getContent(); + + return ctype_print($contents) + ? Sanitizer::sanitizeBody( + body: $contents, + vendor: $this + ->requestLog + ->vendor, + ) + : '--binary--'; + } + + public function build(): ResponseLog + { + return new ResponseLog([ + 'request_log_id' => $this->getRequestLogId(), + 'success' => $this->indicateSuccess(), + 'status_code' => $this->getStatusCode(), + 'reason_phrase' => $this->getReasonPhrase(), + 'headers' => $this->getHeaders(), + 'body' => $this->getBody(), + ]); + } +} diff --git a/src/Helpers/Sanitizer.php b/src/Helpers/Sanitizer.php new file mode 100644 index 0000000..0b0de40 --- /dev/null +++ b/src/Helpers/Sanitizer.php @@ -0,0 +1,66 @@ + $value) { + $hasGenericSensitiveKey = in_array($key, $sensitiveFields, true); + $hasVendorSensitiveKey = in_array($key, $sensitiveFieldsPerVendor, true); + + // When the key is sensitive, mask the value + if ($hasGenericSensitiveKey || $hasVendorSensitiveKey) { + $array[$key] = $mask; + + continue; + } + + // When the value is an array, recursively check the array for sensitive data + if (is_array($value)) { + $array[$key] = self::filterSensitiveData( + array: $value, + vendor: $vendor, + mask: $mask, + ); + } + } + + return $array; + } +} diff --git a/src/ManualRequestResponseLogger.php b/src/ManualRequestResponseLogger.php new file mode 100644 index 0000000..17f1f70 --- /dev/null +++ b/src/ManualRequestResponseLogger.php @@ -0,0 +1,50 @@ +build(); + + $requestLog->save(); + + return $requestLog; + } + + public static function fromResponse( + RequestLog $requestLog, + Response $response, + ): ResponseLog { + $factory = new SymfonyResponseLogFactory( + response: $response, + requestLog: $requestLog, + ); + + $responseLog = $factory->build(); + + $responseLog->save(); + + return $responseLog; + } +} diff --git a/src/Middleware/ApplicationRequestResponseLogger.php b/src/Middleware/ApplicationRequestResponseLogger.php new file mode 100644 index 0000000..9a39e09 --- /dev/null +++ b/src/Middleware/ApplicationRequestResponseLogger.php @@ -0,0 +1,30 @@ +setConnection($connection); + + $model = class_basename($this); + $table = config(sprintf('request-response-log.database.%s_table', Str::snake($model))); + if (empty($table)) { + throw new RuntimeException(sprintf('Table name for %s is not set in the configuration.', $model)); + } + + $this->setTable($table); + } +} diff --git a/src/Models/RequestLog.php b/src/Models/RequestLog.php new file mode 100644 index 0000000..08888b8 --- /dev/null +++ b/src/Models/RequestLog.php @@ -0,0 +1,81 @@ + $responses + */ +class RequestLog extends LogModel +{ + use HasUuids; + use Prunable; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'flow', + 'vendor', + 'method', + 'headers', + 'base_uri', + 'path', + 'query_parameters', + 'body', + 'request_identifier', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'flow' => RequestFlow::class, + 'headers' => 'array', + 'query_parameters' => 'array', + 'body' => 'json', + ]; + + /** + * The request has zero or more responses. + */ + public function responses(): HasMany + { + return $this->hasMany(ResponseLog::class); + } + + public function prunable(): Builder + { + $config = config('request-response-log.prune_after_days'); + + // When the config is set to null, we don't want to prune anything so return a query that will never give any + // results. + if ($config === null) { + return static::query()->whereNull('created_at'); + } + + return static::query()->where('created_at', '<=', Carbon::now()->subDays()); + } +} diff --git a/src/Models/ResponseLog.php b/src/Models/ResponseLog.php new file mode 100644 index 0000000..cad48ea --- /dev/null +++ b/src/Models/ResponseLog.php @@ -0,0 +1,56 @@ + + */ + protected $fillable = [ + 'request_log_id', + 'success', + 'status_code', + 'reason_phrase', + 'headers', + 'body', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'success' => 'boolean', + 'headers' => 'array', + 'body' => 'json', + ]; + + /** + * The response belongs to a request. + */ + public function request(): BelongsTo + { + return $this->belongsTo(RequestLog::class, 'request_log_id'); + } +} diff --git a/src/RequestResponseLogServiceProvider.php b/src/RequestResponseLogServiceProvider.php new file mode 100644 index 0000000..4601337 --- /dev/null +++ b/src/RequestResponseLogServiceProvider.php @@ -0,0 +1,25 @@ +publishes( + paths: [ + __DIR__ . '/../config/request-response-log.php' => config_path('request-response-log.php'), + ], + groups: 'config', + ); + + $this->mergeConfigFrom( + path: __DIR__ . '/../config/request-response-log.php', + key: 'request-response-log', + ); + + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + } +} diff --git a/src/RequestResponseLogger.php b/src/RequestResponseLogger.php new file mode 100644 index 0000000..665cfdc --- /dev/null +++ b/src/RequestResponseLogger.php @@ -0,0 +1,42 @@ +build(); + + $requestLog->save(); + + return $handler($request, $options)->then( + function (ResponseInterface $response) use ($requestLog): ResponseInterface { + $responseLogFactory = new PsrResponseLogFactory( + response: $response, + requestLog: $requestLog, + ); + + $responseLog = $responseLogFactory->build(); + + $responseLog->save(); + + return $response; + }, + ); + }; + }; + } +} diff --git a/tests/Factories/PsrRequestLogFactoryTest.php b/tests/Factories/PsrRequestLogFactoryTest.php new file mode 100644 index 0000000..b45afd7 --- /dev/null +++ b/tests/Factories/PsrRequestLogFactoryTest.php @@ -0,0 +1,46 @@ + 'application/json', + ], + body: '{"hello": "world"}', + ), + vendor: 'test', + flow: RequestFlow::Incoming, + requestIdentifier: 'hello-world', + ); + + $requestLog = $factory->build(); + + $requestLog->save(); + + $this->assertDatabaseHas('request_logs', [ + 'id' => $requestLog->id, + 'flow' => 'in', + 'vendor' => 'test', + 'method' => 'POST', + 'headers' => json_encode(['Host' => ['echo.hoppscotch.io'], 'Accept' => ['application/json']]), + 'base_uri' => 'https://echo.hoppscotch.io', + 'path' => '/path', + 'query_parameters' => json_encode(['test' => '1']), + 'body' => json_encode(['hello' => 'world']), + 'request_identifier' => 'hello-world', + ]); + } +} diff --git a/tests/Factories/PsrResponseLogFactoryTest.php b/tests/Factories/PsrResponseLogFactoryTest.php new file mode 100644 index 0000000..931e672 --- /dev/null +++ b/tests/Factories/PsrResponseLogFactoryTest.php @@ -0,0 +1,66 @@ + 'application/json', + ], + body: '{"hello": "world"}', + ), + vendor: 'test', + flow: RequestFlow::Incoming, + requestIdentifier: 'hello-world', + ); + + $requestLog = $factory->build(); + + $requestLog->save(); + + return $requestLog; + } + + public function test_it_can_build_a_response_log(): void + { + $requestLog = $this->generateRequestLog(); + + $factory = new PsrResponseLogFactory( + response: new Response( + status: 200, + headers: [ + 'Content-Type' => 'application/json', + ], + body: '{"hello": "world"}', + ), + requestLog: $requestLog, + ); + + $responseLog = $factory->build(); + + $responseLog->save(); + + $this->assertDatabaseHas('response_logs', [ + 'id' => $responseLog->id, + 'request_log_id' => $requestLog->id, + 'status_code' => 200, + 'headers' => json_encode(['Content-Type' => ['application/json']]), + 'body' => json_encode(['hello' => 'world']), + ]); + } +} diff --git a/tests/Factories/SymfonyRequestLogFactoryTest.php b/tests/Factories/SymfonyRequestLogFactoryTest.php new file mode 100644 index 0000000..ab18b39 --- /dev/null +++ b/tests/Factories/SymfonyRequestLogFactoryTest.php @@ -0,0 +1,45 @@ +headers->set('Accept', 'application/json'); + + $factory = new SymfonyRequestLogFactory( + request: $request, + vendor: 'test', + flow: RequestFlow::Incoming, + requestIdentifier: 'hello-world', + ); + + $requestLog = $factory->build(); + + $requestLog->save(); + + $this->assertDatabaseHas('request_logs', [ + 'id' => $requestLog->id, + 'flow' => 'in', + 'vendor' => 'test', + 'method' => 'POST', + 'headers' => '{"host":["echo.hoppscotch.io"],"user-agent":["Symfony"],"accept":["application\\/json"],"accept-language":["en-us,en;q=0.5"],"accept-charset":["ISO-8859-1,utf-8;q=0.7,*;q=0.7"],"content-type":["application\\/x-www-form-urlencoded"]}', + 'base_uri' => 'https://echo.hoppscotch.io', + 'path' => '/path', + 'query_parameters' => json_encode(['test' => '1']), + 'body' => json_encode(['hello' => 'world']), + 'request_identifier' => 'hello-world', + ]); + } +} diff --git a/tests/Factories/SymfonyResponseLogFactoryTest.php b/tests/Factories/SymfonyResponseLogFactoryTest.php new file mode 100644 index 0000000..8895a8b --- /dev/null +++ b/tests/Factories/SymfonyResponseLogFactoryTest.php @@ -0,0 +1,66 @@ +headers->set('Accept', 'application/json'); + + $factory = new SymfonyRequestLogFactory( + request: $request, + vendor: 'test', + flow: RequestFlow::Incoming, + requestIdentifier: 'hello-world', + ); + + $requestLog = $factory->build(); + + $requestLog->save(); + + return $requestLog; + } + + public function test_it_can_build_a_response_log(): void + { + $requestLog = $this->generateRequestLog(); + + $factory = new SymfonyResponseLogFactory( + response: new Response( + content: '{"hello": "world"}', + status: 200, + headers: [ + 'Content-Type' => 'application/json', + ], + ), + requestLog: $requestLog, + ); + + $responseLog = $factory->build(); + + $responseLog->save(); + + $this->assertDatabaseHas('response_logs', [ + 'id' => $responseLog->id, + 'request_log_id' => $requestLog->id, + 'status_code' => 200, + // Ignore the headers as Symfony's Response class adds the date header automatically + // 'headers' => json_encode(['Content-Type' => ['application/json']]), + 'body' => json_encode(['hello' => 'world']), + ]); + } +} diff --git a/tests/Helpers/SanitizerTest.php b/tests/Helpers/SanitizerTest.php new file mode 100644 index 0000000..1412ad0 --- /dev/null +++ b/tests/Helpers/SanitizerTest.php @@ -0,0 +1,102 @@ + 'secret', + 'hello' => 'world', + ]; + + $sanitizedData = Sanitizer::filterSensitiveData($data); + + $this->assertArrayHasKey('password', $sanitizedData); + $this->assertArrayHasKey('hello', $sanitizedData); + $this->assertSame('********', $sanitizedData['password']); + $this->assertSame('world', $sanitizedData['hello']); + } + + public function test_it_filters_sensitive_data_vendor(): void + { + config()->set('request-response-log.security.sensitive_fields_per_vendor.test-vendor', ['hello']); + + $data = [ + 'password' => 'secret', + 'hello' => 'world', + 'foo' => 'bar', + ]; + + $sanitizedData = Sanitizer::filterSensitiveData($data, 'test-vendor'); + + $this->assertArrayHasKey('password', $sanitizedData); + $this->assertArrayHasKey('hello', $sanitizedData); + $this->assertArrayHasKey('foo', $sanitizedData); + $this->assertSame('********', $sanitizedData['password']); + $this->assertSame('********', $sanitizedData['hello']); + $this->assertSame('bar', $sanitizedData['foo']); + } + + public function test_it_filters_sensitive_data_nested(): void + { + $data = [ + 'foo' => 'bar', + 'nested' => [ + 'password' => 'secret2', + 'hello_to' => 'the_world', + ], + ]; + + $sanitizedData = Sanitizer::filterSensitiveData($data); + + $this->assertArrayHasKey('foo', $sanitizedData); + $this->assertArrayHasKey('nested', $sanitizedData); + $this->assertArrayHasKey('password', $sanitizedData['nested']); + $this->assertArrayHasKey('hello_to', $sanitizedData['nested']); + $this->assertSame('********', $sanitizedData['nested']['password']); + $this->assertSame('the_world', $sanitizedData['nested']['hello_to']); + } + + public function test_it_sanitizes_json(): void + { + $body = json_encode([ + 'password' => 'secret', + 'hello' => 'world', + ]); + + $sanitizedBody = Sanitizer::sanitizeBody($body); + + $this->assertIsArray($sanitizedBody); + $this->assertArrayHasKey('password', $sanitizedBody); + $this->assertArrayHasKey('hello', $sanitizedBody); + $this->assertSame('********', $sanitizedBody['password']); + $this->assertSame('world', $sanitizedBody['hello']); + } + + public function test_it_ignores_non_json(): void + { + $body = 'worldsecret'; + + $sanitizedBody = Sanitizer::sanitizeBody($body); + + $this->assertIsString($sanitizedBody); + $this->assertStringContainsString('hello', $sanitizedBody); + $this->assertStringContainsString('world', $sanitizedBody); + $this->assertStringContainsString('password', $sanitizedBody); + $this->assertStringContainsString('secret', $sanitizedBody); + $this->assertStringNotContainsString('********', $sanitizedBody); + } + + public function test_it_ignores_json_strings(): void + { + $sanitizedData = Sanitizer::sanitizeBody(json_encode('hello')); + + $this->assertIsString($sanitizedData); + $this->assertSame('hello', $sanitizedData); + } +} diff --git a/tests/ManualRequestResponseLoggerTest.php b/tests/ManualRequestResponseLoggerTest.php new file mode 100644 index 0000000..acc9d09 --- /dev/null +++ b/tests/ManualRequestResponseLoggerTest.php @@ -0,0 +1,80 @@ +headers->set('Accept', 'application/json'); + + ManualRequestResponseLogger::fromRequest( + request: $request, + vendor: 'test', + flow: RequestFlow::Incoming, + requestIdentifier: 'manual-request-log', + ); + + $this->assertDatabaseHas('request_logs', [ + 'flow' => 'in', + 'vendor' => 'test', + 'method' => 'POST', + 'headers' => '{"host":["echo.hoppscotch.io"],"user-agent":["Symfony"],"accept":["application\\/json"],"accept-language":["en-us,en;q=0.5"],"accept-charset":["ISO-8859-1,utf-8;q=0.7,*;q=0.7"],"content-type":["application\\/x-www-form-urlencoded"]}', + 'base_uri' => 'https://echo.hoppscotch.io', + 'path' => '/path', + 'query_parameters' => json_encode(['test' => '2']), + 'body' => json_encode(['hello' => 'manual-request']), + 'request_identifier' => 'manual-request-log', + ]); + } + + public function test_it_logs_a_response(): void + { + $request = Request::create( + uri: 'https://echo.hoppscotch.io/path?test=2', + method: 'POST', + content: '{"hello": "manual-request"}', + ); + $request->headers->set('Accept', 'application/json'); + + $requestLog = ManualRequestResponseLogger::fromRequest( + vendor: 'test', + request: $request, + flow: RequestFlow::Incoming, + requestIdentifier: 'manual-request-log', + ); + + $response = new Response( + content: '{"hello": "world"}', + status: 200, + headers: [ + 'Content-Type' => 'application/json', + ], + ); + + ManualRequestResponseLogger::fromResponse( + requestLog: $requestLog, + response: $response, + ); + + $this->assertDatabaseHas('response_logs', [ + 'request_log_id' => $requestLog->id, + 'success' => 1, + 'status_code' => 200, + 'reason_phrase' => 'OK', + // Ignore the headers as Symfony's Response class adds the date header automatically + // 'headers' => json_encode(['Content-Type' => 'application/json']), + 'body' => json_encode(['hello' => 'world']), + ]); + } +} diff --git a/tests/Middleware/ApplicationRequestResponseLoggerTest.php b/tests/Middleware/ApplicationRequestResponseLoggerTest.php new file mode 100644 index 0000000..26291d1 --- /dev/null +++ b/tests/Middleware/ApplicationRequestResponseLoggerTest.php @@ -0,0 +1,38 @@ + 'success']), + ); + + $next = function () { + return response('This is the response', 200); + }; + + $middleware = new ApplicationRequestResponseLogger(); + + $response = $middleware->handle($request, $next, 'application'); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertDatabaseHas(RequestLog::class, [ + 'flow' => 'in', + 'vendor' => 'application', + 'method' => 'POST', + 'path' => '/app-middleware', + 'query_parameters' => json_encode(['app' => '1']), + 'body' => json_encode(['logged' => 'success']), + ]); + } +} diff --git a/tests/Models/LogModelTest.php b/tests/Models/LogModelTest.php new file mode 100644 index 0000000..fe4fde9 --- /dev/null +++ b/tests/Models/LogModelTest.php @@ -0,0 +1,65 @@ +set('request-response-log.database.connection', 'test'); + + $model = new RequestLog(); + + $this->assertSame('test', $model->getConnectionName()); + } + + public function test_it_validates_the_connection_from_the_config(): void + { + config()->set('request-response-log.database.connection'); + + $this->expectException(RuntimeException::class); + + new RequestLog(); + } + + public function test_it_uses_the_request_table_from_the_config(): void + { + config()->set('request-response-log.database.request_log_table', 'test_table'); + + $model = new RequestLog(); + + $this->assertSame('test_table', $model->getTable()); + } + + public function test_it_validates_the_request_table_from_the_config(): void + { + config()->set('request-response-log.database.request_log_table'); + + $this->expectException(RuntimeException::class); + + new RequestLog(); + } + + public function test_it_uses_the_response_table_from_the_config(): void + { + config()->set('request-response-log.database.response_log_table', 'test_table'); + + $model = new ResponseLog(); + + $this->assertSame('test_table', $model->getTable()); + } + + public function test_it_validates_the_response_table_from_the_config(): void + { + config()->set('request-response-log.database.response_log_table'); + + $this->expectException(RuntimeException::class); + + new ResponseLog(); + } +} diff --git a/tests/Models/RequestLogTest.php b/tests/Models/RequestLogTest.php new file mode 100644 index 0000000..e087bad --- /dev/null +++ b/tests/Models/RequestLogTest.php @@ -0,0 +1,59 @@ +requestLog = RequestLog::create([ + 'flow' => RequestFlow::Incoming, + 'vendor' => 'vendor', + 'method' => 'POST', + 'headers' => ['Content-Type' => 'application/json'], + 'base_uri' => 'https://echo.hoppscotch.io', + 'path' => '/path', + 'query_parameters' => ['test' => 1], + 'body' => ['hello' => 'world'], + 'request_identifier' => 'hello-world', + ]); + $this->requestLog->created_at = Carbon::now()->subYear(); + $this->requestLog->save(['timestamps' => false]); + + ResponseLog::create([ + 'request_log_id' => $this->requestLog->id, + 'success' => true, + 'status_code' => 200, + 'reason_phrase' => 'OK', + 'headers' => ['Content-Type' => 'application/json'], + 'body' => ['hello' => 'world'], + ]); + } + + public function test_it_relates_to_response(): void + { + $this->assertCount(1, $this->requestLog->responses); + } + + public function test_it_selects_correct_models_for_prune(): void + { + $this->assertSame(1, (new RequestLog())->prunable()->count()); + } + + public function test_it_selects_no_module_for_prune_when_disabled(): void + { + config(['request-response-log.prune_after_days' => null]); + + $this->assertSame(0, (new RequestLog())->prunable()->count()); + } +} diff --git a/tests/Models/ResponseLogTest.php b/tests/Models/ResponseLogTest.php new file mode 100644 index 0000000..a77021c --- /dev/null +++ b/tests/Models/ResponseLogTest.php @@ -0,0 +1,46 @@ +requestLog = RequestLog::create([ + 'flow' => RequestFlow::Incoming, + 'vendor' => 'vendor', + 'method' => 'POST', + 'headers' => ['Content-Type' => 'application/json'], + 'base_uri' => 'https://echo.hoppscotch.io', + 'path' => '/path', + 'query_parameters' => ['test' => 1], + 'body' => ['hello' => 'world'], + 'request_identifier' => 'hello-world', + ]); + $this->responseLog = ResponseLog::create([ + 'request_log_id' => $this->requestLog->id, + 'success' => true, + 'status_code' => 200, + 'reason_phrase' => 'OK', + 'headers' => ['Content-Type' => 'application/json'], + 'body' => ['hello' => 'world'], + ]); + } + + public function test_it_relates_to_request(): void + { + $this->assertInstanceOf(RequestLog::class, $this->responseLog->request); + $this->assertSame($this->requestLog->id, $this->responseLog->request->id); + } +} diff --git a/tests/RequestResponseLoggerTest.php b/tests/RequestResponseLoggerTest.php new file mode 100644 index 0000000..db8c8a4 --- /dev/null +++ b/tests/RequestResponseLoggerTest.php @@ -0,0 +1,44 @@ + 'application/json'], '{"hello": "World!"}'), + ]); + + $handlerStack = HandlerStack::create($mock); + $handlerStack->push(RequestResponseLogger::middleware('middleware-test-vendor')); + + $client = new Client(['handler' => $handlerStack]); + $client->request( + method: 'PATCH', + uri: 'https://echo.hoppscotch.io/middleware?test=true', + options: [ + 'headers' => ['Accept' => 'application/json'], + 'json' => ['hello' => 'world'], + ], + ); + + $this->assertDatabaseHas(RequestLog::class, [ + 'flow' => 'out', + 'vendor' => 'middleware-test-vendor', + 'method' => 'PATCH', + 'headers' => json_encode(['Content-Length' => ['17'], 'User-Agent' => ['GuzzleHttp/7'], 'Content-Type' => ['application/json'], 'Host' => ['echo.hoppscotch.io'], 'Accept' => ['application/json']]), + 'base_uri' => 'https://echo.hoppscotch.io', + 'path' => '/middleware', + 'query_parameters' => json_encode(['test' => 'true']), + 'body' => json_encode(['hello' => 'world']), + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..9a6cba4 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,23 @@ +