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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ Response bodies for sensitive endpoints can be kept out of the database entirely
],
```

### Excluding request bodies

Request bodies work the same way via `excluded_request_body` — useful for login requests, payloads containing personal data, or anything else that shouldn't sit in your logs:

```php
'excluded_request_body' => [
LoginRequest::class, // exclude the body for a single request
// SensitiveConnector::class, // or a whole connector
// '*', // or every request
],
```

### Response body limits

To keep the table lean, Barstool only stores response bodies that are:
Expand All @@ -170,6 +182,8 @@ To keep the table lean, Barstool only stores response bodies that are:
'max_response_size' => 100,
```

Request bodies have a matching limit, `max_request_size` (also kilobytes, default `100`) — oversized request bodies are stored as `<Unsupported Barstool Request Content>`.

You may also spot a couple of other placeholder values in the `barstools` table: streamed request/response bodies are stored as `<Streamed Body>` (reading them would consume the stream before your application gets it), and multipart request bodies as `<Multipart Body>`.

### Database connection
Expand Down
16 changes: 16 additions & 0 deletions config/barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
*/
'max_response_size' => 100,

/*
* The maximum size of the request body that will be stored in kilobytes.
*/
'max_request_size' => 100,

/*
* Indicates if successful response bodies and headers should be kept.
* If false, the request, status, duration and outcome are still recorded
Expand Down Expand Up @@ -84,6 +89,17 @@
// SensitiveConnector::class // Exclude `token` header for this request
],

/*
* Any request body that should be excluded from the recording.
* This is useful for sensitive data that should not be stored.
* You may exclude entire connectors or requests by class name.
*/
'excluded_request_body' => [
// '*', // All bodies
// SensitiveConnector::class,
// SensitiveRequest::class,
],

/*
* Any response headers that should be excluded from the recording.
* Will replace the header value with `REDACTED`.
Expand Down
56 changes: 42 additions & 14 deletions src/Barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Psr\Http\Message\UriInterface;
use Illuminate\Support\Facades\Context;
use Saloon\Barstool\Enums\RecordingType;
use Saloon\Contracts\Body\BodyRepository;
use Saloon\Barstool\Jobs\RecordBarstoolJob;
use Saloon\Repositories\Body\StreamBodyRepository;
use Saloon\Exceptions\Request\FatalRequestException;
Expand Down Expand Up @@ -131,28 +130,20 @@ public static function record(PendingRequest|Response|FatalRequestException $dat
* method: string,
* url: string,
* request_headers: array<string, string>|null,
* request_body: BodyRepository|string|null,
* request_body: string|null,
* successful: false,
* context?: array<string, mixed>
* }
*/
private static function getRequestData(PendingRequest $request): array
{
$body = $request->body();

$body = match (true) {
$body instanceof StreamBodyRepository => '<Streamed Body>',
$body instanceof MultipartBodyRepository => '<Multipart Body>',
default => $body,
};

$data = [
'connector_class' => get_class($request->getConnector()),
'request_class' => get_class($request->getRequest()),
'method' => $request->getMethod()->value,
'url' => $request->getUrl(),
'request_headers' => self::getRequestHeaders($request),
'request_body' => $body,
'request_body' => self::getRequestBody($request),
'successful' => false,
];

Expand Down Expand Up @@ -350,6 +341,41 @@ private static function supportedContentTypes(): array
];
}

public static function getRequestBody(PendingRequest $request): ?string
{
$body = $request->body();

if ($body === null) {
return null;
}

if ($body instanceof StreamBodyRepository) {
return '<Streamed Body>';
}

if ($body instanceof MultipartBodyRepository) {
return '<Multipart Body>';
}

$excludedBodies = config('barstool.excluded_request_body', []);

if (in_array('*', $excludedBodies)
|| in_array(get_class($request->getConnector()), $excludedBodies)
|| in_array(get_class($request->getRequest()), $excludedBodies)) {
return 'REDACTED';
}

// Saloon's standard repositories are all Stringable; fall back to
// encoding the raw contents for custom repositories that are not.
$body = $body instanceof \Stringable
? (string) $body
: (string) json_encode($body->all());

return self::checkContentSize($body, (int) config('barstool.max_request_size', 100))
? $body
: '<Unsupported Barstool Request Content>';
}

/**
* @return array<string, string>|null
*/
Expand Down Expand Up @@ -425,19 +451,21 @@ public static function getResponseBody(Response $response): string

$body = $response->body();

return self::checkContentSize($body) ? $body : '<Unsupported Barstool Response Content>';
return self::checkContentSize($body, (int) config('barstool.max_response_size', 100))
? $body
: '<Unsupported Barstool Response Content>';
}

/**
* Check if the content is within limits
*/
private static function checkContentSize(mixed $body): bool
private static function checkContentSize(mixed $body, int $maxKilobytes): bool
{
try {
$body = (string) $body;

// strlen, not mb_strlen: the limit is about storage size, so count bytes
return intdiv(strlen($body), 1000) <= config('barstool.max_response_size', 100);
return intdiv(strlen($body), 1000) <= $maxKilobytes;
} catch (\Throwable) {
return false;
}
Expand Down
56 changes: 56 additions & 0 deletions tests/BarstoolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
use Saloon\Exceptions\Request\FatalRequestException;
use Saloon\Barstool\Tests\Fixtures\Requests\PostRequest;
use Saloon\Barstool\Tests\Fixtures\Requests\GetFileRequest;
use Saloon\Barstool\Tests\Fixtures\Requests\JsonPostRequest;
use Saloon\Barstool\Tests\Fixtures\Requests\SoloUserRequest;
use Saloon\Barstool\Tests\Fixtures\Connectors\RandomConnector;
use Saloon\Barstool\Tests\Fixtures\Requests\RequestWithConnector;
Expand Down Expand Up @@ -1260,3 +1261,58 @@

expect(BarstoolRecorder::calculateDuration($pendingRequest))->toBe(1);
});

it('records json request bodies as strings', function () {
config()->set('barstool.enabled', true);

MockClient::global([
JsonPostRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200),
]);

(new RandomConnector)->send(new JsonPostRequest(['name' => 'Doc Holliday']));

expect(Barstool::sole()->request_body)->toBe(json_encode(['name' => 'Doc Holliday']));
});

it('can exclude request bodies for all requests, entire connectors or entire requests', function ($excluded) {
config()->set('barstool.enabled', true);
config()->set('barstool.excluded_request_body', [$excluded]);

MockClient::global([
JsonPostRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200),
]);

(new RandomConnector)->send(new JsonPostRequest(['password' => 'super-secret']));

expect(Barstool::sole()->request_body)->toBe('REDACTED');
})->with([
'*',
RandomConnector::class,
JsonPostRequest::class,
]);

it('limits stored request bodies to max_request_size in bytes', function () {
config()->set('barstool.enabled', true);
config()->set('barstool.max_request_size', 1);

MockClient::global([
JsonPostRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200),
]);

// 700 three-byte characters: 700 chars but 2100 bytes - over a 1KB byte limit
(new RandomConnector)->send(new JsonPostRequest(['blob' => str_repeat('€', 700)]));

expect(Barstool::sole()->request_body)->toBe('<Unsupported Barstool Request Content>');
});

it('stores a null request body when the request has none', function () {
config()->set('barstool.enabled', true);

MockClient::global([
SoloUserRequest::class => MockResponse::make(body: ['data' => 'ok'], status: 200),
]);

(new SoloUserRequest)->send();

expect(Barstool::sole()->request_body)->toBeNull();
});
35 changes: 35 additions & 0 deletions tests/Fixtures/Requests/JsonPostRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Saloon\Barstool\Tests\Fixtures\Requests;

use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Contracts\Body\HasBody;
use Saloon\Traits\Body\HasJsonBody;

class JsonPostRequest extends Request implements HasBody
{
use HasJsonBody;

protected Method $method = Method::POST;

/**
* @param array<string, mixed> $payload
*/
public function __construct(protected array $payload = ['name' => 'Doc Holliday']) {}

/**
* @return array<string, mixed>
*/
protected function defaultBody(): array
{
return $this->payload;
}

public function resolveEndpoint(): string
{
return 'user';
}
}