diff --git a/config/barstool.php b/config/barstool.php index e0b0fe2..2fb5d61 100644 --- a/config/barstool.php +++ b/config/barstool.php @@ -2,6 +2,10 @@ declare(strict_types=1); +use Saloon\Barstool\Actions\RecordSaloonRequestAction; +use Saloon\Barstool\Actions\RecordSaloonResponseAction; +use Saloon\Barstool\Actions\RecordSaloonFatalExceptionAction; + return [ /* @@ -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, + ], ]; diff --git a/src/Actions/RecordSaloonFatalExceptionAction.php b/src/Actions/RecordSaloonFatalExceptionAction.php new file mode 100644 index 0000000..4f20b01 --- /dev/null +++ b/src/Actions/RecordSaloonFatalExceptionAction.php @@ -0,0 +1,52 @@ +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(), + ]; + } +} diff --git a/src/Actions/RecordSaloonRequestAction.php b/src/Actions/RecordSaloonRequestAction.php new file mode 100644 index 0000000..aa94796 --- /dev/null +++ b/src/Actions/RecordSaloonRequestAction.php @@ -0,0 +1,99 @@ +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|null, + * request_body: BodyRepository|string|null, + * successful: false + * } + */ + private static function getRequestData(PendingRequest $request): array + { + $body = $request->body(); + + $body = match (true) { + $body instanceof StreamBodyRepository => '', + $body instanceof MultipartBodyRepository => '', + 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 + */ + 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(); + } +} diff --git a/src/Actions/RecordSaloonResponseAction.php b/src/Actions/RecordSaloonResponseAction.php new file mode 100644 index 0000000..c1f9dcf --- /dev/null +++ b/src/Actions/RecordSaloonResponseAction.php @@ -0,0 +1,135 @@ +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, + * 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 ''; + } + + $contentTypeHeaderKey = $response->headers()->get('Content-Type') ? 'Content-Type' : 'content-type'; + + if (! Str::startsWith(mb_strtolower((string) $response->headers()->get($contentTypeHeaderKey)), self::supportedContentTypes())) { + return ''; + } + + $body = $response->body(); + + return self::checkContentSize($body) ? $body : ''; + } + + /** + * 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', + ]; + } +} diff --git a/src/Barstool.php b/src/Barstool.php index 8a42b59..82fa381 100755 --- a/src/Barstool.php +++ b/src/Barstool.php @@ -5,15 +5,10 @@ namespace Saloon\Barstool; use Saloon\Http\Response; -use Illuminate\Support\Str; use Saloon\Http\PendingRequest; -use Psr\Http\Message\UriInterface; 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; -use Saloon\Repositories\Body\MultipartBodyRepository; class Barstool { @@ -39,117 +34,6 @@ public static function shouldRecord(PendingRequest|Response|FatalRequestExceptio return true; } - public static function record(PendingRequest|Response|FatalRequestException $data): void - { - match (true) { - $data instanceof PendingRequest => self::recordRequest($data), - $data instanceof Response => self::recordResponse($data), - $data instanceof FatalRequestException => self::recordFatal($data), - }; - } - - /** - * @return array{ - * connector_class: class-string, - * request_class: class-string, - * method: string, - * url: string, - * request_headers: array|null, - * request_body: BodyRepository|string|null, - * successful: false - * } - */ - private static function getRequestData(PendingRequest $request): array - { - $body = $request->body(); - - $body = match (true) { - $body instanceof StreamBodyRepository => '', - $body instanceof MultipartBodyRepository => '', - 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{ - * url: UriInterface, - * response_headers: array, - * 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(), - ]; - } - - /** - * @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(), - ]; - } - - private static function recordRequest(PendingRequest $data): void - { - $uuid = Str::uuid()->toString(); - - $data->headers()->add('X-Barstool-UUID', $uuid); - - self::persist(RecordingType::REQUEST, self::getRequestData($data), $uuid); - } - - private static function recordResponse(Response $data): void - { - $psrRequest = $data->getPsrRequest(); - - $uuid = $psrRequest->getHeader('X-Barstool-UUID')[0] ?? null; - if (is_null($uuid)) { - return; - } - - $payload = [ - 'duration' => self::calculateDuration($data), - ...self::getResponseData($data), - ]; - - self::persist(RecordingType::RESPONSE, $payload, $uuid); - } - public static function calculateDuration(Response|PendingRequest $data): int { $config = $data->getConnector()->config(); @@ -160,23 +44,10 @@ public static function calculateDuration(Response|PendingRequest $data): int return $responseTime - $requestTime; } - private static function recordFatal(FatalRequestException $data): void - { - $pendingRequest = $data->getPendingRequest(); - $uuid = $pendingRequest->headers()->get('X-Barstool-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 + public static function persist(RecordingType $type, array $payload, string $uuid): void { if (self::shouldQueue()) { RecordBarstoolJob::dispatch($type, $payload, $uuid) @@ -196,104 +67,4 @@ private static function shouldQueue(): bool { return config('barstool.queue.enabled', false) === true; } - - /** - * 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', - ]; - } - - /** - * @return array|null - */ - public 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(); - } - - public 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 ''; - } - - $contentTypeHeaderKey = $response->headers()->get('Content-Type') ? 'Content-Type' : 'content-type'; - - if (! Str::startsWith(mb_strtolower((string) $response->headers()->get($contentTypeHeaderKey)), self::supportedContentTypes())) { - return ''; - } - - $body = $response->body(); - - return self::checkContentSize($body) ? $body : ''; - } - - /** - * 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; - } - } } diff --git a/src/BarstoolServiceProvider.php b/src/BarstoolServiceProvider.php index 29db6b6..7325c2b 100644 --- a/src/BarstoolServiceProvider.php +++ b/src/BarstoolServiceProvider.php @@ -10,7 +10,11 @@ use Saloon\Http\PendingRequest; use Spatie\LaravelPackageTools\Package; use Saloon\Exceptions\Request\FatalRequestException; +use Saloon\Barstool\Support\Config as BarstoolConfig; +use Saloon\Barstool\Actions\RecordSaloonRequestAction; use Spatie\LaravelPackageTools\PackageServiceProvider; +use Saloon\Barstool\Actions\RecordSaloonResponseAction; +use Saloon\Barstool\Actions\RecordSaloonFatalExceptionAction; class BarstoolServiceProvider extends PackageServiceProvider { @@ -26,41 +30,29 @@ public function configurePackage(Package $package): void public function packageRegistered(): void { Config::globalMiddleware() - ->onFatalException(function (FatalRequestException $exception) { - if (Barstool::shouldRecord($exception) === false) { - return; - } + ->onFatalException(static function (FatalRequestException $exception): void { - Barstool::record($exception); + BarstoolConfig::getAction( + 'record_fatal_exception', + RecordSaloonFatalExceptionAction::class + )->execute($exception); }, order: PipeOrder::FIRST) - ->onRequest(function (PendingRequest $request) { - if (Barstool::shouldRecord($request) === false) { - return; - } + ->onRequest(static function (PendingRequest $request): void { - $request->getConnector()->config()->add( - 'barstool-request-time', - microtime(true) * 1000 - ); + BarstoolConfig::getAction( + 'record_request', + RecordSaloonRequestAction::class + )->execute($request); - Barstool::record($request); }) - ->onResponse(function (Response $response) { - if (Barstool::shouldRecord($response) === false) { - return; - } + ->onResponse(static function (Response $response): void { - $response->getConnector()->config()->add( - 'barstool-response-time', - microtime(true) * 1000 - ); + BarstoolConfig::getAction( + 'record_response', + RecordSaloonResponseAction::class + )->execute($response); - if ($response->successful() && config('barstool.keep_successful_responses') === false) { - return; - } - - Barstool::record($response); }); } } diff --git a/src/Exceptions/InvalidActionClass.php b/src/Exceptions/InvalidActionClass.php new file mode 100644 index 0000000..8e86031 --- /dev/null +++ b/src/Exceptions/InvalidActionClass.php @@ -0,0 +1,15 @@ + $actionBaseClass + * @return TAction + */ + public static function getAction(string $actionName, string $actionBaseClass): object + { + $actionClass = self::getActionClass($actionName, $actionBaseClass); + + return app($actionClass); + } + + /** + * @template TAction of object + * + * @param class-string $actionBaseClass + * @return class-string + */ + public static function getActionClass(string $actionName, string $actionBaseClass): string + { + $actionClass = config("barstool.actions.{$actionName}"); + + if ($actionClass === null) { + return $actionBaseClass; + } + + self::ensureValidActionClass($actionName, $actionBaseClass, $actionClass); + + return $actionClass; + } + + /** + * @template TAction of object + * + * @param class-string $actionBaseClass + * @param class-string $actionClass + * + * @phpstan-assert class-string $actionClass + */ + protected static function ensureValidActionClass(string $actionName, string $actionBaseClass, string $actionClass): void + { + if (! is_a($actionClass, $actionBaseClass, true)) { + throw InvalidActionClass::make($actionName, $actionBaseClass, $actionClass); + } + } +} diff --git a/tests/BarstoolTest.php b/tests/BarstoolTest.php index f4fe21a..bacebe7 100644 --- a/tests/BarstoolTest.php +++ b/tests/BarstoolTest.php @@ -21,8 +21,8 @@ use function Pest\Laravel\assertDatabaseCount; use function Pest\Laravel\assertDatabaseEmpty; -use Saloon\Barstool\Barstool as BarstoolRecorder; use Saloon\Exceptions\Request\FatalRequestException; +use Saloon\Barstool\Actions\RecordSaloonResponseAction; use Saloon\Barstool\Tests\Fixtures\Requests\PostRequest; use Saloon\Barstool\Tests\Fixtures\Requests\GetFileRequest; use Saloon\Barstool\Tests\Fixtures\Requests\SoloUserRequest; @@ -738,7 +738,7 @@ $response = SaloonResponse::fromPsrResponse($psrResponse, $pendingRequest, $pendingRequest->createPsrRequest()); - BarstoolRecorder::record($response); + app(RecordSaloonResponseAction::class)->execute($response); $barstool = Barstool::where('uuid', $pendingRequest->headers()->get('X-Barstool-UUID'))->sole();