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 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..25d63ed --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,25 @@ +in(__DIR__) + ->append([__FILE__]); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS' => true, + 'no_empty_phpdoc' => true, + 'no_superfluous_phpdoc_tags' => [ + 'allow_mixed' => true, + ], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => 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/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 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" diff --git a/composer.json b/composer.json index f353291..4e20523 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": { @@ -38,12 +37,15 @@ "require-dev": { "phpunit/phpunit": "^9.0", "phpstan/phpstan": "^1", - "squizlabs/php_codesniffer": "^3.7", - "mockery/mockery": "^1.5" + "mockery/mockery": "^1.5", + "php-cs-fixer/shim": "^3.87", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1" }, "scripts": { - "lint": "vendor/bin/phpcs", - "analyze": "vendor/bin/phpstan --xdebug", + "lint": "vendor/bin/php-cs-fixer fix --dry-run --diff", + "fix": "vendor/bin/php-cs-fixer fix", + "analyze": "vendor/bin/phpstan", "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 e66c9cd..afc7555 100644 --- a/src/ApiException.php +++ b/src/ApiException.php @@ -2,29 +2,8 @@ namespace Sendy\Api; -final class ApiException extends \Exception -{ - /** @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; - } -} +/** + * @deprecated This interface exists for backwards compatibility and may be removed in a future version. + * @internal + */ +interface ApiException extends \Throwable {} diff --git a/src/Connection.php b/src/Connection.php index dc23ae8..b4c9cee 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -2,15 +2,12 @@ namespace Sendy\Api; -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\SendyException; +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 +23,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 +33,7 @@ class Connection private const TOKEN_URL = '/oauth/token'; - private const VERSION = '1.0.2'; - - /** @var Client|null */ - private ?Client $client = null; + private ?TransportInterface $transport = null; /** @var string The Client ID as UUID */ private string $clientId; @@ -66,10 +62,9 @@ class Connection /** @var mixed */ private $state = null; - /** @var callable(Client) */ + /** @var callable($this) */ private $tokenUpdateCallback; - /** @var bool */ private bool $oauthClient = false; public ?Meta $meta; @@ -77,46 +72,22 @@ class Connection public ?RateLimits $rateLimits; /** - * @return Client + * @var array> */ - public function getClient(): Client - { - if ($this->client instanceof Client) { - return $this->client; - } + public array $sendyHeaders = []; - $userAgent = sprintf("Sendy/%s PHP/%s", self::VERSION, phpversion()); - - if ($this->isOauthClient()) { - $userAgent .= ' OAuth/2.0'; - } - - $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; + public function getTransport(): TransportInterface + { + return $this->transport ??= TransportFactory::create(); } - /** - * @param Client $client - */ - public function setClient(Client $client): void + public function setTransport(TransportInterface $transport): Connection { - $this->client = $client; + $this->transport = $transport; + + return $this; } - /** - * @param string $userAgentAppendix - * @return Connection - */ public function setUserAgentAppendix(string $userAgentAppendix): Connection { $this->userAgentAppendix = $userAgentAppendix; @@ -124,10 +95,6 @@ public function setUserAgentAppendix(string $userAgentAppendix): Connection return $this; } - /** - * @param string $clientId - * @return Connection - */ public function setClientId(string $clientId): Connection { $this->clientId = $clientId; @@ -135,10 +102,6 @@ public function setClientId(string $clientId): Connection return $this; } - /** - * @param string $clientSecret - * @return Connection - */ public function setClientSecret(string $clientSecret): Connection { $this->clientSecret = $clientSecret; @@ -146,10 +109,6 @@ public function setClientSecret(string $clientSecret): Connection return $this; } - /** - * @param string $authorizationCode - * @return Connection - */ public function setAuthorizationCode(string $authorizationCode): Connection { $this->authorizationCode = $authorizationCode; @@ -157,18 +116,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; @@ -176,18 +128,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; @@ -195,18 +140,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; @@ -214,10 +152,6 @@ public function setRefreshToken(string $refreshToken): Connection return $this; } - /** - * @param string $redirectUrl - * @return Connection - */ public function setRedirectUrl(string $redirectUrl): Connection { $this->redirectUrl = $redirectUrl; @@ -227,19 +161,14 @@ public function setRedirectUrl(string $redirectUrl): Connection /** * @param mixed|null $state - * @return Connection */ - public function setState($state) + public function setState($state): Connection { $this->state = $state; return $this; } - /** - * @param callable $tokenUpdateCallback - * @return Connection - */ public function setTokenUpdateCallback(callable $tokenUpdateCallback): Connection { $this->tokenUpdateCallback = $tokenUpdateCallback; @@ -249,31 +178,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; @@ -281,6 +201,9 @@ public function setOauthClient(bool $oauthClient): Connection return $this; } + /** + * @throws SendyException + */ public function checkOrAcquireAccessToken(): void { if (empty($this->accessToken) || ($this->tokenHasExpired() && $this->isOauthClient())) { @@ -299,74 +222,67 @@ public function tokenHasExpired(): bool return $this->tokenExpires - 10 < time(); } + /** + * @throws SendyException + */ 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->getClient()->post(self::BASE_URL . self::TOKEN_URL, ['form_params' => $parameters]); - - Message::rewindBody($response); - - $responseBody = $response->getBody()->getContents(); + 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, + ]; + } - $body = json_decode($responseBody, true); + $body = $this->performRequest( + $this->createRequest('POST', self::BASE_URL . self::TOKEN_URL, json_encode($parameters)), + false, + ); - 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); - } - } 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 null|StreamInterface|resource|string $body - * @param array|null $params - * @param array|null $headers - * @return Request + * @param array $params + * @param array $headers */ - private function createRequest( + public function createRequest( string $method, string $endpoint, - $body = null, - ?array $params = null, - ?array $headers = null + ?string $body = null, + array $params = [], + array $headers = [] ): Request { + $userAgent = sprintf("SendySDK/%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(); - - $headers['Authorization'] = "Bearer {$this->accessToken}"; - if (! empty($params)) { $endpoint .= strpos($endpoint, '?') === false ? '?' : '&'; $endpoint .= http_build_query($params); @@ -380,8 +296,7 @@ private function createRequest( * @param array $params * @param array $headers * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException */ public function get($url, array $params = [], array $headers = []): array { @@ -398,14 +313,13 @@ public function get($url, array $params = [], array $headers = []): array * @param array $params * @param array $headers * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException */ public function post($url, ?array $body = null, array $params = [], array $headers = []): array { $url = self::API_URL . $url; - if (!is_null($body)) { + if (! is_null($body)) { $body = json_encode($body); } @@ -420,8 +334,7 @@ public function post($url, ?array $body = null, array $params = [], array $heade * @param array> $params * @param array> $headers * @return array> - * @throws ApiException - * @throws GuzzleException + * @throws SendyException */ public function put($url, array $body = [], array $params = [], array $headers = []): array { @@ -436,7 +349,7 @@ public function put($url, array $body = [], array $params = [], array $headers = /** * @param UriInterface|string $url * @return array> - * @throws ApiException|\GuzzleHttp\Exception\GuzzleException + * @throws SendyException */ public function delete($url): array { @@ -448,44 +361,40 @@ public function delete($url): array } /** - * @param Request $request - * @return mixed[]|\mixed[][]|\string[][] - * @throws ApiException - * @throws GuzzleException + * @return array> + * @throws SendyException */ - private function performRequest(Request $request): array + private function performRequest(Request $request, bool $checkAccessToken = true): array { - try { - $response = $this->getClient()->send($request); + if ($checkAccessToken) { + $this->checkOrAcquireAccessToken(); - return $this->parseResponse($response); - } catch (\Exception $e) { - $this->parseException($e); + $request->setHeader('Authorization', "Bearer {$this->accessToken}"); } + + $response = $this->getTransport()->send($request); + + return $this->parseResponse($response, $request); } /** - * @param ResponseInterface $response * @return array> - * @throws ApiException + * @throws SendyException */ - public function parseResponse(ResponseInterface $response): array + public function parseResponse(Response $response, Request $request): array { $this->extractRateLimits($response); + $this->extractSendyHeaders($response); + + if ($exception = $response->toException($request)) { + 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,52 +409,25 @@ public function parseResponse(ResponseInterface $response): array return $json; } - /** - * @param \Exception $e - * @return void - * @throws ApiException - */ - public function parseException(\Exception $e): void + private function extractRateLimits(Response $response): 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']); + $this->rateLimits = RateLimits::buildFromResponse($response); } - private function extractRateLimits(ResponseInterface $response): void + /** + * Extract the x-sendy-* headers from the response. + */ + private function extractSendyHeaders(Response $response): void { - $this->rateLimits = RateLimits::buildFromResponse($response); + $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 - * - * @param string $resource - * @return Resource */ public function __get(string $resource): Resource { diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php new file mode 100644 index 0000000..5dd9e67 --- /dev/null +++ b/src/Exceptions/ClientException.php @@ -0,0 +1,8 @@ +> */ + private array $errors = []; + + /** + * @param array> $errors + */ + final 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/HttpException.php b/src/Exceptions/HttpException.php new file mode 100644 index 0000000..3fbfdd7 --- /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/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 new file mode 100644 index 0000000..e950db4 --- /dev/null +++ b/src/Exceptions/ServerException.php @@ -0,0 +1,8 @@ + + */ + private array $headers; + + private ?string $body; + + /** + * @param array $headers + */ + public function __construct( + string $method, + string $url, + array $headers = [], + ?string $body = null + ) { + $this->method = strtoupper($method); + $this->url = $url; + $this->headers = array_change_key_case($headers, CASE_LOWER); + $this->body = $body; + } + + public function getMethod(): string + { + return $this->method; + } + + public function getUrl(): string + { + if (substr($this->url, 0, 1) === '/') { + return Connection::BASE_URL . $this->url; + } + + return $this->url; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + 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 new file mode 100644 index 0000000..0717c9e --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,180 @@ + '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|string> $headers + */ + 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), + ); + $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 JsonException 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 JsonException("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); + + if (isset($decodedBody['error_description'], $decodedBody['hint'])) { + $summary .= ": {$decodedBody['error_description']} ({$decodedBody['hint']})"; + } elseif (isset($decodedBody['message'])) { + $summary .= ": {$decodedBody['message']}"; + } + + return $summary; + } + + /** + * Extract errors from the response body. + * + * @return array> + */ + public function getErrors(): array + { + $data = json_decode($this->body, true); + + return $data['errors'] ?? []; + } + + public function toException(Request $request): ?HttpException + { + if ($this->statusCode === 422) { + return ValidationException::fromRequestAndResponse( + $request, + $this, + $this->getDecodedBody()['message'] ?? 'Validation failed', + ); + } + + if ($this->statusCode >= 400 && $this->statusCode < 500) { + return ClientException::fromRequestAndResponse($request, $this); + } + + if ($this->statusCode >= 500) { + return ServerException::fromRequestAndResponse($request, $this); + } + + return null; + } +} diff --git a/src/Http/Transport/CurlTransport.php b/src/Http/Transport/CurlTransport.php new file mode 100644 index 0000000..9e87e78 --- /dev/null +++ b/src/Http/Transport/CurlTransport.php @@ -0,0 +1,92 @@ +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. + * + * @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/MockTransport.php b/src/Http/Transport/MockTransport.php new file mode 100644 index 0000000..15ea0b5 --- /dev/null +++ b/src/Http/Transport/MockTransport.php @@ -0,0 +1,38 @@ + + */ + private array $requests = []; + + public function __construct(?Response $response = null) + { + $this->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 new file mode 100644 index 0000000..fc75bd5 --- /dev/null +++ b/src/Http/Transport/Psr18Transport.php @@ -0,0 +1,89 @@ +client = $client; + $this->requestFactory = $requestFactory; + $this->streamFactory = $streamFactory; + $this->uriFactory = $uriFactory; + $this->userAgent = $userAgent; + } + + 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 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 $this->userAgent; + } +} diff --git a/src/Http/Transport/TransportFactory.php b/src/Http/Transport/TransportFactory.php new file mode 100644 index 0000000..5a1d2c3 --- /dev/null +++ b/src/Http/Transport/TransportFactory.php @@ -0,0 +1,91 @@ + $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 661d954..82000fc 100644 --- a/src/RateLimits.php +++ b/src/RateLimits.php @@ -2,7 +2,7 @@ namespace Sendy\Api; -use Psr\Http\Message\ResponseInterface; +use Sendy\Api\Http\Response; final class RateLimits { @@ -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; @@ -28,13 +22,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/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..f45f31f 100644 --- a/src/Resources/Parcelshop.php +++ b/src/Resources/Parcelshop.php @@ -2,10 +2,9 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; -class Parcelshop extends Resource +final class Parcelshop extends Resource { /** * List parcel shops @@ -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..950cdff 100644 --- a/src/Resources/Service.php +++ b/src/Resources/Service.php @@ -2,10 +2,9 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; -class Service extends Resource +final class Service extends Resource { /** * List services associated with a carrier @@ -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..8d143cb 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 @@ -147,10 +138,8 @@ public function labels(string $id): array * * Get a PDF with the export documents for a specific shipment * - * @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..5b984e3 100644 --- a/src/Resources/ShippingPreference.php +++ b/src/Resources/ShippingPreference.php @@ -2,10 +2,9 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; -class ShippingPreference extends Resource +final class ShippingPreference extends Resource { /** * List all shipping preferences @@ -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..a5646f3 100644 --- a/src/Resources/Shop.php +++ b/src/Resources/Shop.php @@ -2,10 +2,9 @@ namespace Sendy\Api\Resources; -use GuzzleHttp\Exception\GuzzleException; -use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; -class Shop extends Resource +final class Shop extends Resource { /** * List all shops @@ -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 { @@ -28,10 +26,8 @@ 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 ApiException - * @throws GuzzleException + * @throws SendyException */ public function get(string $id): array { diff --git a/src/Resources/Webhook.php b/src/Resources/Webhook.php index 8e690ba..2d51792 100644 --- a/src/Resources/Webhook.php +++ b/src/Resources/Webhook.php @@ -2,14 +2,15 @@ namespace Sendy\Api\Resources; +use Sendy\Api\Exceptions\SendyException; + final class Webhook extends Resource { /** * List all webhooks * + * @throws SendyException * @return array> - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException * @link https://app.sendy.nl/api/docs#tag/Webhooks/operation/api.webhooks.index */ public function list(): array @@ -21,9 +22,9 @@ public function list(): array * Create a new webhook * * @param array> $data + * + * @throws SendyException * @return array> - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException * @link https://app.sendy.nl/api/docs#tag/Webhooks/operation/api.webhooks.store */ public function create(array $data): array @@ -35,9 +36,9 @@ public function create(array $data): array * Delete a webhook * * @param string $id The ID of the webhook + * + * @throws SendyException * @return array - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException */ public function delete(string $id): array { @@ -49,9 +50,9 @@ public function delete(string $id): array * * @param string $id The id of the webhook to be updated * @param array> $data + * + * @throws SendyException * @return array> - * @throws \GuzzleHttp\Exception\GuzzleException - * @throws \Sendy\Api\ApiException */ public function update(string $id, array $data): array { diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 1014666..8d4b75c 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -2,17 +2,13 @@ namespace Sendy\Api\Tests; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\ClientException; -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\Http\Request; +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; @@ -20,27 +16,27 @@ class ConnectionTest extends TestCase { public function testUserAgentIsSet(): void { - $connection = new Connection(); + $phpVersion = phpversion(); + $curlVersion = curl_version()['version']; + $connection = $this->createConnection(); $this->assertEquals( - sprintf('Sendy/1.0.2 PHP/%s', phpversion()), - $connection->getClient()->getConfig('headers')['User-Agent'] + "SendySDK/3.0.0 PHP/{$phpVersion} curl/{$curlVersion}", + $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->getClient()->getConfig('headers')['User-Agent'] + "SendySDK/3.0.0 PHP/{$phpVersion} curl/{$curlVersion} WooCommerce/6.2", + $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->getClient()->getConfig('headers')['User-Agent'] + "SendySDK/3.0.0 PHP/{$phpVersion} OAuth/2.0 curl/{$curlVersion}", + $connection->createRequest('GET', '/')->getHeaders()['user-agent'], ); } @@ -48,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() + 60); + $connection->setTokenExpires(time() + 600); $this->assertFalse($connection->tokenHasExpired()); } @@ -86,7 +82,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 } @@ -95,9 +91,9 @@ public function testParseResponseReturnsEmptyArrayWhenResponseHasNoContent(): vo { $connection = new Connection(); - $response = new Response(204); + $response = new Response(204, [], ''); - $this->assertEquals([], $connection->parseResponse($response)); + $this->assertEquals([], $connection->parseResponse($response, new Request('GET', '/foo'))); } public function testParseResponseThrowsApiExceptionWithInvalidJson(): void @@ -109,7 +105,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 @@ -125,13 +121,13 @@ public function testParseResponseExtractsMeta(): void 'path' => '/foo/bar', 'per_page' => 25, 'to' => 25, - 'total' => 27 + 'total' => 27, ], ]; $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); } @@ -142,12 +138,12 @@ public function testParseResponseUnwrapsData(): void $responseBody = [ 'data' => [ 'foo' => 'bar', - ] + ], ]; $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', @@ -155,105 +151,22 @@ public function testParseResponseUnwrapsData(): void $response = new Response(200, [], json_encode($responseBody)); - $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()); - } + $this->assertEquals(['foo' => 'bar'], $connection->parseResponse($response, new Request('GET', '/foo'))); } 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->setClient($client); + $connection->setTransport($transport); $connection->setClientId('clientId'); $connection->setRedirectUrl('https://www.example.com/'); @@ -266,24 +179,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->setClient($client); + $connection->setTransport($transport); $connection->setClientId('clientId'); $connection->setClientSecret('clientSecret'); @@ -297,7 +208,7 @@ public function testTokensAreAcquiredWithRefreshToken(): void $this->assertEquals( 'https://app.sendy.nl/oauth/token', - (string) $mockHandler->getLastRequest()->getUri() + $transport->getLastRequest()->getUrl(), ); } @@ -305,17 +216,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->setClient($client); + $connection->setTransport($transport); $connection->setClientId('clientId'); $connection->setClientSecret('clientSecret'); @@ -335,28 +244,63 @@ public function testGetRequestIsBuiltAndSent(): void $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - $mockHandler = new MockHandler([ + $transport = new MockTransport( new Response(200, [], json_encode(['foo' => 'bar'])), + ); + + $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()); + } + + public function testGetRequestWithQueryParametersIsBuiltAndSent(): void + { + $connection = new Connection(); + $connection->setAccessToken('PersonalAccessToken'); + + $transport = new MockTransport( new Response(200, [], json_encode(['foo' => 'bar'])), - new Response(500, [], 'Something went wrong'), - ]); + ); - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + $connection->setTransport($transport); - $connection->setClient($client); + $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()); + } - $this->assertEquals(['foo' => 'bar'], $connection->get('/foo')); + public function testGetRequestWith4xxResponseThrowsClientException(): void + { + $connection = new Connection(); + $connection->setAccessToken('PersonalAccessToken'); - $this->assertEquals('/api/foo', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('GET', $mockHandler->getLastRequest()->getMethod()); + $transport = new MockTransport( + new Response(418, [], '{}'), + ); - $connection->get('/foo', ['baz' => 'foo']); + $connection->setTransport($transport); - $this->assertEquals('/api/foo?baz=foo', (string) $mockHandler->getLastRequest()->getUri()); - $this->assertEquals('GET', $mockHandler->getLastRequest()->getMethod()); + $this->expectException(\Sendy\Api\Exceptions\ClientException::class); + $this->expectExceptionMessage('418 - I\'m a teapot'); + $this->expectExceptionCode(418); + $connection->get('/brew-coffee'); + } - $this->expectException(ApiException::class); - $this->expectExceptionMessage('Something went wrong'); + 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'); @@ -367,18 +311,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->setClient($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 @@ -386,19 +328,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->setClient($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 @@ -406,18 +346,24 @@ public function testPutRequestIsBuiltAndSent(): void $connection = new Connection(); $connection->setAccessToken('PersonalAccessToken'); - $mockHandler = new MockHandler([ + $mockTransport = new MockTransport( new Response(201, [], json_encode(['foo' => 'bar'])), - ]); - - $client = new Client(['handler' => HandlerStack::create($mockHandler)]); + ); - $connection->setClient($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::createCurlTransport()); + + return $connection; } } diff --git a/tests/RateLimitsTest.php b/tests/RateLimitsTest.php index 624b66c..8bd081e 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..9296566 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..3ebfccc 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..8fb31f8 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..5aeba9d 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,47 +14,47 @@ 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', 'url' => 'https://example.com/webhook', 'events' => [ 'shipment.generated', - ] - ] + ], + ], ])), - ]); + ); - $resource = new Webhook($this->buildConnectionWithMockHandler($handler)); + $resource = new Webhook($this->buildConnectionWithMockTransport($transport)); $resource->create([ 'url' => 'https://example.com/webhook', @@ -63,29 +63,29 @@ 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', 'url' => 'https://example.com/updated-webhook', 'events' => [ 'shipment.generated', - ] - ] + ], + ], ])), - ]); + ); - $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..ee5193e --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,54 @@ +toException(new Request('GET', '/foo')); + + $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(new Request('GET', '/foo')); + } + + public function testToExceptionHandlesValidationMessages(): void + { + $response = new Response(422, [], json_encode(['message' => 'Error message'])); + + $exception = $response->toException(new Request('GET', '/foo')); + + $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(new Request('GET', '/foo'))->getErrors()); + + $response = new Response(422, [], json_encode(['message' => 'Error message'])); + $this->assertSame([], $response->toException(new Request('GET', '/foo'))->getErrors()); + } +} diff --git a/tests/TestsEndpoints.php b/tests/TestsEndpoints.php index b7a269b..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->setClient($client); + $connection->setTransport($transport); return $connection; }