Skip to content
Closed
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
10 changes: 10 additions & 0 deletions config/barstool.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

declare(strict_types=1);

use Saloon\Barstool\Actions\RecordSaloonRequestAction;
use Saloon\Barstool\Actions\RecordSaloonResponseAction;
use Saloon\Barstool\Actions\RecordSaloonFatalExceptionAction;

return [

/*
Expand Down Expand Up @@ -77,4 +81,10 @@
'connection' => env('BARSTOOL_QUEUE_CONNECTION'),
'queue' => env('BARSTOOL_QUEUE_NAME'),
],

'actions' => [
'record_fatal_exception' => RecordSaloonFatalExceptionAction::class,
'record_request' => RecordSaloonRequestAction::class,
'record_response' => RecordSaloonResponseAction::class,
],
];
52 changes: 52 additions & 0 deletions src/Actions/RecordSaloonFatalExceptionAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Saloon\Barstool\Actions;

use Saloon\Barstool\Barstool;
use Psr\Http\Message\UriInterface;
use Saloon\Barstool\Enums\RecordingType;
use Saloon\Exceptions\Request\FatalRequestException;

class RecordSaloonFatalExceptionAction
{
public function execute(FatalRequestException $exception): void
{
if (Barstool::shouldRecord($exception) === false) {
return;
}

$pendingRequest = $exception->getPendingRequest();
$uuid = $pendingRequest->headers()->get('X-Barstool-UUID');

$payload = [
'duration' => Barstool::calculateDuration($pendingRequest),
...self::getFatalData($exception),
];

Barstool::persist(RecordingType::FATAL, $payload, $uuid);
}

/**
* @return array{
* url: UriInterface,
* response_headers: null,
* response_body: null,
* response_status: null,
* successful: false,
* fatal_error: string
* }
*/
private static function getFatalData(FatalRequestException $exception): array
{
return [
'url' => $exception->getPendingRequest()->getUri(),
'response_headers' => null,
'response_body' => null,
'response_status' => null,
'successful' => false,
'fatal_error' => $exception->getMessage(),
];
}
}
99 changes: 99 additions & 0 deletions src/Actions/RecordSaloonRequestAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Saloon\Barstool\Actions;

use Illuminate\Support\Str;
use Saloon\Barstool\Barstool;
use Saloon\Http\PendingRequest;
use Saloon\Barstool\Enums\RecordingType;
use Saloon\Contracts\Body\BodyRepository;
use Saloon\Repositories\Body\StreamBodyRepository;
use Saloon\Repositories\Body\MultipartBodyRepository;

class RecordSaloonRequestAction
{
public function execute(PendingRequest $request): void
{

if (Barstool::shouldRecord($request) === false) {
return;
}

$request->getConnector()->config()->add(
'barstool-request-time',
microtime(true) * 1000
);

$uuid = Str::uuid()->toString();

$request->headers()->add('X-Barstool-UUID', $uuid);

Barstool::persist(RecordingType::REQUEST, self::getRequestData($request), $uuid);
}

/**
* @return array{
* connector_class: class-string,
* request_class: class-string,
* method: string,
* url: string,
* request_headers: array<string, string>|null,
* request_body: BodyRepository|string|null,
* successful: false
* }
*/
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,
};

return [
'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,
'successful' => false,
];
}

/**
* @return array<string, string>
*/
private static function getRequestHeaders(PendingRequest $request): array
{
$excludedHeaders = config('barstool.excluded_request_headers', []);
$headers = collect($request->headers()->all());

// Check if all headers are excluded
if (in_array('*', $excludedHeaders)) {
return $headers->reject(fn ($value, $key) => $key !== 'X-Barstool-UUID')->toArray();
}

// Check if the connector class is excluded
if (in_array(get_class($request->getConnector()), $excludedHeaders)) {
return $headers->reject(fn ($value, $key) => $key !== 'X-Barstool-UUID')->toArray();
}

// Check if the request class is excluded
if (in_array(get_class($request->getRequest()), $excludedHeaders)) {
return $headers->reject(fn ($value, $key) => $key !== 'X-Barstool-UUID')->toArray();
}

return $headers->map(function ($value, $key) use ($excludedHeaders) {
if (in_array($key, $excludedHeaders)) {
$value = 'REDACTED';
}

return $value;
})->toArray();
}
}
135 changes: 135 additions & 0 deletions src/Actions/RecordSaloonResponseAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

namespace Saloon\Barstool\Actions;

use Saloon\Http\Response;
use Illuminate\Support\Str;
use Saloon\Barstool\Barstool;
use Psr\Http\Message\UriInterface;
use Saloon\Barstool\Enums\RecordingType;

class RecordSaloonResponseAction
{
public function execute(Response $response): void
{

if (Barstool::shouldRecord($response) === false) {
return;
}

$response->getConnector()->config()->add(
'barstool-response-time',
microtime(true) * 1000
);

if ($response->successful() && config('barstool.keep_successful_responses') === false) {
return;
}

$psrRequest = $response->getPsrRequest();

$uuid = $psrRequest->getHeader('X-Barstool-UUID')[0] ?? null;
if (is_null($uuid)) {
return;
}

$payload = [
'duration' => Barstool::calculateDuration($response),
...self::getResponseData($response),
];

Barstool::persist(RecordingType::RESPONSE, $payload, $uuid);
}

/**
* @return array{
* url: UriInterface,
* response_headers: array<string, mixed>,
* response_body: string,
* response_status: int,
* successful: bool
* }
*/
private static function getResponseData(Response $response): array
{
$responseBody = self::getResponseBody($response);

return [
'url' => $response->getPsrRequest()->getUri(),
'response_headers' => $response->headers()->all(),
'response_body' => $responseBody,
'response_status' => $response->status(),
'successful' => $response->successful(),
];
}

private static function getResponseBody(Response $response): string
{
$excludedBodies = config('barstool.excluded_response_body', []);

// Check if all bodies are excluded
if (in_array('*', $excludedBodies)) {
return 'REDACTED';
}

// Check if the connector class is excluded
if (in_array(get_class($response->getConnector()), $excludedBodies)) {
return 'REDACTED';
}

// Check if the request class is excluded
if (in_array(get_class($response->getRequest()), $excludedBodies)) {
return 'REDACTED';
}

// Non-seekable bodies (e.g. Guzzle's `stream => true`) can only be read once, so reading
// one here would leave it at EOF and hand the application an empty body. Record a
// placeholder instead, matching how streamed request bodies are handled.
if (! $response->getPsrResponse()->getBody()->isSeekable()) {
return '<Streamed Body>';
}

$contentTypeHeaderKey = $response->headers()->get('Content-Type') ? 'Content-Type' : 'content-type';

if (! Str::startsWith(mb_strtolower((string) $response->headers()->get($contentTypeHeaderKey)), self::supportedContentTypes())) {
return '<Unsupported Barstool Response Content>';
}

$body = $response->body();

return self::checkContentSize($body) ? $body : '<Unsupported Barstool Response Content>';
}

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

return intdiv(mb_strlen($body), 1000) <= config('barstool.max_response_size', 100);
} catch (\Throwable) {
return false;
}
}

/**
* Get the supported content types for response bodies.
*
* @return string[]
*/
private static function supportedContentTypes(): array
{
return [
'application/json',
'application/xml',
'application/soap+xml',
'text/xml',
'text/html',
'text/plain',
];
}
}
Loading