From cbdd9a91944b6b742589dea65b1ab6d30ba9d32b Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 28 May 2025 18:19:12 +0200 Subject: [PATCH 01/21] Add Transport abstraction layer Removes the hard dependency on Guzzle by introducing a Transport abstraction layer, which allows using different HTTP clients. Add more granular exceptions which all extend the base SendyException. --- composer.json | 7 +- src/ApiException.php | 9 +- src/Connection.php | 182 +++++++--------------- src/Exceptions/ClientException.php | 10 ++ src/Exceptions/SendyException.php | 12 ++ src/Exceptions/ServerException.php | 10 ++ src/Exceptions/TransportException.php | 10 ++ src/Exceptions/ValidationException.php | 7 + src/Http/Request.php | 58 +++++++ src/Http/Response.php | 179 +++++++++++++++++++++ src/Http/Transport/CurlTransport.php | 94 +++++++++++ src/Http/Transport/LaravelTransport.php | 38 +++++ src/Http/Transport/Psr18Transport.php | 85 ++++++++++ src/Http/Transport/TransportFactory.php | 53 +++++++ src/Http/Transport/TransportInterface.php | 18 +++ src/Http/Transport/WordpressTransport.php | 43 +++++ src/RateLimits.php | 13 +- src/Resources/Label.php | 2 +- src/Resources/Parcelshop.php | 2 +- src/Resources/Webhook.php | 11 +- tests/ConnectionTest.php | 23 ++- tests/TestsEndpoints.php | 2 +- 22 files changed, 717 insertions(+), 151 deletions(-) create mode 100644 src/Exceptions/ClientException.php create mode 100644 src/Exceptions/SendyException.php create mode 100644 src/Exceptions/ServerException.php create mode 100644 src/Exceptions/TransportException.php create mode 100644 src/Exceptions/ValidationException.php create mode 100644 src/Http/Request.php create mode 100644 src/Http/Response.php create mode 100644 src/Http/Transport/CurlTransport.php create mode 100644 src/Http/Transport/LaravelTransport.php create mode 100644 src/Http/Transport/Psr18Transport.php create mode 100644 src/Http/Transport/TransportFactory.php create mode 100644 src/Http/Transport/TransportInterface.php create mode 100644 src/Http/Transport/WordpressTransport.php diff --git a/composer.json b/composer.json index f353291..fbe769a 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,7 @@ }, "require": { "php": ">=7.4.0", - "ext-json": "*", - "guzzlehttp/guzzle": "~6.0|~7.0" + "ext-json": "*" }, "config": { "platform": { @@ -39,7 +38,9 @@ "phpunit/phpunit": "^9.0", "phpstan/phpstan": "^1", "squizlabs/php_codesniffer": "^3.7", - "mockery/mockery": "^1.5" + "mockery/mockery": "^1.5", + "guzzlehttp/guzzle": "^7.9", + "symfony/http-client": "^5.4" }, "scripts": { "lint": "vendor/bin/phpcs", diff --git a/src/ApiException.php b/src/ApiException.php index 977eea5..c23c921 100644 --- a/src/ApiException.php +++ b/src/ApiException.php @@ -2,7 +2,12 @@ namespace Sendy\Api; -final class ApiException extends \Exception +/** + * @deprecated This class exists for backwards compatibility and may be removed in a future version. + * @todo Replace internal usages with the new more granular exceptions in `Sendy\Api\Exceptions`. + * @internal + */ +class ApiException extends \Exception { /** @var array */ private array $errors = []; @@ -13,7 +18,7 @@ final class ApiException extends \Exception * @param \Throwable|null $previous * @param string[][] $errors */ - public function __construct(string $message = "", int $code = 0, \Throwable $previous = null, array $errors = []) + public function __construct(string $message = "", int $code = 0, ?\Throwable $previous = null, array $errors = []) { $this->errors = $errors; diff --git a/src/Connection.php b/src/Connection.php index c8be086..01d2a4e 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -4,13 +4,15 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\BadResponseException; -use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\ServerException; use GuzzleHttp\Psr7\Message; -use GuzzleHttp\Psr7\Request; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; +use Sendy\Api\Exceptions\TransportException; +use Sendy\Api\Http\Request; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\TransportFactory; +use Sendy\Api\Http\Transport\TransportInterface; use Sendy\Api\Resources\Resource; /** @@ -26,7 +28,9 @@ */ class Connection { - private const BASE_URL = 'https://app.sendy.nl'; + public const VERSION = '3.0.0'; + + public const BASE_URL = 'https://app.sendy.nl'; private const API_URL = '/api'; @@ -34,10 +38,8 @@ class Connection private const TOKEN_URL = '/oauth/token'; - private const VERSION = '1.0.2'; - - /** @var Client|null */ - private ?Client $client = null; + /** @var TransportInterface|null */ + private ?TransportInterface $transport = null; /** @var string The Client ID as UUID */ private string $clientId; @@ -66,7 +68,7 @@ class Connection /** @var mixed */ private $state = null; - /** @var callable(Client) */ + /** @var callable($this) */ private $tokenUpdateCallback; /** @var bool */ @@ -77,40 +79,23 @@ class Connection public ?RateLimits $rateLimits; /** - * @return Client + * @return TransportInterface */ - public function getClient(): Client + public function getTransport(): TransportInterface { - if ($this->client instanceof Client) { - return $this->client; - } - - $userAgent = sprintf("Sendy/%s PHP/%s", self::VERSION, phpversion()); - - if ($this->isOauthClient()) { - $userAgent .= ' OAuth/2.0'; + if ($this->transport instanceof TransportInterface) { + return $this->transport; } - $userAgent .= " {$this->userAgentAppendix}"; - - $this->client = new Client([ - 'http_errors' => true, - 'expect' => false, - 'base_uri' => self::BASE_URL, - 'headers' => [ - 'User-Agent' => trim($userAgent), - ] - ]); - - return $this->client; + return $this->transport = TransportFactory::create(); } /** - * @param Client $client + * @param TransportInterface $transport */ - public function setClient(Client $client): void + public function setTransport(TransportInterface $transport): void { - $this->client = $client; + $this->transport = $transport; } /** @@ -319,24 +304,22 @@ private function acquireAccessToken(): void ]; } - $response = $this->getClient()->post(self::BASE_URL . self::TOKEN_URL, ['form_params' => $parameters]); + $response = $this->post(self::BASE_URL . self::TOKEN_URL, $parameters); - Message::rewindBody($response); - - $responseBody = $response->getBody()->getContents(); - - $body = json_decode($responseBody, true); + try { + $body = $response->getDecodedBody(); + } catch (\JsonException $e) { + throw new ApiException( + 'Could not acquire tokens, json decode failed. Got response: ' . $response->getBody() + ); + } - if (json_last_error() === JSON_ERROR_NONE) { - $this->accessToken = $body['access_token']; - $this->refreshToken = $body['refresh_token']; - $this->tokenExpires = time() + $body['expires_in']; + $this->accessToken = $body['access_token']; + $this->refreshToken = $body['refresh_token']; + $this->tokenExpires = time() + $body['expires_in']; - if (is_callable($this->tokenUpdateCallback)) { - call_user_func($this->tokenUpdateCallback, $this); - } - } else { - throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $responseBody); + if (is_callable($this->tokenUpdateCallback)) { + call_user_func($this->tokenUpdateCallback, $this); } } catch (BadResponseException $e) { throw new ApiException('Something went wrong. Got: ' . $e->getMessage(), 0, $e); @@ -346,7 +329,7 @@ private function acquireAccessToken(): void /** * @param string $method * @param string $endpoint - * @param null|StreamInterface|resource|string $body + * @param string $body * @param array|null $params * @param array|null $headers * @return Request @@ -354,13 +337,22 @@ private function acquireAccessToken(): void private function createRequest( string $method, string $endpoint, - $body = null, - array $params = null, - array $headers = null + ?string $body = null, + array $params = [], + array $headers = [] ): Request { + $userAgent = sprintf("Sendy/%s PHP/%s", self::VERSION, phpversion()); + + if ($this->isOauthClient()) { + $userAgent .= ' OAuth/2.0'; + } + + $userAgent .= " {$this->getTransport()->getUserAgent()} {$this->userAgentAppendix}"; + $headers = array_merge($headers, [ - 'Accept' => 'application/json', + 'Accept' => 'application/json', 'Content-Type' => 'application/json', + 'User-Agent' => trim($userAgent), ]); $this->checkOrAcquireAccessToken(); @@ -380,8 +372,7 @@ private function createRequest( * @param array $params * @param array $headers * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws TransportException */ public function get($url, array $params = [], array $headers = []): array { @@ -398,10 +389,9 @@ public function get($url, array $params = [], array $headers = []): array * @param array $params * @param array $headers * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws TransportException */ - public function post($url, array $body = null, array $params = [], array $headers = []): array + public function post($url, ?array $body = null, array $params = [], array $headers = []): array { $url = self::API_URL . $url; @@ -420,8 +410,7 @@ public function post($url, array $body = null, array $params = [], array $header * @param array> $params * @param array> $headers * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws TransportException */ public function put($url, array $body = [], array $params = [], array $headers = []): array { @@ -436,7 +425,7 @@ public function put($url, array $body = [], array $params = [], array $headers = /** * @param UriInterface|string $url * @return array> - * @throws ApiException|\GuzzleHttp\Exception\GuzzleException + * @throws TransportException */ public function delete($url): array { @@ -447,45 +436,30 @@ public function delete($url): array return $this->performRequest($request); } - /** - * @param Request $request - * @return mixed[]|\mixed[][]|\string[][] - * @throws ApiException - * @throws GuzzleException - */ private function performRequest(Request $request): array { - try { - $response = $this->getClient()->send($request); + $response = $this->getTransport()->send($request); - return $this->parseResponse($response); - } catch (\Exception $e) { - $this->parseException($e); - } + return $this->parseResponse($response); } /** - * @param ResponseInterface $response * @return array> * @throws ApiException */ - public function parseResponse(ResponseInterface $response): array + public function parseResponse(Response $response): array { $this->extractRateLimits($response); + if ($exception = $response->toException()) { + throw $exception; + } + if ($response->getStatusCode() === 204) { return []; } - Message::rewindBody($response); - - $responseBody = $response->getBody()->getContents(); - - $json = json_decode($responseBody, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new ApiException("Json decode failed. Got: " . $responseBody); - } + $json = $response->getDecodedBody(); if (array_key_exists('data', $json)) { if (array_key_exists('meta', $json)) { @@ -500,43 +474,7 @@ public function parseResponse(ResponseInterface $response): array return $json; } - /** - * @param \Exception $e - * @return void - * @throws ApiException - */ - public function parseException(\Exception $e): void - { - if (! $e instanceof BadResponseException) { - throw new ApiException($e->getMessage(), 0, $e); - } - - $this->extractRateLimits($e->getResponse()); - - if ($e instanceof ServerException) { - throw new ApiException($e->getMessage(), $e->getResponse()->getStatusCode()); - } - - $response = $e->getResponse(); - - Message::rewindBody($response); - - $responseBody = $response->getBody()->getContents(); - - $json = json_decode($responseBody, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new ApiException("Json decode failed. Got: " . $responseBody); - } - - if (array_key_exists('errors', $json)) { - throw new ApiException($json['message'], 0, $e, $json['errors']); - } - - throw new ApiException($json['message']); - } - - private function extractRateLimits(ResponseInterface $response): void + private function extractRateLimits(Response $response): void { $this->rateLimits = RateLimits::buildFromResponse($response); } diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php new file mode 100644 index 0000000..cbbbea0 --- /dev/null +++ b/src/Exceptions/ClientException.php @@ -0,0 +1,10 @@ + + */ + private array $headers; + + private ?string $body; + + public function __construct( + string $method, + string $url, + array $headers = [], + ?string $body = null + ) { + $this->method = strtoupper($method); + $this->url = $url; + $this->headers = $headers; + $this->body = $body; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUrl(): string + { + if (str_starts_with($this->url, '/')) { + return Connection::BASE_URL . $this->url; + } + + return $this->url; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): ?string + { + return $this->body; + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php new file mode 100644 index 0000000..bb5cdf4 --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,179 @@ + 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-status', + 208 => 'Already Reported', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Time-out', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Request Entity Too Large', + 414 => 'Request-URI Too Large', + 415 => 'Unsupported Media Type', + 416 => 'Requested range not satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Unordered Collection', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Time-out', + 505 => 'HTTP Version not supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required', + ]; + + private int $statusCode; + + /** + * @var array> + */ + private array $headers; + + private string $body; + + /** + * @param array> $headers + */ + public function __construct(int $statusCode, array $headers, string $body) + { + $this->statusCode = $statusCode; + $this->headers = $headers; + $this->body = $body; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * @return array> + */ + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): string + { + return $this->body; + } + + /** + * Decode the JSON body of the response. + * + * @return array + * @throws ApiException If the body is not valid JSON. + */ + public function getDecodedBody(): array + { + try { + return json_decode($this->body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new ApiException("Json decode failed. Got: {$this->body}", $this->statusCode, $e); + } + } + + /** + * Get a summary of the response, suitable for use in exception messages. + */ + public function getSummary(): string + { + $summary = $this->statusCode . ' ' . (self::PHRASES[$this->statusCode] ?? 'Unknown Status'); + $decodedBody = json_decode($this->body, true); + $message = $decodedBody['message'] ?? $decodedBody['error_description'] ?? null; + + if ($message) { + $summary .= ': ' . $message; + } + + return $summary; + } + + /** + * Extract errors from the response body. + * + * @return array> + */ + public function getErrors(): array + { + $data = json_decode($this->body, true); + + return $data['errors'] ?? []; + } + + /** + * @return ClientException|ServerException|null + */ + public function toException() + { + if ($this->statusCode === 422) { + return new ValidationException( + $this->getDecodedBody()['message'] ?? 'Validation failed', + $this->statusCode, + null, + $this->getErrors() + ); + } + + if ($this->statusCode >= 400 && $this->statusCode < 500) { + return new ClientException($this->getSummary(), $this->statusCode, null, $this->getErrors()); + } + + if ($this->statusCode >= 500) { + return new ServerException($this->getSummary(), $this->statusCode, null, $this->getErrors()); + } + + return null; + } +} diff --git a/src/Http/Transport/CurlTransport.php b/src/Http/Transport/CurlTransport.php new file mode 100644 index 0000000..9f9144e --- /dev/null +++ b/src/Http/Transport/CurlTransport.php @@ -0,0 +1,94 @@ +getUrl()); + curl_setopt($curlHandle, CURLOPT_CUSTOMREQUEST, $request->getMethod()); + curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($curlHandle, CURLOPT_HEADER, true); + curl_setopt($curlHandle, CURLOPT_HTTPHEADER, $this->formatHeaders($request->getHeaders())); + + if ($body = $request->getBody()) { + curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $body); + } + + $response = curl_exec($curlHandle); + + if ($response === false) { + $error = curl_error($curlHandle); + + throw new TransportException('cURL error: ' . $error); + } + + $headerSize = curl_getinfo($curlHandle, CURLINFO_HEADER_SIZE); + $headers = substr($response, 0, $headerSize); + $body = substr($response, $headerSize); + $statusCode = curl_getinfo($curlHandle, CURLINFO_RESPONSE_CODE); + + return new Response($statusCode, $this->parseHeaders($headers), $body); + } finally { + curl_close($curlHandle); + } + } + + public function getUserAgent(): string + { + if (!extension_loaded('curl')) { + return 'curl'; + } + + return 'curl/' . curl_version()['version']; + } + + /** + * Formats headers for cURL. + * + * @param array $headers + * @return list + */ + private function formatHeaders(array $headers): array + { + $formatted = []; + foreach ($headers as $name => $value) { + $formatted[] = $name . ': ' . $value; + } + return $formatted; + } + + /** + * Parses the raw header string into an associative array. + * + * @param string $rawHeaders + * + * @return array> + */ + private function parseHeaders(string $rawHeaders): array + { + $headers = []; + $lines = explode("\r\n", $rawHeaders); + + foreach ($lines as $line) { + if (strpos($line, ':') !== false) { + [$name, $value] = explode(': ', $line, 2); + $headers[strtolower($name)][] = $value; + } + } + + return $headers; + } +} diff --git a/src/Http/Transport/LaravelTransport.php b/src/Http/Transport/LaravelTransport.php new file mode 100644 index 0000000..561c903 --- /dev/null +++ b/src/Http/Transport/LaravelTransport.php @@ -0,0 +1,38 @@ +getHeaders(); + $contentType = \Illuminate\Support\Arr::pull($headers, 'Content-Type', 'application/json'); + + try { + $response = \Illuminate\Support\Facades\Http::withHeaders($headers) + ->withBody($request->getBody(), $contentType) + ->withMethod($request->getMethod()) + ->withUrl($request->getUrl()) + ->send(); + } catch (\Throwable $e) { + throw new TransportException($e->getMessage(), $e->getCode(), $e); + } + + return new Response($response->status(), $response->headers(), $response->body()); + } + + public function getUserAgent(): string + { + return 'LaravelHttpClient/' . Application::VERSION; + } +} diff --git a/src/Http/Transport/Psr18Transport.php b/src/Http/Transport/Psr18Transport.php new file mode 100644 index 0000000..c03aedf --- /dev/null +++ b/src/Http/Transport/Psr18Transport.php @@ -0,0 +1,85 @@ +client = $client; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + $this->uriFactory = $uriFactory; + } + + public function send(Request $request): Response + { + $psrRequest = $this->requestFactory->createRequest( + $request->getMethod(), + $this->uriFactory->createUri($request->getUrl()) + ); + + foreach ($request->getHeaders() as $name => $value) { + $psrRequest = $psrRequest->withHeader($name, $value); + } + + if ($body = $request->getBody()) { + $psrRequest = $psrRequest->withBody( + $this->streamFactory->createStream($body) + ); + } + + try { + $psrResponse = $this->client->sendRequest($psrRequest); + } catch (\Throwable $e) { + throw new \Sendy\Api\Exceptions\TransportException($e->getMessage(), $e->getCode(), $e); + } + + return new Response( + $psrResponse->getStatusCode(), + $psrResponse->getHeaders(), + (string) $psrResponse->getBody(), + ); + } + + public function getClient(): ClientInterface + { + return $this->client; + } + + public function getRequestFactory(): RequestFactoryInterface + { + return $this->requestFactory; + } + + public function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory; + } + + public function getUriFactory(): UriFactoryInterface + { + return $this->uriFactory; + } + + public function getUserAgent(): string + { + return 'PSR-18 (' . str_replace('\\', '_', get_class($this->client)) . ')'; + } +} diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php new file mode 100644 index 0000000..3763af2 --- /dev/null +++ b/src/Http/Transport/TransportFactory.php @@ -0,0 +1,53 @@ + $request->getMethod(), + 'headers' => $request->getHeaders(), + 'body' => $request->getBody(), + ]; + + if ($request->getMethod() === 'GET') { + unset($args['body']); + } + + $response = wp_remote_request($request->getUrl(), $args); + + if (is_wp_error($response)) { + throw new \Sendy\Api\Exceptions\TransportException($response->get_error_message()); + } + + return new Response( + wp_remote_retrieve_response_code($response), + wp_remote_retrieve_headers($response), + wp_remote_retrieve_body($response) + ); + } + + public function getUserAgent(): string + { + return 'WP_Http/' . get_bloginfo('version'); + } +} diff --git a/src/RateLimits.php b/src/RateLimits.php index 661d954..3cb784f 100644 --- a/src/RateLimits.php +++ b/src/RateLimits.php @@ -3,6 +3,7 @@ namespace Sendy\Api; use Psr\Http\Message\ResponseInterface; +use Sendy\Api\Http\Response; final class RateLimits { @@ -28,13 +29,15 @@ public function __construct(int $retryAfter, int $limit, int $remaining, int $re $this->reset = $reset; } - public static function buildFromResponse(ResponseInterface $response): RateLimits + public static function buildFromResponse(Response $response): RateLimits { + $headers = $response->getHeaders(); + return new self( - (int) implode("", $response->getHeader('Retry-After')), - (int) implode("", $response->getHeader('X-RateLimit-Limit')), - (int) implode("", $response->getHeader('X-RateLimit-Remaining')), - (int) implode("", $response->getHeader('X-RateLimit-Reset')) + (int) ($headers['retry-after'][0] ?? 0), + (int) ($headers['x-ratelimit-limit'][0] ?? 0), + (int) ($headers['x-ratelimit-remaining'][0] ?? 0), + (int) ($headers['x-ratelimit-reset'][0] ?? 0) ); } } diff --git a/src/Resources/Label.php b/src/Resources/Label.php index 2ad5bb7..e19625d 100644 --- a/src/Resources/Label.php +++ b/src/Resources/Label.php @@ -21,7 +21,7 @@ final class Label extends Resource * @throws ApiException * @link https://app.sendy.nl/api/docs#tag/Documents/operation/api.labels.index */ - public function get(array $shipmentIds, string $paperType = null, string $startLocation = null): array + public function get(array $shipmentIds, ?string $paperType = null, ?string $startLocation = null): array { $params = [ 'ids' => $shipmentIds, diff --git a/src/Resources/Parcelshop.php b/src/Resources/Parcelshop.php index 265b3b6..e13eecc 100644 --- a/src/Resources/Parcelshop.php +++ b/src/Resources/Parcelshop.php @@ -27,7 +27,7 @@ public function list( float $latitude, float $longitude, string $country, - string $postalCode = null + ?string $postalCode = null ): array { $params = [ 'carriers' => $carriers, diff --git a/src/Resources/Webhook.php b/src/Resources/Webhook.php index 8e690ba..e48bff5 100644 --- a/src/Resources/Webhook.php +++ b/src/Resources/Webhook.php @@ -7,9 +7,9 @@ final class Webhook extends Resource /** * List all webhooks * - * @return array> * @throws \GuzzleHttp\Exception\GuzzleException * @throws \Sendy\Api\ApiException + * @return array> * @link https://app.sendy.nl/api/docs#tag/Webhooks/operation/api.webhooks.index */ public function list(): array @@ -21,9 +21,10 @@ public function list(): array * Create a new webhook * * @param array> $data - * @return array> + * * @throws \GuzzleHttp\Exception\GuzzleException * @throws \Sendy\Api\ApiException + * @return array> * @link https://app.sendy.nl/api/docs#tag/Webhooks/operation/api.webhooks.store */ public function create(array $data): array @@ -35,9 +36,10 @@ public function create(array $data): array * Delete a webhook * * @param string $id The ID of the webhook - * @return array + * * @throws \GuzzleHttp\Exception\GuzzleException * @throws \Sendy\Api\ApiException + * @return array */ public function delete(string $id): array { @@ -49,9 +51,10 @@ public function delete(string $id): array * * @param string $id The id of the webhook to be updated * @param array> $data - * @return array> + * * @throws \GuzzleHttp\Exception\GuzzleException * @throws \Sendy\Api\ApiException + * @return array> */ public function update(string $id, array $data): array { diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 8adcff4..faa37c1 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -7,12 +7,11 @@ use GuzzleHttp\Exception\ServerException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\TestCase; use Sendy\Api\ApiException; use Sendy\Api\Connection; -use PHPUnit\Framework\TestCase; use Sendy\Api\Meta; use Sendy\Api\Resources\Me; @@ -24,7 +23,7 @@ public function testUserAgentIsSet(): void $this->assertEquals( sprintf('Sendy/1.0.2 PHP/%s', phpversion()), - $connection->getClient()->getConfig('headers')['User-Agent'] + $connection->getTransport()->getConfig('headers')['User-Agent'] ); $connection = new Connection(); @@ -32,7 +31,7 @@ public function testUserAgentIsSet(): void $this->assertEquals( sprintf('Sendy/1.0.2 PHP/%s WooCommerce/6.2', phpversion()), - $connection->getClient()->getConfig('headers')['User-Agent'] + $connection->getTransport()->getConfig('headers')['User-Agent'] ); $connection = new Connection(); @@ -40,7 +39,7 @@ public function testUserAgentIsSet(): void $this->assertEquals( sprintf('Sendy/1.0.2 PHP/%s OAuth/2.0', phpversion()), - $connection->getClient()->getConfig('headers')['User-Agent'] + $connection->getTransport()->getConfig('headers')['User-Agent'] ); } @@ -253,7 +252,7 @@ public function testTokensAreAcquiredWithAuthorizationCode(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $connection->setClientId('clientId'); $connection->setRedirectUrl('https://www.example.com/'); @@ -283,7 +282,7 @@ public function testTokensAreAcquiredWithRefreshToken(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $connection->setClientId('clientId'); $connection->setClientSecret('clientSecret'); @@ -315,7 +314,7 @@ public function testTokenUpdateCallbackIsCalled(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $connection->setClientId('clientId'); $connection->setClientSecret('clientSecret'); @@ -343,7 +342,7 @@ public function testGetRequestIsBuiltAndSent(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $this->assertEquals(['foo' => 'bar'], $connection->get('/foo')); @@ -373,7 +372,7 @@ public function testDeleteRequestIsBuiltAndSent(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $this->assertEquals([], $connection->delete('/bar')); @@ -392,7 +391,7 @@ public function testPostRequestIsBuiltAndSent(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $this->assertEquals(['foo' => 'bar'], $connection->post('/foo', ['request' => 'body'])); @@ -412,7 +411,7 @@ public function testPutRequestIsBuiltAndSent(): void $client = new Client(['handler' => HandlerStack::create($mockHandler)]); - $connection->setClient($client); + $connection->setTransport($client); $this->assertEquals(['foo' => 'bar'], $connection->put('/foo', ['request' => 'body'])); diff --git a/tests/TestsEndpoints.php b/tests/TestsEndpoints.php index b7a269b..1323f1a 100644 --- a/tests/TestsEndpoints.php +++ b/tests/TestsEndpoints.php @@ -17,7 +17,7 @@ public function buildConnectionWithMockHandler(MockHandler $handler): Connection $client = new Client(['handler' => HandlerStack::create($handler)]); - $connection->setClient($client); + $connection->setTransport($client); return $connection; } From dd9ffa1f2357f72e5d39d989d1fc97c037a54ce1 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Mon, 2 Jun 2025 17:03:03 +0200 Subject: [PATCH 02/21] Fix tests --- src/Connection.php | 86 +++---- src/Exceptions/SendyException.php | 3 - src/Http/Request.php | 13 +- src/Http/Response.php | 9 +- src/Http/Transport/MockTransport.php | 38 +++ src/Http/Transport/Psr18Transport.php | 7 +- src/Http/Transport/TransportFactory.php | 18 +- src/Http/Transport/TransportInterface.php | 3 + src/RateLimits.php | 1 - src/Resources/Carrier.php | 9 +- src/Resources/Label.php | 6 +- src/Resources/Me.php | 3 + src/Resources/Parcelshop.php | 6 +- src/Resources/Service.php | 6 +- src/Resources/Shipment.php | 30 +-- src/Resources/ShippingPreference.php | 6 +- src/Resources/Shop.php | 9 +- src/Resources/Webhook.php | 14 +- tests/ConnectionTest.php | 257 ++++++++------------- tests/RateLimitsTest.php | 5 +- tests/Resources/CarrierTest.php | 29 ++- tests/Resources/LabelTest.php | 31 ++- tests/Resources/MeTest.php | 17 +- tests/Resources/ParcelshopTest.php | 19 +- tests/Resources/ServiceTest.php | 14 +- tests/Resources/ShipmentTest.php | 147 +++++++----- tests/Resources/ShippingPreferenceTest.php | 17 +- tests/Resources/ShopTest.php | 26 +-- tests/Resources/WebhookTest.php | 54 ++--- tests/ResponseTest.php | 53 +++++ tests/TestsEndpoints.php | 12 +- 31 files changed, 492 insertions(+), 456 deletions(-) create mode 100644 src/Http/Transport/MockTransport.php create mode 100644 tests/ResponseTest.php diff --git a/src/Connection.php b/src/Connection.php index 01d2a4e..14821b0 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -2,11 +2,6 @@ namespace Sendy\Api; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\BadResponseException; -use GuzzleHttp\Exception\ServerException; -use GuzzleHttp\Psr7\Message; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; use Sendy\Api\Exceptions\TransportException; use Sendy\Api\Http\Request; @@ -286,62 +281,53 @@ public function tokenHasExpired(): bool private function acquireAccessToken(): void { - try { - if (empty($this->refreshToken)) { - $parameters = [ - 'redirect_uri' => $this->redirectUrl, - 'grant_type' => 'authorization_code', - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, - 'code' => $this->authorizationCode, - ]; - } else { - $parameters = [ - 'refresh_token' => $this->refreshToken, - 'grant_type' => 'refresh_token', - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, - ]; - } - - $response = $this->post(self::BASE_URL . self::TOKEN_URL, $parameters); + if (empty($this->refreshToken)) { + $parameters = [ + 'redirect_uri' => $this->redirectUrl, + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $this->authorizationCode, + ]; + } else { + $parameters = [ + 'refresh_token' => $this->refreshToken, + 'grant_type' => 'refresh_token', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]; + } - try { - $body = $response->getDecodedBody(); - } catch (\JsonException $e) { - throw new ApiException( - 'Could not acquire tokens, json decode failed. Got response: ' . $response->getBody() - ); - } + $body = $this->performRequest( + $this->createRequest('POST', self::BASE_URL . self::TOKEN_URL, json_encode($parameters)), + false + ); - $this->accessToken = $body['access_token']; - $this->refreshToken = $body['refresh_token']; - $this->tokenExpires = time() + $body['expires_in']; + $this->accessToken = $body['access_token']; + $this->refreshToken = $body['refresh_token']; + $this->tokenExpires = time() + $body['expires_in']; - if (is_callable($this->tokenUpdateCallback)) { - call_user_func($this->tokenUpdateCallback, $this); - } - } catch (BadResponseException $e) { - throw new ApiException('Something went wrong. Got: ' . $e->getMessage(), 0, $e); + if (is_callable($this->tokenUpdateCallback)) { + call_user_func($this->tokenUpdateCallback, $this); } } /** * @param string $method * @param string $endpoint - * @param string $body - * @param array|null $params - * @param array|null $headers + * @param string|null $body + * @param array $params + * @param array $headers * @return Request */ - private function createRequest( + public function createRequest( string $method, string $endpoint, ?string $body = null, array $params = [], array $headers = [] ): Request { - $userAgent = sprintf("Sendy/%s PHP/%s", self::VERSION, phpversion()); + $userAgent = sprintf("SendySDK/%s PHP/%s", self::VERSION, phpversion()); if ($this->isOauthClient()) { $userAgent .= ' OAuth/2.0'; @@ -355,10 +341,6 @@ private function createRequest( 'User-Agent' => trim($userAgent), ]); - $this->checkOrAcquireAccessToken(); - - $headers['Authorization'] = "Bearer {$this->accessToken}"; - if (! empty($params)) { $endpoint .= strpos($endpoint, '?') === false ? '?' : '&'; $endpoint .= http_build_query($params); @@ -436,8 +418,14 @@ public function delete($url): array return $this->performRequest($request); } - private function performRequest(Request $request): array + private function performRequest(Request $request, bool $checkAccessToken = true): array { + if ($checkAccessToken) { + $this->checkOrAcquireAccessToken(); + + $request->setHeader('Authorization', "Bearer {$this->accessToken}"); + } + $response = $this->getTransport()->send($request); return $this->parseResponse($response); diff --git a/src/Exceptions/SendyException.php b/src/Exceptions/SendyException.php index 23d7c5b..bf07004 100644 --- a/src/Exceptions/SendyException.php +++ b/src/Exceptions/SendyException.php @@ -4,9 +4,6 @@ use Sendy\Api\ApiException; -/** - * @internal - */ abstract class SendyException extends ApiException { } diff --git a/src/Http/Request.php b/src/Http/Request.php index b778d26..658e258 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -20,6 +20,9 @@ final class Request private ?string $body; + /** + * @param array $headers + */ public function __construct( string $method, string $url, @@ -28,7 +31,7 @@ public function __construct( ) { $this->method = strtoupper($method); $this->url = $url; - $this->headers = $headers; + $this->headers = array_change_key_case($headers, CASE_LOWER); $this->body = $body; } @@ -46,6 +49,9 @@ public function getUrl(): string return $this->url; } + /** + * @return array + */ public function getHeaders(): array { return $this->headers; @@ -55,4 +61,9 @@ public function getBody(): ?string { return $this->body; } + + public function setHeader(string $name, string $value): void + { + $this->headers[strtolower($name)] = $value; + } } diff --git a/src/Http/Response.php b/src/Http/Response.php index bb5cdf4..801713f 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -82,12 +82,15 @@ final class Response private string $body; /** - * @param array> $headers + * @param array|string> $headers */ public function __construct(int $statusCode, array $headers, string $body) { $this->statusCode = $statusCode; - $this->headers = $headers; + $this->headers = array_map( + fn($value) => is_array($value) ? $value : [$value], + array_change_key_case($headers, CASE_LOWER) + ); $this->body = $body; } @@ -129,7 +132,7 @@ public function getDecodedBody(): array */ public function getSummary(): string { - $summary = $this->statusCode . ' ' . (self::PHRASES[$this->statusCode] ?? 'Unknown Status'); + $summary = $this->statusCode . ' - ' . (self::PHRASES[$this->statusCode] ?? 'Unknown Status'); $decodedBody = json_decode($this->body, true); $message = $decodedBody['message'] ?? $decodedBody['error_description'] ?? null; diff --git a/src/Http/Transport/MockTransport.php b/src/Http/Transport/MockTransport.php new file mode 100644 index 0000000..2039f99 --- /dev/null +++ b/src/Http/Transport/MockTransport.php @@ -0,0 +1,38 @@ +response = $response ?? new Response(200, [], json_encode(['success' => true])); + } + + public function send(Request $request): Response + { + $this->requests[] = $request; + + return $this->response; + } + + public function getUserAgent(): string + { + return 'MockTransport/1.0'; + } + + public function getLastRequest(): ?Request + { + return end($this->requests) ?: null; + } +} diff --git a/src/Http/Transport/Psr18Transport.php b/src/Http/Transport/Psr18Transport.php index c03aedf..05b3c0a 100644 --- a/src/Http/Transport/Psr18Transport.php +++ b/src/Http/Transport/Psr18Transport.php @@ -15,17 +15,20 @@ class Psr18Transport implements TransportInterface private RequestFactoryInterface $requestFactory; private StreamFactoryInterface $streamFactory; private UriFactoryInterface $uriFactory; + private string $userAgent; public function __construct( ClientInterface $client, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, - UriFactoryInterface $uriFactory + UriFactoryInterface $uriFactory, + string $userAgent ) { $this->client = $client; $this->requestFactory = $requestFactory; $this->streamFactory = $streamFactory; $this->uriFactory = $uriFactory; + $this->userAgent = $userAgent; } public function send(Request $request): Response @@ -80,6 +83,6 @@ public function getUriFactory(): UriFactoryInterface public function getUserAgent(): string { - return 'PSR-18 (' . str_replace('\\', '_', get_class($this->client)) . ')'; + return $this->userAgent; } } diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php index 3763af2..a544c81 100644 --- a/src/Http/Transport/TransportFactory.php +++ b/src/Http/Transport/TransportFactory.php @@ -24,7 +24,7 @@ class_exists(\GuzzleHttp\Client::class) && } if (extension_loaded('curl')) { - return self::createGuzzleTransport(); + return self::createCurlTransport(); } throw new \LogicException( @@ -36,14 +36,26 @@ public static function createGuzzleTransport(): Psr18Transport { $httpFactory = new \GuzzleHttp\Psr7\HttpFactory(); - return new Psr18Transport(new \GuzzleHttp\Client(), $httpFactory, $httpFactory, $httpFactory); + return new Psr18Transport( + new \GuzzleHttp\Client(), + $httpFactory, + $httpFactory, + $httpFactory, + \GuzzleHttp\Utils::defaultUserAgent() + ); } public static function createSymfonyTransport(): Psr18Transport { $client = new \Symfony\Component\HttpClient\Psr18Client(); - return new Psr18Transport($client, $client, $client, $client); + return new Psr18Transport( + $client, + $client, + $client, + $client, + 'SymfonyHttpClient' + ); } public static function createCurlTransport(): CurlTransport diff --git a/src/Http/Transport/TransportInterface.php b/src/Http/Transport/TransportInterface.php index 71bb257..6278288 100644 --- a/src/Http/Transport/TransportInterface.php +++ b/src/Http/Transport/TransportInterface.php @@ -14,5 +14,8 @@ interface TransportInterface */ public function send(Request $request): Response; + /** + * Get the part of the user agent string that identifies the HTTP client. + */ public function getUserAgent(): string; } diff --git a/src/RateLimits.php b/src/RateLimits.php index 3cb784f..2524b3b 100644 --- a/src/RateLimits.php +++ b/src/RateLimits.php @@ -2,7 +2,6 @@ namespace Sendy\Api; -use Psr\Http\Message\ResponseInterface; use Sendy\Api\Http\Response; final class RateLimits diff --git a/src/Resources/Carrier.php b/src/Resources/Carrier.php index 150d3eb..b9983d2 100644 --- a/src/Resources/Carrier.php +++ b/src/Resources/Carrier.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; final class Carrier extends Resource { @@ -13,8 +12,7 @@ final class Carrier extends Resource * Display all carriers in a list. * * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Carriers/operation/api.carriers.index */ public function list(): array @@ -29,8 +27,7 @@ public function list(): array * * @param int $id The id of the carrier * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Carriers/operation/api.carriers.show */ public function get(int $id): array diff --git a/src/Resources/Label.php b/src/Resources/Label.php index e19625d..df8274b 100644 --- a/src/Resources/Label.php +++ b/src/Resources/Label.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; final class Label extends Resource { @@ -17,8 +16,7 @@ final class Label extends Resource * @param null|'top-left'|'top-right'|'bottom-left'|'bottom-right' $startLocation Where to start combining the * labels. Only used when $paperType is set to A4. * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Documents/operation/api.labels.index */ public function get(array $shipmentIds, ?string $paperType = null, ?string $startLocation = null): array diff --git a/src/Resources/Me.php b/src/Resources/Me.php index a86411f..1860e4d 100644 --- a/src/Resources/Me.php +++ b/src/Resources/Me.php @@ -2,6 +2,8 @@ namespace Sendy\Api\Resources; +use Sendy\Api\Exceptions\SendyException; + final class Me extends Resource { /** @@ -11,6 +13,7 @@ final class Me extends Resource * * @link https://app.sendy.nl/api/docs#tag/User/operation/api.me * @return array> + * @throws SendyException */ public function get(): array { diff --git a/src/Resources/Parcelshop.php b/src/Resources/Parcelshop.php index e13eecc..e48a0f0 100644 --- a/src/Resources/Parcelshop.php +++ b/src/Resources/Parcelshop.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; class Parcelshop extends Resource { @@ -18,8 +17,7 @@ class Parcelshop extends Resource * @param string $country The country code of the location. * @param string|null $postalCode The postal code of the location. * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Parcel-shops */ public function list( diff --git a/src/Resources/Service.php b/src/Resources/Service.php index 4f69d8a..de247a3 100644 --- a/src/Resources/Service.php +++ b/src/Resources/Service.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; class Service extends Resource { @@ -14,8 +13,7 @@ class Service extends Resource * * @param int $carrierId The id of the carrier * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Services/operation/api.carriers.services.index */ public function list(int $carrierId): array diff --git a/src/Resources/Shipment.php b/src/Resources/Shipment.php index 4999adc..1088de0 100644 --- a/src/Resources/Shipment.php +++ b/src/Resources/Shipment.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; use Sendy\Api\Meta; final class Shipment extends Resource @@ -15,8 +14,7 @@ final class Shipment extends Resource * * @param int $page The page number to fetch * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Shipments/operation/api.shipments.index * @see Meta */ @@ -32,8 +30,7 @@ public function list(int $page = 1): array * * @param string $id The UUID of the shipment * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Shipments/operation/api.shipments.show */ public function get(string $id): array @@ -49,8 +46,7 @@ public function get(string $id): array * @param string $id The UUID of the shipment * @param array> $data * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException */ public function update(string $id, array $data): array { @@ -68,8 +64,7 @@ public function update(string $id, array $data): array * * @param string $id The UUID of the shipment * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Shipments/operation/api.shipments.destroy */ public function delete(string $id): array @@ -85,8 +80,7 @@ public function delete(string $id): array * @param array> $data * @param bool $generateDirectly Should the shipment be generated right away. This will increase the response time. * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Shipments/operation/api.shipments.preference * @see ShippingPreference */ @@ -100,8 +94,7 @@ public function createFromPreference(array $data, bool $generateDirectly = true) * * @param array> $data * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Shipments/operation/api.shipments.smart-rule */ public function createWithSmartRules(array $data): array @@ -117,8 +110,7 @@ public function createWithSmartRules(array $data): array * @param string $id The UUID of the shipment * @param bool $asynchronous Whether the shipping label should be generated asynchronously * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Shipments/operation/api.shipments.generate */ public function generate(string $id, bool $asynchronous = true): array @@ -133,8 +125,7 @@ public function generate(string $id, bool $asynchronous = true): array * * @param string $id The UUID of the shipment * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Documents/operation/api.shipments.labels.index */ public function labels(string $id): array @@ -149,8 +140,7 @@ public function labels(string $id): array * * @param string $id * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Documents/operation/api.shipments.documents.index */ public function documents(string $id): array diff --git a/src/Resources/ShippingPreference.php b/src/Resources/ShippingPreference.php index 6c0f6f7..393f5ec 100644 --- a/src/Resources/ShippingPreference.php +++ b/src/Resources/ShippingPreference.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; class ShippingPreference extends Resource { @@ -14,8 +13,7 @@ class ShippingPreference extends Resource * * @link https://app.sendy.nl/api/docs#tag/Shipping-preferences * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException */ public function list(): array { diff --git a/src/Resources/Shop.php b/src/Resources/Shop.php index fed9a03..bf39599 100644 --- a/src/Resources/Shop.php +++ b/src/Resources/Shop.php @@ -2,8 +2,7 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; class Shop extends Resource { @@ -14,8 +13,7 @@ class Shop extends Resource * * @link https://app.sendy.nl/api/docs#tag/Shops/operation/api.shops.index * @return array> - * @throws GuzzleException - * @throws ApiException + * @throws SendyException */ public function list(): array { @@ -30,8 +28,7 @@ public function list(): array * @link https://app.sendy.nl/api/docs#tag/Shops/operation/api.shops.show * @param string $id * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException */ public function get(string $id): array { diff --git a/src/Resources/Webhook.php b/src/Resources/Webhook.php index e48bff5..2d51792 100644 --- a/src/Resources/Webhook.php +++ b/src/Resources/Webhook.php @@ -2,13 +2,14 @@ namespace Sendy\Api\Resources; +use Sendy\Api\Exceptions\SendyException; + final class Webhook extends Resource { /** * List all webhooks * - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException + * @throws SendyException * @return array> * @link https://app.sendy.nl/api/docs#tag/Webhooks/operation/api.webhooks.index */ @@ -22,8 +23,7 @@ public function list(): array * * @param array> $data * - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException + * @throws SendyException * @return array> * @link https://app.sendy.nl/api/docs#tag/Webhooks/operation/api.webhooks.store */ @@ -37,8 +37,7 @@ public function create(array $data): array * * @param string $id The ID of the webhook * - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException + * @throws SendyException * @return array */ public function delete(string $id): array @@ -52,8 +51,7 @@ public function delete(string $id): array * @param string $id The id of the webhook to be updated * @param array> $data * - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException + * @throws SendyException * @return array> */ public function update(string $id, array $data): array diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index faa37c1..f5b8ec2 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -2,16 +2,12 @@ namespace Sendy\Api\Tests; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ClientException; -use GuzzleHttp\Exception\ServerException; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; use Sendy\Api\ApiException; use Sendy\Api\Connection; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Http\Transport\TransportFactory; use Sendy\Api\Meta; use Sendy\Api\Resources\Me; @@ -19,27 +15,24 @@ class ConnectionTest extends TestCase { public function testUserAgentIsSet(): void { - $connection = new Connection(); - + $connection = $this->createConnection(); $this->assertEquals( - sprintf('Sendy/1.0.2 PHP/%s', phpversion()), - $connection->getTransport()->getConfig('headers')['User-Agent'] + sprintf('SendySDK/3.0.0 PHP/%s GuzzleHttp/7', phpversion()), + $connection->createRequest('GET', '/')->getHeaders()['user-agent'] ); - $connection = new Connection(); + $connection = $this->createConnection(); $connection->setUserAgentAppendix('WooCommerce/6.2'); - $this->assertEquals( - sprintf('Sendy/1.0.2 PHP/%s WooCommerce/6.2', phpversion()), - $connection->getTransport()->getConfig('headers')['User-Agent'] + sprintf('SendySDK/3.0.0 PHP/%s GuzzleHttp/7 WooCommerce/6.2', phpversion()), + $connection->createRequest('GET', '/')->getHeaders()['user-agent'] ); - $connection = new Connection(); + $connection = $this->createConnection(); $connection->setOauthClient(true); - $this->assertEquals( - sprintf('Sendy/1.0.2 PHP/%s OAuth/2.0', phpversion()), - $connection->getTransport()->getConfig('headers')['User-Agent'] + sprintf('SendySDK/3.0.0 PHP/%s OAuth/2.0 GuzzleHttp/7', phpversion()), + $connection->createRequest('GET', '/')->getHeaders()['user-agent'] ); } @@ -94,7 +87,7 @@ public function testParseResponseReturnsEmptyArrayWhenResponseHasNoContent(): vo { $connection = new Connection(); - $response = new Response(204); + $response = new Response(204, [], ''); $this->assertEquals([], $connection->parseResponse($response)); } @@ -157,102 +150,19 @@ public function testParseResponseUnwrapsData(): void $this->assertEquals(['foo' => 'bar'], $connection->parseResponse($response)); } - public function testParseExceptionHandlesExceptions(): void - { - $exception = new \Exception('RandomException'); - - $connection = new Connection(); - - $this->expectException(ApiException::class); - $this->expectExceptionMessage('RandomException'); - - $connection->parseException($exception); - } - - public function testParseExceptionHandlesServerExceptions(): void - { - $exception = new ServerException('Server exception', new Request('GET', '/'), new Response(500)); - - $connection = new Connection(); - - $this->expectException(ApiException::class); - $this->expectExceptionMessage('Server exception'); - - $connection->parseException($exception); - } - - public function testParseExceptionHandlesInvalidJson(): void - { - $exception = new ClientException('Foo', new Request('GET', '/'), new Response(422, [], 'InvalidJson')); - - $connection = new Connection(); - - $this->expectException(ApiException::class); - $this->expectExceptionMessage('Json decode failed. Got: InvalidJson'); - - $connection->parseException($exception); - } - - public function testParseExceptionHandlesErrorsMessages(): void - { - $exception = new ClientException( - 'Foo', - new Request('GET', '/'), - new Response(422, [], json_encode(['message' => 'Error message'])) - ); - - $connection = new Connection(); - - $this->expectException(ApiException::class); - $this->expectExceptionMessage('Error message'); - - $connection->parseException($exception); - } - - public function testParseExceptionSetsErrors(): void - { - $exception = new ClientException( - 'Foo', - new Request('GET', '/'), - new Response(422, [], json_encode(['message' => 'Error message', 'errors' => ['First', 'Second']])) - ); - - $connection = new Connection(); - - try { - $connection->parseException($exception); - } catch (ApiException $e) { - $this->assertSame(['First', 'Second'], $e->getErrors()); - } - - $exception = new ClientException( - 'Foo', - new Request('GET', '/'), - new Response(422, [], json_encode(['message' => 'Error message'])) - ); - - try { - $connection->parseException($exception); - } catch (ApiException $e) { - $this->assertSame([], $e->getErrors()); - } - } - public function testTokensAreAcquiredWithAuthorizationCode(): void { $connection = new Connection(); - $mockHandler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([ 'access_token' => 'FromAuthCode', 'refresh_token' => 'RefreshToken', 'expires_in' => 3600, ])) - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + ); - $connection->setTransport($client); + $connection->setTransport($transport); $connection->setClientId('clientId'); $connection->setRedirectUrl('https://www.example.com/'); @@ -265,24 +175,22 @@ public function testTokensAreAcquiredWithAuthorizationCode(): void $this->assertEquals('RefreshToken', $connection->getRefreshToken()); $this->assertEquals(time() + 3600, $connection->getTokenExpires()); - $this->assertEquals('https://app.sendy.nl/oauth/token', (string) $mockHandler->getLastRequest()->getUri()); + $this->assertEquals('https://app.sendy.nl/oauth/token', $transport->getLastRequest()->getUrl()); } public function testTokensAreAcquiredWithRefreshToken(): void { $connection = new Connection(); - $mockHandler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([ 'access_token' => 'NewAccessToken', 'refresh_token' => 'NewRefreshToken', 'expires_in' => 3600, ])) - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + ); - $connection->setTransport($client); + $connection->setTransport($transport); $connection->setClientId('clientId'); $connection->setClientSecret('clientSecret'); @@ -296,7 +204,7 @@ public function testTokensAreAcquiredWithRefreshToken(): void $this->assertEquals( 'https://app.sendy.nl/oauth/token', - (string) $mockHandler->getLastRequest()->getUri() + $transport->getLastRequest()->getUrl() ); } @@ -304,17 +212,15 @@ public function testTokenUpdateCallbackIsCalled(): void { $connection = new Connection(); - $mockHandler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([ 'access_token' => 'NewAccessToken', 'refresh_token' => 'NewRefreshToken', 'expires_in' => 3600, ])) - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + ); - $connection->setTransport($client); + $connection->setTransport($transport); $connection->setClientId('clientId'); $connection->setClientSecret('clientSecret'); @@ -334,28 +240,63 @@ public function testGetRequestIsBuiltAndSent(): void $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - $mockHandler = new MockHandler([ - new Response(200, [], json_encode(['foo' => 'bar'])), - new Response(200, [], json_encode(['foo' => 'bar'])), - new Response(500, [], 'Something went wrong'), - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + $transport = new MockTransport( + new Response(200, [], json_encode(['foo' => 'bar'])) + ); - $connection->setTransport($client); + $connection->setTransport($transport); $this->assertEquals(['foo' => 'bar'], $connection->get('/foo')); + $this->assertEquals('https://app.sendy.nl/api/foo', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); + } - $this->assertEquals('/api/foo', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('GET', $mockHandler->getLastRequest()->getMethod()); + public function testGetRequestWithQueryParametersIsBuiltAndSent(): void + { + $connection = new Connection(); + $connection->setAccessToken('PersonalAccessToken'); - $connection->get('/foo', ['baz' => 'foo']); + $transport = new MockTransport( + new Response(200, [], json_encode(['foo' => 'bar'])) + ); - $this->assertEquals('/api/foo?baz=foo', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('GET', $mockHandler->getLastRequest()->getMethod()); + $connection->setTransport($transport); - $this->expectException(ApiException::class); - $this->expectExceptionMessage('Something went wrong'); + $this->assertEquals(['foo' => 'bar'], $connection->get('/foo', ['baz' => 'foo'])); + $this->assertEquals('https://app.sendy.nl/api/foo?baz=foo', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); + } + + public function testGetRequestWith4xxResponseThrowsClientException(): void + { + $connection = new Connection(); + $connection->setAccessToken('PersonalAccessToken'); + + $transport = new MockTransport( + new Response(418, [], '{}') + ); + + $connection->setTransport($transport); + + $this->expectException(\Sendy\Api\Exceptions\ClientException::class); + $this->expectExceptionMessage('418 - I\'m a teapot'); + $this->expectExceptionCode(418); + $connection->get('/brew-coffee'); + } + + public function testGetRequestWith5xxResponseThrowsServerException(): void + { + $connection = new Connection(); + $connection->setAccessToken('PersonalAccessToken'); + + $transport = new MockTransport( + new Response(500, [], '{"message": "Something went wrong"}') + ); + + $connection->setTransport($transport); + + $this->expectException(\Sendy\Api\Exceptions\ServerException::class); + $this->expectExceptionMessage('500 - Internal Server Error: Something went wrong'); $this->expectExceptionCode(500); $connection->get('/foo'); @@ -366,18 +307,16 @@ public function testDeleteRequestIsBuiltAndSent(): void $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - $mockHandler = new MockHandler([ + $transport = new MockTransport( new Response(204, [], json_encode(['foo' => 'bar'])), - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + ); - $connection->setTransport($client); + $connection->setTransport($transport); $this->assertEquals([], $connection->delete('/bar')); - $this->assertEquals('/api/bar', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('DELETE', $mockHandler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/bar', $transport->getLastRequest()->getUrl()); + $this->assertEquals('DELETE', $transport->getLastRequest()->getMethod()); } public function testPostRequestIsBuiltAndSent(): void @@ -385,19 +324,17 @@ public function testPostRequestIsBuiltAndSent(): void $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - $mockHandler = new MockHandler([ + $transport = new MockTransport( new Response(201, [], json_encode(['foo' => 'bar'])), - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + ); - $connection->setTransport($client); + $connection->setTransport($transport); $this->assertEquals(['foo' => 'bar'], $connection->post('/foo', ['request' => 'body'])); - $this->assertEquals('/api/foo', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('POST', $mockHandler->getLastRequest()->getMethod()); - $this->assertEquals('{"request":"body"}', $mockHandler->getLastRequest()->getBody()->getContents()); + $this->assertEquals('https://app.sendy.nl/api/foo', $transport->getLastRequest()->getUrl()); + $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); + $this->assertEquals('{"request":"body"}', $transport->getLastRequest()->getBody()); } public function testPutRequestIsBuiltAndSent(): void @@ -405,18 +342,24 @@ public function testPutRequestIsBuiltAndSent(): void $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - $mockHandler = new MockHandler([ - new Response(201, [], json_encode(['foo' => 'bar'])), - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + $mockTransport = new MockTransport( + new Response(201, [], json_encode(['foo' => 'bar'])) + ); - $connection->setTransport($client); + $connection->setTransport($mockTransport); $this->assertEquals(['foo' => 'bar'], $connection->put('/foo', ['request' => 'body'])); - $this->assertEquals('/api/foo', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('PUT', $mockHandler->getLastRequest()->getMethod()); - $this->assertEquals('{"request":"body"}', $mockHandler->getLastRequest()->getBody()->getContents()); + $this->assertEquals('https://app.sendy.nl/api/foo', $mockTransport->getLastRequest()->getUrl()); + $this->assertEquals('PUT', $mockTransport->getLastRequest()->getMethod()); + $this->assertEquals('{"request":"body"}', $mockTransport->getLastRequest()->getBody()); + } + + private function createConnection(): Connection + { + $connection = new Connection(); + $connection->setTransport(TransportFactory::createGuzzleTransport()); + + return $connection; } } diff --git a/tests/RateLimitsTest.php b/tests/RateLimitsTest.php index 624b66c..82fd7e0 100644 --- a/tests/RateLimitsTest.php +++ b/tests/RateLimitsTest.php @@ -2,7 +2,7 @@ namespace Sendy\Api\Tests; -use GuzzleHttp\Psr7\Response; +use Sendy\Api\Http\Response; use Sendy\Api\RateLimits; use PHPUnit\Framework\TestCase; @@ -17,7 +17,8 @@ public function testBuildFromResponseBuildsRateLimitsObject(): void 'X-RateLimit-Limit' => '180', 'X-RateLimit-Remaining' => '179', 'X-RateLimit-Reset' => '1681381136', - ] + ], + '' ); $this->assertInstanceOf(RateLimits::class, RateLimits::buildFromResponse($response)); diff --git a/tests/Resources/CarrierTest.php b/tests/Resources/CarrierTest.php index 1a8de9e..8486bff 100644 --- a/tests/Resources/CarrierTest.php +++ b/tests/Resources/CarrierTest.php @@ -2,13 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Connection; -use Sendy\Api\Resources\Carrier; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Carrier; use Sendy\Api\Tests\TestsEndpoints; class CarrierTest extends TestCase @@ -17,29 +14,29 @@ class CarrierTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Carrier($this->buildConnectionWithMockHandler($handler)); + $resource = new Carrier($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list()); - $this->assertEquals('/api/carriers', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/carriers', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testGet(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Carrier($this->buildConnectionWithMockHandler($handler)); + $resource = new Carrier($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->get(1)); - $this->assertEquals('/api/carriers/1', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/carriers/1', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/LabelTest.php b/tests/Resources/LabelTest.php index 75c1a60..b0dec3b 100644 --- a/tests/Resources/LabelTest.php +++ b/tests/Resources/LabelTest.php @@ -2,11 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\Carrier; -use Sendy\Api\Resources\Label; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Label; use Sendy\Api\Tests\TestsEndpoints; class LabelTest extends TestCase @@ -15,37 +14,37 @@ class LabelTest extends TestCase public function testGet(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Label($this->buildConnectionWithMockHandler($handler)); + $resource = new Label($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->get(['123456'])); $this->assertEquals( - '/api/labels?ids%5B0%5D=123456', - (string) $handler->getLastRequest()->getUri() + 'https://app.sendy.nl/api/labels?ids%5B0%5D=123456', + $transport->getLastRequest()->getUrl() ); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testParametersAreSetInURL(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Label($this->buildConnectionWithMockHandler($handler)); + $resource = new Label($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->get(['123456', 'A4', 'top-left'])); $this->assertEquals( - '/api/labels?ids%5B0%5D=123456&ids%5B1%5D=A4&ids%5B2%5D=top-left', - (string) $handler->getLastRequest()->getUri() + 'https://app.sendy.nl/api/labels?ids%5B0%5D=123456&ids%5B1%5D=A4&ids%5B2%5D=top-left', + $transport->getLastRequest()->getUrl() ); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/MeTest.php b/tests/Resources/MeTest.php index 7bd71a5..6d6f974 100644 --- a/tests/Resources/MeTest.php +++ b/tests/Resources/MeTest.php @@ -2,11 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\Label; -use Sendy\Api\Resources\Me; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Me; use Sendy\Api\Tests\TestsEndpoints; class MeTest extends TestCase @@ -15,15 +14,15 @@ class MeTest extends TestCase public function testGet(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Me($this->buildConnectionWithMockHandler($handler)); + $resource = new Me($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->get()); - $this->assertEquals('/api/me', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/me', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/ParcelshopTest.php b/tests/Resources/ParcelshopTest.php index 912b05f..a198228 100644 --- a/tests/Resources/ParcelshopTest.php +++ b/tests/Resources/ParcelshopTest.php @@ -2,10 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\Parcelshop; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Parcelshop; use Sendy\Api\Tests\TestsEndpoints; class ParcelshopTest extends TestCase @@ -14,18 +14,19 @@ class ParcelshopTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Parcelshop($this->buildConnectionWithMockHandler($handler)); + $resource = new Parcelshop($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list(['DHL'], 52.040588, 5.564890, 'NL', '3905KW')); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); $this->assertEquals( - '/api/parcel_shops?carriers%5B0%5D=DHL&latitude=52.040588&longitude=5.56489&country=NL&postal_code=3905KW', - (string) $handler->getLastRequest()->getUri() + 'https://app.sendy.nl/api/parcel_shops' . + '?carriers%5B0%5D=DHL&latitude=52.040588&longitude=5.56489&country=NL&postal_code=3905KW', + $transport->getLastRequest()->getUrl() ); } } diff --git a/tests/Resources/ServiceTest.php b/tests/Resources/ServiceTest.php index 49d1feb..08b7224 100644 --- a/tests/Resources/ServiceTest.php +++ b/tests/Resources/ServiceTest.php @@ -2,8 +2,8 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; use Sendy\Api\Resources\Service; use PHPUnit\Framework\TestCase; use Sendy\Api\Tests\TestsEndpoints; @@ -14,15 +14,15 @@ class ServiceTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Service($this->buildConnectionWithMockHandler($handler)); + $resource = new Service($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list(1337)); - $this->assertEquals('/api/carriers/1337/services', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/carriers/1337/services', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/ShipmentTest.php b/tests/Resources/ShipmentTest.php index 9a1d840..875c847 100644 --- a/tests/Resources/ShipmentTest.php +++ b/tests/Resources/ShipmentTest.php @@ -2,10 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\Shipment; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Shipment; use Sendy\Api\Tests\TestsEndpoints; class ShipmentTest extends TestCase @@ -14,148 +14,167 @@ class ShipmentTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list()); - $this->assertEquals('/api/shipments?page=1', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shipments?page=1', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testGet(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->get('1337')); - $this->assertEquals('/api/shipments/1337', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shipments/1337', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testUpdate(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->update('1337', ['foo' => 'bar'])); - $this->assertEquals('/api/shipments/1337', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('PUT', $handler->getLastRequest()->getMethod()); - $this->assertEquals('{"foo":"bar"}', $handler->getLastRequest()->getBody()->getContents()); + $this->assertEquals('https://app.sendy.nl/api/shipments/1337', $transport->getLastRequest()->getUrl()); + $this->assertEquals('PUT', $transport->getLastRequest()->getMethod()); + $this->assertEquals('{"foo":"bar"}', $transport->getLastRequest()->getBody()); } public function testDelete(): void { - $handler = new MockHandler([ - new Response(204), - ]); + $transport = new MockTransport( + new Response(204, [], ''), + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->delete('1337')); - $this->assertEquals('/api/shipments/1337', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('DELETE', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shipments/1337', $transport->getLastRequest()->getUrl()); + $this->assertEquals('DELETE', $transport->getLastRequest()->getMethod()); } public function testCreateFromPreference(): void { - $handler = new MockHandler([ - new Response(200, [], '{}'), - new Response(200, [], '{}'), - ]); + $transport = new MockTransport( + new Response(200, [], json_encode([])), + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->createFromPreference(['foo' => 'bar'], false)); $this->assertEquals( - '/api/shipments/preference?generateDirectly=0', - (string)$handler->getLastRequest()->getUri() + 'https://app.sendy.nl/api/shipments/preference?generateDirectly=0', + $transport->getLastRequest()->getUrl() + ); + $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); + $this->assertEquals('{"foo":"bar"}', $transport->getLastRequest()->getBody()); + } + + public function testCreateAndGenerateFromPreference(): void + { + $transport = new MockTransport( + new Response(200, [], json_encode([])), ); - $this->assertEquals('POST', $handler->getLastRequest()->getMethod()); - $this->assertEquals('{"foo":"bar"}', $handler->getLastRequest()->getBody()->getContents()); + + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->createFromPreference(['foo' => 'bar'])); $this->assertEquals( - '/api/shipments/preference?generateDirectly=1', - (string)$handler->getLastRequest()->getUri() + 'https://app.sendy.nl/api/shipments/preference?generateDirectly=1', + $transport->getLastRequest()->getUrl() ); } public function testCreateWithSmartRules(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode(['foo' => 'bar'])), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); - $resource->createWithSmartRules(['foo' => 'bar']); + $this->assertEquals(['foo' => 'bar'], $resource->createWithSmartRules(['foo' => 'bar'])); - $this->assertEquals('/api/shipments/smart-rule', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('POST', $handler->getLastRequest()->getMethod()); - $this->assertEquals('{"foo":"bar"}', $handler->getLastRequest()->getBody()->getContents()); + $this->assertEquals('https://app.sendy.nl/api/shipments/smart-rule', $transport->getLastRequest()->getUrl()); + $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); + $this->assertEquals('{"foo":"bar"}', $transport->getLastRequest()->getBody()); } - - public function testGenerate(): void + public function testGenerateAsynchronous(): void { - $handler = new MockHandler([ - new Response(200, [], '{}'), + $transport = new MockTransport( new Response(200, [], '{}'), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->generate('1337')); - $this->assertEquals('/api/shipments/1337/generate', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('POST', $handler->getLastRequest()->getMethod()); - $this->assertEquals('{"asynchronous":true}', $handler->getLastRequest()->getBody()->getContents()); + $this->assertEquals('https://app.sendy.nl/api/shipments/1337/generate', $transport->getLastRequest()->getUrl()); + $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); + $this->assertEquals('{"asynchronous":true}', $transport->getLastRequest()->getBody()); + } + + public function testGenerateSynchronous(): void + { + $transport = new MockTransport( + new Response(200, [], '{}'), + ); + + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->generate('1337', false)); - $this->assertEquals('/api/shipments/1337/generate', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('{"asynchronous":false}', $handler->getLastRequest()->getBody()->getContents()); + $this->assertEquals('https://app.sendy.nl/api/shipments/1337/generate', $transport->getLastRequest()->getUrl()); + $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); + $this->assertEquals('{"asynchronous":false}', $transport->getLastRequest()->getBody()); } public function testLabels(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->labels('1337')); - $this->assertEquals('/api/shipments/1337/labels', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shipments/1337/labels', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testDocuments(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shipment($this->buildConnectionWithMockHandler($handler)); + $resource = new Shipment($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->documents('1337')); - $this->assertEquals('/api/shipments/1337/documents', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals( + 'https://app.sendy.nl/api/shipments/1337/documents', + $transport->getLastRequest()->getUrl() + ); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/ShippingPreferenceTest.php b/tests/Resources/ShippingPreferenceTest.php index 2015acc..3fecbd1 100644 --- a/tests/Resources/ShippingPreferenceTest.php +++ b/tests/Resources/ShippingPreferenceTest.php @@ -2,11 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\ShippingPreference; use PHPUnit\Framework\TestCase; -use Sendy\Api\Resources\Shop; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\ShippingPreference; use Sendy\Api\Tests\TestsEndpoints; class ShippingPreferenceTest extends TestCase @@ -15,15 +14,15 @@ class ShippingPreferenceTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new ShippingPreference($this->buildConnectionWithMockHandler($handler)); + $resource = new ShippingPreference($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list()); - $this->assertEquals('/api/shipping_preferences', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shipping_preferences', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/ShopTest.php b/tests/Resources/ShopTest.php index ab4d59d..903226b 100644 --- a/tests/Resources/ShopTest.php +++ b/tests/Resources/ShopTest.php @@ -2,10 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\Shop; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Shop; use Sendy\Api\Tests\TestsEndpoints; class ShopTest extends TestCase @@ -14,29 +14,29 @@ class ShopTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shop($this->buildConnectionWithMockHandler($handler)); + $resource = new Shop($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list()); - $this->assertEquals('/api/shops', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shops', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testGet(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Shop($this->buildConnectionWithMockHandler($handler)); + $resource = new Shop($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->get('1337')); - $this->assertEquals('/api/shops/1337', (string) $handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/shops/1337', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } } diff --git a/tests/Resources/WebhookTest.php b/tests/Resources/WebhookTest.php index 0fc3e1e..91dff24 100644 --- a/tests/Resources/WebhookTest.php +++ b/tests/Resources/WebhookTest.php @@ -2,10 +2,10 @@ namespace Sendy\Api\Tests\Resources; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\Psr7\Response; -use Sendy\Api\Resources\Webhook; use PHPUnit\Framework\TestCase; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\MockTransport; +use Sendy\Api\Resources\Webhook; use Sendy\Api\Tests\TestsEndpoints; class WebhookTest extends TestCase @@ -14,35 +14,35 @@ class WebhookTest extends TestCase public function testList(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode([])), - ]); + ); - $resource = new Webhook($this->buildConnectionWithMockHandler($handler)); + $resource = new Webhook($this->buildConnectionWithMockTransport($transport)); $this->assertEquals([], $resource->list()); - $this->assertEquals('/api/webhooks', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('GET', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/webhooks', $transport->getLastRequest()->getUrl()); + $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } public function testDelete(): void { - $handler = new MockHandler([ - new Response(204), - ]); + $transport = new MockTransport( + new Response(204, [], ''), + ); - $resource = new Webhook($this->buildConnectionWithMockHandler($handler)); + $resource = new Webhook($this->buildConnectionWithMockTransport($transport)); - $resource->delete('webhook-id'); + $this->assertEquals([], $resource->delete('webhook-id')); - $this->assertEquals('/api/webhooks/webhook-id', $handler->getLastRequest()->getUri()); - $this->assertEquals('DELETE', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/webhooks/webhook-id', $transport->getLastRequest()->getUrl()); + $this->assertEquals('DELETE', $transport->getLastRequest()->getMethod()); } public function testCreate(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(201, [], json_encode([ 'data' => [ 'id' => 'webhook-id', @@ -52,9 +52,9 @@ public function testCreate(): void ] ] ])), - ]); + ); - $resource = new Webhook($this->buildConnectionWithMockHandler($handler)); + $resource = new Webhook($this->buildConnectionWithMockTransport($transport)); $resource->create([ 'url' => 'https://example.com/webhook', @@ -63,17 +63,17 @@ public function testCreate(): void ], ]); - $this->assertEquals('/api/webhooks', (string)$handler->getLastRequest()->getUri()); - $this->assertEquals('POST', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/webhooks', $transport->getLastRequest()->getUrl()); + $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); $this->assertEquals( '{"url":"https:\/\/example.com\/webhook","events":["shipments.generated"]}', - $handler->getLastRequest()->getBody()->getContents() + $transport->getLastRequest()->getBody() ); } public function testUpdate(): void { - $handler = new MockHandler([ + $transport = new MockTransport( new Response(201, [], json_encode([ 'data' => [ 'id' => 'webhook-id', @@ -83,9 +83,9 @@ public function testUpdate(): void ] ] ])), - ]); + ); - $resource = new Webhook($this->buildConnectionWithMockHandler($handler)); + $resource = new Webhook($this->buildConnectionWithMockTransport($transport)); $resource->update('webhook-id', [ 'url' => 'https://example.com/updated-webhook', @@ -94,11 +94,11 @@ public function testUpdate(): void ], ]); - $this->assertEquals('/api/webhooks/webhook-id', $handler->getLastRequest()->getUri()); - $this->assertEquals('PUT', $handler->getLastRequest()->getMethod()); + $this->assertEquals('https://app.sendy.nl/api/webhooks/webhook-id', $transport->getLastRequest()->getUrl()); + $this->assertEquals('PUT', $transport->getLastRequest()->getMethod()); $this->assertEquals( '{"url":"https:\/\/example.com\/updated-webhook","events":["shipment.generated"]}', - $handler->getLastRequest()->getBody()->getContents() + $transport->getLastRequest()->getBody() ); } } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php new file mode 100644 index 0000000..af9c3f5 --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,53 @@ +toException(); + + $this->assertInstanceOf(ServerException::class, $exception); + $this->assertSame(500, $exception->getCode()); + $this->assertSame('500 - Internal Server Error', $exception->getMessage()); + } + + public function testToExceptionHandlesInvalidJson(): void + { + $response = new Response(422, [], 'InvalidJson'); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Json decode failed. Got: InvalidJson'); + + $response->toException(); + } + + public function testToExceptionHandlesValidationMessages(): void + { + $response = new Response(422, [], json_encode(['message' => 'Error message'])); + + $exception = $response->toException(); + + $this->assertInstanceOf(ValidationException::class, $exception); + $this->assertSame(422, $exception->getCode()); + $this->assertSame('Error message', $exception->getMessage()); + } + + public function testToExceptionSetsErrors(): void + { + $response = new Response(422, [], json_encode(['message' => 'Error message', 'errors' => ['First', 'Second']])); + $this->assertSame(['First', 'Second'], $response->toException()->getErrors()); + + $response = new Response(422, [], json_encode(['message' => 'Error message'])); + $this->assertSame([], $response->toException()->getErrors()); + } +} diff --git a/tests/TestsEndpoints.php b/tests/TestsEndpoints.php index 1323f1a..b083bb6 100644 --- a/tests/TestsEndpoints.php +++ b/tests/TestsEndpoints.php @@ -2,22 +2,16 @@ namespace Sendy\Api\Tests; -use GuzzleHttp\Client; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Response; use Sendy\Api\Connection; +use Sendy\Api\Http\Transport\MockTransport; trait TestsEndpoints { - public function buildConnectionWithMockHandler(MockHandler $handler): Connection + public function buildConnectionWithMockTransport(MockTransport $transport): Connection { $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - - $client = new Client(['handler' => HandlerStack::create($handler)]); - - $connection->setTransport($client); + $connection->setTransport($transport); return $connection; } From d6d5939988a8feace642bd64268c78031fb65d58 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Mon, 2 Jun 2025 17:58:56 +0200 Subject: [PATCH 03/21] Add JsonException Change SendyException into an interface to allow extending native exceptions like JsonException. --- src/ApiException.php | 28 ++---------------------- src/Connection.php | 9 +++++++- src/Exceptions/ClientException.php | 3 ++- src/Exceptions/HasErrors.php | 30 ++++++++++++++++++++++++++ src/Exceptions/JsonException.php | 14 ++++++++++++ src/Exceptions/SendyException.php | 8 ++++++- src/Exceptions/ServerException.php | 3 ++- src/Exceptions/TransportException.php | 9 +++++++- src/Exceptions/ValidationException.php | 3 +++ src/Http/Response.php | 3 ++- 10 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 src/Exceptions/HasErrors.php create mode 100644 src/Exceptions/JsonException.php diff --git a/src/ApiException.php b/src/ApiException.php index c23c921..086379d 100644 --- a/src/ApiException.php +++ b/src/ApiException.php @@ -3,33 +3,9 @@ namespace Sendy\Api; /** - * @deprecated This class exists for backwards compatibility and may be removed in a future version. - * @todo Replace internal usages with the new more granular exceptions in `Sendy\Api\Exceptions`. + * @deprecated This interface exists for backwards compatibility and may be removed in a future version. * @internal */ -class ApiException extends \Exception +interface ApiException extends \Throwable { - /** @var array */ - private array $errors = []; - - /** - * @param string $message - * @param int $code - * @param \Throwable|null $previous - * @param string[][] $errors - */ - public function __construct(string $message = "", int $code = 0, ?\Throwable $previous = null, array $errors = []) - { - $this->errors = $errors; - - parent::__construct($message, $code, $previous); - } - - /** - * @return array - */ - public function getErrors(): array - { - return $this->errors; - } } diff --git a/src/Connection.php b/src/Connection.php index 14821b0..f9463b3 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -3,6 +3,9 @@ namespace Sendy\Api; use Psr\Http\Message\UriInterface; +use Sendy\Api\Exceptions\ClientException; +use Sendy\Api\Exceptions\SendyException; +use Sendy\Api\Exceptions\ServerException; use Sendy\Api\Exceptions\TransportException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; @@ -418,6 +421,10 @@ public function delete($url): array return $this->performRequest($request); } + /** + * @return array> + * @throws SendyException + */ private function performRequest(Request $request, bool $checkAccessToken = true): array { if ($checkAccessToken) { @@ -433,7 +440,7 @@ private function performRequest(Request $request, bool $checkAccessToken = true) /** * @return array> - * @throws ApiException + * @throws SendyException */ public function parseResponse(Response $response): array { diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php index cbbbea0..8917e1e 100644 --- a/src/Exceptions/ClientException.php +++ b/src/Exceptions/ClientException.php @@ -5,6 +5,7 @@ /** * Represents an HTTP 4xx error that occurred during a request to the Sendy API. */ -class ClientException extends SendyException +class ClientException extends \Exception implements SendyException { + use HasErrors; } diff --git a/src/Exceptions/HasErrors.php b/src/Exceptions/HasErrors.php new file mode 100644 index 0000000..716d114 --- /dev/null +++ b/src/Exceptions/HasErrors.php @@ -0,0 +1,30 @@ + */ + private array $errors = []; + + /** + * @param string $message + * @param int $code + * @param \Throwable|null $previous + * @param string[][] $errors + */ + public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, array $errors = []) + { + $this->errors = $errors; + + parent::__construct($message, $code, $previous); + } + + /** + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/Exceptions/JsonException.php b/src/Exceptions/JsonException.php new file mode 100644 index 0000000..323d7dd --- /dev/null +++ b/src/Exceptions/JsonException.php @@ -0,0 +1,14 @@ + + */ + public function getErrors(): array; } diff --git a/src/Exceptions/ServerException.php b/src/Exceptions/ServerException.php index 549c860..4672e64 100644 --- a/src/Exceptions/ServerException.php +++ b/src/Exceptions/ServerException.php @@ -5,6 +5,7 @@ /** * Represents an HTTP 5xx error that occurred during a request to the Sendy API. */ -class ServerException extends SendyException +class ServerException extends \Exception implements SendyException { + use HasErrors; } diff --git a/src/Exceptions/TransportException.php b/src/Exceptions/TransportException.php index 621de4d..98debe8 100644 --- a/src/Exceptions/TransportException.php +++ b/src/Exceptions/TransportException.php @@ -5,6 +5,13 @@ /** * Indicates that an HTTP request did not result in a proper response, e.g. a network error, timeout, or driver error. */ -class TransportException extends SendyException +class TransportException extends \Exception implements SendyException { + /** + * @internal This exists for backwards compatibility with the ApiException interface. + */ + public function getErrors(): array + { + return []; + } } diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index ab879b5..78d62ed 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -2,6 +2,9 @@ namespace Sendy\Api\Exceptions; +/** + * Represents an HTTP 422 error that occurred during a request to the Sendy API. + */ class ValidationException extends ClientException { } diff --git a/src/Http/Response.php b/src/Http/Response.php index 801713f..92ddf4d 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -4,6 +4,7 @@ use Sendy\Api\ApiException; use Sendy\Api\Exceptions\ClientException; +use Sendy\Api\Exceptions\JsonException; use Sendy\Api\Exceptions\ServerException; use Sendy\Api\Exceptions\ValidationException; @@ -123,7 +124,7 @@ public function getDecodedBody(): array try { return json_decode($this->body, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { - throw new ApiException("Json decode failed. Got: {$this->body}", $this->statusCode, $e); + throw new JsonException("Json decode failed. Got: {$this->body}", $this->statusCode, $e); } } From 9cb253c28292af5cb2b1d6741bc4ca24730d4f5f Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 13 Jun 2025 15:43:39 +0200 Subject: [PATCH 04/21] Improve exception message --- src/Http/Response.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Http/Response.php b/src/Http/Response.php index 92ddf4d..d79417f 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -135,10 +135,11 @@ public function getSummary(): string { $summary = $this->statusCode . ' - ' . (self::PHRASES[$this->statusCode] ?? 'Unknown Status'); $decodedBody = json_decode($this->body, true); - $message = $decodedBody['message'] ?? $decodedBody['error_description'] ?? null; - if ($message) { - $summary .= ': ' . $message; + if (isset($decodedBody['error_description'], $decodedBody['hint'])) { + $summary .= ": {$decodedBody['error_description']} ({$decodedBody['hint']})"; + } elseif (isset($decodedBody['message'])) { + $summary .= ": {$decodedBody['message']}"; } return $summary; From d6a0c500082f285c081fb21260681ca08b6d7e01 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Mon, 23 Jun 2025 11:50:40 +0200 Subject: [PATCH 05/21] Add version to Symfony user agent --- src/Http/Transport/TransportFactory.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php index a544c81..e914340 100644 --- a/src/Http/Transport/TransportFactory.php +++ b/src/Http/Transport/TransportFactory.php @@ -49,12 +49,18 @@ public static function createSymfonyTransport(): Psr18Transport { $client = new \Symfony\Component\HttpClient\Psr18Client(); + $userAgent = 'SymfonyHttpClient'; + + if (class_exists(\Symfony\Component\HttpKernel\Kernel::class)) { + $userAgent .= '/' . \Symfony\Component\HttpKernel\Kernel::VERSION; + } + return new Psr18Transport( $client, $client, $client, $client, - 'SymfonyHttpClient' + $userAgent ); } From 819aca12212ef6376ec333f31a742cee4fc7e41d Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Mon, 23 Jun 2025 15:06:52 +0200 Subject: [PATCH 06/21] Fix `@throws` tag --- src/Connection.php | 17 ++++++++++------- src/Http/Response.php | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index f9463b3..c7253d4 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -3,10 +3,7 @@ namespace Sendy\Api; use Psr\Http\Message\UriInterface; -use Sendy\Api\Exceptions\ClientException; use Sendy\Api\Exceptions\SendyException; -use Sendy\Api\Exceptions\ServerException; -use Sendy\Api\Exceptions\TransportException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; use Sendy\Api\Http\Transport\TransportFactory; @@ -264,6 +261,9 @@ public function setOauthClient(bool $oauthClient): Connection return $this; } + /** + * @throws SendyException + */ public function checkOrAcquireAccessToken(): void { if (empty($this->accessToken) || ($this->tokenHasExpired() && $this->isOauthClient())) { @@ -282,6 +282,9 @@ public function tokenHasExpired(): bool return $this->tokenExpires - 10 < time(); } + /** + * @throws SendyException + */ private function acquireAccessToken(): void { if (empty($this->refreshToken)) { @@ -357,7 +360,7 @@ public function createRequest( * @param array $params * @param array $headers * @return array> - * @throws TransportException + * @throws SendyException */ public function get($url, array $params = [], array $headers = []): array { @@ -374,7 +377,7 @@ public function get($url, array $params = [], array $headers = []): array * @param array $params * @param array $headers * @return array> - * @throws TransportException + * @throws SendyException */ public function post($url, ?array $body = null, array $params = [], array $headers = []): array { @@ -395,7 +398,7 @@ public function post($url, ?array $body = null, array $params = [], array $heade * @param array> $params * @param array> $headers * @return array> - * @throws TransportException + * @throws SendyException */ public function put($url, array $body = [], array $params = [], array $headers = []): array { @@ -410,7 +413,7 @@ public function put($url, array $body = [], array $params = [], array $headers = /** * @param UriInterface|string $url * @return array> - * @throws TransportException + * @throws SendyException */ public function delete($url): array { diff --git a/src/Http/Response.php b/src/Http/Response.php index d79417f..6d96cb7 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -117,7 +117,7 @@ public function getBody(): string * Decode the JSON body of the response. * * @return array - * @throws ApiException If the body is not valid JSON. + * @throws JsonException If the body is not valid JSON. */ public function getDecodedBody(): array { From fbb502429a46215bb104ac062de64c4ee2f574ee Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 3 Jul 2025 16:01:24 +0200 Subject: [PATCH 07/21] Extract x-sendy-* headers --- src/Connection.php | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index c7253d4..30e8258 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -73,6 +73,11 @@ class Connection public ?RateLimits $rateLimits; + /** + * @var array> + */ + public array $sendyHeaders = []; + /** * @return TransportInterface */ @@ -289,17 +294,17 @@ private function acquireAccessToken(): void { if (empty($this->refreshToken)) { $parameters = [ - 'redirect_uri' => $this->redirectUrl, - 'grant_type' => 'authorization_code', - 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, - 'code' => $this->authorizationCode, + 'code' => $this->authorizationCode, ]; } else { $parameters = [ 'refresh_token' => $this->refreshToken, - 'grant_type' => 'refresh_token', - 'client_id' => $this->clientId, + 'grant_type' => 'refresh_token', + 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, ]; } @@ -448,6 +453,7 @@ private function performRequest(Request $request, bool $checkAccessToken = true) public function parseResponse(Response $response): array { $this->extractRateLimits($response); + $this->extractSendyHeaders($response); if ($exception = $response->toException()) { throw $exception; @@ -477,6 +483,18 @@ private function extractRateLimits(Response $response): void $this->rateLimits = RateLimits::buildFromResponse($response); } + /** + * Extract the x-sendy-* headers from the response. + */ + private function extractSendyHeaders(Response $response): void + { + $this->sendyHeaders = array_filter( + $response->getHeaders(), + fn(string $key) => substr($key, 0, 8) === 'x-sendy-', + ARRAY_FILTER_USE_KEY + ); + } + /** * Magic method to fetch the resource object * From a16d46e606cfacece1f6e272aa9049cfd51978b7 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 19 Aug 2025 15:33:19 +0200 Subject: [PATCH 08/21] Add the request and response to HTTP exceptions --- src/Connection.php | 6 ++-- src/Exceptions/ClientException.php | 3 +- src/Exceptions/HasErrors.php | 8 ++++-- src/Exceptions/HttpException.php | 46 ++++++++++++++++++++++++++++++ src/Exceptions/ServerException.php | 3 +- src/Http/Response.php | 19 ++++++------ tests/ConnectionTest.php | 11 +++---- tests/ResponseTest.php | 11 +++---- 8 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 src/Exceptions/HttpException.php diff --git a/src/Connection.php b/src/Connection.php index 30e8258..b419721 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -443,19 +443,19 @@ private function performRequest(Request $request, bool $checkAccessToken = true) $response = $this->getTransport()->send($request); - return $this->parseResponse($response); + return $this->parseResponse($response, $request); } /** * @return array> * @throws SendyException */ - public function parseResponse(Response $response): array + public function parseResponse(Response $response, Request $request): array { $this->extractRateLimits($response); $this->extractSendyHeaders($response); - if ($exception = $response->toException()) { + if ($exception = $response->toException($request)) { throw $exception; } diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php index 8917e1e..74fc0f6 100644 --- a/src/Exceptions/ClientException.php +++ b/src/Exceptions/ClientException.php @@ -5,7 +5,6 @@ /** * Represents an HTTP 4xx error that occurred during a request to the Sendy API. */ -class ClientException extends \Exception implements SendyException +class ClientException extends HttpException implements SendyException { - use HasErrors; } diff --git a/src/Exceptions/HasErrors.php b/src/Exceptions/HasErrors.php index 716d114..786e4e1 100644 --- a/src/Exceptions/HasErrors.php +++ b/src/Exceptions/HasErrors.php @@ -13,8 +13,12 @@ trait HasErrors * @param \Throwable|null $previous * @param string[][] $errors */ - public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null, array $errors = []) - { + final public function __construct( + string $message = '', + int $code = 0, + ?\Throwable $previous = null, + array $errors = [] + ) { $this->errors = $errors; parent::__construct($message, $code, $previous); diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php new file mode 100644 index 0000000..ca8fb95 --- /dev/null +++ b/src/Exceptions/HttpException.php @@ -0,0 +1,46 @@ +getSummary(), + $response->getStatusCode(), + null, + $response->getErrors() + ); + + $exception->request = $request; + $exception->response = $response; + + return $exception; + } + + public function getRequest(): Request + { + return $this->request; + } + + public function getResponse(): Response + { + return $this->response; + } +} diff --git a/src/Exceptions/ServerException.php b/src/Exceptions/ServerException.php index 4672e64..4e38841 100644 --- a/src/Exceptions/ServerException.php +++ b/src/Exceptions/ServerException.php @@ -5,7 +5,6 @@ /** * Represents an HTTP 5xx error that occurred during a request to the Sendy API. */ -class ServerException extends \Exception implements SendyException +class ServerException extends HttpException implements SendyException { - use HasErrors; } diff --git a/src/Http/Response.php b/src/Http/Response.php index 6d96cb7..8308737 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -4,6 +4,7 @@ use Sendy\Api\ApiException; use Sendy\Api\Exceptions\ClientException; +use Sendy\Api\Exceptions\HttpException; use Sendy\Api\Exceptions\JsonException; use Sendy\Api\Exceptions\ServerException; use Sendy\Api\Exceptions\ValidationException; @@ -157,26 +158,22 @@ public function getErrors(): array return $data['errors'] ?? []; } - /** - * @return ClientException|ServerException|null - */ - public function toException() + public function toException(Request $request): ?HttpException { if ($this->statusCode === 422) { - return new ValidationException( - $this->getDecodedBody()['message'] ?? 'Validation failed', - $this->statusCode, - null, - $this->getErrors() + return ValidationException::fromRequestAndResponse( + $request, + $this, + $this->getDecodedBody()['message'] ?? 'Validation failed' ); } if ($this->statusCode >= 400 && $this->statusCode < 500) { - return new ClientException($this->getSummary(), $this->statusCode, null, $this->getErrors()); + return ClientException::fromRequestAndResponse($request, $this); } if ($this->statusCode >= 500) { - return new ServerException($this->getSummary(), $this->statusCode, null, $this->getErrors()); + return ServerException::fromRequestAndResponse($request, $this); } return null; diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index f5b8ec2..28829f9 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Sendy\Api\ApiException; use Sendy\Api\Connection; +use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; use Sendy\Api\Http\Transport\MockTransport; use Sendy\Api\Http\Transport\TransportFactory; @@ -89,7 +90,7 @@ public function testParseResponseReturnsEmptyArrayWhenResponseHasNoContent(): vo $response = new Response(204, [], ''); - $this->assertEquals([], $connection->parseResponse($response)); + $this->assertEquals([], $connection->parseResponse($response, new Request('GET', '/foo'))); } public function testParseResponseThrowsApiExceptionWithInvalidJson(): void @@ -101,7 +102,7 @@ public function testParseResponseThrowsApiExceptionWithInvalidJson(): void $this->expectException(ApiException::class); $this->expectExceptionMessage('Json decode failed. Got: InvalidJson'); - $connection->parseResponse($response); + $connection->parseResponse($response, new Request('GET', '/foo')); } public function testParseResponseExtractsMeta(): void @@ -123,7 +124,7 @@ public function testParseResponseExtractsMeta(): void $response = new Response(200, [], json_encode($responseBody)); - $this->assertEquals([], $connection->parseResponse($response)); + $this->assertEquals([], $connection->parseResponse($response, new Request('GET', '/foo'))); $this->assertInstanceOf(Meta::class, $connection->meta); } @@ -139,7 +140,7 @@ public function testParseResponseUnwrapsData(): void $response = new Response(200, [], json_encode($responseBody)); - $this->assertEquals(['foo' => 'bar'], $connection->parseResponse($response)); + $this->assertEquals(['foo' => 'bar'], $connection->parseResponse($response, new Request('GET', '/foo'))); $responseBody = [ 'foo' => 'bar', @@ -147,7 +148,7 @@ public function testParseResponseUnwrapsData(): void $response = new Response(200, [], json_encode($responseBody)); - $this->assertEquals(['foo' => 'bar'], $connection->parseResponse($response)); + $this->assertEquals(['foo' => 'bar'], $connection->parseResponse($response, new Request('GET', '/foo'))); } public function testTokensAreAcquiredWithAuthorizationCode(): void diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index af9c3f5..ee5193e 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -6,6 +6,7 @@ use Sendy\Api\ApiException; use Sendy\Api\Exceptions\ServerException; use Sendy\Api\Exceptions\ValidationException; +use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; class ResponseTest extends TestCase @@ -14,7 +15,7 @@ public function testToExceptionReturnsServerException(): void { $response = new Response(500, [], ''); - $exception = $response->toException(); + $exception = $response->toException(new Request('GET', '/foo')); $this->assertInstanceOf(ServerException::class, $exception); $this->assertSame(500, $exception->getCode()); @@ -28,14 +29,14 @@ public function testToExceptionHandlesInvalidJson(): void $this->expectException(ApiException::class); $this->expectExceptionMessage('Json decode failed. Got: InvalidJson'); - $response->toException(); + $response->toException(new Request('GET', '/foo')); } public function testToExceptionHandlesValidationMessages(): void { $response = new Response(422, [], json_encode(['message' => 'Error message'])); - $exception = $response->toException(); + $exception = $response->toException(new Request('GET', '/foo')); $this->assertInstanceOf(ValidationException::class, $exception); $this->assertSame(422, $exception->getCode()); @@ -45,9 +46,9 @@ public function testToExceptionHandlesValidationMessages(): void public function testToExceptionSetsErrors(): void { $response = new Response(422, [], json_encode(['message' => 'Error message', 'errors' => ['First', 'Second']])); - $this->assertSame(['First', 'Second'], $response->toException()->getErrors()); + $this->assertSame(['First', 'Second'], $response->toException(new Request('GET', '/foo'))->getErrors()); $response = new Response(422, [], json_encode(['message' => 'Error message'])); - $this->assertSame([], $response->toException()->getErrors()); + $this->assertSame([], $response->toException(new Request('GET', '/foo'))->getErrors()); } } From b2a7518ee3ecd69cd31cc8009544700b2b11f6e3 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 17 Sep 2025 11:13:06 +0200 Subject: [PATCH 09/21] Add script to bump version --- bump_version.sh | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100755 bump_version.sh diff --git a/bump_version.sh b/bump_version.sh new file mode 100755 index 0000000..8872e6e --- /dev/null +++ b/bump_version.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Print the current version of the project and bump it to the given version. + +set -e + +current_version=$(echo "echo Connection::VERSION . PHP_EOL;" | cat src/Connection.php - | php) +echo "Current version: $current_version" + +if [[ -z "$1" ]] +then + echo "To bump the version, provide the new version number as an argument." + exit 1 +fi + +# Remove the 'v' prefix if it exists +new_version=${1#v} + +echo "New version: $new_version" + +if ! [[ "$new_version" =~ ^[0-9]+\.[0-9]+\.[0-9](-[a-z]+\.[0-9]+)?$ ]] +then + echo "Invalid version format. Please use semantic versioning (https://semver.org/)." + exit 1 +fi + +echo "Bumping version to: $new_version" + +perl -pi -e "s/^ public const VERSION = .*/ public const VERSION = '$new_version';/" src/Connection.php + +echo +echo "To release the new version, first, commit the changes:" +echo " git add --all" +echo " git commit -m "$new_version"" +echo " git push" +echo +echo "Once the commit is pushed to the master branch, create a release on GitHub to distribute the new version:" +echo " https://github.com/sendynl/php-sdk/releases/new?tag=v$new_version" From 67f9af2446cbf515e0524a722d976ec9521ac430 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 17 Sep 2025 14:10:14 +0200 Subject: [PATCH 10/21] Apply PER-CS code style --- .gitignore | 3 +- .php-cs-fixer.dist.php | 24 +++++++ composer.json | 7 +- phpcs.xml.dist | 48 ------------- src/ApiException.php | 4 +- src/Connection.php | 85 +++-------------------- src/Exceptions/ClientException.php | 4 +- src/Exceptions/HasErrors.php | 3 - src/Exceptions/HttpException.php | 2 +- src/Exceptions/ServerException.php | 4 +- src/Exceptions/ValidationException.php | 4 +- src/Http/Response.php | 4 +- src/Http/Transport/CurlTransport.php | 6 +- src/Http/Transport/Psr18Transport.php | 4 +- src/Http/Transport/TransportFactory.php | 10 +-- src/Http/Transport/TransportInterface.php | 2 - src/Http/Transport/WordpressTransport.php | 2 +- src/Meta.php | 12 +--- src/RateLimits.php | 8 +-- src/Resources/Shipment.php | 1 - src/Resources/Shop.php | 1 - tests/ConnectionTest.php | 30 ++++---- tests/RateLimitsTest.php | 2 +- tests/Resources/LabelTest.php | 4 +- tests/Resources/ParcelshopTest.php | 6 +- tests/Resources/ShipmentTest.php | 6 +- tests/Resources/WebhookTest.php | 12 ++-- 27 files changed, 87 insertions(+), 211 deletions(-) create mode 100644 .php-cs-fixer.dist.php delete mode 100644 phpcs.xml.dist diff --git a/.gitignore b/.gitignore index 992d660..141ec2e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ vendor .idea composer.lock .phpunit.result.cache -.phpunit.cache \ No newline at end of file +.phpunit.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..336125c --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,24 @@ +in(__DIR__) + ->append([__FILE__]); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => true, + ], + 'no_empty_phpdoc' => true, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => [ + 'after_heredoc' => true, + // https://cs.symfony.com/doc/rules/control_structure/trailing_comma_in_multiline.html + // only enable for the elements that are safe to use with PHP 7.4+ + 'elements' => ['arguments', 'arrays'], + ], + ]) + ->setFinder($finder); diff --git a/composer.json b/composer.json index fbe769a..e2482f2 100644 --- a/composer.json +++ b/composer.json @@ -37,13 +37,14 @@ "require-dev": { "phpunit/phpunit": "^9.0", "phpstan/phpstan": "^1", - "squizlabs/php_codesniffer": "^3.7", "mockery/mockery": "^1.5", "guzzlehttp/guzzle": "^7.9", - "symfony/http-client": "^5.4" + "symfony/http-client": "^5.4", + "friendsofphp/php-cs-fixer": "^3.86" }, "scripts": { - "lint": "vendor/bin/phpcs", + "lint": "vendor/bin/php-cs-fixer fix --dry-run --diff", + "fix": "vendor/bin/php-cs-fixer fix", "analyze": "vendor/bin/phpstan --xdebug", "test": "vendor/bin/phpunit" } diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 9ce187d..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,48 +0,0 @@ - - - The KeenDelivery Coding Standards - - src - tests - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/ApiException.php b/src/ApiException.php index 086379d..afc7555 100644 --- a/src/ApiException.php +++ b/src/ApiException.php @@ -6,6 +6,4 @@ * @deprecated This interface exists for backwards compatibility and may be removed in a future version. * @internal */ -interface ApiException extends \Throwable -{ -} +interface ApiException extends \Throwable {} diff --git a/src/Connection.php b/src/Connection.php index b419721..b6b5f40 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -33,7 +33,6 @@ class Connection private const TOKEN_URL = '/oauth/token'; - /** @var TransportInterface|null */ private ?TransportInterface $transport = null; /** @var string The Client ID as UUID */ @@ -66,7 +65,6 @@ class Connection /** @var callable($this) */ private $tokenUpdateCallback; - /** @var bool */ private bool $oauthClient = false; public ?Meta $meta; @@ -78,9 +76,6 @@ class Connection */ public array $sendyHeaders = []; - /** - * @return TransportInterface - */ public function getTransport(): TransportInterface { if ($this->transport instanceof TransportInterface) { @@ -90,18 +85,11 @@ public function getTransport(): TransportInterface return $this->transport = TransportFactory::create(); } - /** - * @param TransportInterface $transport - */ public function setTransport(TransportInterface $transport): void { $this->transport = $transport; } - /** - * @param string $userAgentAppendix - * @return Connection - */ public function setUserAgentAppendix(string $userAgentAppendix): Connection { $this->userAgentAppendix = $userAgentAppendix; @@ -109,10 +97,6 @@ public function setUserAgentAppendix(string $userAgentAppendix): Connection return $this; } - /** - * @param string $clientId - * @return Connection - */ public function setClientId(string $clientId): Connection { $this->clientId = $clientId; @@ -120,10 +104,6 @@ public function setClientId(string $clientId): Connection return $this; } - /** - * @param string $clientSecret - * @return Connection - */ public function setClientSecret(string $clientSecret): Connection { $this->clientSecret = $clientSecret; @@ -131,10 +111,6 @@ public function setClientSecret(string $clientSecret): Connection return $this; } - /** - * @param string $authorizationCode - * @return Connection - */ public function setAuthorizationCode(string $authorizationCode): Connection { $this->authorizationCode = $authorizationCode; @@ -142,18 +118,11 @@ public function setAuthorizationCode(string $authorizationCode): Connection return $this; } - /** - * @return string - */ public function getAccessToken(): string { return $this->accessToken; } - /** - * @param string $accessToken - * @return Connection - */ public function setAccessToken(string $accessToken): Connection { $this->accessToken = $accessToken; @@ -161,18 +130,11 @@ public function setAccessToken(string $accessToken): Connection return $this; } - /** - * @return int - */ public function getTokenExpires(): int { return $this->tokenExpires; } - /** - * @param int $tokenExpires - * @return Connection - */ public function setTokenExpires(int $tokenExpires): Connection { $this->tokenExpires = $tokenExpires; @@ -180,18 +142,11 @@ public function setTokenExpires(int $tokenExpires): Connection return $this; } - /** - * @return string - */ public function getRefreshToken(): string { return $this->refreshToken; } - /** - * @param string $refreshToken - * @return Connection - */ public function setRefreshToken(string $refreshToken): Connection { $this->refreshToken = $refreshToken; @@ -199,10 +154,6 @@ public function setRefreshToken(string $refreshToken): Connection return $this; } - /** - * @param string $redirectUrl - * @return Connection - */ public function setRedirectUrl(string $redirectUrl): Connection { $this->redirectUrl = $redirectUrl; @@ -221,10 +172,6 @@ public function setState($state) return $this; } - /** - * @param callable $tokenUpdateCallback - * @return Connection - */ public function setTokenUpdateCallback(callable $tokenUpdateCallback): Connection { $this->tokenUpdateCallback = $tokenUpdateCallback; @@ -234,31 +181,22 @@ public function setTokenUpdateCallback(callable $tokenUpdateCallback): Connectio /** * Build the URL to authorize the application - * - * @return string */ public function getAuthorizationUrl(): string { return self::BASE_URL . self::AUTH_URL . '?' . http_build_query([ - 'client_id' => $this->clientId, - 'redirect_uri' => $this->redirectUrl, - 'response_type' => 'code', - 'state' => $this->state, - ]); + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'response_type' => 'code', + 'state' => $this->state, + ]); } - /** - * @return bool - */ public function isOauthClient(): bool { return $this->oauthClient; } - /** - * @param bool $oauthClient - * @return Connection - */ public function setOauthClient(bool $oauthClient): Connection { $this->oauthClient = $oauthClient; @@ -311,7 +249,7 @@ private function acquireAccessToken(): void $body = $this->performRequest( $this->createRequest('POST', self::BASE_URL . self::TOKEN_URL, json_encode($parameters)), - false + false, ); $this->accessToken = $body['access_token']; @@ -324,12 +262,8 @@ private function acquireAccessToken(): void } /** - * @param string $method - * @param string $endpoint - * @param string|null $body * @param array $params * @param array $headers - * @return Request */ public function createRequest( string $method, @@ -388,7 +322,7 @@ public function post($url, ?array $body = null, array $params = [], array $heade { $url = self::API_URL . $url; - if (!is_null($body)) { + if (! is_null($body)) { $body = json_encode($body); } @@ -491,15 +425,12 @@ private function extractSendyHeaders(Response $response): void $this->sendyHeaders = array_filter( $response->getHeaders(), fn(string $key) => substr($key, 0, 8) === 'x-sendy-', - ARRAY_FILTER_USE_KEY + ARRAY_FILTER_USE_KEY, ); } /** * Magic method to fetch the resource object - * - * @param string $resource - * @return Resource */ public function __get(string $resource): Resource { diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php index 74fc0f6..5dd9e67 100644 --- a/src/Exceptions/ClientException.php +++ b/src/Exceptions/ClientException.php @@ -5,6 +5,4 @@ /** * Represents an HTTP 4xx error that occurred during a request to the Sendy API. */ -class ClientException extends HttpException implements SendyException -{ -} +class ClientException extends HttpException implements SendyException {} diff --git a/src/Exceptions/HasErrors.php b/src/Exceptions/HasErrors.php index 786e4e1..6429b7c 100644 --- a/src/Exceptions/HasErrors.php +++ b/src/Exceptions/HasErrors.php @@ -8,9 +8,6 @@ trait HasErrors private array $errors = []; /** - * @param string $message - * @param int $code - * @param \Throwable|null $previous * @param string[][] $errors */ final public function __construct( diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php index ca8fb95..3fbfdd7 100644 --- a/src/Exceptions/HttpException.php +++ b/src/Exceptions/HttpException.php @@ -25,7 +25,7 @@ public static function fromRequestAndResponse(Request $request, Response $respon $message ?? $response->getSummary(), $response->getStatusCode(), null, - $response->getErrors() + $response->getErrors(), ); $exception->request = $request; diff --git a/src/Exceptions/ServerException.php b/src/Exceptions/ServerException.php index 4e38841..e950db4 100644 --- a/src/Exceptions/ServerException.php +++ b/src/Exceptions/ServerException.php @@ -5,6 +5,4 @@ /** * Represents an HTTP 5xx error that occurred during a request to the Sendy API. */ -class ServerException extends HttpException implements SendyException -{ -} +class ServerException extends HttpException implements SendyException {} diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index 78d62ed..77cba58 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -5,6 +5,4 @@ /** * Represents an HTTP 422 error that occurred during a request to the Sendy API. */ -class ValidationException extends ClientException -{ -} +class ValidationException extends ClientException {} diff --git a/src/Http/Response.php b/src/Http/Response.php index 8308737..523f268 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -91,7 +91,7 @@ public function __construct(int $statusCode, array $headers, string $body) $this->statusCode = $statusCode; $this->headers = array_map( fn($value) => is_array($value) ? $value : [$value], - array_change_key_case($headers, CASE_LOWER) + array_change_key_case($headers, CASE_LOWER), ); $this->body = $body; } @@ -164,7 +164,7 @@ public function toException(Request $request): ?HttpException return ValidationException::fromRequestAndResponse( $request, $this, - $this->getDecodedBody()['message'] ?? 'Validation failed' + $this->getDecodedBody()['message'] ?? 'Validation failed', ); } diff --git a/src/Http/Transport/CurlTransport.php b/src/Http/Transport/CurlTransport.php index 9f9144e..9e87e78 100644 --- a/src/Http/Transport/CurlTransport.php +++ b/src/Http/Transport/CurlTransport.php @@ -10,7 +10,7 @@ class CurlTransport implements TransportInterface { public function send(Request $request): Response { - if (!extension_loaded('curl')) { + if (! extension_loaded('curl')) { throw new TransportException('cURL PHP extension is not loaded.'); } @@ -48,7 +48,7 @@ public function send(Request $request): Response public function getUserAgent(): string { - if (!extension_loaded('curl')) { + if (! extension_loaded('curl')) { return 'curl'; } @@ -73,8 +73,6 @@ private function formatHeaders(array $headers): array /** * Parses the raw header string into an associative array. * - * @param string $rawHeaders - * * @return array> */ private function parseHeaders(string $rawHeaders): array diff --git a/src/Http/Transport/Psr18Transport.php b/src/Http/Transport/Psr18Transport.php index 05b3c0a..6692716 100644 --- a/src/Http/Transport/Psr18Transport.php +++ b/src/Http/Transport/Psr18Transport.php @@ -35,7 +35,7 @@ public function send(Request $request): Response { $psrRequest = $this->requestFactory->createRequest( $request->getMethod(), - $this->uriFactory->createUri($request->getUrl()) + $this->uriFactory->createUri($request->getUrl()), ); foreach ($request->getHeaders() as $name => $value) { @@ -44,7 +44,7 @@ public function send(Request $request): Response if ($body = $request->getBody()) { $psrRequest = $psrRequest->withBody( - $this->streamFactory->createStream($body) + $this->streamFactory->createStream($body), ); } diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php index e914340..2c8c960 100644 --- a/src/Http/Transport/TransportFactory.php +++ b/src/Http/Transport/TransportFactory.php @@ -17,8 +17,8 @@ public static function create(): TransportInterface } if ( - class_exists(\GuzzleHttp\Client::class) && - is_subclass_of(\GuzzleHttp\Client::class, ClientInterface::class) + class_exists(\GuzzleHttp\Client::class) + && is_subclass_of(\GuzzleHttp\Client::class, ClientInterface::class) ) { return self::createGuzzleTransport(); } @@ -28,7 +28,7 @@ class_exists(\GuzzleHttp\Client::class) && } throw new \LogicException( - 'No suitable HTTP client found.' + 'No suitable HTTP client found.', ); } @@ -41,7 +41,7 @@ public static function createGuzzleTransport(): Psr18Transport $httpFactory, $httpFactory, $httpFactory, - \GuzzleHttp\Utils::defaultUserAgent() + \GuzzleHttp\Utils::defaultUserAgent(), ); } @@ -60,7 +60,7 @@ public static function createSymfonyTransport(): Psr18Transport $client, $client, $client, - $userAgent + $userAgent, ); } diff --git a/src/Http/Transport/TransportInterface.php b/src/Http/Transport/TransportInterface.php index 6278288..d7802de 100644 --- a/src/Http/Transport/TransportInterface.php +++ b/src/Http/Transport/TransportInterface.php @@ -8,8 +8,6 @@ interface TransportInterface { /** - * @param Request $request - * * @throws \Sendy\Api\Exceptions\TransportException */ public function send(Request $request): Response; diff --git a/src/Http/Transport/WordpressTransport.php b/src/Http/Transport/WordpressTransport.php index d61922e..b3db3e4 100644 --- a/src/Http/Transport/WordpressTransport.php +++ b/src/Http/Transport/WordpressTransport.php @@ -32,7 +32,7 @@ public function send(Request $request): Response return new Response( wp_remote_retrieve_response_code($response), wp_remote_retrieve_headers($response), - wp_remote_retrieve_body($response) + wp_remote_retrieve_body($response), ); } diff --git a/src/Meta.php b/src/Meta.php index 22080fa..655b145 100644 --- a/src/Meta.php +++ b/src/Meta.php @@ -18,15 +18,6 @@ class Meta public int $total; - /** - * @param int $currentPage - * @param int $from - * @param int $lastPage - * @param string $path - * @param int $perPage - * @param int $to - * @param int $total - */ public function __construct( int $currentPage, int $from, @@ -47,7 +38,6 @@ public function __construct( /** * @param array $meta - * @return Meta */ public static function buildFromResponse(array $meta): Meta { @@ -58,7 +48,7 @@ public static function buildFromResponse(array $meta): Meta $meta['path'], $meta['per_page'], $meta['to'], - $meta['total'] + $meta['total'], ); } } diff --git a/src/RateLimits.php b/src/RateLimits.php index 2524b3b..82000fc 100644 --- a/src/RateLimits.php +++ b/src/RateLimits.php @@ -14,12 +14,6 @@ final class RateLimits public int $reset; - /** - * @param int $retryAfter - * @param int $limit - * @param int $remaining - * @param int $reset - */ public function __construct(int $retryAfter, int $limit, int $remaining, int $reset) { $this->retryAfter = $retryAfter; @@ -36,7 +30,7 @@ public static function buildFromResponse(Response $response): RateLimits (int) ($headers['retry-after'][0] ?? 0), (int) ($headers['x-ratelimit-limit'][0] ?? 0), (int) ($headers['x-ratelimit-remaining'][0] ?? 0), - (int) ($headers['x-ratelimit-reset'][0] ?? 0) + (int) ($headers['x-ratelimit-reset'][0] ?? 0), ); } } diff --git a/src/Resources/Shipment.php b/src/Resources/Shipment.php index 1088de0..8d143cb 100644 --- a/src/Resources/Shipment.php +++ b/src/Resources/Shipment.php @@ -138,7 +138,6 @@ public function labels(string $id): array * * Get a PDF with the export documents for a specific shipment * - * @param string $id * @return array> * @throws SendyException * @link https://app.sendy.nl/api/docs#tag/Documents/operation/api.shipments.documents.index diff --git a/src/Resources/Shop.php b/src/Resources/Shop.php index bf39599..e94d0ef 100644 --- a/src/Resources/Shop.php +++ b/src/Resources/Shop.php @@ -26,7 +26,6 @@ public function list(): array * Get a specific shop by its UUID * * @link https://app.sendy.nl/api/docs#tag/Shops/operation/api.shops.show - * @param string $id * @return array> * @throws SendyException */ diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 28829f9..730d516 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -19,21 +19,21 @@ public function testUserAgentIsSet(): void $connection = $this->createConnection(); $this->assertEquals( sprintf('SendySDK/3.0.0 PHP/%s GuzzleHttp/7', phpversion()), - $connection->createRequest('GET', '/')->getHeaders()['user-agent'] + $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); $connection = $this->createConnection(); $connection->setUserAgentAppendix('WooCommerce/6.2'); $this->assertEquals( sprintf('SendySDK/3.0.0 PHP/%s GuzzleHttp/7 WooCommerce/6.2', phpversion()), - $connection->createRequest('GET', '/')->getHeaders()['user-agent'] + $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); $connection = $this->createConnection(); $connection->setOauthClient(true); $this->assertEquals( sprintf('SendySDK/3.0.0 PHP/%s OAuth/2.0 GuzzleHttp/7', phpversion()), - $connection->createRequest('GET', '/')->getHeaders()['user-agent'] + $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); } @@ -79,7 +79,7 @@ public function testAuthorizationUrlIsBuilt(): void // phpcs:disable $this->assertEquals( 'https://app.sendy.nl/oauth/authorize?client_id=client-id&redirect_uri=https%3A%2F%2Fexample.com&response_type=code&state=state', - $connection->getAuthorizationUrl() + $connection->getAuthorizationUrl(), ); // phpcs:enable } @@ -118,7 +118,7 @@ public function testParseResponseExtractsMeta(): void 'path' => '/foo/bar', 'per_page' => 25, 'to' => 25, - 'total' => 27 + 'total' => 27, ], ]; @@ -135,7 +135,7 @@ public function testParseResponseUnwrapsData(): void $responseBody = [ 'data' => [ 'foo' => 'bar', - ] + ], ]; $response = new Response(200, [], json_encode($responseBody)); @@ -160,7 +160,7 @@ public function testTokensAreAcquiredWithAuthorizationCode(): void 'access_token' => 'FromAuthCode', 'refresh_token' => 'RefreshToken', 'expires_in' => 3600, - ])) + ])), ); $connection->setTransport($transport); @@ -188,7 +188,7 @@ public function testTokensAreAcquiredWithRefreshToken(): void 'access_token' => 'NewAccessToken', 'refresh_token' => 'NewRefreshToken', 'expires_in' => 3600, - ])) + ])), ); $connection->setTransport($transport); @@ -205,7 +205,7 @@ public function testTokensAreAcquiredWithRefreshToken(): void $this->assertEquals( 'https://app.sendy.nl/oauth/token', - $transport->getLastRequest()->getUrl() + $transport->getLastRequest()->getUrl(), ); } @@ -218,7 +218,7 @@ public function testTokenUpdateCallbackIsCalled(): void 'access_token' => 'NewAccessToken', 'refresh_token' => 'NewRefreshToken', 'expires_in' => 3600, - ])) + ])), ); $connection->setTransport($transport); @@ -242,7 +242,7 @@ public function testGetRequestIsBuiltAndSent(): void $connection->setAccessToken('PersonalAccessToken'); $transport = new MockTransport( - new Response(200, [], json_encode(['foo' => 'bar'])) + new Response(200, [], json_encode(['foo' => 'bar'])), ); $connection->setTransport($transport); @@ -258,7 +258,7 @@ public function testGetRequestWithQueryParametersIsBuiltAndSent(): void $connection->setAccessToken('PersonalAccessToken'); $transport = new MockTransport( - new Response(200, [], json_encode(['foo' => 'bar'])) + new Response(200, [], json_encode(['foo' => 'bar'])), ); $connection->setTransport($transport); @@ -274,7 +274,7 @@ public function testGetRequestWith4xxResponseThrowsClientException(): void $connection->setAccessToken('PersonalAccessToken'); $transport = new MockTransport( - new Response(418, [], '{}') + new Response(418, [], '{}'), ); $connection->setTransport($transport); @@ -291,7 +291,7 @@ public function testGetRequestWith5xxResponseThrowsServerException(): void $connection->setAccessToken('PersonalAccessToken'); $transport = new MockTransport( - new Response(500, [], '{"message": "Something went wrong"}') + new Response(500, [], '{"message": "Something went wrong"}'), ); $connection->setTransport($transport); @@ -344,7 +344,7 @@ public function testPutRequestIsBuiltAndSent(): void $connection->setAccessToken('PersonalAccessToken'); $mockTransport = new MockTransport( - new Response(201, [], json_encode(['foo' => 'bar'])) + new Response(201, [], json_encode(['foo' => 'bar'])), ); $connection->setTransport($mockTransport); diff --git a/tests/RateLimitsTest.php b/tests/RateLimitsTest.php index 82fd7e0..8bd081e 100644 --- a/tests/RateLimitsTest.php +++ b/tests/RateLimitsTest.php @@ -18,7 +18,7 @@ public function testBuildFromResponseBuildsRateLimitsObject(): void 'X-RateLimit-Remaining' => '179', 'X-RateLimit-Reset' => '1681381136', ], - '' + '', ); $this->assertInstanceOf(RateLimits::class, RateLimits::buildFromResponse($response)); diff --git a/tests/Resources/LabelTest.php b/tests/Resources/LabelTest.php index b0dec3b..9296566 100644 --- a/tests/Resources/LabelTest.php +++ b/tests/Resources/LabelTest.php @@ -24,7 +24,7 @@ public function testGet(): void $this->assertEquals( 'https://app.sendy.nl/api/labels?ids%5B0%5D=123456', - $transport->getLastRequest()->getUrl() + $transport->getLastRequest()->getUrl(), ); $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); @@ -42,7 +42,7 @@ public function testParametersAreSetInURL(): void $this->assertEquals( 'https://app.sendy.nl/api/labels?ids%5B0%5D=123456&ids%5B1%5D=A4&ids%5B2%5D=top-left', - $transport->getLastRequest()->getUrl() + $transport->getLastRequest()->getUrl(), ); $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); diff --git a/tests/Resources/ParcelshopTest.php b/tests/Resources/ParcelshopTest.php index a198228..3ebfccc 100644 --- a/tests/Resources/ParcelshopTest.php +++ b/tests/Resources/ParcelshopTest.php @@ -24,9 +24,9 @@ public function testList(): void $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); $this->assertEquals( - 'https://app.sendy.nl/api/parcel_shops' . - '?carriers%5B0%5D=DHL&latitude=52.040588&longitude=5.56489&country=NL&postal_code=3905KW', - $transport->getLastRequest()->getUrl() + 'https://app.sendy.nl/api/parcel_shops' + . '?carriers%5B0%5D=DHL&latitude=52.040588&longitude=5.56489&country=NL&postal_code=3905KW', + $transport->getLastRequest()->getUrl(), ); } } diff --git a/tests/Resources/ShipmentTest.php b/tests/Resources/ShipmentTest.php index 875c847..8fb31f8 100644 --- a/tests/Resources/ShipmentTest.php +++ b/tests/Resources/ShipmentTest.php @@ -81,7 +81,7 @@ public function testCreateFromPreference(): void $this->assertEquals( 'https://app.sendy.nl/api/shipments/preference?generateDirectly=0', - $transport->getLastRequest()->getUrl() + $transport->getLastRequest()->getUrl(), ); $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); $this->assertEquals('{"foo":"bar"}', $transport->getLastRequest()->getBody()); @@ -98,7 +98,7 @@ public function testCreateAndGenerateFromPreference(): void $this->assertEquals([], $resource->createFromPreference(['foo' => 'bar'])); $this->assertEquals( 'https://app.sendy.nl/api/shipments/preference?generateDirectly=1', - $transport->getLastRequest()->getUrl() + $transport->getLastRequest()->getUrl(), ); } @@ -173,7 +173,7 @@ public function testDocuments(): void $this->assertEquals( 'https://app.sendy.nl/api/shipments/1337/documents', - $transport->getLastRequest()->getUrl() + $transport->getLastRequest()->getUrl(), ); $this->assertEquals('GET', $transport->getLastRequest()->getMethod()); } diff --git a/tests/Resources/WebhookTest.php b/tests/Resources/WebhookTest.php index 91dff24..5aeba9d 100644 --- a/tests/Resources/WebhookTest.php +++ b/tests/Resources/WebhookTest.php @@ -49,8 +49,8 @@ public function testCreate(): void 'url' => 'https://example.com/webhook', 'events' => [ 'shipment.generated', - ] - ] + ], + ], ])), ); @@ -67,7 +67,7 @@ public function testCreate(): void $this->assertEquals('POST', $transport->getLastRequest()->getMethod()); $this->assertEquals( '{"url":"https:\/\/example.com\/webhook","events":["shipments.generated"]}', - $transport->getLastRequest()->getBody() + $transport->getLastRequest()->getBody(), ); } @@ -80,8 +80,8 @@ public function testUpdate(): void 'url' => 'https://example.com/updated-webhook', 'events' => [ 'shipment.generated', - ] - ] + ], + ], ])), ); @@ -98,7 +98,7 @@ public function testUpdate(): void $this->assertEquals('PUT', $transport->getLastRequest()->getMethod()); $this->assertEquals( '{"url":"https:\/\/example.com\/updated-webhook","events":["shipment.generated"]}', - $transport->getLastRequest()->getBody() + $transport->getLastRequest()->getBody(), ); } } From 1305bf931aad8322b105d97b1b7bd36eeccc1dd1 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 17 Sep 2025 14:45:55 +0200 Subject: [PATCH 11/21] Clean up --- .php-cs-fixer.dist.php | 5 +++-- src/Connection.php | 9 ++------- src/Exceptions/HasErrors.php | 6 +++--- src/Exceptions/SendyException.php | 2 +- src/Http/Response.php | 1 - src/Http/Transport/LaravelTransport.php | 6 ++++-- src/Http/Transport/MockTransport.php | 4 ++-- src/Http/Transport/Psr18Transport.php | 3 ++- src/Http/Transport/TransportFactory.php | 2 +- src/Http/Transport/WordpressTransport.php | 3 ++- src/Meta.php | 2 +- src/Resources/Parcelshop.php | 2 +- src/Resources/Service.php | 2 +- src/Resources/ShippingPreference.php | 2 +- src/Resources/Shop.php | 2 +- 15 files changed, 25 insertions(+), 26 deletions(-) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 336125c..25d63ed 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -7,13 +7,14 @@ return (new PhpCsFixer\Config()) ->setRules([ '@PER-CS' => true, + 'no_empty_phpdoc' => true, 'no_superfluous_phpdoc_tags' => [ 'allow_mixed' => true, ], - 'no_empty_phpdoc' => true, + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, 'phpdoc_trim' => true, 'phpdoc_trim_consecutive_blank_line_separation' => true, - 'not_operator_with_successor_space' => true, 'trailing_comma_in_multiline' => [ 'after_heredoc' => true, // https://cs.symfony.com/doc/rules/control_structure/trailing_comma_in_multiline.html diff --git a/src/Connection.php b/src/Connection.php index b6b5f40..dbadf5d 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -78,11 +78,7 @@ class Connection public function getTransport(): TransportInterface { - if ($this->transport instanceof TransportInterface) { - return $this->transport; - } - - return $this->transport = TransportFactory::create(); + return $this->transport ??= TransportFactory::create(); } public function setTransport(TransportInterface $transport): void @@ -163,9 +159,8 @@ public function setRedirectUrl(string $redirectUrl): Connection /** * @param mixed|null $state - * @return Connection */ - public function setState($state) + public function setState($state): Connection { $this->state = $state; diff --git a/src/Exceptions/HasErrors.php b/src/Exceptions/HasErrors.php index 6429b7c..3e57db6 100644 --- a/src/Exceptions/HasErrors.php +++ b/src/Exceptions/HasErrors.php @@ -4,11 +4,11 @@ trait HasErrors { - /** @var array */ + /** @var array> */ private array $errors = []; /** - * @param string[][] $errors + * @param array> $errors */ final public function __construct( string $message = '', @@ -22,7 +22,7 @@ final public function __construct( } /** - * @return array + * @return array> */ public function getErrors(): array { diff --git a/src/Exceptions/SendyException.php b/src/Exceptions/SendyException.php index 01eac69..bf7ac60 100644 --- a/src/Exceptions/SendyException.php +++ b/src/Exceptions/SendyException.php @@ -9,7 +9,7 @@ interface SendyException extends ApiException /** * Get the error details from the response. * - * @return array + * @return array> */ public function getErrors(): array; } diff --git a/src/Http/Response.php b/src/Http/Response.php index 523f268..0717c9e 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,7 +2,6 @@ namespace Sendy\Api\Http; -use Sendy\Api\ApiException; use Sendy\Api\Exceptions\ClientException; use Sendy\Api\Exceptions\HttpException; use Sendy\Api\Exceptions\JsonException; diff --git a/src/Http/Transport/LaravelTransport.php b/src/Http/Transport/LaravelTransport.php index 561c903..4ae4d05 100644 --- a/src/Http/Transport/LaravelTransport.php +++ b/src/Http/Transport/LaravelTransport.php @@ -3,6 +3,8 @@ namespace Sendy\Api\Http\Transport; use Illuminate\Foundation\Application; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; use Sendy\Api\Exceptions\TransportException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; @@ -16,10 +18,10 @@ class LaravelTransport implements TransportInterface public function send(Request $request): Response { $headers = $request->getHeaders(); - $contentType = \Illuminate\Support\Arr::pull($headers, 'Content-Type', 'application/json'); + $contentType = Arr::pull($headers, 'Content-Type', 'application/json'); try { - $response = \Illuminate\Support\Facades\Http::withHeaders($headers) + $response = Http::withHeaders($headers) ->withBody($request->getBody(), $contentType) ->withMethod($request->getMethod()) ->withUrl($request->getUrl()) diff --git a/src/Http/Transport/MockTransport.php b/src/Http/Transport/MockTransport.php index 2039f99..15ea0b5 100644 --- a/src/Http/Transport/MockTransport.php +++ b/src/Http/Transport/MockTransport.php @@ -10,9 +10,9 @@ class MockTransport implements TransportInterface private ?Response $response; /** - * @var Request[] + * @var list */ - private $requests = []; + private array $requests = []; public function __construct(?Response $response = null) { diff --git a/src/Http/Transport/Psr18Transport.php b/src/Http/Transport/Psr18Transport.php index 6692716..fc75bd5 100644 --- a/src/Http/Transport/Psr18Transport.php +++ b/src/Http/Transport/Psr18Transport.php @@ -6,6 +6,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UriFactoryInterface; +use Sendy\Api\Exceptions\TransportException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; @@ -51,7 +52,7 @@ public function send(Request $request): Response try { $psrResponse = $this->client->sendRequest($psrRequest); } catch (\Throwable $e) { - throw new \Sendy\Api\Exceptions\TransportException($e->getMessage(), $e->getCode(), $e); + throw new TransportException($e->getMessage(), $e->getCode(), $e); } return new Response( diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php index 2c8c960..d73a59c 100644 --- a/src/Http/Transport/TransportFactory.php +++ b/src/Http/Transport/TransportFactory.php @@ -8,7 +8,7 @@ final class TransportFactory { public static function create(): TransportInterface { - if (class_exists(\Symfony\Component\HttpClient\HttpClient::class)) { + if (class_exists(\Symfony\Component\HttpClient\Psr18Client::class)) { try { return self::createSymfonyTransport(); } catch (\LogicException $exception) { diff --git a/src/Http/Transport/WordpressTransport.php b/src/Http/Transport/WordpressTransport.php index b3db3e4..93b02b7 100644 --- a/src/Http/Transport/WordpressTransport.php +++ b/src/Http/Transport/WordpressTransport.php @@ -2,6 +2,7 @@ namespace Sendy\Api\Http\Transport; +use Sendy\Api\Exceptions\TransportException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; @@ -26,7 +27,7 @@ public function send(Request $request): Response $response = wp_remote_request($request->getUrl(), $args); if (is_wp_error($response)) { - throw new \Sendy\Api\Exceptions\TransportException($response->get_error_message()); + throw new TransportException($response->get_error_message()); } return new Response( diff --git a/src/Meta.php b/src/Meta.php index 655b145..c945792 100644 --- a/src/Meta.php +++ b/src/Meta.php @@ -2,7 +2,7 @@ namespace Sendy\Api; -class Meta +final class Meta { public int $currentPage; diff --git a/src/Resources/Parcelshop.php b/src/Resources/Parcelshop.php index e48a0f0..f45f31f 100644 --- a/src/Resources/Parcelshop.php +++ b/src/Resources/Parcelshop.php @@ -4,7 +4,7 @@ use Sendy\Api\Exceptions\SendyException; -class Parcelshop extends Resource +final class Parcelshop extends Resource { /** * List parcel shops diff --git a/src/Resources/Service.php b/src/Resources/Service.php index de247a3..950cdff 100644 --- a/src/Resources/Service.php +++ b/src/Resources/Service.php @@ -4,7 +4,7 @@ use Sendy\Api\Exceptions\SendyException; -class Service extends Resource +final class Service extends Resource { /** * List services associated with a carrier diff --git a/src/Resources/ShippingPreference.php b/src/Resources/ShippingPreference.php index 393f5ec..5b984e3 100644 --- a/src/Resources/ShippingPreference.php +++ b/src/Resources/ShippingPreference.php @@ -4,7 +4,7 @@ use Sendy\Api\Exceptions\SendyException; -class ShippingPreference extends Resource +final class ShippingPreference extends Resource { /** * List all shipping preferences diff --git a/src/Resources/Shop.php b/src/Resources/Shop.php index e94d0ef..a5646f3 100644 --- a/src/Resources/Shop.php +++ b/src/Resources/Shop.php @@ -4,7 +4,7 @@ use Sendy\Api\Exceptions\SendyException; -class Shop extends Resource +final class Shop extends Resource { /** * List all shops From d64fdef39d86e5c5a4b96f4a84bbb5895a975057 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 18 Sep 2025 11:39:26 +0200 Subject: [PATCH 12/21] Avoid pulling in polyfills The symfony/http-client and friendsofphp/php-cs-fixer dev dependencies relied on symfony/polyfill-php80. This caused PHPStan to not warn about using the str_starts_with function in a PHP 7.4 project. --- composer.json | 6 +++--- src/Http/Request.php | 2 +- src/Http/Transport/TransportFactory.php | 22 +++++++++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index e2482f2..45be793 100644 --- a/composer.json +++ b/composer.json @@ -38,9 +38,9 @@ "phpunit/phpunit": "^9.0", "phpstan/phpstan": "^1", "mockery/mockery": "^1.5", - "guzzlehttp/guzzle": "^7.9", - "symfony/http-client": "^5.4", - "friendsofphp/php-cs-fixer": "^3.86" + "php-cs-fixer/shim": "^3.87", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1" }, "scripts": { "lint": "vendor/bin/php-cs-fixer fix --dry-run --diff", diff --git a/src/Http/Request.php b/src/Http/Request.php index 658e258..3ec1461 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -42,7 +42,7 @@ public function getMethod(): string public function getUrl(): string { - if (str_starts_with($this->url, '/')) { + if (substr($this->url, 0, 1) === '/') { return Connection::BASE_URL . $this->url; } diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php index d73a59c..5a1d2c3 100644 --- a/src/Http/Transport/TransportFactory.php +++ b/src/Http/Transport/TransportFactory.php @@ -3,6 +3,9 @@ namespace Sendy\Api\Http\Transport; use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\UriFactoryInterface; final class TransportFactory { @@ -34,10 +37,20 @@ class_exists(\GuzzleHttp\Client::class) public static function createGuzzleTransport(): Psr18Transport { + assert(class_exists(\GuzzleHttp\Psr7\HttpFactory::class)); + assert(class_exists(\GuzzleHttp\Client::class)); + assert(class_exists(\GuzzleHttp\Utils::class)); + $httpFactory = new \GuzzleHttp\Psr7\HttpFactory(); + $client = new \GuzzleHttp\Client(); + + assert($client instanceof ClientInterface); + assert($httpFactory instanceof RequestFactoryInterface); + assert($httpFactory instanceof StreamFactoryInterface); + assert($httpFactory instanceof UriFactoryInterface); return new Psr18Transport( - new \GuzzleHttp\Client(), + $client, $httpFactory, $httpFactory, $httpFactory, @@ -47,8 +60,15 @@ public static function createGuzzleTransport(): Psr18Transport public static function createSymfonyTransport(): Psr18Transport { + assert(class_exists(\Symfony\Component\HttpClient\Psr18Client::class)); + $client = new \Symfony\Component\HttpClient\Psr18Client(); + assert($client instanceof ClientInterface); + assert($client instanceof RequestFactoryInterface); + assert($client instanceof StreamFactoryInterface); + assert($client instanceof UriFactoryInterface); + $userAgent = 'SymfonyHttpClient'; if (class_exists(\Symfony\Component\HttpKernel\Kernel::class)) { From 9b18c94a73965f21c2313654ac5656ffac5fc8d3 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 18 Sep 2025 11:43:06 +0200 Subject: [PATCH 13/21] Delete WordPress and Laravel transports These will be moved to their respective repositories when upgrading to version 3 of the Sendy SDK. --- src/Http/Transport/LaravelTransport.php | 40 --------------------- src/Http/Transport/WordpressTransport.php | 44 ----------------------- 2 files changed, 84 deletions(-) delete mode 100644 src/Http/Transport/LaravelTransport.php delete mode 100644 src/Http/Transport/WordpressTransport.php diff --git a/src/Http/Transport/LaravelTransport.php b/src/Http/Transport/LaravelTransport.php deleted file mode 100644 index 4ae4d05..0000000 --- a/src/Http/Transport/LaravelTransport.php +++ /dev/null @@ -1,40 +0,0 @@ -getHeaders(); - $contentType = Arr::pull($headers, 'Content-Type', 'application/json'); - - try { - $response = Http::withHeaders($headers) - ->withBody($request->getBody(), $contentType) - ->withMethod($request->getMethod()) - ->withUrl($request->getUrl()) - ->send(); - } catch (\Throwable $e) { - throw new TransportException($e->getMessage(), $e->getCode(), $e); - } - - return new Response($response->status(), $response->headers(), $response->body()); - } - - public function getUserAgent(): string - { - return 'LaravelHttpClient/' . Application::VERSION; - } -} diff --git a/src/Http/Transport/WordpressTransport.php b/src/Http/Transport/WordpressTransport.php deleted file mode 100644 index 93b02b7..0000000 --- a/src/Http/Transport/WordpressTransport.php +++ /dev/null @@ -1,44 +0,0 @@ - $request->getMethod(), - 'headers' => $request->getHeaders(), - 'body' => $request->getBody(), - ]; - - if ($request->getMethod() === 'GET') { - unset($args['body']); - } - - $response = wp_remote_request($request->getUrl(), $args); - - if (is_wp_error($response)) { - throw new TransportException($response->get_error_message()); - } - - return new Response( - wp_remote_retrieve_response_code($response), - wp_remote_retrieve_headers($response), - wp_remote_retrieve_body($response), - ); - } - - public function getUserAgent(): string - { - return 'WP_Http/' . get_bloginfo('version'); - } -} From b85f1a5f3d30e50f9978257db270a99cad9fa575 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 18 Sep 2025 11:58:17 +0200 Subject: [PATCH 14/21] Fix tests --- tests/ConnectionTest.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 730d516..8d4b75c 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -16,23 +16,26 @@ class ConnectionTest extends TestCase { public function testUserAgentIsSet(): void { + $phpVersion = phpversion(); + $curlVersion = curl_version()['version']; + $connection = $this->createConnection(); $this->assertEquals( - sprintf('SendySDK/3.0.0 PHP/%s GuzzleHttp/7', phpversion()), + "SendySDK/3.0.0 PHP/{$phpVersion} curl/{$curlVersion}", $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); $connection = $this->createConnection(); $connection->setUserAgentAppendix('WooCommerce/6.2'); $this->assertEquals( - sprintf('SendySDK/3.0.0 PHP/%s GuzzleHttp/7 WooCommerce/6.2', phpversion()), + "SendySDK/3.0.0 PHP/{$phpVersion} curl/{$curlVersion} WooCommerce/6.2", $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); $connection = $this->createConnection(); $connection->setOauthClient(true); $this->assertEquals( - sprintf('SendySDK/3.0.0 PHP/%s OAuth/2.0 GuzzleHttp/7', phpversion()), + "SendySDK/3.0.0 PHP/{$phpVersion} OAuth/2.0 curl/{$curlVersion}", $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); } @@ -41,11 +44,11 @@ public function testTokenExpires(): void { $connection = new Connection(); - $connection->setTokenExpires(time() - 3600); + $connection->setTokenExpires(time() - 600); $this->assertTrue($connection->tokenHasExpired()); - $connection->setTokenExpires(time() + 3600); + $connection->setTokenExpires(time() + 600); $this->assertFalse($connection->tokenHasExpired()); } @@ -359,7 +362,7 @@ public function testPutRequestIsBuiltAndSent(): void private function createConnection(): Connection { $connection = new Connection(); - $connection->setTransport(TransportFactory::createGuzzleTransport()); + $connection->setTransport(TransportFactory::createCurlTransport()); return $connection; } From 42df44c86a2234a68ee6a9eb8ae15ed013fdb243 Mon Sep 17 00:00:00 2001 From: Wim Griffioen Date: Wed, 1 Oct 2025 10:11:11 +0200 Subject: [PATCH 15/21] Make the setter fluid --- src/Connection.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Connection.php b/src/Connection.php index dbadf5d..7a55b87 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -81,9 +81,11 @@ public function getTransport(): TransportInterface return $this->transport ??= TransportFactory::create(); } - public function setTransport(TransportInterface $transport): void + public function setTransport(TransportInterface $transport): Connection { $this->transport = $transport; + + return $this; } public function setUserAgentAppendix(string $userAgentAppendix): Connection From 4a711b850d6175ba5fb437bbbb42c0f24fadc2b9 Mon Sep 17 00:00:00 2001 From: Wim Griffioen Date: Wed, 1 Oct 2025 10:12:29 +0200 Subject: [PATCH 16/21] Exclude new files from exporting --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitattributes b/.gitattributes index 82f8b45..ea2ff46 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,5 @@ /phpcs.xml.dist export-ignore /phpstan.neon export-ignore /phpunit.xml.dist export-ignore +/.php-cs-fixer.dist.php export-ignore +/bump-version.sh export-ignore From 1ed718645b6d7b9f05a683922b06a1c33bd42098 Mon Sep 17 00:00:00 2001 From: Wim Griffioen Date: Thu, 2 Oct 2025 13:36:12 +0200 Subject: [PATCH 17/21] Update the README for the changes --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/README.md b/README.md index 2c26564..12cea95 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,51 @@ $connection->setClientId('your-client-id') }); ``` +### Transports + +By default, the SDK will create a `Transport` with the `TransportFactory` if you do not supply one. + +To create your own `Transport` you have to create a class which implements `Sendy\Api\Http\Tranport\TransportInterface`. + +An example for the Laravel framework could look like this + +```php +use Illuminate\Foundation\Application; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; +use Sendy\Api\Exceptions\TransportException; +use Sendy\Api\Http\Request; +use Sendy\Api\Http\Response; +use Sendy\Api\Http\Transport\TransportInterface + +class LaravelTransport implements TransportInterface +{ + public function send(Request $request): Response + { + $headers = $request->getHeaders(); + $contentType = Arr::pull($headers, 'Content-Type', 'application/json'); + + try { + $response = Http::withHeaders($headers) + ->withBody($request->getBody(), $contentType) + ->withMethod($request->getMethod()) + ->withUrl($request->getUrl()) + ->send(); + } catch (\Throwable $e) { + throw new TransportException($e->getMessage(), $e->getCode(), $e); + } + + return new Response($response->status(), $response->headers(), $response->body()); + } + + public function getUserAgent() : string + { + return 'LaravelHttpClient/' . Application::VERSION; + } +} + +``` + ### Endpoints The endpoints in the API documentation are mapped to the resource as defined in the Resources directory. Please consult From 4d4b162c37602c14c2618049458ed03000db247b Mon Sep 17 00:00:00 2001 From: Wim Griffioen Date: Thu, 2 Oct 2025 13:40:08 +0200 Subject: [PATCH 18/21] Remove unnecessary nullable indicator --- src/Connection.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index 79101a7..b4c9cee 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -266,8 +266,8 @@ public function createRequest( string $method, string $endpoint, ?string $body = null, - ?array $params = [], - ?array $headers = [] + array $params = [], + array $headers = [] ): Request { $userAgent = sprintf("SendySDK/%s PHP/%s", self::VERSION, phpversion()); From dd5fd414b9e7b660925c9215af318f2093c579e0 Mon Sep 17 00:00:00 2001 From: Wim Griffioen Date: Thu, 2 Oct 2025 13:40:24 +0200 Subject: [PATCH 19/21] Remove unnecessary xdebug parameter --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 45be793..4e20523 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "scripts": { "lint": "vendor/bin/php-cs-fixer fix --dry-run --diff", "fix": "vendor/bin/php-cs-fixer fix", - "analyze": "vendor/bin/phpstan --xdebug", + "analyze": "vendor/bin/phpstan", "test": "vendor/bin/phpunit" } } From 75e9dc2e6703c499295f56d05c745b1924256471 Mon Sep 17 00:00:00 2001 From: Wim Griffioen Date: Tue, 4 Nov 2025 12:09:27 +0100 Subject: [PATCH 20/21] Handle exceptions when tokens are revoked --- src/Connection.php | 38 ++++++++++++++++++++++++--- tests/ConnectionTest.php | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index b4c9cee..f501266 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -3,6 +3,7 @@ namespace Sendy\Api; use Psr\Http\Message\UriInterface; +use Sendy\Api\Exceptions\ClientException; use Sendy\Api\Exceptions\SendyException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; @@ -244,10 +245,21 @@ private function acquireAccessToken(): void ]; } - $body = $this->performRequest( - $this->createRequest('POST', self::BASE_URL . self::TOKEN_URL, json_encode($parameters)), - false, - ); + try { + $body = $this->performRequest( + $this->createRequest('POST', self::BASE_URL . self::TOKEN_URL, json_encode($parameters)), + false, + ); + } catch (ClientException $exception) { + // When the refresh token was refreshed in another process, it could occur that the connection still holds + // a valid refresh token with a revoked refresh token. When this occurs it is safe to use the valid access + // token. + if ($this->refreshTokenIsRevoked($exception->getResponse()) && $this->tokenExpires > time()) { + return; + } + + throw $exception; + } $this->accessToken = $body['access_token']; $this->refreshToken = $body['refresh_token']; @@ -439,4 +451,22 @@ public function __get(string $resource): Resource return new $className($this); } + + /** + * @throws Exceptions\JsonException + */ + private function refreshTokenIsRevoked(Response $response): bool + { + if ($response->getStatusCode() !== 400) { + return false; + } + + $body = $response->getDecodedBody(); + + if (! isset($body['error'], $body['hint'])) { + return false; + } + + return $body['error'] === 'invalid_grant' && $body['hint'] === 'Token has been revoked'; + } } diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 8d4b75c..b30a84e 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -5,6 +5,8 @@ use PHPUnit\Framework\TestCase; use Sendy\Api\ApiException; use Sendy\Api\Connection; +use Sendy\Api\Exceptions\ClientException; +use Sendy\Api\Exceptions\SendyException; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; use Sendy\Api\Http\Transport\MockTransport; @@ -212,6 +214,60 @@ public function testTokensAreAcquiredWithRefreshToken(): void ); } + public function testRevokedRefreshTokenIsHandled(): void + { + $connection = new Connection(); + + $transport = new MockTransport( + new Response(400, [], json_encode([ + 'error' => 'invalid_grant', + 'hint' => 'Token has been revoked', + ])), + ); + + $connection->setTransport($transport); + + $connection->setOauthClient(true); + $connection->setClientId('clientId'); + $connection->setAccessToken('accessToken'); + $connection->setClientSecret('clientSecret'); + $connection->setRefreshToken('RefreshToken'); + $connection->setTokenExpires(time() + 5); + + try { + $connection->checkOrAcquireAccessToken(); + } catch (SendyException $exception) { + $this->fail($exception->getMessage()); + } + + $this->assertTrue(true); + } + + public function testUnexpectedExceptionWhenRefreshingTokensAreHandled(): void + { + $connection = new Connection(); + + $transport = new MockTransport( + new Response(400, [], json_encode([ + 'error' => 'unknown_error', + 'hint' => 'Unknown error', + ])), + ); + + $connection->setTransport($transport); + + $connection->setOauthClient(true); + $connection->setClientId('clientId'); + $connection->setAccessToken('accessToken'); + $connection->setClientSecret('clientSecret'); + $connection->setRefreshToken('RefreshToken'); + $connection->setTokenExpires(time() + 5); + + $this->expectException(ClientException::class); + + $connection->checkOrAcquireAccessToken(); + } + public function testTokenUpdateCallbackIsCalled(): void { $connection = new Connection(); From 2e85f3bd5ce8162a65bd7542505d41ed86b34be5 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 4 Nov 2025 15:29:33 +0100 Subject: [PATCH 21/21] Explain the purpose of transports --- README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 12cea95..7273ff2 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,13 @@ $connection->setClientId('your-client-id') ### Transports -By default, the SDK will create a `Transport` with the `TransportFactory` if you do not supply one. +The Sendy PHP SDK uses a concept called "transports" to send the HTTP requests to the Sendy API. By default, the [`TransportFactory`](https://github.com/sendynl/php-sdk/blob/main/src/Http/Transport/TransportFactory.php) will pick a suitable Transport for the environment if you do not supply one. -To create your own `Transport` you have to create a class which implements `Sendy\Api\Http\Tranport\TransportInterface`. +If, for example, your application already has a specific HTTP client library available, you may want to provide your own transport implementation. To create your own transport, create a class that implements `Sendy\Api\Http\Transport\TransportInterface`. -An example for the Laravel framework could look like this +
+ +An example for the Laravel framework could look like this (click to expand) ```php use Illuminate\Foundation\Application; @@ -135,13 +137,22 @@ class LaravelTransport implements TransportInterface return new Response($response->status(), $response->headers(), $response->body()); } - + public function getUserAgent() : string { return 'LaravelHttpClient/' . Application::VERSION; } } +``` + +
+ +To use your transport, set it on the connection: + +```php +$connection = new \Sendy\Api\Connection(); +$connection->setTransport(new LaravelTransport()); ``` ### Endpoints