Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions config/barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],
];
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<env name="DB_USERNAME" value="root"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="3307"/>
<env name="CACHE_STORE" value="array"/>
</php>
<source>
<include>
Expand Down
55 changes: 36 additions & 19 deletions src/Barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<string, mixed> $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;
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/Enums/RecordingType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Saloon\Barstool\Enums;

enum RecordingType: string
{
case REQUEST = 'request';
case RESPONSE = 'response';
case FATAL = 'fatal';
}
45 changes: 45 additions & 0 deletions src/Jobs/RecordBarstoolJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Saloon\Barstool\Jobs;

use Saloon\Barstool\Models\Barstool;
use Saloon\Barstool\Enums\RecordingType;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;

class RecordBarstoolJob implements ShouldBeUnique, ShouldQueue
{
use Queueable;

public int $tries = 3;

/** @var array<int> */
public array $backoff = [5, 30];

public int $uniqueFor = 60;

/**
* @param array<string, mixed> $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,
);
}
}
3 changes: 2 additions & 1 deletion src/Models/Barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
152 changes: 151 additions & 1 deletion tests/BarstoolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});