diff --git a/composer.json b/composer.json index 01b82d21..dff07bc8 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "phpstan/phpstan": "^1.9", "saloonphp/xml-wrangler": "^1.1", "spatie/ray": "^1.33", - "symfony/dom-crawler": "^6.0" + "symfony/dom-crawler": "^6.0 || ^7.0", + "symfony/var-dumper": "^6.3 || ^7.0" }, "conflict": { "sammyjo20/saloon": "*" @@ -43,7 +44,8 @@ "suggest": { "illuminate/collections": "Required for the response collect() method.", "symfony/dom-crawler": "Required for the response dom() method.", - "saloonphp/xml-wrangler": "Required for the response xmlReader() method." + "saloonphp/xml-wrangler": "Required for the response xmlReader() method.", + "symfony/var-dumper": "Required for default debugging drivers." }, "minimum-stability": "stable", "autoload": { diff --git a/src/Helpers/Debugger.php b/src/Helpers/Debugger.php new file mode 100644 index 00000000..e1e91d02 --- /dev/null +++ b/src/Helpers/Debugger.php @@ -0,0 +1,79 @@ +getHeaders() as $headerName => $value) { + $headers[$headerName] = implode(';', $value); + } + + $className = explode('\\', $pendingRequest->getRequest()::class); + $label = end($className); + + VarDumper::dump([ + 'connector' => $pendingRequest->getConnector()::class, + 'request' => $pendingRequest->getRequest()::class, + 'method' => $psrRequest->getMethod(), + 'uri' => (string)$psrRequest->getUri(), + 'headers' => $headers, + 'body' => (string)$psrRequest->getBody(), + ], 'Saloon Request (' . $label . ') ->'); + } + + /** + * Debug a response with Symfony Var Dumper + */ + public static function symfonyResponseDebugger(Response $response, ResponseInterface $psrResponse): void + { + $headers = []; + + foreach ($psrResponse->getHeaders() as $headerName => $value) { + $headers[$headerName] = implode(';', $value); + } + + $className = explode('\\', $response->getRequest()::class); + $label = end($className); + + VarDumper::dump([ + 'status' => $response->status(), + 'headers' => $headers, + 'body' => $response->body(), + ], 'Saloon Response (' . $label . ') ->'); + } + + /** + * Kill the application + * + * This is a method as it can be easily mocked during tests + */ + public static function die(): void + { + $handler = self::$dieHandler ?? static fn () => exit(1); + + $handler(); + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php index 3d66eb3d..2d84ae3a 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -195,7 +195,6 @@ public function getSenderException(): ?Throwable * * @param array-key|null $key * @return ($key is null ? array : mixed) - * @throws \JsonException */ public function json(string|int|null $key = null, mixed $default = null): mixed { @@ -217,7 +216,6 @@ public function json(string|int|null $key = null, mixed $default = null): mixed * * @param array-key|null $key * @return ($key is null ? array : mixed) - * @throws \JsonException */ public function array(int|string|null $key = null, mixed $default = null): mixed { @@ -226,8 +224,6 @@ public function array(int|string|null $key = null, mixed $default = null): mixed /** * Get the JSON decoded body of the response as an object. - * - * @throws \JsonException */ public function object(): object { @@ -273,7 +269,6 @@ public function xmlReader(): XmlReader * * @param array-key|null $key * @return \Illuminate\Support\Collection - * @throws \JsonException */ public function collect(string|int|null $key = null): Collection { @@ -309,8 +304,6 @@ public function dto(): mixed /** * Convert the response into a DTO or throw a LogicException if the response failed - * - * @throws LogicException */ public function dtoOrFail(): mixed { diff --git a/src/Traits/HasDebugging.php b/src/Traits/HasDebugging.php index fd7f8196..daa321c7 100644 --- a/src/Traits/HasDebugging.php +++ b/src/Traits/HasDebugging.php @@ -6,6 +6,7 @@ use Saloon\Http\Response; use Saloon\Enums\PipeOrder; +use Saloon\Helpers\Debugger; use Saloon\Http\PendingRequest; trait HasDebugging @@ -13,14 +14,29 @@ trait HasDebugging /** * Register a request debugger * - * @param callable(\Saloon\Http\PendingRequest, \Psr\Http\Message\RequestInterface): void $onRequest + * Leave blank for a default debugger (requires symfony/var-dump) + * + * @param callable(\Saloon\Http\PendingRequest, \Psr\Http\Message\RequestInterface): void|null $onRequest * @return $this */ - public function debugRequest(callable $onRequest): static + public function debugRequest(?callable $onRequest = null, bool $die = false): static { + // When the user has not specified a callable to debug with, we will use this default + // debugging driver. This will use symfony/var-dumper to display a nice output to + // the user's screen of the request. + + $onRequest ??= Debugger::symfonyRequestDebugger(...); + + // Register the middleware - we will use PipeOrder::FIRST to ensure that the response + // is shown before it is modified by the user's middleware. + $this->middleware()->onRequest( - callable: static function (PendingRequest $pendingRequest) use ($onRequest): void { + callable: static function (PendingRequest $pendingRequest) use ($onRequest, $die): void { $onRequest($pendingRequest, $pendingRequest->createPsrRequest()); + + if ($die) { + Debugger::die(); + } }, order: PipeOrder::LAST ); @@ -31,18 +47,50 @@ public function debugRequest(callable $onRequest): static /** * Register a response debugger * - * @param callable(\Saloon\Http\Response, \Psr\Http\Message\ResponseInterface): void $onResponse + * Leave blank for a default debugger (requires symfony/var-dump) + * + * @param callable(\Saloon\Http\Response, \Psr\Http\Message\ResponseInterface): void|null $onResponse * @return $this */ - public function debugResponse(callable $onResponse): static + public function debugResponse(?callable $onResponse = null, bool $die = false): static { + // When the user has not specified a callable to debug with, we will use this default + // debugging driver. This will use symfony/var-dumper to display a nice output to + // the user's screen of the response. + + $onResponse ??= Debugger::symfonyResponseDebugger(...); + + // Register the middleware - we will use PipeOrder::FIRST to ensure that the response + // is shown before it is modified by the user's middleware. + $this->middleware()->onResponse( - callable: static function (Response $response) use ($onResponse): void { + callable: static function (Response $response) use ($onResponse, $die): void { $onResponse($response, $response->getPsrResponse()); + + if ($die) { + Debugger::die(); + } }, order: PipeOrder::FIRST ); return $this; } + + /** + * Dump a pretty output of the request and response. + * + * This is useful if you would like to see the request right before it is sent + * to inspect the body and URI to ensure it is correct. You can also inspect + * the raw response as it comes back. + * + * Note that any changes made to the PSR request by the sender will not be + * reflected by this output. + * + * Requires symfony/var-dumper + */ + public function debug(bool $die = false): static + { + return $this->debugRequest()->debugResponse(die: $die); + } } diff --git a/tests/Feature/DebugTest.php b/tests/Feature/DebugTest.php index 2dc9b4ac..a6fcf706 100644 --- a/tests/Feature/DebugTest.php +++ b/tests/Feature/DebugTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use Saloon\Http\Response; +use Saloon\Helpers\Debugger; use Saloon\Http\PendingRequest; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Symfony\Component\VarDumper\VarDumper; use Saloon\Tests\Fixtures\Requests\UserRequest; use Saloon\Tests\Fixtures\Connectors\TestConnector; use Saloon\Tests\Fixtures\Requests\AlwaysThrowRequest; @@ -133,3 +135,135 @@ expect($middlewareCount)->toBe(2); } }); + +test('the default debugRequest driver will dump an output using symfony var-dumper', function () { + $output = fopen('php://memory', 'rwb+'); + + VarDumper::setHandler(getCustomVarDump($output)); + + $connector = new TestConnector; + + $connector->withMockClient(new MockClient([ + new MockResponse(['name' => 'Sam'], 500), + ])); + + $connector->debugRequest()->send(new UserRequest); + + VarDumper::setHandler(null); + + rewind($output); + + $output = stream_get_contents($output); + + $expected = << array:6 [ + "connector" => "Saloon\Tests\Fixtures\Connectors\TestConnector" + "request" => "Saloon\Tests\Fixtures\Requests\UserRequest" + "method" => "GET" + "uri" => "https://tests.saloon.dev/api/user" + "headers" => array:2 [ + "Host" => "tests.saloon.dev" + "Accept" => "application/json" + ] + "body" => "" + ]\n + END; + + expect($output)->toEqual(str_replace("\r\n", "\n", $expected)); +}); + +test('the default debugResponse driver will dump an output using symfony var-dumper', function () { + $output = fopen('php://memory', 'rwb+'); + + VarDumper::setHandler(getCustomVarDump($output)); + + $connector = new TestConnector; + + $connector->withMockClient(new MockClient([ + new MockResponse(['name' => 'Sam'], 500), + ])); + + $connector->debugResponse()->send(new UserRequest); + + VarDumper::setHandler(null); + + rewind($output); + + $output = stream_get_contents($output); + + $expected = << array:3 [ + "status" => 500 + "headers" => [] + "body" => "{"name":"Sam"}" + ]\n + END; + + expect($output)->toEqual(str_replace("\r\n", "\n", $expected)); +}); + +test('the debug method will output both request and response at the same time', function () { + $output = fopen('php://memory', 'rwb+'); + + VarDumper::setHandler(getCustomVarDump($output)); + + $connector = new TestConnector; + + $connector->withMockClient(new MockClient([ + new MockResponse(['name' => 'Sam'], 500), + ])); + + $connector->debug()->send(new UserRequest); + + VarDumper::setHandler(null); + + rewind($output); + + $output = stream_get_contents($output); + + $expected = << array:6 [ + "connector" => "Saloon\Tests\Fixtures\Connectors\TestConnector" + "request" => "Saloon\Tests\Fixtures\Requests\UserRequest" + "method" => "GET" + "uri" => "https://tests.saloon.dev/api/user" + "headers" => array:2 [ + "Host" => "tests.saloon.dev" + "Accept" => "application/json" + ] + "body" => "" + ] + Saloon Response (UserRequest) -> array:3 [ + "status" => 500 + "headers" => [] + "body" => "{"name":"Sam"}" + ]\n + END; + + expect($output)->toEqual(str_replace("\r\n", "\n", $expected)); +}); + +test('the debug method can kill the application', function () { + $killed = false; + + $output = fopen('php://memory', 'rwb+'); + + VarDumper::setHandler(getCustomVarDump($output)); + + Debugger::$dieHandler = static function () use (&$killed) { + $killed = true; + }; + + $connector = new TestConnector; + + $connector->withMockClient(new MockClient([ + new MockResponse(['name' => 'Sam'], 500), + ])); + + $connector->debug(die: true)->send(new UserRequest); + + VarDumper::setHandler(null); + Debugger::$dieHandler = null; + + expect($killed)->toBeTrue(); +}); diff --git a/tests/Pest.php b/tests/Pest.php index b01cd93a..977dbd48 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -26,6 +26,8 @@ | */ +use Symfony\Component\VarDumper\Cloner\VarCloner; +use Symfony\Component\VarDumper\Dumper\CliDumper; use Saloon\Tests\Fixtures\Connectors\TestConnector; /* @@ -48,3 +50,22 @@ function connector(): TestConnector { return new TestConnector; } + +/** + * @param resource $output + */ +function getCustomVarDump(mixed $output): Closure +{ + return static function ($var, ?string $label = null) use ($output) { + $dumper = new CliDumper; + $cloner = new VarCloner; + + $var = $cloner->cloneVar($var); + + if (null !== $label) { + $var = $var->withContext(['label' => $label]); + } + + $dumper->dump($var, $output); + }; +}