From 23316fec5bb0fdf5b1ac187f4bb0c55c0e1111d2 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Sat, 11 Jan 2025 00:31:09 +0100 Subject: [PATCH] wip --- composer.json | 3 +- phpstan-baseline.neon | 6 - src/Contracts/SupportsDebuggingContract.php | 16 +- src/Contracts/SupportsTestmodeInPayload.php | 2 +- src/Exceptions/ApiException.php | 19 +- src/Exceptions/ClientException.php | 5 + .../CurlConnectTimeoutException.php | 7 - src/Exceptions/ForbiddenException.php | 21 ++ ...dapterDoesNotSupportDebuggingException.php | 7 - .../InvalidAuthenticationException.php | 19 ++ src/Exceptions/MethodNotAllowedException.php | 21 ++ .../MissingAuthenticationException.php | 11 + src/Exceptions/MollieException.php | 5 + src/Exceptions/NotFoundException.php | 21 ++ src/Exceptions/RequestException.php | 26 +++ src/Exceptions/RequestTimeoutException.php | 21 ++ src/Exceptions/ResponseException.php | 40 ++++ .../ServiceUnavailableException.php | 21 ++ src/Exceptions/TooManyRequestsException.php | 21 ++ src/Exceptions/UnauthorizedException.php | 21 ++ src/Exceptions/ValidationException.php | 36 ++++ src/Fake/MockMollieHttpAdapter.php | 4 +- src/Helpers/Debugger.php | 76 +++++++ .../Adapter/CurlConnectionErrorException.php | 26 +++ src/Http/Adapter/CurlException.php | 24 +++ src/Http/Adapter/CurlMollieHttpAdapter.php | 204 +++++++++++------- src/Http/Adapter/GuzzleMollieHttpAdapter.php | 29 +-- src/Http/Adapter/MollieHttpAdapterPicker.php | 4 +- src/Http/Adapter/PSR18MollieHttpAdapter.php | 50 +++-- src/Http/Auth/AccessTokenAuthenticator.php | 4 +- src/Http/Auth/ApiKeyAuthenticator.php | 4 +- src/Http/Middleware/ApplyIdempotencyKey.php | 2 + .../Middleware/ConvertResponseToException.php | 59 +++++ src/Http/Middleware/GuardResponse.php | 35 --- src/Http/Middleware/Handlers.php | 12 +- .../ThrowExceptionIfRequestFailed.php | 49 ----- src/Http/PendingRequest.php | 34 ++- .../PendingRequest/AuthenticateRequest.php | 4 +- src/Http/Request.php | 2 + src/Http/Response.php | 4 +- src/Traits/HandlesDebugging.php | 70 +++--- src/Traits/IsDebuggableAdapter.php | 47 ---- src/Traits/ManagesPsrRequests.php | 5 +- src/Traits/SendsRequests.php | 13 +- .../Adapter/GuzzleMollieHttpAdapterTest.php | 2 +- tests/Http/Middleware/GuardResponseTest.php | 103 --------- tests/MollieApiClientTest.php | 45 ++-- 47 files changed, 766 insertions(+), 494 deletions(-) create mode 100644 src/Exceptions/ClientException.php delete mode 100644 src/Exceptions/CurlConnectTimeoutException.php create mode 100644 src/Exceptions/ForbiddenException.php delete mode 100644 src/Exceptions/HttpAdapterDoesNotSupportDebuggingException.php create mode 100644 src/Exceptions/InvalidAuthenticationException.php create mode 100644 src/Exceptions/MethodNotAllowedException.php create mode 100644 src/Exceptions/MissingAuthenticationException.php create mode 100644 src/Exceptions/MollieException.php create mode 100644 src/Exceptions/NotFoundException.php create mode 100644 src/Exceptions/RequestException.php create mode 100644 src/Exceptions/RequestTimeoutException.php create mode 100644 src/Exceptions/ResponseException.php create mode 100644 src/Exceptions/ServiceUnavailableException.php create mode 100644 src/Exceptions/TooManyRequestsException.php create mode 100644 src/Exceptions/UnauthorizedException.php create mode 100644 src/Helpers/Debugger.php create mode 100644 src/Http/Adapter/CurlConnectionErrorException.php create mode 100644 src/Http/Adapter/CurlException.php create mode 100644 src/Http/Middleware/ConvertResponseToException.php delete mode 100644 src/Http/Middleware/GuardResponse.php delete mode 100644 src/Http/Middleware/ThrowExceptionIfRequestFailed.php delete mode 100644 src/Traits/IsDebuggableAdapter.php delete mode 100644 tests/Http/Middleware/GuardResponseTest.php diff --git a/composer.json b/composer.json index b77b819dd..4c641565b 100644 --- a/composer.json +++ b/composer.json @@ -61,7 +61,8 @@ "friendsofphp/php-cs-fixer": "^3.66", "guzzlehttp/guzzle": "^7.6", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "symfony/var-dumper": "^6.0" }, "suggest": { "mollie/oauth2-mollie-php": "Use OAuth to authenticate with the Mollie API. This is needed for some endpoints. Visit https://docs.mollie.com/ for more information." diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3147f0c35..d20c5bf6f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -366,12 +366,6 @@ parameters: count: 1 path: tests/EndpointCollection/SubscriptionEndpointCollectionTest.php - - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' - identifier: method.alreadyNarrowedType - count: 1 - path: tests/Http/Middleware/GuardResponseTest.php - - message: '#^Access to an undefined property Mollie\\Api\\Resources\\AnyResource\:\:\$customField\.$#' identifier: property.notFound diff --git a/src/Contracts/SupportsDebuggingContract.php b/src/Contracts/SupportsDebuggingContract.php index 0c67aa8bb..439b6219f 100644 --- a/src/Contracts/SupportsDebuggingContract.php +++ b/src/Contracts/SupportsDebuggingContract.php @@ -4,17 +4,9 @@ interface SupportsDebuggingContract { - /** - * Enable debugging for the current request. - * - * @return HttpAdapterContract|Connector - */ - public function enableDebugging(); + public function debugRequest(?callable $debugger = null, bool $die = false): self; - /** - * Disable debugging for the current request. - * - * @return HttpAdapterContract|Connector - */ - public function disableDebugging(); + public function debugResponse(?callable $debugger = null, bool $die = false): self; + + public function debug(bool $die = false): self; } diff --git a/src/Contracts/SupportsTestmodeInPayload.php b/src/Contracts/SupportsTestmodeInPayload.php index 6c3efa505..4a9c16050 100644 --- a/src/Contracts/SupportsTestmodeInPayload.php +++ b/src/Contracts/SupportsTestmodeInPayload.php @@ -2,7 +2,7 @@ namespace Mollie\Api\Contracts; -interface SupportsTestmodeInPayload extends SupportsTestmode +interface SupportsTestmodeInPayload extends SupportsTestmode, HasPayload { // } diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php index 6b4bbdb49..858694f26 100644 --- a/src/Exceptions/ApiException.php +++ b/src/Exceptions/ApiException.php @@ -7,15 +7,13 @@ use Psr\Http\Message\ResponseInterface; use Throwable; -class ApiException extends \Exception +class ApiException extends MollieException { - protected ?string $field = null; - protected string $plainMessage; protected ?RequestInterface $request; - protected ?ResponseInterface $response; + protected ?ResponseInterface $response = null; /** * ISO8601 representation of the moment this exception was thrown @@ -30,7 +28,6 @@ class ApiException extends \Exception public function __construct( string $message = '', int $code = 0, - ?string $field = null, ?RequestInterface $request = null, ?ResponseInterface $response = null, ?Throwable $previous = null @@ -40,12 +37,7 @@ public function __construct( $this->raisedAt = new DateTimeImmutable; $formattedRaisedAt = $this->raisedAt->format(DateTimeImmutable::ATOM); - $message = "[{$formattedRaisedAt}] ".$message; - - if (! empty($field)) { - $this->field = (string) $field; - $message .= ". Field: {$this->field}"; - } + $message = "[{$formattedRaisedAt}] " . $message; if (! empty($response)) { $this->response = $response; @@ -75,11 +67,6 @@ public function __construct( parent::__construct($message, $code, $previous); } - public function getField(): ?string - { - return $this->field; - } - public function getDocumentationUrl(): ?string { return $this->getUrl('documentation'); diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php new file mode 100644 index 000000000..37cf14a4c --- /dev/null +++ b/src/Exceptions/ClientException.php @@ -0,0 +1,5 @@ +json(); + + return new self( + 'Your request was understood but not allowed. ' . + sprintf('Error executing API call (%d: %s): %s', 403, $body->title, $body->detail), + 403, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/HttpAdapterDoesNotSupportDebuggingException.php b/src/Exceptions/HttpAdapterDoesNotSupportDebuggingException.php deleted file mode 100644 index 3970a3b33..000000000 --- a/src/Exceptions/HttpAdapterDoesNotSupportDebuggingException.php +++ /dev/null @@ -1,7 +0,0 @@ -token = $token; + parent::__construct($message ?: "Invalid authentication token: '{$token}'"); + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/src/Exceptions/MethodNotAllowedException.php b/src/Exceptions/MethodNotAllowedException.php new file mode 100644 index 000000000..d9e51b6d0 --- /dev/null +++ b/src/Exceptions/MethodNotAllowedException.php @@ -0,0 +1,21 @@ +json(); + + return new self( + 'The HTTP method is not supported. ' . + sprintf('Error executing API call (%d: %s): %s', 405, $body->title, $body->detail), + 405, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/MissingAuthenticationException.php b/src/Exceptions/MissingAuthenticationException.php new file mode 100644 index 000000000..5f7e2c6f8 --- /dev/null +++ b/src/Exceptions/MissingAuthenticationException.php @@ -0,0 +1,11 @@ +json(); + + return new self( + 'The object referenced by your API request does not exist. ' . + sprintf('Error executing API call (%d: %s): %s', 404, $body->title, $body->detail), + 404, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/RequestException.php b/src/Exceptions/RequestException.php new file mode 100644 index 000000000..865d3a1b6 --- /dev/null +++ b/src/Exceptions/RequestException.php @@ -0,0 +1,26 @@ +request = $request; + } + + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/Exceptions/RequestTimeoutException.php b/src/Exceptions/RequestTimeoutException.php new file mode 100644 index 000000000..1c0c9af54 --- /dev/null +++ b/src/Exceptions/RequestTimeoutException.php @@ -0,0 +1,21 @@ +json(); + + return new self( + 'The request took too long to complete. ' . + sprintf('Error executing API call (%d: %s): %s', 408, $body->title, $body->detail), + 408, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/ResponseException.php b/src/Exceptions/ResponseException.php new file mode 100644 index 000000000..c990bb13c --- /dev/null +++ b/src/Exceptions/ResponseException.php @@ -0,0 +1,40 @@ +response = $response; + $this->request = $request; + $this->field = $field; + parent::__construct($message); + } + + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + public function getRequest(): ?RequestInterface + { + return $this->request; + } + + public function getField(): ?string + { + return $this->field; + } +} diff --git a/src/Exceptions/ServiceUnavailableException.php b/src/Exceptions/ServiceUnavailableException.php new file mode 100644 index 000000000..5aa4a8f0d --- /dev/null +++ b/src/Exceptions/ServiceUnavailableException.php @@ -0,0 +1,21 @@ +json(); + + return new self( + 'The service is temporarily unavailable. ' . + sprintf('Error executing API call (%d: %s): %s', 503, $body->title, $body->detail), + 503, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/TooManyRequestsException.php b/src/Exceptions/TooManyRequestsException.php new file mode 100644 index 000000000..85171e090 --- /dev/null +++ b/src/Exceptions/TooManyRequestsException.php @@ -0,0 +1,21 @@ +json(); + + return new self( + 'Your request exceeded the rate limit. ' . + sprintf('Error executing API call (%d: %s): %s', 429, $body->title, $body->detail), + 429, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/UnauthorizedException.php b/src/Exceptions/UnauthorizedException.php new file mode 100644 index 000000000..5579d7e9b --- /dev/null +++ b/src/Exceptions/UnauthorizedException.php @@ -0,0 +1,21 @@ +json(); + + return new self( + 'Your request wasn\'t executed due to failed authentication. Check your API key. ' . + sprintf('Error executing API call (%d: %s): %s', 401, $body->title, $body->detail), + 401, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } +} diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index da004efbd..03c01f866 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -2,6 +2,42 @@ namespace Mollie\Api\Exceptions; +use Mollie\Api\Http\Response; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + class ValidationException extends ApiException { + private ?string $field; + + public function __construct( + string $message, + int $code, + ?string $field, + ?RequestInterface $request, + ?ResponseInterface $response + ) { + parent::__construct($message, $code, $request, $response); + $this->field = $field; + } + + public function getField(): ?string + { + return $this->field; + } + + public static function fromResponse(Response $response): self + { + $body = $response->json(); + $field = ! empty($body->field) ? $body->field : null; + + return new self( + 'We could not process your request due to validation errors. ' . + sprintf('Error executing API call (%d: %s): %s', 422, $body->title, $body->detail), + 422, + $field, + $response->getPsrRequest(), + $response->getPsrResponse() + ); + } } diff --git a/src/Fake/MockMollieHttpAdapter.php b/src/Fake/MockMollieHttpAdapter.php index 56e5df6cb..f0788f9d5 100644 --- a/src/Fake/MockMollieHttpAdapter.php +++ b/src/Fake/MockMollieHttpAdapter.php @@ -89,7 +89,7 @@ public function recorded(?callable $callback = null): array return $this->recorded; } - return array_filter($this->recorded, fn ($recorded) => $callback($recorded[0], $recorded[1])); + return array_filter($this->recorded, fn($recorded) => $callback($recorded[0], $recorded[1])); } /** @@ -98,7 +98,7 @@ public function recorded(?callable $callback = null): array public function assertSent($callback): void { if (is_string($callback)) { - $callback = fn ($request) => get_class($request) === $callback; + $callback = fn($request) => get_class($request) === $callback; } PHPUnit::assertTrue( diff --git a/src/Helpers/Debugger.php b/src/Helpers/Debugger.php new file mode 100644 index 000000000..8e6922db4 --- /dev/null +++ b/src/Helpers/Debugger.php @@ -0,0 +1,76 @@ +getHeaders() as $headerName => $value) { + $headers[$headerName] = implode(';', $value); + } + + VarDumper::dump([ + 'request' => get_class($pendingRequest->getRequest()), + 'method' => $psrRequest->getMethod(), + 'uri' => (string) $psrRequest->getUri(), + 'headers' => $headers, + 'body' => (string) $psrRequest->getBody(), + ], 'Mollie Request (' . self::getLabel($pendingRequest->getRequest()) . ') ->'); + } + + /** + * 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); + } + + VarDumper::dump([ + 'status' => $response->status(), + 'headers' => $headers, + 'body' => json_decode((string) $psrResponse->getBody(), true), + ], 'Mollie Response (' . self::getLabel($response) . ') ->'); + } + + /** + * Kill the application + */ + public static function die(): void + { + $handler = self::$dieHandler ?? static function (): int { + exit(1); + }; + + $handler(); + } + + private static function getLabel(object $object): string + { + $className = explode('\\', get_class($object)); + return end($className); + } +} diff --git a/src/Http/Adapter/CurlConnectionErrorException.php b/src/Http/Adapter/CurlConnectionErrorException.php new file mode 100644 index 000000000..978361a39 --- /dev/null +++ b/src/Http/Adapter/CurlConnectionErrorException.php @@ -0,0 +1,26 @@ +request = $request; + } + + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/Http/Adapter/CurlException.php b/src/Http/Adapter/CurlException.php new file mode 100644 index 000000000..904e2bda4 --- /dev/null +++ b/src/Http/Adapter/CurlException.php @@ -0,0 +1,24 @@ +curlErrorNumber = $curlErrorNumber; + } + + public function getCurlErrorNumber(): int + { + return $this->curlErrorNumber; + } +} diff --git a/src/Http/Adapter/CurlMollieHttpAdapter.php b/src/Http/Adapter/CurlMollieHttpAdapter.php index 8a0a1dd8a..24cea68fc 100644 --- a/src/Http/Adapter/CurlMollieHttpAdapter.php +++ b/src/Http/Adapter/CurlMollieHttpAdapter.php @@ -3,14 +3,17 @@ namespace Mollie\Api\Http\Adapter; use Composer\CaBundle\CaBundle; +use CurlHandle; use Mollie\Api\Contracts\HttpAdapterContract; -use Mollie\Api\Exceptions\ApiException; -use Mollie\Api\Exceptions\CurlConnectTimeoutException; +use Mollie\Api\Http\Adapter\CurlConnectionErrorException; +use Mollie\Api\Http\Adapter\CurlException; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Response; +use Mollie\Api\Http\ResponseStatusCode; use Mollie\Api\Traits\HasDefaultFactories; use Mollie\Api\Types\Method; use Throwable; +use Psr\Http\Message\RequestInterface; final class CurlMollieHttpAdapter implements HttpAdapterContract { @@ -38,10 +41,10 @@ final class CurlMollieHttpAdapter implements HttpAdapterContract /** * @throws \Mollie\Api\Exceptions\ApiException - * @throws \Mollie\Api\Exceptions\CurlConnectTimeoutException */ public function sendRequest(PendingRequest $pendingRequest): Response { + $lastException = null; for ($i = 0; $i <= self::MAX_RETRIES; $i++) { usleep($i * self::DELAY_INCREASE_MS); @@ -49,8 +52,44 @@ public function sendRequest(PendingRequest $pendingRequest): Response [$headers, $body, $statusCode] = $this->send($pendingRequest); return $this->createResponse($pendingRequest, $statusCode, $headers, $body); - } catch (CurlConnectTimeoutException $e) { - return $this->createResponse($pendingRequest, 504, [], null, $e); + } catch (CurlConnectionErrorException $e) { + // Connection errors are fatal and shouldn't be retried + $lastException = $e; + } catch (CurlException $e) { + // Only retry non-connection CURL errors + $lastException = $e; + } + } + + return $this->createResponse($pendingRequest, ResponseStatusCode::HTTP_GATEWAY_TIMEOUT, [], null, $lastException); + } + + /** + * @throws CurlException + */ + protected function send(PendingRequest $pendingRequest): array + { + $curl = null; + $request = $pendingRequest->createPsrRequest(); + + try { + $curl = $this->initializeCurl($request->getUri()); + + $this->setCurlHeaders($curl, $pendingRequest->headers()->all()); + $this->setCurlMethodOptions($curl, $pendingRequest->method(), $request->getBody()); + + $startTime = microtime(true); + $response = curl_exec($curl); + $endTime = microtime(true); + + if ($response === false) { + $this->handleCurlError($curl, $endTime - $startTime, $request); + } + + return $this->extractResponseDetails($curl, $response); + } finally { + if ($curl !== null) { + curl_close($curl); } } } @@ -75,118 +114,109 @@ protected function createResponse(PendingRequest $pendingRequest, int $statusCod ); } - /** - * @throws \Mollie\Api\Exceptions\ApiException - */ - protected function send(PendingRequest $pendingRequest): array + private function initializeCurl(string $url): CurlHandle { - $request = $pendingRequest->createPsrRequest(); - - $curl = $this->initializeCurl($request->getUri()); - $this->setCurlHeaders($curl, $pendingRequest->headers()->all()); - $this->setCurlMethodOptions($curl, $pendingRequest->method(), $request->getBody()); - - $startTime = microtime(true); - $response = curl_exec($curl); - $endTime = microtime(true); - - if ($response === false) { - $this->handleCurlError($curl, $endTime - $startTime); + $curl = curl_init($url); + if ($curl === false) { + throw new CurlException('Failed to initialize CURL'); } - [$headers, $content, $statusCode] = $this->extractResponseDetails($curl, $response); - curl_close($curl); + $this->setCurlOption($curl, CURLOPT_RETURNTRANSFER, true); + $this->setCurlOption($curl, CURLOPT_HEADER, true); + $this->setCurlOption($curl, CURLOPT_CONNECTTIMEOUT, self::DEFAULT_CONNECT_TIMEOUT); + $this->setCurlOption($curl, CURLOPT_TIMEOUT, self::DEFAULT_TIMEOUT); + $this->setCurlOption($curl, CURLOPT_SSL_VERIFYPEER, true); + $this->setCurlOption($curl, CURLOPT_CAINFO, CaBundle::getBundledCaBundlePath()); - return [$headers, $content, $statusCode]; + return $curl; } - private function initializeCurl(string $url) + private function setCurlOption(CurlHandle $curl, int $option, $value): void { - $curl = curl_init($url); - - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - curl_setopt($curl, CURLOPT_HEADER, true); - curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, self::DEFAULT_CONNECT_TIMEOUT); - curl_setopt($curl, CURLOPT_TIMEOUT, self::DEFAULT_TIMEOUT); - curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($curl, CURLOPT_CAINFO, CaBundle::getBundledCaBundlePath()); - - return $curl; + if (curl_setopt($curl, $option, $value) === false) { + throw new CurlException( + sprintf('Failed to set CURL option %d', $option), + curl_errno($curl), + ); + } } - private function setCurlHeaders($curl, array $headers) + private function setCurlHeaders(CurlHandle $curl, array $headers): void { $headers['Content-Type'] = 'application/json'; - curl_setopt($curl, CURLOPT_HTTPHEADER, $this->parseHeaders($headers)); + -$this->setCurlOption($curl, CURLOPT_HTTPHEADER, $this->parseHeaders($headers)); } - private function setCurlMethodOptions($curl, string $method, ?string $body): void + private function setCurlMethodOptions(CurlHandle $curl, string $method, ?string $body): void { switch ($method) { case Method::POST: - curl_setopt($curl, CURLOPT_POST, true); - curl_setopt($curl, CURLOPT_POSTFIELDS, $body); - + $this->setCurlOption($curl, CURLOPT_POST, true); + $this->setCurlOption($curl, CURLOPT_POSTFIELDS, $body); break; case Method::PATCH: - curl_setopt($curl, CURLOPT_CUSTOMREQUEST, Method::PATCH); - curl_setopt($curl, CURLOPT_POSTFIELDS, $body); - + $this->setCurlOption($curl, CURLOPT_CUSTOMREQUEST, Method::PATCH); + $this->setCurlOption($curl, CURLOPT_POSTFIELDS, $body); break; case Method::DELETE: - curl_setopt($curl, CURLOPT_CUSTOMREQUEST, Method::DELETE); - curl_setopt($curl, CURLOPT_POSTFIELDS, $body); - + $this->setCurlOption($curl, CURLOPT_CUSTOMREQUEST, Method::DELETE); + $this->setCurlOption($curl, CURLOPT_POSTFIELDS, $body); break; case Method::GET: default: if ($method !== Method::GET) { - throw new \InvalidArgumentException('Invalid HTTP method: '.$method); + throw new \InvalidArgumentException('Invalid HTTP method: ' . $method); } - break; } } - private function handleCurlError($curl, float $executionTime): void + /** + * @throws CurlException + * @return never + */ + private function handleCurlError(CurlHandle $curl, float $executionTime, RequestInterface $request): void { $curlErrorNumber = curl_errno($curl); - $curlErrorMessage = 'Curl error: '.curl_error($curl); + $curlErrorMessage = 'Curl error: ' . curl_error($curl); - if ($this->isConnectTimeoutError($curlErrorNumber, $executionTime)) { - throw new CurlConnectTimeoutException('Unable to connect to Mollie. '.$curlErrorMessage); - } - - throw new ApiException($curlErrorMessage); + throw $this->mapCurlErrorToException($curlErrorNumber, $curlErrorMessage, $request, $executionTime); } - private function extractResponseDetails($curl, string $response): array + private function mapCurlErrorToException(int $curlErrorNumber, string $curlErrorMessage, RequestInterface $request, float $executionTime): CurlException { - $headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE); - $headerValues = substr($response, 0, $headerSize); - $content = substr($response, $headerSize); - $statusCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + static $messages = [ + \CURLE_UNSUPPORTED_PROTOCOL => 'Unsupported protocol. Please check the URL.', + \CURLE_URL_MALFORMAT => 'Malformed URL. Please check the URL format.', + \CURLE_COULDNT_RESOLVE_PROXY => 'Could not resolve proxy. Please check your proxy settings.', + \CURLE_COULDNT_RESOLVE_HOST => 'Could not resolve host. Please check your internet connection and DNS settings.', + \CURLE_COULDNT_CONNECT => 'Could not connect to host. Please check if the service is available.', + \CURLE_FTP_ACCESS_DENIED => 'Remote access denied. Please check your authentication credentials.', + \CURLE_OUT_OF_MEMORY => 'Out of memory while processing the request.', + \CURLE_OPERATION_TIMEOUTED => 'Operation timed out. The request took too long to complete.', + \CURLE_SSL_CONNECT_ERROR => 'SSL connection error. Please check your SSL/TLS configuration.', + \CURLE_GOT_NOTHING => 'Server returned nothing. Empty response received.', + \CURLE_SSL_CERTPROBLEM => 'Problem with the local SSL certificate.', + \CURLE_SSL_CIPHER => 'Problem with the SSL cipher.', + \CURLE_SSL_CACERT => 'Problem with the SSL CA cert.', + \CURLE_BAD_CONTENT_ENCODING => 'Unrecognized content encoding.', + ]; - $headers = []; - $headerLines = explode("\r\n", $headerValues); - foreach ($headerLines as $headerLine) { - if (strpos($headerLine, ':') !== false) { - [$key, $value] = explode(': ', $headerLine, 2); - $headers[$key] = $value; - } + $message = $messages[$curlErrorNumber] ?? 'An error occurred while making the request.'; + $message .= ' ' . $curlErrorMessage; + + if ($this->isConnectionError($curlErrorNumber, $executionTime)) { + return new CurlConnectionErrorException($message, $curlErrorNumber, $request); } - return [$headers, $content, $statusCode]; + return new CurlException($message, $curlErrorNumber); } - /** - * @param string|float $executionTime - */ - protected function isConnectTimeoutError(int $curlErrorNumber, $executionTime): bool + private function isConnectionError(int $curlErrorNumber, float $executionTime): bool { $connectErrors = [ \CURLE_COULDNT_RESOLVE_HOST => true, @@ -200,22 +230,38 @@ protected function isConnectTimeoutError(int $curlErrorNumber, $executionTime): } if ($curlErrorNumber === \CURLE_OPERATION_TIMEOUTED) { - if ($executionTime > self::DEFAULT_TIMEOUT) { - return false; - } - - return true; + // Only treat it as a connection error if it timed out during the connection phase + return $executionTime <= self::DEFAULT_CONNECT_TIMEOUT; } return false; } + private function extractResponseDetails(CurlHandle $curl, string $response): array + { + $headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE); + $headerValues = substr($response, 0, $headerSize); + $content = substr($response, $headerSize); + $statusCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + + $headers = []; + $headerLines = explode("\r\n", $headerValues); + foreach ($headerLines as $headerLine) { + if (strpos($headerLine, ':') !== false) { + [$key, $value] = explode(': ', $headerLine, 2); + $headers[$key] = $value; + } + } + + return [$headers, $content, $statusCode]; + } + private function parseHeaders(array $headers): array { $result = []; foreach ($headers as $key => $value) { - $result[] = $key.': '.$value; + $result[] = $key . ': ' . $value; } return $result; diff --git a/src/Http/Adapter/GuzzleMollieHttpAdapter.php b/src/Http/Adapter/GuzzleMollieHttpAdapter.php index 2d114c38a..8c3277e76 100644 --- a/src/Http/Adapter/GuzzleMollieHttpAdapter.php +++ b/src/Http/Adapter/GuzzleMollieHttpAdapter.php @@ -11,20 +11,16 @@ use GuzzleHttp\Psr7\HttpFactory; use GuzzleHttp\RequestOptions as GuzzleRequestOptions; use Mollie\Api\Contracts\HttpAdapterContract; -use Mollie\Api\Contracts\SupportsDebuggingContract; use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Response; -use Mollie\Api\Traits\IsDebuggableAdapter; use Mollie\Api\Utils\Factories; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Throwable; -final class GuzzleMollieHttpAdapter implements HttpAdapterContract, SupportsDebuggingContract +final class GuzzleMollieHttpAdapter implements HttpAdapterContract { - use IsDebuggableAdapter; - /** * Default response timeout (in seconds). */ @@ -35,11 +31,6 @@ final class GuzzleMollieHttpAdapter implements HttpAdapterContract, SupportsDebu */ public const DEFAULT_CONNECT_TIMEOUT = 2; - /** - * HTTP status code for an empty ok response. - */ - public const HTTP_NO_CONTENT = 204; - protected ClientInterface $httpClient; public function __construct(ClientInterface $httpClient) @@ -62,7 +53,7 @@ public function factories(): Factories /** * Instantiate a default adapter with sane configuration for Guzzle. */ - public static function createDefault(): self + public static function createClient(): self { $retryMiddlewareFactory = new GuzzleRetryMiddlewareFactory; $handlerStack = HandlerStack::create(); @@ -92,18 +83,14 @@ public function sendRequest(PendingRequest $pendingRequest): Response return $this->createResponse($response, $request, $pendingRequest); } catch (ConnectException $e) { - if (! $this->debug) { - $request = null; - } - - throw new ApiException($e->getMessage(), $e->getCode(), null, $request, null); + // throw new FailedConnectionException } catch (RequestException $e) { - // Prevent sensitive request data from ending up in exception logs unintended - if (! $this->debug) { - $request = null; + if (! $response = $e->getResponse()) { + // throw new FailedConnection } - return $this->createResponse($e->getResponse(), $request, $pendingRequest, $e); + /** @var ResponseInterface $response */ + return $this->createResponse($response, $request, $pendingRequest, $e); } } @@ -132,6 +119,6 @@ protected function createResponse( */ public function version(): string { - return 'Guzzle/'.ClientInterface::MAJOR_VERSION; + return 'Guzzle/' . ClientInterface::MAJOR_VERSION; } } diff --git a/src/Http/Adapter/MollieHttpAdapterPicker.php b/src/Http/Adapter/MollieHttpAdapterPicker.php index 0805cf2da..1b2f3b6c3 100644 --- a/src/Http/Adapter/MollieHttpAdapterPicker.php +++ b/src/Http/Adapter/MollieHttpAdapterPicker.php @@ -33,7 +33,7 @@ public function pickHttpAdapter($httpClient): HttpAdapterContract private function createDefaultAdapter(): HttpAdapterContract { if ($this->guzzleIsDetected()) { - return GuzzleMollieHttpAdapter::createDefault(); + return GuzzleMollieHttpAdapter::createClient(); } return new CurlMollieHttpAdapter; @@ -41,6 +41,6 @@ private function createDefaultAdapter(): HttpAdapterContract private function guzzleIsDetected(): bool { - return interface_exists('\\'.\GuzzleHttp\ClientInterface::class); + return interface_exists('\\' . \GuzzleHttp\ClientInterface::class); } } diff --git a/src/Http/Adapter/PSR18MollieHttpAdapter.php b/src/Http/Adapter/PSR18MollieHttpAdapter.php index 7e2f082cb..9d805ee55 100644 --- a/src/Http/Adapter/PSR18MollieHttpAdapter.php +++ b/src/Http/Adapter/PSR18MollieHttpAdapter.php @@ -3,23 +3,24 @@ namespace Mollie\Api\Http\Adapter; use Mollie\Api\Contracts\HttpAdapterContract; -use Mollie\Api\Contracts\SupportsDebuggingContract; use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Response; -use Mollie\Api\Traits\IsDebuggableAdapter; use Mollie\Api\Utils\Factories; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; +use Psr\Http\Client\NetworkExceptionInterface; +use Psr\Http\Client\RequestExceptionInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UriFactoryInterface; +use Throwable; -final class PSR18MollieHttpAdapter implements HttpAdapterContract, SupportsDebuggingContract +final class PSR18MollieHttpAdapter implements HttpAdapterContract { - use IsDebuggableAdapter; - private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; @@ -67,29 +68,36 @@ public function sendRequest(PendingRequest $pendingRequest): Response try { $response = $this->httpClient->sendRequest($request); - return new Response( - $response, - $request, - $pendingRequest - ); - } catch (ClientExceptionInterface $e) { - if (! $this->debug) { - $request = null; + return $this->createResponse($response, $request, $pendingRequest); + } catch (NetworkExceptionInterface $e) { + // throw new FailedConnectionException; + } catch (RequestExceptionInterface $e) { + if (! method_exists($e, 'getResponse') || ! $response = $e->getResponse()) { + // throw new FailedConnectionException } - throw new ApiException( - 'Error while sending request to Mollie API: '.$e->getMessage(), - 0, - $e, - $request, - null - ); + /** @var ResponseInterface $response */ + return $this->createResponse($response, $request, $pendingRequest, $e); } } /** - * {@inheritdoc} + * Create a response. */ + protected function createResponse( + ResponseInterface $psrResponse, + RequestInterface $psrRequest, + PendingRequest $pendingRequest, + ?Throwable $exception = null + ): Response { + return new Response( + $psrResponse, + $psrRequest, + $pendingRequest, + $exception + ); + } + public function version(): string { return 'PSR18MollieHttpAdapter'; diff --git a/src/Http/Auth/AccessTokenAuthenticator.php b/src/Http/Auth/AccessTokenAuthenticator.php index 2efd1b9d2..637b7ce6e 100644 --- a/src/Http/Auth/AccessTokenAuthenticator.php +++ b/src/Http/Auth/AccessTokenAuthenticator.php @@ -2,7 +2,7 @@ namespace Mollie\Api\Http\Auth; -use Mollie\Api\Exceptions\ApiException; +use Mollie\Api\Exceptions\InvalidAuthenticationException; class AccessTokenAuthenticator extends BearerTokenAuthenticator { @@ -10,7 +10,7 @@ public function __construct( string $token ) { if (! preg_match('/^access_\w+$/', trim($token))) { - throw new ApiException("Invalid OAuth access token: '{$token}'. An access token must start with 'access_'."); + throw new InvalidAuthenticationException($token, "Invalid OAuth access token. An access token must start with 'access_'."); } parent::__construct($token); diff --git a/src/Http/Auth/ApiKeyAuthenticator.php b/src/Http/Auth/ApiKeyAuthenticator.php index 6baaed1b2..86e388473 100644 --- a/src/Http/Auth/ApiKeyAuthenticator.php +++ b/src/Http/Auth/ApiKeyAuthenticator.php @@ -2,7 +2,7 @@ namespace Mollie\Api\Http\Auth; -use Mollie\Api\Exceptions\ApiException; +use Mollie\Api\Exceptions\InvalidAuthenticationException; class ApiKeyAuthenticator extends BearerTokenAuthenticator { @@ -10,7 +10,7 @@ public function __construct( string $token ) { if (! preg_match('/^(live|test)_\w{30,}$/', trim($token))) { - throw new ApiException("Invalid API key: '{$token}'. An API key must start with 'test_' or 'live_' and must be at least 30 characters long."); + throw new InvalidAuthenticationException($token, "Invalid API key. An API key must start with 'test_' or 'live_' and must be at least 30 characters long."); } parent::__construct($token); diff --git a/src/Http/Middleware/ApplyIdempotencyKey.php b/src/Http/Middleware/ApplyIdempotencyKey.php index 9f7e67d96..489eb304d 100644 --- a/src/Http/Middleware/ApplyIdempotencyKey.php +++ b/src/Http/Middleware/ApplyIdempotencyKey.php @@ -5,6 +5,7 @@ use Mollie\Api\Contracts\RequestMiddleware; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Types\Method; +use Mollie\Api\Contracts\IdempotencyKeyGeneratorContract; class ApplyIdempotencyKey implements RequestMiddleware { @@ -25,6 +26,7 @@ public function __invoke(PendingRequest $pendingRequest): PendingRequest return $pendingRequest; } + /** @var IdempotencyKeyGeneratorContract $idempotencyKeyGenerator */ $pendingRequest->headers()->add( self::IDEMPOTENCY_KEY_HEADER, $idempotencyKey ?? $idempotencyKeyGenerator->generate() diff --git a/src/Http/Middleware/ConvertResponseToException.php b/src/Http/Middleware/ConvertResponseToException.php new file mode 100644 index 000000000..43234dba8 --- /dev/null +++ b/src/Http/Middleware/ConvertResponseToException.php @@ -0,0 +1,59 @@ +successful()) { + return; + } + + $status = $response->status(); + + switch ($status) { + case 401: + throw UnauthorizedException::fromResponse($response); + case 403: + throw ForbiddenException::fromResponse($response); + case 404: + throw NotFoundException::fromResponse($response); + case 405: + throw MethodNotAllowedException::fromResponse($response); + case 408: + throw RequestTimeoutException::fromResponse($response); + case 422: + throw ValidationException::fromResponse($response); + case 429: + throw TooManyRequestsException::fromResponse($response); + case 503: + throw ServiceUnavailableException::fromResponse($response); + default: + throw new ApiException( + sprintf( + 'Error executing API call (%d: %s): %s', + $status, + $response->json()->title, + $response->json()->detail + ), + $status, + $response->getPsrRequest(), + $response->getPsrResponse(), + null + ); + } + } +} diff --git a/src/Http/Middleware/GuardResponse.php b/src/Http/Middleware/GuardResponse.php deleted file mode 100644 index 45d48bd93..000000000 --- a/src/Http/Middleware/GuardResponse.php +++ /dev/null @@ -1,35 +0,0 @@ -getResponse() : $response; - - if (($isEmpty = $response->isEmpty()) && $response->status() !== ResponseStatusCode::HTTP_NO_CONTENT) { - throw new ApiException('No response body found.'); - } - - if ($isEmpty) { - return; - } - - $data = $response->json(); - - // @todo check if this is still necessary as it seems to be from api v1 - if (isset($data->error)) { - throw new ApiException($data->error->message); - } - } -} diff --git a/src/Http/Middleware/Handlers.php b/src/Http/Middleware/Handlers.php index 4427ba432..122684446 100644 --- a/src/Http/Middleware/Handlers.php +++ b/src/Http/Middleware/Handlers.php @@ -41,22 +41,14 @@ public function getHandlers(): array /** * Execute the handlers * - * @param PendingRequest|Response|mixed $payload - * @return PendingRequest|Response|IsResponseAware|ViableResponse + * @param mixed $payload + * @return mixed */ public function execute($payload) { /** @var Handler $handler */ foreach ($this->sortHandlers() as $handler) { $payload = call_user_func($handler->callback(), $payload); - - /** - * If the handler returns a value that is not an instance of PendingRequest or Response, - * we assume that the handler has transformed the payload in some way and we return the transformed value. - */ - if ($payload instanceof ViableResponse) { - return $payload; - } } return $payload; diff --git a/src/Http/Middleware/ThrowExceptionIfRequestFailed.php b/src/Http/Middleware/ThrowExceptionIfRequestFailed.php deleted file mode 100644 index b4e60ac3a..000000000 --- a/src/Http/Middleware/ThrowExceptionIfRequestFailed.php +++ /dev/null @@ -1,49 +0,0 @@ -successful()) { - return; - } - - $body = $response->json(); - - $message = "Error executing API call ({$body->status}: {$body->title}): {$body->detail}"; - - $field = null; - - if (! empty($body->field)) { - $field = $body->field; - } - - if (isset($body->_links, $body->_links->documentation)) { - $message .= ". Documentation: {$body->_links->documentation->href}"; - } - - if ($response->getPendingRequest()->payload()) { - $streamFactory = $response - ->getPendingRequest() - ->getFactoryCollection() - ->streamFactory; - - $message .= ". Request body: {$response->getPendingRequest()->payload()->toStream($streamFactory)->getContents()}"; - } - - throw new ApiException( - $message, - $response->status(), - $field, - $response->getPsrRequest(), - $response->getPsrResponse(), - $response->getSenderException() - ); - } -} diff --git a/src/Http/PendingRequest.php b/src/Http/PendingRequest.php index b2e3e088e..9eee22f04 100644 --- a/src/Http/PendingRequest.php +++ b/src/Http/PendingRequest.php @@ -5,6 +5,7 @@ use Mollie\Api\Contracts\Connector; use Mollie\Api\Contracts\IsResponseAware; use Mollie\Api\Contracts\PayloadRepository; +use Mollie\Api\Contracts\SupportsTestmode; use Mollie\Api\Contracts\SupportsTestmodeInPayload; use Mollie\Api\Contracts\SupportsTestmodeInQuery; use Mollie\Api\Http\Middleware\ApplyIdempotencyKey; @@ -12,7 +13,7 @@ use Mollie\Api\Http\Middleware\Hydrate; use Mollie\Api\Http\Middleware\MiddlewarePriority; use Mollie\Api\Http\Middleware\ResetIdempotencyKey; -use Mollie\Api\Http\Middleware\ThrowExceptionIfRequestFailed; +use Mollie\Api\Http\Middleware\ConvertResponseToException; use Mollie\Api\Http\PendingRequest\AddTestmodeIfEnabled; use Mollie\Api\Http\PendingRequest\AuthenticateRequest; use Mollie\Api\Http\PendingRequest\MergeRequestProperties; @@ -72,16 +73,25 @@ public function __construct(Connector $connector, Request $request) /** On response */ ->onResponse(new ResetIdempotencyKey, 'idempotency') ->onResponse(new Hydrate, 'hydrate', MiddlewarePriority::LOW) - ->onResponse(new GuardResponse, MiddlewarePriority::HIGH) - ->onResponse(new ThrowExceptionIfRequestFailed, MiddlewarePriority::HIGH); + ->onResponse(new ConvertResponseToException, MiddlewarePriority::HIGH); } public function setTestmode(bool $testmode): self { + if (! $this->request instanceof SupportsTestmode) { + return $this; + } + if ($this->request instanceof SupportsTestmodeInQuery) { $this->query()->add('testmode', $testmode); - } elseif ($this->request instanceof SupportsTestmodeInPayload) { - $this->payload()->add('testmode', $testmode); + } else if ($this->request instanceof SupportsTestmodeInPayload) { + $payload = $this->payload(); + + if ($payload === null) { + return $this; + } + + $payload->add('testmode', $testmode); } return $this; @@ -89,13 +99,19 @@ public function setTestmode(bool $testmode): self public function getTestmode(): bool { - if (! $this->request instanceof SupportsTestmodeInQuery && ! $this->request instanceof SupportsTestmodeInPayload) { + if (! $this->request instanceof SupportsTestmode) { return false; } - return $this->request instanceof SupportsTestmodeInQuery - ? $this->query()->get('testmode', false) - : $this->payload()->get('testmode', false); + if ($this->request instanceof SupportsTestmodeInQuery) { + return $this->query()->get('testmode', false); + } + + $payload = $this->payload(); + + return $payload + ? $payload->get('testmode', false) + : false; } public function setPayload(PayloadRepository $bodyRepository): self diff --git a/src/Http/PendingRequest/AuthenticateRequest.php b/src/Http/PendingRequest/AuthenticateRequest.php index bc2be737f..7bec05a29 100644 --- a/src/Http/PendingRequest/AuthenticateRequest.php +++ b/src/Http/PendingRequest/AuthenticateRequest.php @@ -2,7 +2,7 @@ namespace Mollie\Api\Http\PendingRequest; -use Mollie\Api\Exceptions\ApiException; +use Mollie\Api\Exceptions\MissingAuthenticationException; use Mollie\Api\Http\PendingRequest; class AuthenticateRequest @@ -12,7 +12,7 @@ public function __invoke(PendingRequest $pendingRequest): PendingRequest $authenticator = $pendingRequest->getConnector()->getAuthenticator(); if (! $authenticator) { - throw new ApiException('You have not set an API key or OAuth access token. Please use setApiKey() to set the API key.'); + throw new MissingAuthenticationException(); } $authenticator->authenticate($pendingRequest); diff --git a/src/Http/Request.php b/src/Http/Request.php index 9ba7e92fa..55faf5e9a 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -3,6 +3,7 @@ namespace Mollie\Api\Http; use LogicException; +use Mollie\Api\Traits\HandlesDebugging; use Mollie\Api\Traits\HandlesTestmode; use Mollie\Api\Traits\HasMiddleware; use Mollie\Api\Traits\HasRequestProperties; @@ -11,6 +12,7 @@ abstract class Request { use HandlesTestmode; use HasMiddleware; + use HandlesDebugging; use HasRequestProperties; /** diff --git a/src/Http/Response.php b/src/Http/Response.php index 2b4216de3..755e61649 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -3,7 +3,7 @@ namespace Mollie\Api\Http; use Mollie\Api\Contracts\Connector; -use Mollie\Api\Exceptions\ApiException; +use Mollie\Api\Exceptions\ClientException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; @@ -54,7 +54,7 @@ private function decodeJson(): stdClass $decoded = json_decode($body = $this->body() ?: '{}'); if (json_last_error() !== JSON_ERROR_NONE) { - throw new ApiException("Unable to decode Mollie response: '{$body}'."); + throw new ClientException("Unable to decode Mollie response: '{$body}'."); } return $decoded; diff --git a/src/Traits/HandlesDebugging.php b/src/Traits/HandlesDebugging.php index 9cd4bec07..f5a258cc2 100644 --- a/src/Traits/HandlesDebugging.php +++ b/src/Traits/HandlesDebugging.php @@ -2,57 +2,73 @@ namespace Mollie\Api\Traits; -use Mollie\Api\Contracts\Connector; -use Mollie\Api\Contracts\SupportsDebuggingContract; -use Mollie\Api\Exceptions\HttpAdapterDoesNotSupportDebuggingException; +use Mollie\Api\Helpers\Debugger; +use Mollie\Api\Http\PendingRequest; +use Mollie\Api\Http\Response; /** - * @mixin MollieApiClient + * @mixin HasMiddleware */ trait HandlesDebugging { /** - * Enable debugging mode. + * Enable request debugging with an optional custom debugger. * - * @throws \Mollie\Api\Exceptions\HttpAdapterDoesNotSupportDebuggingException + * @param callable|null $debugger Custom request debugger function + * @param bool $die Whether to die after dumping + * @return $this */ - public function enableDebugging(): Connector + public function debugRequest(?callable $debugger = null, bool $die = false): self { - $this->setDebugging(true); + $debugger ??= fn(...$args) => Debugger::symfonyRequestDebugger(...$args); + + $this->middleware()->onRequest(function (PendingRequest $pendingRequest) use ($debugger, $die): PendingRequest { + $debugger($pendingRequest, $pendingRequest->createPsrRequest()); + + if ($die) { + Debugger::die(); + } + + return $pendingRequest; + }); return $this; } /** - * Disable debugging mode. + * Enable response debugging with an optional custom debugger. * - * @throws \Mollie\Api\Exceptions\HttpAdapterDoesNotSupportDebuggingException + * @param callable|null $debugger Custom response debugger function + * @param bool $die Whether to die after dumping + * @return $this */ - public function disableDebugging(): Connector + public function debugResponse(?callable $debugger = null, bool $die = false): self { - $this->setDebugging(false); + $debugger ??= fn(...$args) => Debugger::symfonyResponseDebugger(...$args); + + $this->middleware()->onResponse(function (Response $response) use ($debugger, $die): Response { + $debugger($response, $response->getPsrResponse()); + + if ($die) { + Debugger::die(); + } + + return $response; + },); return $this; } /** - * Toggle debugging mode. If debugging mode is enabled, the attempted request will be included in the ApiException. - * By default, debugging is disabled to prevent leaking sensitive request data into exception logs. + * Enable both request and response debugging. * - * @throws \Mollie\Api\Exceptions\HttpAdapterDoesNotSupportDebuggingException + * @param bool $die Whether to die after dumping + * @return $this */ - public function setDebugging(bool $enable) + public function debug(bool $die = false): self { - if (! $this->httpClient instanceof SupportsDebuggingContract) { - throw new HttpAdapterDoesNotSupportDebuggingException( - 'Debugging is not supported by '.get_class($this->httpClient).'.' - ); - } - - if ($enable) { - $this->httpClient->enableDebugging(); - } else { - $this->httpClient->disableDebugging(); - } + return $this + ->debugRequest() + ->debugResponse(null, $die); } } diff --git a/src/Traits/IsDebuggableAdapter.php b/src/Traits/IsDebuggableAdapter.php deleted file mode 100644 index d57c1469a..000000000 --- a/src/Traits/IsDebuggableAdapter.php +++ /dev/null @@ -1,47 +0,0 @@ -debug = true; - - return $this; - } - - /** - * Disable debugging. If debugging mode is enabled, the request will - * be included in the ApiException. By default, debugging is disabled to prevent - * sensitive request data from leaking into exception logs. - */ - public function disableDebugging(): HttpAdapterContract - { - $this->debug = false; - - return $this; - } - - /** - * Whether debugging is enabled. If debugging mode is enabled, the request will - * be included in the ApiException. By default, debugging is disabled to prevent - * sensitive request data from leaking into exception logs. - */ - public function debuggingIsActive(): bool - { - return $this->debug; - } -} diff --git a/src/Traits/ManagesPsrRequests.php b/src/Traits/ManagesPsrRequests.php index 3ed95bda0..ebb118fc2 100644 --- a/src/Traits/ManagesPsrRequests.php +++ b/src/Traits/ManagesPsrRequests.php @@ -29,8 +29,9 @@ public function createPsrRequest(): RequestInterface $request = $request->withHeader($headerName, $headerValue); } - if ($this->payload() instanceof PayloadRepository) { - $request = $request->withBody($this->payload()->toStream($factories->streamFactory)); + /** @var PayloadRepository|null */ + if (($payload = $this->payload()) instanceof PayloadRepository) { + $request = $request->withBody($payload->toStream($factories->streamFactory)); } return $request; diff --git a/src/Traits/SendsRequests.php b/src/Traits/SendsRequests.php index 62b3cf4d8..4b1ec1096 100644 --- a/src/Traits/SendsRequests.php +++ b/src/Traits/SendsRequests.php @@ -2,9 +2,11 @@ namespace Mollie\Api\Traits; +use Mollie\Api\Exceptions\RequestException; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Request; use Mollie\Api\MollieApiClient; +use Psr\Http\Client\ClientExceptionInterface; /** * @mixin MollieApiClient @@ -14,11 +16,14 @@ trait SendsRequests public function send(Request $request): object { $pendingRequest = new PendingRequest($this, $request); - $pendingRequest = $pendingRequest->executeRequestHandlers(); - return $pendingRequest->executeResponseHandlers( - $this->httpClient->sendRequest($pendingRequest) - ); + try { + $response = $this->httpClient->sendRequest($pendingRequest); + + return $pendingRequest->executeResponseHandlers($response); + } catch (RequestException $e) { + throw $e; + } } } diff --git a/tests/Http/Adapter/GuzzleMollieHttpAdapterTest.php b/tests/Http/Adapter/GuzzleMollieHttpAdapterTest.php index b5a3ea80f..070a47c8e 100644 --- a/tests/Http/Adapter/GuzzleMollieHttpAdapterTest.php +++ b/tests/Http/Adapter/GuzzleMollieHttpAdapterTest.php @@ -18,7 +18,7 @@ class GuzzleMollieHttpAdapterTest extends TestCase /** @test */ public function test_debugging_is_supported() { - $adapter = GuzzleMollieHttpAdapter::createDefault(); + $adapter = GuzzleMollieHttpAdapter::createClient(); $this->assertFalse($adapter->debuggingIsActive()); $adapter->enableDebugging(); diff --git a/tests/Http/Middleware/GuardResponseTest.php b/tests/Http/Middleware/GuardResponseTest.php deleted file mode 100644 index a583c4faf..000000000 --- a/tests/Http/Middleware/GuardResponseTest.php +++ /dev/null @@ -1,103 +0,0 @@ -createMock(Response::class); - - // Mock the status method to return a status other than HTTP_NO_CONTENT - $responseMock->expects($this->once()) - ->method('status') - ->willReturn(ResponseStatusCode::HTTP_OK); - - // Mock the body method to return an empty body - $responseMock->expects($this->once()) - ->method('isEmpty') - ->willReturn(true); - - $guardResponse = new GuardResponse; - - // Expect the ApiException to be thrown due to no response body - $this->expectException(ApiException::class); - $this->expectExceptionMessage('No response body found.'); - - $guardResponse($responseMock); - } - - /** - * @test - */ - public function it_does_not_throw_exception_if_http_no_content(): void - { - $responseMock = $this->createMock(Response::class); - - // Mock the status method to return HTTP_NO_CONTENT - $responseMock->expects($this->once()) - ->method('status') - ->willReturn(ResponseStatusCode::HTTP_NO_CONTENT); - - // Mock the body method to return an empty body - $responseMock->expects($this->once()) - ->method('isEmpty') - ->willReturn(true); - - $guardResponse = new GuardResponse; - - // No exception should be thrown - $guardResponse($responseMock); - } - - /** - * @test - */ - public function it_throws_exception_if_response_contains_error_message(): void - { - $responseMock = $this->createMock(Response::class); - - // Mock the json method to return an error object - $responseMock->expects($this->once()) - ->method('json') - ->willReturn((object) ['error' => (object) ['message' => 'Some error occurred']]); - - $guardResponse = new GuardResponse; - - // Expect the ApiException to be thrown due to error in the response - $this->expectException(ApiException::class); - $this->expectExceptionMessage('Some error occurred'); - - $guardResponse($responseMock); - } - - /** - * @test - */ - public function it_passes_if_valid_json_and_no_error_message(): void - { - $responseMock = $this->createMock(Response::class); - - // Mock the json method to return valid data - $responseMock->expects($this->once()) - ->method('json') - ->willReturn((object) ['data' => 'valid']); - - $guardResponse = new GuardResponse; - - // No exception should be thrown - $guardResponse($responseMock); - - // If the test reaches here without exceptions, it passes - $this->assertTrue(true); - } -} diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index 853fe7c63..4fa66490b 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -2,13 +2,14 @@ namespace Tests; +use Exception; use GuzzleHttp\Client; use Mollie\Api\Contracts\HasPayload; use Mollie\Api\Exceptions\ApiException; -use Mollie\Api\Exceptions\HttpAdapterDoesNotSupportDebuggingException; +use Mollie\Api\Exceptions\RequestException; +use Mollie\Api\Exceptions\ValidationException; use Mollie\Api\Fake\MockMollieClient; use Mollie\Api\Fake\MockResponse; -use Mollie\Api\Http\Adapter\CurlMollieHttpAdapter; use Mollie\Api\Http\Adapter\GuzzleMollieHttpAdapter; use Mollie\Api\Http\Data\CreatePaymentPayload; use Mollie\Api\Http\Data\Money; @@ -49,7 +50,7 @@ public function send_returns_body_as_object() /** @test */ public function send_creates_api_exception_correctly() { - $this->expectException(ApiException::class); + $this->expectException(ValidationException::class); $this->expectExceptionMessage('Error executing API call (422: Unprocessable Entity): Non-existent parameter "recurringType" for this API call. Did you mean: "sequenceType"?'); $this->expectExceptionCode(422); @@ -59,7 +60,7 @@ public function send_creates_api_exception_correctly() try { $client->send(new DynamicGetRequest('')); - } catch (ApiException $e) { + } catch (ValidationException $e) { $this->assertEquals('recurringType', $e->getField()); $this->assertNotEmpty($e->getDocumentationUrl()); @@ -70,7 +71,7 @@ public function send_creates_api_exception_correctly() /** @test */ public function send_creates_api_exception_without_field_and_documentation_url() { - $this->expectException(ApiException::class); + $this->expectException(ValidationException::class); $this->expectExceptionMessage('Error executing API call (422: Unprocessable Entity): Non-existent parameter "recurringType" for this API call. Did you mean: "sequenceType"?'); $this->expectExceptionCode(422); @@ -80,7 +81,7 @@ public function send_creates_api_exception_without_field_and_documentation_url() try { $client->send(new DynamicGetRequest('')); - } catch (ApiException $e) { + } catch (ValidationException $e) { $this->assertNull($e->getField()); $this->assertNull($e->getDocumentationUrl()); @@ -111,24 +112,6 @@ public function can_be_serialized_and_unserialized() $this->assertNotEmpty($client_copy->methods); } - /** @test */ - public function enabling_debugging_throws_an_exception_if_http_adapter_does_not_support_it() - { - $this->expectException(HttpAdapterDoesNotSupportDebuggingException::class); - $client = new MollieApiClient(new CurlMollieHttpAdapter); - - $client->enableDebugging(); - } - - /** @test */ - public function disabling_debugging_throws_an_exception_if_http_adapter_does_not_support_it() - { - $this->expectException(HttpAdapterDoesNotSupportDebuggingException::class); - $client = new MollieApiClient(new CurlMollieHttpAdapter); - - $client->disableDebugging(); - } - /** * This test verifies that our request headers are correctly sent to Mollie. * If these are broken, it could be that some payments do not work. @@ -337,6 +320,20 @@ public function testmode_is_not_removed_when_not_using_api_key_authentication() $this->assertTrue($response->getPendingRequest()->query()->get('testmode')); } + /** @test */ + public function when_debugging_is_enabled_the_request_is_removed_from_exceptions_to_prevent_leaking_sensitive_data() + { + $client = new MockMollieClient([ + DynamicGetRequest::class => function (PendingRequest $pendingRequest) { + throw new RequestException('test', $pendingRequest->createPsrRequest()); + }, + ]); + + $client->test(true); + + $client->send(new DynamicGetRequest('')); + } + /** @test */ public function can_hydrate_response_into_custom_resource_wrapper_class() {