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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+