From c2482d6cd8864e3f68cba71689b2b2907b8a5ef9 Mon Sep 17 00:00:00 2001 From: Madalin Tache Date: Fri, 27 Mar 2026 16:34:23 +0200 Subject: [PATCH 1/3] Add queue support for Barstool recordings, including tests and documentation --- README.md | 17 +++++ config/barstool.php | 11 +++ src/Barstool.php | 55 ++++++++++----- src/Jobs/RecordBarstoolJob.php | 41 +++++++++++ src/Models/Barstool.php | 3 +- tests/BarstoolTest.php | 124 +++++++++++++++++++++++++++++++++ 6 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 src/Jobs/RecordBarstoolJob.php diff --git a/README.md b/README.md index 56ea387..2784b3a 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,23 @@ The logging will even log fatal errors caused by your saloon requests so you can > [!TIP] > We will be adding more features soon, so keep an eye out for updates! +## Queue Support + +By default, Barstool writes recordings to the database synchronously. If you'd like to offload this to a queue, you can enable it in the config: + +```php +// config/barstool.php +'queue' => [ + 'enabled' => env('BARSTOOL_QUEUE_ENABLED', false), + 'connection' => env('BARSTOOL_QUEUE_CONNECTION'), // null uses default connection + 'queue' => env('BARSTOOL_QUEUE_NAME'), // null uses default queue +], +``` + +Or simply set `BARSTOOL_QUEUE_ENABLED=true` in your `.env` file. + +When queue support is enabled, recordings are dispatched as jobs instead of being written inline. Each job is **unique** (preventing duplicates) and uses **idempotent writes** (`updateOrCreate`), so recordings are safe even if a job is retried. Failed jobs will automatically retry up to 3 times with a backoff of 5 and 30 seconds. + ## Testing diff --git a/config/barstool.php b/config/barstool.php index af87815..e0b0fe2 100644 --- a/config/barstool.php +++ b/config/barstool.php @@ -66,4 +66,15 @@ // SensitiveRequest::class // Exclude ALL headers for this request // SensitiveConnector::class // Exclude `token` header for this request ], + + /* + * Queue configuration for recording. + * When enabled, recordings will be dispatched as queued jobs + * instead of being written to the database synchronously. + */ + 'queue' => [ + 'enabled' => env('BARSTOOL_QUEUE_ENABLED', false), + 'connection' => env('BARSTOOL_QUEUE_CONNECTION'), + 'queue' => env('BARSTOOL_QUEUE_NAME'), + ], ]; diff --git a/src/Barstool.php b/src/Barstool.php index a9560f3..b076334 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Saloon\Http\PendingRequest; use Psr\Http\Message\UriInterface; +use Saloon\Barstool\Jobs\RecordBarstoolJob; use Saloon\Contracts\Body\BodyRepository; use Saloon\Repositories\Body\StreamBodyRepository; use Saloon\Exceptions\Request\FatalRequestException; @@ -132,10 +133,7 @@ private static function recordRequest(PendingRequest $data): void $data->headers()->add('X-Barstool-UUID', $uuid); - $entry = new Models\Barstool; - $entry->uuid = $uuid; - $entry->fill([...self::getRequestData($data)]); - $entry->save(); + self::persist('request', self::getRequestData($data), $uuid); } private static function recordResponse(Response $data): void @@ -147,15 +145,12 @@ private static function recordResponse(Response $data): void return; } - $entry = Models\Barstool::query()->firstWhere('uuid', $uuid); + $payload = [ + 'duration' => self::calculateDuration($data), + ...self::getResponseData($data), + ]; - if ($entry) { - $entry->fill([ - 'duration' => self::calculateDuration($data), - ...self::getResponseData($data), - ]); - $entry->save(); - } + self::persist('response', $payload, $uuid); } public static function calculateDuration(Response|PendingRequest $data): int @@ -173,15 +168,37 @@ private static function recordFatal(FatalRequestException $data): void $pendingRequest = $data->getPendingRequest(); $uuid = $pendingRequest->headers()->get('X-Barstool-UUID'); - $entry = Models\Barstool::query()->firstWhere('uuid', $uuid); + $payload = [ + 'duration' => self::calculateDuration($pendingRequest), + ...self::getFatalData($data), + ]; + + self::persist('fatal', $payload, $uuid); + } + + /** + * @param 'request'|'response'|'fatal' $type + * @param array $payload + */ + private static function persist(string $type, array $payload, string $uuid): void + { + if (self::shouldQueue()) { + RecordBarstoolJob::dispatch($type, $payload, $uuid) + ->onConnection(config('barstool.queue.connection')) + ->onQueue(config('barstool.queue.queue')); - if ($entry) { - $entry->fill([ - 'duration' => self::calculateDuration($pendingRequest), - ...self::getFatalData($data), - ]); - $entry->save(); + return; } + + Models\Barstool::query()->updateOrCreate( + ['uuid' => $uuid], + $payload, + ); + } + + private static function shouldQueue(): bool + { + return config('barstool.queue.enabled', false) === true; } /** diff --git a/src/Jobs/RecordBarstoolJob.php b/src/Jobs/RecordBarstoolJob.php new file mode 100644 index 0000000..41a27e4 --- /dev/null +++ b/src/Jobs/RecordBarstoolJob.php @@ -0,0 +1,41 @@ + */ + public array $backoff = [5, 30]; + + public int $uniqueFor = 60; + + public function __construct( + public readonly string $type, + public readonly array $data, + public readonly string $uuid, + ) {} + + public function uniqueId(): string + { + return "{$this->uuid}-{$this->type}"; + } + + public function handle(): void + { + Barstool::query()->updateOrCreate( + ['uuid' => $this->uuid], + $this->data, + ); + } +} diff --git a/src/Models/Barstool.php b/src/Models/Barstool.php index 5f2e34b..ac9b0f5 100644 --- a/src/Models/Barstool.php +++ b/src/Models/Barstool.php @@ -22,9 +22,10 @@ class Barstool extends Model use MassPrunable; - public const string|null UPDATED_AT = null; + public const ?string UPDATED_AT = null; protected $fillable = [ + 'uuid', 'connector_class', 'request_class', 'method', diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index d04d0ee..7f2e456 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -4,8 +4,10 @@ use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Artisan; use Saloon\Barstool\Models\Barstool; +use Saloon\Barstool\Jobs\RecordBarstoolJob; use Saloon\Http\Connectors\NullConnector; use function Pest\Laravel\assertDatabaseHas; @@ -565,3 +567,125 @@ ->response_body->toBe(json_encode($responseBody)); }); + +it('dispatches jobs when queue is enabled', function () { + Queue::fake(); + + config()->set('barstool.enabled', true); + config()->set('barstool.queue.enabled', true); + + MockClient::global([ + SoloUserRequest::class => MockResponse::make( + body: ['data' => [['name' => 'John Wayne']]], + status: 200, + ), + ]); + + (new SoloUserRequest)->send(); + + Queue::assertPushed(RecordBarstoolJob::class, 2); + + Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { + return $job->type === 'request'; + }); + + Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { + return $job->type === 'response'; + }); + + assertDatabaseCount('barstools', 0); +}); + +it('dispatches jobs on the configured queue connection and name', function () { + Queue::fake(); + + config()->set('barstool.enabled', true); + config()->set('barstool.queue.enabled', true); + config()->set('barstool.queue.connection', 'redis'); + config()->set('barstool.queue.queue', 'barstool-recordings'); + + MockClient::global([ + SoloUserRequest::class => MockResponse::make( + body: ['data' => [['name' => 'John Wayne']]], + status: 200, + ), + ]); + + (new SoloUserRequest)->send(); + + Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { + return $job->connection === 'redis' && $job->queue === 'barstool-recordings'; + }); +}); + +it('processes queued jobs and creates database records', function () { + config()->set('barstool.enabled', true); + config()->set('barstool.queue.enabled', true); + + MockClient::global([ + SoloUserRequest::class => MockResponse::make( + body: ['data' => [['name' => 'John Wayne']]], + status: 200, + ), + ]); + + $response = (new SoloUserRequest)->send(); + + assertDatabaseCount('barstools', 1); + assertDatabaseHas('barstools', [ + 'connector_class' => NullConnector::class, + 'request_class' => SoloUserRequest::class, + 'method' => 'GET', + 'url' => 'https://tests.saloon.dev/api/user', + 'response_status' => 200, + 'successful' => true, + ]); +}); + +it('dispatches a fatal job when queue is enabled and a fatal exception occurs', function () { + Queue::fake(); + + config()->set('barstool.enabled', true); + config()->set('barstool.queue.enabled', true); + + MockClient::global([ + SoloUserRequest::class => MockResponse::make( + body: ['error' => 'Something went wrong'], + status: 500, + )->throw(fn ($pendingRequest) => new FatalRequestException(new \Exception('Fatal error'), $pendingRequest)), + ]); + + try { + (new SoloUserRequest)->send(); + } catch (FatalRequestException) { + // Expected + } + + Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { + return $job->type === 'request'; + }); + + Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { + return $job->type === 'fatal'; + }); +}); + +it('does not dispatch jobs when queue is disabled', function () { + Queue::fake(); + + config()->set('barstool.enabled', true); + config()->set('barstool.queue.enabled', false); + + MockClient::global([ + SoloUserRequest::class => MockResponse::make( + body: ['data' => [['name' => 'John Wayne']]], + status: 200, + ), + ]); + + (new SoloUserRequest)->send(); + + Queue::assertNothingPushed(); + + assertDatabaseCount('barstools', 1); +}); From ccb747dda54e9d1063605d9c6eadc280d820c20a Mon Sep 17 00:00:00 2001 From: Madalin Tache Date: Fri, 27 Mar 2026 16:42:54 +0200 Subject: [PATCH 2/3] Refactor Barstool recording types to use an enum for stronger type safety and improve test assertions for job payloads. --- src/Barstool.php | 10 +++---- src/Enums/RecordingType.php | 12 ++++++++ src/Jobs/RecordBarstoolJob.php | 8 +++-- tests/BarstoolTest.php | 55 ++++++++++++++++++++++++---------- 4 files changed, 63 insertions(+), 22 deletions(-) create mode 100644 src/Enums/RecordingType.php diff --git a/src/Barstool.php b/src/Barstool.php index b076334..02a54a9 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Saloon\Http\PendingRequest; use Psr\Http\Message\UriInterface; +use Saloon\Barstool\Enums\RecordingType; use Saloon\Barstool\Jobs\RecordBarstoolJob; use Saloon\Contracts\Body\BodyRepository; use Saloon\Repositories\Body\StreamBodyRepository; @@ -133,7 +134,7 @@ private static function recordRequest(PendingRequest $data): void $data->headers()->add('X-Barstool-UUID', $uuid); - self::persist('request', self::getRequestData($data), $uuid); + self::persist(RecordingType::Request, self::getRequestData($data), $uuid); } private static function recordResponse(Response $data): void @@ -150,7 +151,7 @@ private static function recordResponse(Response $data): void ...self::getResponseData($data), ]; - self::persist('response', $payload, $uuid); + self::persist(RecordingType::Response, $payload, $uuid); } public static function calculateDuration(Response|PendingRequest $data): int @@ -173,14 +174,13 @@ private static function recordFatal(FatalRequestException $data): void ...self::getFatalData($data), ]; - self::persist('fatal', $payload, $uuid); + self::persist(RecordingType::Fatal, $payload, $uuid); } /** - * @param 'request'|'response'|'fatal' $type * @param array $payload */ - private static function persist(string $type, array $payload, string $uuid): void + private static function persist(RecordingType $type, array $payload, string $uuid): void { if (self::shouldQueue()) { RecordBarstoolJob::dispatch($type, $payload, $uuid) diff --git a/src/Enums/RecordingType.php b/src/Enums/RecordingType.php new file mode 100644 index 0000000..3daed7f --- /dev/null +++ b/src/Enums/RecordingType.php @@ -0,0 +1,12 @@ + $data + */ public function __construct( - public readonly string $type, + public readonly RecordingType $type, public readonly array $data, public readonly string $uuid, ) {} public function uniqueId(): string { - return "{$this->uuid}-{$this->type}"; + return "{$this->uuid}-{$this->type->value}"; } public function handle(): void diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index 7f2e456..f5791b3 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Artisan; use Saloon\Barstool\Models\Barstool; use Saloon\Barstool\Jobs\RecordBarstoolJob; +use Saloon\Barstool\Enums\RecordingType; use Saloon\Http\Connectors\NullConnector; use function Pest\Laravel\assertDatabaseHas; @@ -568,7 +569,7 @@ }); -it('dispatches jobs when queue is enabled', function () { +it('dispatches jobs when queue is enabled with correct payload', function () { Queue::fake(); config()->set('barstool.enabled', true); @@ -578,6 +579,7 @@ SoloUserRequest::class => MockResponse::make( body: ['data' => [['name' => 'John Wayne']]], status: 200, + headers: ['Content-Type' => 'application/json'], ), ]); @@ -586,11 +588,20 @@ Queue::assertPushed(RecordBarstoolJob::class, 2); Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === 'request'; + return $job->type === RecordingType::Request + && $job->data['connector_class'] === NullConnector::class + && $job->data['request_class'] === SoloUserRequest::class + && $job->data['method'] === 'GET' + && $job->data['url'] === 'https://tests.saloon.dev/api/user' + && $job->data['successful'] === false; }); Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === 'response'; + return $job->type === RecordingType::Response + && $job->data['response_status'] === 200 + && $job->data['successful'] === true + && $job->data['response_body'] === json_encode(['data' => [['name' => 'John Wayne']]]) + && array_key_exists('duration', $job->data); }); assertDatabaseCount('barstools', 0); @@ -618,7 +629,7 @@ }); }); -it('processes queued jobs and creates database records', function () { +it('processes queued jobs and creates database records with correct data', function () { config()->set('barstool.enabled', true); config()->set('barstool.queue.enabled', true); @@ -626,23 +637,31 @@ SoloUserRequest::class => MockResponse::make( body: ['data' => [['name' => 'John Wayne']]], status: 200, + headers: ['Content-Type' => 'application/json'], ), ]); $response = (new SoloUserRequest)->send(); assertDatabaseCount('barstools', 1); - assertDatabaseHas('barstools', [ - 'connector_class' => NullConnector::class, - 'request_class' => SoloUserRequest::class, - 'method' => 'GET', - 'url' => 'https://tests.saloon.dev/api/user', - 'response_status' => 200, - 'successful' => true, - ]); + + $uuid = $response->getPsrRequest()->getHeader('X-Barstool-UUID')[0]; + $barstool = Barstool::where('uuid', $uuid)->sole(); + + expect($barstool) + ->connector_class->toBe(NullConnector::class) + ->request_class->toBe(SoloUserRequest::class) + ->method->toBe('GET') + ->url->toBe('https://tests.saloon.dev/api/user') + ->response_status->toBe(200) + ->successful->toBeTrue() + ->response_body->toBe(json_encode(['data' => [['name' => 'John Wayne']]])) + ->duration->not->toBeNull() + ->request_headers->toBeArray() + ->response_headers->toBeArray(); }); -it('dispatches a fatal job when queue is enabled and a fatal exception occurs', function () { +it('dispatches a fatal job when queue is enabled with correct payload', function () { Queue::fake(); config()->set('barstool.enabled', true); @@ -662,11 +681,17 @@ } Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === 'request'; + return $job->type === RecordingType::Request + && $job->data['connector_class'] === NullConnector::class + && $job->data['request_class'] === SoloUserRequest::class; }); Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === 'fatal'; + return $job->type === RecordingType::Fatal + && $job->data['fatal_error'] === 'Fatal error' + && $job->data['successful'] === false + && $job->data['response_body'] === null + && array_key_exists('duration', $job->data); }); }); From 40cf008c4cf13cab4c38a457df88e0703d625b6a Mon Sep 17 00:00:00 2001 From: Madalin Tache Date: Sun, 29 Mar 2026 10:56:09 +0300 Subject: [PATCH 3/3] Standardize `RecordingType` enum values to uppercase and update related tests and logic for consistency. --- phpunit.xml.dist | 1 + src/Barstool.php | 6 +++--- src/Enums/RecordingType.php | 6 +++--- tests/BarstoolTest.php | 15 ++++++++------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9e3ba0d..a639ee2 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -31,6 +31,7 @@ + diff --git a/src/Barstool.php b/src/Barstool.php index 02a54a9..2e9ee83 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -134,7 +134,7 @@ private static function recordRequest(PendingRequest $data): void $data->headers()->add('X-Barstool-UUID', $uuid); - self::persist(RecordingType::Request, self::getRequestData($data), $uuid); + self::persist(RecordingType::REQUEST, self::getRequestData($data), $uuid); } private static function recordResponse(Response $data): void @@ -151,7 +151,7 @@ private static function recordResponse(Response $data): void ...self::getResponseData($data), ]; - self::persist(RecordingType::Response, $payload, $uuid); + self::persist(RecordingType::RESPONSE, $payload, $uuid); } public static function calculateDuration(Response|PendingRequest $data): int @@ -174,7 +174,7 @@ private static function recordFatal(FatalRequestException $data): void ...self::getFatalData($data), ]; - self::persist(RecordingType::Fatal, $payload, $uuid); + self::persist(RecordingType::FATAL, $payload, $uuid); } /** diff --git a/src/Enums/RecordingType.php b/src/Enums/RecordingType.php index 3daed7f..1cdbbc3 100644 --- a/src/Enums/RecordingType.php +++ b/src/Enums/RecordingType.php @@ -6,7 +6,7 @@ enum RecordingType: string { - case Request = 'request'; - case Response = 'response'; - case Fatal = 'fatal'; + case REQUEST = 'request'; + case RESPONSE = 'response'; + case FATAL = 'fatal'; } diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index f5791b3..c4f8a65 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -3,13 +3,13 @@ declare(strict_types=1); use Saloon\Http\Faking\MockClient; +use Saloon\Barstool\Models\Barstool; use Saloon\Http\Faking\MockResponse; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Artisan; -use Saloon\Barstool\Models\Barstool; -use Saloon\Barstool\Jobs\RecordBarstoolJob; use Saloon\Barstool\Enums\RecordingType; use Saloon\Http\Connectors\NullConnector; +use Saloon\Barstool\Jobs\RecordBarstoolJob; use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\assertDatabaseCount; @@ -588,7 +588,7 @@ Queue::assertPushed(RecordBarstoolJob::class, 2); Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === RecordingType::Request + return $job->type === RecordingType::REQUEST && $job->data['connector_class'] === NullConnector::class && $job->data['request_class'] === SoloUserRequest::class && $job->data['method'] === 'GET' @@ -597,7 +597,7 @@ }); Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === RecordingType::Response + return $job->type === RecordingType::RESPONSE && $job->data['response_status'] === 200 && $job->data['successful'] === true && $job->data['response_body'] === json_encode(['data' => [['name' => 'John Wayne']]]) @@ -632,6 +632,7 @@ it('processes queued jobs and creates database records with correct data', function () { config()->set('barstool.enabled', true); config()->set('barstool.queue.enabled', true); + config()->set('queue.default', 'sync'); MockClient::global([ SoloUserRequest::class => MockResponse::make( @@ -671,7 +672,7 @@ SoloUserRequest::class => MockResponse::make( body: ['error' => 'Something went wrong'], status: 500, - )->throw(fn ($pendingRequest) => new FatalRequestException(new \Exception('Fatal error'), $pendingRequest)), + )->throw(fn ($pendingRequest) => new FatalRequestException(new Exception('Fatal error'), $pendingRequest)), ]); try { @@ -681,13 +682,13 @@ } Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === RecordingType::Request + return $job->type === RecordingType::REQUEST && $job->data['connector_class'] === NullConnector::class && $job->data['request_class'] === SoloUserRequest::class; }); Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { - return $job->type === RecordingType::Fatal + return $job->type === RecordingType::FATAL && $job->data['fatal_error'] === 'Fatal error' && $job->data['successful'] === false && $job->data['response_body'] === null