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/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 a9560f3..2e9ee83 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -8,6 +8,8 @@ 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; use Saloon\Exceptions\Request\FatalRequestException; @@ -132,10 +134,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(RecordingType::REQUEST, self::getRequestData($data), $uuid); } private static function recordResponse(Response $data): void @@ -147,15 +146,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(RecordingType::RESPONSE, $payload, $uuid); } public static function calculateDuration(Response|PendingRequest $data): int @@ -173,15 +169,36 @@ 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(RecordingType::FATAL, $payload, $uuid); + } + + /** + * @param array $payload + */ + private static function persist(RecordingType $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/Enums/RecordingType.php b/src/Enums/RecordingType.php new file mode 100644 index 0000000..1cdbbc3 --- /dev/null +++ b/src/Enums/RecordingType.php @@ -0,0 +1,12 @@ + */ + public array $backoff = [5, 30]; + + public int $uniqueFor = 60; + + /** + * @param array $data + */ + public function __construct( + public readonly RecordingType $type, + public readonly array $data, + public readonly string $uuid, + ) {} + + public function uniqueId(): string + { + return "{$this->uuid}-{$this->type->value}"; + } + + 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..c4f8a65 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -3,10 +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\Enums\RecordingType; use Saloon\Http\Connectors\NullConnector; +use Saloon\Barstool\Jobs\RecordBarstoolJob; use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\assertDatabaseCount; @@ -565,3 +568,150 @@ ->response_body->toBe(json_encode($responseBody)); }); + +it('dispatches jobs when queue is enabled with correct payload', 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, + headers: ['Content-Type' => 'application/json'], + ), + ]); + + (new SoloUserRequest)->send(); + + Queue::assertPushed(RecordBarstoolJob::class, 2); + + Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) { + 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 === 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); +}); + +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 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( + body: ['data' => [['name' => 'John Wayne']]], + status: 200, + headers: ['Content-Type' => 'application/json'], + ), + ]); + + $response = (new SoloUserRequest)->send(); + + assertDatabaseCount('barstools', 1); + + $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 with correct payload', 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 === 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 + && $job->data['fatal_error'] === 'Fatal error' + && $job->data['successful'] === false + && $job->data['response_body'] === null + && array_key_exists('duration', $job->data); + }); +}); + +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); +});