Skip to content

Commit 31cd90d

Browse files
authored
Add queue support for Barstool recordings (#1)
1 parent 0e39907 commit 31cd90d

8 files changed

Lines changed: 275 additions & 21 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ The logging will even log fatal errors caused by your saloon requests so you can
6868
> [!TIP]
6969
> We will be adding more features soon, so keep an eye out for updates!
7070
71+
## Queue Support
72+
73+
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:
74+
75+
```php
76+
// config/barstool.php
77+
'queue' => [
78+
'enabled' => env('BARSTOOL_QUEUE_ENABLED', false),
79+
'connection' => env('BARSTOOL_QUEUE_CONNECTION'), // null uses default connection
80+
'queue' => env('BARSTOOL_QUEUE_NAME'), // null uses default queue
81+
],
82+
```
83+
84+
Or simply set `BARSTOOL_QUEUE_ENABLED=true` in your `.env` file.
85+
86+
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.
87+
7188

7289
## Testing
7390

config/barstool.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,15 @@
6666
// SensitiveRequest::class // Exclude ALL headers for this request
6767
// SensitiveConnector::class // Exclude `token` header for this request
6868
],
69+
70+
/*
71+
* Queue configuration for recording.
72+
* When enabled, recordings will be dispatched as queued jobs
73+
* instead of being written to the database synchronously.
74+
*/
75+
'queue' => [
76+
'enabled' => env('BARSTOOL_QUEUE_ENABLED', false),
77+
'connection' => env('BARSTOOL_QUEUE_CONNECTION'),
78+
'queue' => env('BARSTOOL_QUEUE_NAME'),
79+
],
6980
];

phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<env name="DB_USERNAME" value="root"/>
3232
<env name="DB_HOST" value="127.0.0.1"/>
3333
<env name="DB_PORT" value="3307"/>
34+
<env name="CACHE_STORE" value="array"/>
3435
</php>
3536
<source>
3637
<include>

src/Barstool.php

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use Illuminate\Support\Str;
99
use Saloon\Http\PendingRequest;
1010
use Psr\Http\Message\UriInterface;
11+
use Saloon\Barstool\Enums\RecordingType;
12+
use Saloon\Barstool\Jobs\RecordBarstoolJob;
1113
use Saloon\Contracts\Body\BodyRepository;
1214
use Saloon\Repositories\Body\StreamBodyRepository;
1315
use Saloon\Exceptions\Request\FatalRequestException;
@@ -132,10 +134,7 @@ private static function recordRequest(PendingRequest $data): void
132134

133135
$data->headers()->add('X-Barstool-UUID', $uuid);
134136

135-
$entry = new Models\Barstool;
136-
$entry->uuid = $uuid;
137-
$entry->fill([...self::getRequestData($data)]);
138-
$entry->save();
137+
self::persist(RecordingType::REQUEST, self::getRequestData($data), $uuid);
139138
}
140139

141140
private static function recordResponse(Response $data): void
@@ -147,15 +146,12 @@ private static function recordResponse(Response $data): void
147146
return;
148147
}
149148

150-
$entry = Models\Barstool::query()->firstWhere('uuid', $uuid);
149+
$payload = [
150+
'duration' => self::calculateDuration($data),
151+
...self::getResponseData($data),
152+
];
151153

152-
if ($entry) {
153-
$entry->fill([
154-
'duration' => self::calculateDuration($data),
155-
...self::getResponseData($data),
156-
]);
157-
$entry->save();
158-
}
154+
self::persist(RecordingType::RESPONSE, $payload, $uuid);
159155
}
160156

161157
public static function calculateDuration(Response|PendingRequest $data): int
@@ -173,15 +169,36 @@ private static function recordFatal(FatalRequestException $data): void
173169
$pendingRequest = $data->getPendingRequest();
174170
$uuid = $pendingRequest->headers()->get('X-Barstool-UUID');
175171

176-
$entry = Models\Barstool::query()->firstWhere('uuid', $uuid);
172+
$payload = [
173+
'duration' => self::calculateDuration($pendingRequest),
174+
...self::getFatalData($data),
175+
];
176+
177+
self::persist(RecordingType::FATAL, $payload, $uuid);
178+
}
179+
180+
/**
181+
* @param array<string, mixed> $payload
182+
*/
183+
private static function persist(RecordingType $type, array $payload, string $uuid): void
184+
{
185+
if (self::shouldQueue()) {
186+
RecordBarstoolJob::dispatch($type, $payload, $uuid)
187+
->onConnection(config('barstool.queue.connection'))
188+
->onQueue(config('barstool.queue.queue'));
177189

178-
if ($entry) {
179-
$entry->fill([
180-
'duration' => self::calculateDuration($pendingRequest),
181-
...self::getFatalData($data),
182-
]);
183-
$entry->save();
190+
return;
184191
}
192+
193+
Models\Barstool::query()->updateOrCreate(
194+
['uuid' => $uuid],
195+
$payload,
196+
);
197+
}
198+
199+
private static function shouldQueue(): bool
200+
{
201+
return config('barstool.queue.enabled', false) === true;
185202
}
186203

187204
/**

src/Enums/RecordingType.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Barstool\Enums;
6+
7+
enum RecordingType: string
8+
{
9+
case REQUEST = 'request';
10+
case RESPONSE = 'response';
11+
case FATAL = 'fatal';
12+
}

src/Jobs/RecordBarstoolJob.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Saloon\Barstool\Jobs;
6+
7+
use Saloon\Barstool\Models\Barstool;
8+
use Saloon\Barstool\Enums\RecordingType;
9+
use Illuminate\Foundation\Queue\Queueable;
10+
use Illuminate\Contracts\Queue\ShouldQueue;
11+
use Illuminate\Contracts\Queue\ShouldBeUnique;
12+
13+
class RecordBarstoolJob implements ShouldBeUnique, ShouldQueue
14+
{
15+
use Queueable;
16+
17+
public int $tries = 3;
18+
19+
/** @var array<int> */
20+
public array $backoff = [5, 30];
21+
22+
public int $uniqueFor = 60;
23+
24+
/**
25+
* @param array<string, mixed> $data
26+
*/
27+
public function __construct(
28+
public readonly RecordingType $type,
29+
public readonly array $data,
30+
public readonly string $uuid,
31+
) {}
32+
33+
public function uniqueId(): string
34+
{
35+
return "{$this->uuid}-{$this->type->value}";
36+
}
37+
38+
public function handle(): void
39+
{
40+
Barstool::query()->updateOrCreate(
41+
['uuid' => $this->uuid],
42+
$this->data,
43+
);
44+
}
45+
}

src/Models/Barstool.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ class Barstool extends Model
2222

2323
use MassPrunable;
2424

25-
public const string|null UPDATED_AT = null;
25+
public const ?string UPDATED_AT = null;
2626

2727
protected $fillable = [
28+
'uuid',
2829
'connector_class',
2930
'request_class',
3031
'method',

tests/BarstoolTest.php

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
declare(strict_types=1);
44

55
use Saloon\Http\Faking\MockClient;
6+
use Saloon\Barstool\Models\Barstool;
67
use Saloon\Http\Faking\MockResponse;
8+
use Illuminate\Support\Facades\Queue;
79
use Illuminate\Support\Facades\Artisan;
8-
use Saloon\Barstool\Models\Barstool;
10+
use Saloon\Barstool\Enums\RecordingType;
911
use Saloon\Http\Connectors\NullConnector;
12+
use Saloon\Barstool\Jobs\RecordBarstoolJob;
1013

1114
use function Pest\Laravel\assertDatabaseHas;
1215
use function Pest\Laravel\assertDatabaseCount;
@@ -565,3 +568,150 @@
565568
->response_body->toBe(json_encode($responseBody));
566569

567570
});
571+
572+
it('dispatches jobs when queue is enabled with correct payload', function () {
573+
Queue::fake();
574+
575+
config()->set('barstool.enabled', true);
576+
config()->set('barstool.queue.enabled', true);
577+
578+
MockClient::global([
579+
SoloUserRequest::class => MockResponse::make(
580+
body: ['data' => [['name' => 'John Wayne']]],
581+
status: 200,
582+
headers: ['Content-Type' => 'application/json'],
583+
),
584+
]);
585+
586+
(new SoloUserRequest)->send();
587+
588+
Queue::assertPushed(RecordBarstoolJob::class, 2);
589+
590+
Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
591+
return $job->type === RecordingType::REQUEST
592+
&& $job->data['connector_class'] === NullConnector::class
593+
&& $job->data['request_class'] === SoloUserRequest::class
594+
&& $job->data['method'] === 'GET'
595+
&& $job->data['url'] === 'https://tests.saloon.dev/api/user'
596+
&& $job->data['successful'] === false;
597+
});
598+
599+
Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
600+
return $job->type === RecordingType::RESPONSE
601+
&& $job->data['response_status'] === 200
602+
&& $job->data['successful'] === true
603+
&& $job->data['response_body'] === json_encode(['data' => [['name' => 'John Wayne']]])
604+
&& array_key_exists('duration', $job->data);
605+
});
606+
607+
assertDatabaseCount('barstools', 0);
608+
});
609+
610+
it('dispatches jobs on the configured queue connection and name', function () {
611+
Queue::fake();
612+
613+
config()->set('barstool.enabled', true);
614+
config()->set('barstool.queue.enabled', true);
615+
config()->set('barstool.queue.connection', 'redis');
616+
config()->set('barstool.queue.queue', 'barstool-recordings');
617+
618+
MockClient::global([
619+
SoloUserRequest::class => MockResponse::make(
620+
body: ['data' => [['name' => 'John Wayne']]],
621+
status: 200,
622+
),
623+
]);
624+
625+
(new SoloUserRequest)->send();
626+
627+
Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
628+
return $job->connection === 'redis' && $job->queue === 'barstool-recordings';
629+
});
630+
});
631+
632+
it('processes queued jobs and creates database records with correct data', function () {
633+
config()->set('barstool.enabled', true);
634+
config()->set('barstool.queue.enabled', true);
635+
config()->set('queue.default', 'sync');
636+
637+
MockClient::global([
638+
SoloUserRequest::class => MockResponse::make(
639+
body: ['data' => [['name' => 'John Wayne']]],
640+
status: 200,
641+
headers: ['Content-Type' => 'application/json'],
642+
),
643+
]);
644+
645+
$response = (new SoloUserRequest)->send();
646+
647+
assertDatabaseCount('barstools', 1);
648+
649+
$uuid = $response->getPsrRequest()->getHeader('X-Barstool-UUID')[0];
650+
$barstool = Barstool::where('uuid', $uuid)->sole();
651+
652+
expect($barstool)
653+
->connector_class->toBe(NullConnector::class)
654+
->request_class->toBe(SoloUserRequest::class)
655+
->method->toBe('GET')
656+
->url->toBe('https://tests.saloon.dev/api/user')
657+
->response_status->toBe(200)
658+
->successful->toBeTrue()
659+
->response_body->toBe(json_encode(['data' => [['name' => 'John Wayne']]]))
660+
->duration->not->toBeNull()
661+
->request_headers->toBeArray()
662+
->response_headers->toBeArray();
663+
});
664+
665+
it('dispatches a fatal job when queue is enabled with correct payload', function () {
666+
Queue::fake();
667+
668+
config()->set('barstool.enabled', true);
669+
config()->set('barstool.queue.enabled', true);
670+
671+
MockClient::global([
672+
SoloUserRequest::class => MockResponse::make(
673+
body: ['error' => 'Something went wrong'],
674+
status: 500,
675+
)->throw(fn ($pendingRequest) => new FatalRequestException(new Exception('Fatal error'), $pendingRequest)),
676+
]);
677+
678+
try {
679+
(new SoloUserRequest)->send();
680+
} catch (FatalRequestException) {
681+
// Expected
682+
}
683+
684+
Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
685+
return $job->type === RecordingType::REQUEST
686+
&& $job->data['connector_class'] === NullConnector::class
687+
&& $job->data['request_class'] === SoloUserRequest::class;
688+
});
689+
690+
Queue::assertPushed(RecordBarstoolJob::class, function (RecordBarstoolJob $job) {
691+
return $job->type === RecordingType::FATAL
692+
&& $job->data['fatal_error'] === 'Fatal error'
693+
&& $job->data['successful'] === false
694+
&& $job->data['response_body'] === null
695+
&& array_key_exists('duration', $job->data);
696+
});
697+
});
698+
699+
it('does not dispatch jobs when queue is disabled', function () {
700+
Queue::fake();
701+
702+
config()->set('barstool.enabled', true);
703+
config()->set('barstool.queue.enabled', false);
704+
705+
MockClient::global([
706+
SoloUserRequest::class => MockResponse::make(
707+
body: ['data' => [['name' => 'John Wayne']]],
708+
status: 200,
709+
),
710+
]);
711+
712+
(new SoloUserRequest)->send();
713+
714+
Queue::assertNothingPushed();
715+
716+
assertDatabaseCount('barstools', 1);
717+
});

0 commit comments

Comments
 (0)