From a30919db870dce89d5adab4f2b1fd6a057f07687 Mon Sep 17 00:00:00 2001 From: Carl Alexander Date: Tue, 14 May 2024 11:16:47 -0400 Subject: [PATCH] add capability to offload images to optimole --- composer.json | 9 +- phpstan.neon | 2 + phpunit.xml.dist | 2 +- src/Exception/BadResponseException.php | 18 ++ src/Exception/DashboardApiException.php | 18 ++ .../InvalidDashboardApiResponseException.php | 18 ++ .../InvalidUploadApiResponseException.php | 18 ++ src/Exception/UploadApiException.php | 18 ++ src/Exception/UploadFailedException.php | 18 ++ src/Exception/UploadLimitException.php | 42 +++ src/Http/ClientInterface.php | 22 ++ src/Http/GuzzleClient.php | 88 ++++++ src/Http/WordPressClient.php | 88 ++++++ src/Offload/Manager.php | 259 ++++++++++++++++++ src/Optimole.php | 49 +++- src/ValueObject/OffloadUsage.php | 52 ++++ tests/Unit/Http/GuzzleClientTest.php | 86 ++++++ tests/Unit/Http/WordPressClientTest.php | 130 +++++++++ tests/Unit/Offload/ManagerTest.php | 48 ++++ tests/Unit/OptimoleTest.php | 8 + tests/Unit/ValueObject/OffloadUsageTest.php | 30 ++ tests/bootstrap.php | 15 + 22 files changed, 1034 insertions(+), 4 deletions(-) create mode 100644 src/Exception/BadResponseException.php create mode 100644 src/Exception/DashboardApiException.php create mode 100644 src/Exception/InvalidDashboardApiResponseException.php create mode 100644 src/Exception/InvalidUploadApiResponseException.php create mode 100644 src/Exception/UploadApiException.php create mode 100644 src/Exception/UploadFailedException.php create mode 100644 src/Exception/UploadLimitException.php create mode 100644 src/Http/ClientInterface.php create mode 100644 src/Http/GuzzleClient.php create mode 100644 src/Http/WordPressClient.php create mode 100644 src/Offload/Manager.php create mode 100644 src/ValueObject/OffloadUsage.php create mode 100644 tests/Unit/Http/GuzzleClientTest.php create mode 100644 tests/Unit/Http/WordPressClientTest.php create mode 100644 tests/Unit/Offload/ManagerTest.php create mode 100644 tests/Unit/ValueObject/OffloadUsageTest.php create mode 100644 tests/bootstrap.php diff --git a/composer.json b/composer.json index 35b9170..9fd7768 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,19 @@ }, "require": { "php": ">=7.4", + "ext-json": "*", "symfony/polyfill-php80": "^1.29" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", + "guzzlehttp/guzzle": "^7.0", + "php-stubs/wordpress-stubs": "^6.5", "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^9.6", + "szepeviktor/phpstan-wordpress": "^1.3" + }, + "suggest": { + "guzzlehttp/guzzle": "Use the Guzzle HTTP client to make requests to the API" }, "config": { "optimize-autoloader": true, diff --git a/phpstan.neon b/phpstan.neon index 13569a5..f82c8fa 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,5 @@ +includes: + - ./vendor/szepeviktor/phpstan-wordpress/extension.neon parameters: level: 5 paths: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0fac7f2..b6c1eac 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class BadResponseException extends RuntimeException +{ +} diff --git a/src/Exception/DashboardApiException.php b/src/Exception/DashboardApiException.php new file mode 100644 index 0000000..b8c94a3 --- /dev/null +++ b/src/Exception/DashboardApiException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class DashboardApiException extends BadResponseException +{ +} diff --git a/src/Exception/InvalidDashboardApiResponseException.php b/src/Exception/InvalidDashboardApiResponseException.php new file mode 100644 index 0000000..44394e8 --- /dev/null +++ b/src/Exception/InvalidDashboardApiResponseException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class InvalidDashboardApiResponseException extends DashboardApiException +{ +} diff --git a/src/Exception/InvalidUploadApiResponseException.php b/src/Exception/InvalidUploadApiResponseException.php new file mode 100644 index 0000000..a2adad0 --- /dev/null +++ b/src/Exception/InvalidUploadApiResponseException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class InvalidUploadApiResponseException extends UploadApiException +{ +} diff --git a/src/Exception/UploadApiException.php b/src/Exception/UploadApiException.php new file mode 100644 index 0000000..4ca1784 --- /dev/null +++ b/src/Exception/UploadApiException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class UploadApiException extends BadResponseException +{ +} diff --git a/src/Exception/UploadFailedException.php b/src/Exception/UploadFailedException.php new file mode 100644 index 0000000..e5b9c83 --- /dev/null +++ b/src/Exception/UploadFailedException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +class UploadFailedException extends RuntimeException +{ +} diff --git a/src/Exception/UploadLimitException.php b/src/Exception/UploadLimitException.php new file mode 100644 index 0000000..cb55a92 --- /dev/null +++ b/src/Exception/UploadLimitException.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Exception; + +use Optimole\Sdk\ValueObject\OffloadUsage; + +class UploadLimitException extends UploadApiException +{ + /** + * The offload service usage. + */ + private OffloadUsage $usage; + + /** + * Constructor. + */ + public function __construct(OffloadUsage $usage, string $message = '', int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + + $this->usage = $usage; + } + + /** + * Get the offload service usage. + */ + public function getUsage(): OffloadUsage + { + return $this->usage; + } +} diff --git a/src/Http/ClientInterface.php b/src/Http/ClientInterface.php new file mode 100644 index 0000000..af309c2 --- /dev/null +++ b/src/Http/ClientInterface.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Http; + +interface ClientInterface +{ + /** + * Sends an HTTP request and returns the JSON decoded body. + */ + public function sendRequest(string $method, string $url, $body = null, array $headers = []): ?array; +} diff --git a/src/Http/GuzzleClient.php b/src/Http/GuzzleClient.php new file mode 100644 index 0000000..10b2c4b --- /dev/null +++ b/src/Http/GuzzleClient.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Http; + +use GuzzleHttp\ClientInterface as GuzzleClientInterface; +use GuzzleHttp\Exception\BadResponseException as GuzzleBadResponseException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Request; +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Optimole; + +class GuzzleClient implements ClientInterface +{ + /** + * The Guzzle HTTP client. + */ + private GuzzleClientInterface $client; + + /** + * Constructor. + */ + public function __construct(GuzzleClientInterface $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function sendRequest(string $method, string $url, $body = null, array $headers = []): ?array + { + try { + $response = $this->client->send($this->createRequest($method, $url, $body, $headers), ['verify' => false]); + } catch (GuzzleBadResponseException $exception) { + throw new BadResponseException($exception->getMessage(), $exception->getCode(), $exception); + } catch (GuzzleException $exception) { + throw new RuntimeException($exception->getMessage(), $exception->getCode(), $exception); + } + + $body = (string) $response->getBody(); + + if (empty($body)) { + return null; + } + + $body = (array) json_decode($body, true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new BadResponseException(sprintf('Unable to decode JSON response: %s', json_last_error_msg())); + } + + return $body; + } + + /** + * Create a request object. + */ + private function createRequest(string $method, string $url, $body = null, array $headers = []): Request + { + if (is_array($body)) { + $body = json_encode($body); + } + + if (null !== $body && !is_string($body)) { + throw new InvalidArgumentException('"body" must be a string or an array'); + } + + $headers = array_merge($headers, [ + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ]); + $method = strtolower($method); + + return new Request($method, $url, $headers, $body); + } +} diff --git a/src/Http/WordPressClient.php b/src/Http/WordPressClient.php new file mode 100644 index 0000000..23d3768 --- /dev/null +++ b/src/Http/WordPressClient.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Http; + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Optimole; + +class WordPressClient implements ClientInterface +{ + /** + * The WordPress HTTP client. + */ + private \WP_Http $client; + + /** + * Constructor. + */ + public function __construct(\WP_Http $client) + { + $this->client = $client; + } + + /** + * {@inheritdoc} + */ + public function sendRequest(string $method, string $url, $body = null, array $headers = []): ?array + { + if (is_array($body)) { + $body = json_encode($body); + } + + if (null !== $body && !is_string($body)) { + throw new InvalidArgumentException('"body" must be a string or an array'); + } + + $args = [ + 'method' => $method, + 'headers' => array_merge($headers, [ + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ]), + ]; + + if (null !== $body) { + $args['body'] = $body; + } + + $response = $this->client->request($url, $args); + + if ($response instanceof \WP_Error) { + throw new RuntimeException((string) $response->get_error_message(), (int) $response->get_error_code()); + } elseif (200 !== $this->getResponseStatusCode($response)) { + throw new BadResponseException(sprintf('Response status code: %s', $this->getResponseStatusCode($response))); + } + + if (empty($response['body'])) { + return null; + } + + $body = (array) json_decode($response['body'], true); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new BadResponseException(sprintf('Unable to decode JSON response: %s', json_last_error_msg())); + } + + return $body; + } + + /** + * Get the status code from the given response. + */ + private function getResponseStatusCode(array $response): ?int + { + return $response['response']['code'] ?? null; + } +} diff --git a/src/Offload/Manager.php b/src/Offload/Manager.php new file mode 100644 index 0000000..808e8ae --- /dev/null +++ b/src/Offload/Manager.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Offload; + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\InvalidDashboardApiResponseException; +use Optimole\Sdk\Exception\InvalidUploadApiResponseException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Exception\UploadApiException; +use Optimole\Sdk\Exception\UploadFailedException; +use Optimole\Sdk\Exception\UploadLimitException; +use Optimole\Sdk\Http\ClientInterface; +use Optimole\Sdk\ValueObject\OffloadUsage; + +class Manager +{ + /** + * The HTTP client. + */ + private ClientInterface $httpClient; + + /** + * The Optimole API key. + */ + private string $key; + + /** + * The manager options. + */ + private array $options; + + /** + * Constructor. + */ + public function __construct(ClientInterface $httpClient, string $key, array $options = []) + { + if (empty($options['dashboard_api_url'])) { + throw new InvalidArgumentException('Missing "dashboard_api_url" option'); + } elseif (empty($options['upload_api_url'])) { + throw new InvalidArgumentException('Missing "upload_api_url" option'); + } + + $this->httpClient = $httpClient; + $this->key = $key; + $this->options = array_merge([ + 'upload_api_credentials' => [], + ], $options); + + $this->options['dashboard_api_url'] = rtrim($this->options['dashboard_api_url'], '/'); + $this->options['upload_api_url'] = rtrim($this->options['upload_api_url'], '/'); + } + + /** + * Delete the image with the given image ID. + */ + public function deleteImage(string $imageId): void + { + try { + $this->requestToUploadApi([ + 'id' => $imageId, + 'deleteUrl' => 'true', + ]); + } catch (BadResponseException $exception) { + } + } + + /** + * Get the image URL for the given image ID. + */ + public function getImageUrl(string $imageId): ?string + { + try { + $response = $this->requestToUploadApi([ + 'id' => $imageId, + 'getUrl' => 'true', + ]); + } catch (BadResponseException $exception) { + return null; + } + + if (empty($response['getUrl'])) { + throw new InvalidUploadApiResponseException('Unable to get image URL from upload API'); + } + + return (string) $response['getUrl']; + } + + /** + * Get the offload service usage. + */ + public function getUsage(): OffloadUsage + { + $response = $this->requestToDashboardApi(); + + if (!isset($response['data']['offload_limit'], $response['data']['offloaded_images'])) { + throw new InvalidDashboardApiResponseException('Dashboard API did not return details about offloaded image quota'); + } + + return new OffloadUsage((int) $response['data']['offloaded_images'], (int) $response['data']['offload_limit']); + } + + /** + * Update the metadata of the image with the given ID. + */ + public function updateImageMetadata(string $imageId, int $fileSize = 0, $height = 'auto', $width = 'auto'): void + { + if ('auto' !== $height && !is_int($height)) { + throw new InvalidArgumentException('Image height must be "auto" or an integer.'); + } elseif ('auto' !== $width && !is_int($width)) { + throw new InvalidArgumentException('Image width must be "auto" or an integer.'); + } + + $this->requestToUploadApi([ + 'id' => $imageId, + 'originalFileSize' => $fileSize, + 'height' => is_int($height) ? max(0, $height) : $height, + 'width' => is_int($width) ? max(0, $width) : $width, + 'updateDynamo' => 'success', + ]); + } + + /** + * Upload an image to Optimole and return its image ID. + */ + public function uploadImage(string $filename, string $imageUrl): string + { + if (!file_exists($filename)) { + throw new InvalidArgumentException(sprintf('File "%s" does not exist', $filename)); + } elseif (!is_readable($filename)) { + throw new InvalidArgumentException(sprintf('File "%s" is not readable', $filename)); + } + + $fileMimeType = $this->getMimeType($filename); + + try { + $response = $this->requestToUploadApi([ + 'originalUrl' => $imageUrl, + ]); + } catch (BadResponseException $exception) { + throw new UploadApiException('Unable to get signed URL from upload API', 0, $exception); + } + + if (isset($response['error']) && 'limit_exceeded' === $response['error']) { + throw new UploadLimitException(new OffloadUsage((int) $response['count'], (int) $response['limit'])); + } elseif (isset($response['error'])) { + throw new UploadApiException(sprintf('Upload API returned an error: %s', $response['error'])); + } elseif (isset($response['count'], $response['limit']) && $response['count'] >= $response['limit']) { + throw new UploadLimitException(new OffloadUsage((int) $response['count'], (int) $response['limit'])); + } elseif (!isset($response['tableId'], $response['uploadUrl'])) { + throw new InvalidUploadApiResponseException('Upload API did not return the table ID and upload URL'); + } + + $imageId = (string) $response['tableId']; + $uploadUrl = (string) $response['uploadUrl']; + $uploadUrlMimeType = preg_match('/Content-Type=([^&]*)/', urldecode($uploadUrl), $matches) ? $matches[1] : null; + + if (!is_string($uploadUrlMimeType)) { + throw new RuntimeException('Unable to parse content type from upload URL'); + } elseif (strtolower($fileMimeType) !== strtolower($uploadUrlMimeType)) { + throw new RuntimeException(sprintf('File "%s" MIME type "%s" does not match upload URL MIME type "%s"', $filename, $fileMimeType, $uploadUrlMimeType)); + } + + $image = file_get_contents($filename); + + if (false === $image) { + throw new RuntimeException(sprintf('Unable to get file "%s" content', $filename)); + } + + try { + $this->httpClient->sendRequest('put', $uploadUrl, $image, [ + 'Content-Type' => $fileMimeType, + ]); + } catch (BadResponseException $exception) { + throw new UploadFailedException(sprintf('Unable to upload file "%s": %s', $filename, $exception->getMessage()), 0, $exception); + } + + $imagesize = getimagesize($filename); + + $this->updateImageMetadata($imageId, filesize($filename) ?: 0, $imagesize && !empty($imagesize[1]) ? $imagesize[1] : 'auto', $imagesize && !empty($imagesize[0]) ? $imagesize[0] : 'auto'); + + return $imageId; + } + + /** + * Get the MIME type of the given file. + */ + private function getMimeType(string $filename): string + { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + + if (false === $finfo) { + throw new RuntimeException('Unable to open fileinfo database'); + } + + $mimeType = finfo_file($finfo, $filename); + + finfo_close($finfo); + + if (false === $mimeType) { + throw new RuntimeException(sprintf('Unable to get MIME type for file "%s"', $filename)); + } + + return $mimeType; + } + + /** + * Get the upload API credentials from the dashboard API. + */ + private function getUploadApiCredentialsFromDashboardApi(): array + { + $response = $this->requestToDashboardApi(); + + if (!isset($response['data']['cdn_key'], $response['data']['cdn_secret'])) { + throw new InvalidDashboardApiResponseException('Dashboard API did not return upload API credentials'); + } + + return [ + 'userKey' => $response['data']['cdn_key'], + 'secret' => $response['data']['cdn_secret'], + ]; + } + + /** + * Make a request to the dashboard API. + */ + private function requestToDashboardApi(): array + { + return $this->httpClient->sendRequest('post', sprintf('%s/optml/v2/account/details', $this->options['dashboard_api_url']), null, [ + 'Authorization' => sprintf('Bearer %s', $this->key), + 'Content-Type' => 'application/json', + ]); + } + + /** + * Make a request to the upload API. + */ + private function requestToUploadApi(array $body): array + { + if (!isset($this->options['upload_api_credentials']['userKey'], $this->options['upload_api_credentials']['secret'])) { + $this->options['upload_api_credentials'] = $this->getUploadApiCredentialsFromDashboardApi(); + } + + return $this->httpClient->sendRequest('post', $this->options['upload_api_url'], array_merge($this->options['upload_api_credentials'], $body), [ + 'Content-Type' => 'application/json', + ]); + } +} diff --git a/src/Optimole.php b/src/Optimole.php index 893eeba..afcb7de 100644 --- a/src/Optimole.php +++ b/src/Optimole.php @@ -13,17 +13,38 @@ namespace Optimole\Sdk; +use GuzzleHttp\Client; use Optimole\Sdk\Exception\BadMethodCallException; use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Http\ClientInterface; +use Optimole\Sdk\Http\GuzzleClient; +use Optimole\Sdk\Http\WordPressClient; +use Optimole\Sdk\Offload\Manager; use Optimole\Sdk\Resource\Asset; use Optimole\Sdk\Resource\Image; /** - * @method static Asset asset(string $assetUrl, string $cacheBuster = '') - * @method static Image image(string $imageUrl, string $cacheBuster = '') + * @method static Asset asset(string $assetUrl, string $cacheBuster = '') + * @method static Image image(string $imageUrl, string $cacheBuster = '') + * @method static Manager offload() */ final class Optimole { + /** + * The Optimole SDK version. + */ + public const VERSION = '1.0.0'; + + /** + * The Optimole dashboard API URL. + */ + private const DASHBOARD_API_URL = 'https://dashboard.optimole.com/api'; + + /** + * The Optimole upload API URL. + */ + private const UPLOAD_API_URL = 'https://generateurls-prod.i.optimole.com/upload'; + /** * The Optimole SDK factory. */ @@ -73,6 +94,8 @@ public static function init(string $key, array $options = []): void $options = array_merge([ 'base_domain' => 'i.optimole.com', 'cache_buster' => '', + 'dashboard_api_url' => self::DASHBOARD_API_URL, + 'upload_api_url' => self::UPLOAD_API_URL, ], $options); self::$instance = new self($key, $options); @@ -94,6 +117,14 @@ private function createImage(string $imageUrl, string $cacheBuster = ''): Image return new Image($this->getDomain(), $imageUrl, $cacheBuster ?: $this->options['cache_buster']); } + /** + * Create an instance of offload manager. + */ + private function createOffload(array $options = []): Manager + { + return new Manager($this->getHttpClient(), $this->key, array_merge($this->options, $options)); + } + /** * Get the Optimole domain to use. */ @@ -101,4 +132,18 @@ private function getDomain(): string { return $this->options['domain'] ?? sprintf('%s.%s', $this->key, $this->options['base_domain']); } + + /** + * Get the HTTP client available in the environment. + */ + private function getHttpClient(): ClientInterface + { + if (class_exists(Client::class)) { + return new GuzzleClient(new Client()); + } elseif (function_exists('_wp_http_get_object')) { + return new WordPressClient(_wp_http_get_object()); + } + + throw new RuntimeException('Unable to find a suitable HTTP client for this environment'); + } } diff --git a/src/ValueObject/OffloadUsage.php b/src/ValueObject/OffloadUsage.php new file mode 100644 index 0000000..4f87a4d --- /dev/null +++ b/src/ValueObject/OffloadUsage.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\ValueObject; + +final class OffloadUsage +{ + /** + * The current number of offloaded images. + */ + private int $current; + + /** + * The maximum number of offloaded images allowed. + */ + private int $limit; + + /** + * Constructor. + */ + public function __construct(int $current, int $limit) + { + $this->current = $current; + $this->limit = $limit; + } + + /** + * Get the current number of offloaded images. + */ + public function getCurrent(): int + { + return $this->current; + } + + /** + * Get the maximum number of offloaded images allowed. + */ + public function getLimit(): int + { + return $this->limit; + } +} diff --git a/tests/Unit/Http/GuzzleClientTest.php b/tests/Unit/Http/GuzzleClientTest.php new file mode 100644 index 0000000..17dce3c --- /dev/null +++ b/tests/Unit/Http/GuzzleClientTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Unit\Http; + +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\BadResponseException as GuzzleBadResponseException; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Request; +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Http\GuzzleClient; +use Optimole\Sdk\Optimole; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; + +class GuzzleClientTest extends TestCase +{ + public function testSendRequestConvertsGuzzleBadResponseException() + { + $this->expectException(BadResponseException::class); + + $guzzle = $this->createMock(ClientInterface::class); + + $guzzle->expects($this->once()) + ->method('send') + ->willThrowException($this->createMock(GuzzleBadResponseException::class)); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestConvertsGuzzleException() + { + $this->expectException(RuntimeException::class); + + $guzzle = $this->createMock(ClientInterface::class); + + $guzzle->expects($this->once()) + ->method('send') + ->willThrowException($this->createMock(GuzzleException::class)); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestIsSuccessful() + { + $guzzle = $this->createMock(ClientInterface::class); + + $guzzle->expects($this->once()) + ->method('send') + ->willReturnCallback(function ($request, $options) { + $this->assertInstanceOf(Request::class, $request); + + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('https://example.com', (string) $request->getUri()); + $this->assertSame('{"foo":"bar"}', (string) $request->getBody()); + $this->assertSame(['Host' => ['example.com'], 'Content-Type' => ['application/json'], 'User-Agent' => [sprintf('optimole-sdk-php/%s', Optimole::VERSION)]], $request->getHeaders()); + $this->assertSame(['verify' => false], $options); + + return $this->createMock(ResponseInterface::class); + }); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWithBodyNotStringOrArray() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"body" must be a string or an array'); + + $guzzle = $this->createMock(ClientInterface::class); + + (new GuzzleClient($guzzle))->sendRequest('GET', 'https://example.com', new \stdClass(), ['Content-Type' => 'application/json']); + } +} diff --git a/tests/Unit/Http/WordPressClientTest.php b/tests/Unit/Http/WordPressClientTest.php new file mode 100644 index 0000000..b779341 --- /dev/null +++ b/tests/Unit/Http/WordPressClientTest.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Optimole\Sdk\Exception\BadResponseException; +use Optimole\Sdk\Exception\InvalidArgumentException; +use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Http\WordPressClient; +use Optimole\Sdk\Optimole; +use PHPUnit\Framework\TestCase; + +class WordPressClientTest extends TestCase +{ + public function testSendRequestReturnsJsonDecodedBody() + { + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['body' => '{"bar":"foo"}', 'response' => ['code' => 200]]); + + $this->assertSame(['bar' => 'foo'], (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json'])); + } + + public function testSendRequestReturnsNullWithEmptyBody() + { + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['body' => '', 'response' => ['code' => 200]]); + + $this->assertNull((new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json'])); + } + + public function testSendRequestWhenCannotDecodeJsonBody() + { + $this->expectException(BadResponseException::class); + $this->expectExceptionMessage('Unable to decode JSON response: State mismatch (invalid or malformed JSON)'); + + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['body' => '[}', 'response' => ['code' => 200]]); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWhenRequestDoesntReturn200StatusCode() + { + $this->expectException(BadResponseException::class); + $this->expectExceptionMessage('Response status code: 400'); + + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(['response' => ['code' => 400]]); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWhenRequestReturnsWpErrorObject() + { + $this->expectException(RuntimeException::class); + + $wordpressHttp = $this->createMock(WP_Http::class); + $wordpressHttp->expects($this->once()) + ->method('request') + ->with($this->identicalTo('https://example.com'), $this->identicalTo([ + 'method' => 'GET', + 'headers' => [ + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('optimole-sdk-php/%s', Optimole::VERSION), + ], + 'body' => '{"foo":"bar"}', + ])) + ->willReturn(new WP_Error('http_request_failed', 'An error occurred')); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', ['foo' => 'bar'], ['Content-Type' => 'application/json']); + } + + public function testSendRequestWithBodyNotStringOrArray() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"body" must be a string or an array'); + + $wordpressHttp = $this->createMock(WP_Http::class); + + (new WordPressClient($wordpressHttp))->sendRequest('GET', 'https://example.com', new stdClass(), ['Content-Type' => 'application/json']); + } +} diff --git a/tests/Unit/Offload/ManagerTest.php b/tests/Unit/Offload/ManagerTest.php new file mode 100644 index 0000000..8e4e807 --- /dev/null +++ b/tests/Unit/Offload/ManagerTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Unit\Offload; + +use Optimole\Sdk\Http\ClientInterface; +use Optimole\Sdk\Offload\Manager; +use PHPUnit\Framework\TestCase; + +class ManagerTest extends TestCase +{ + public function testDeleteImage() + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(function (...$args) { + static $expected = [ + [ + ['post', 'https://dashboard_api_url/optml/v2/account/details', null, ['Authorization' => 'Bearer optimole_key', 'Content-Type' => 'application/json']], + ['data' => ['cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret']], + ], + [ + ['post', 'https://upload_api_url', ['userKey' => 'cdn_key', 'secret' => 'cdn_secret', 'id' => 'image_id', 'deleteUrl' => 'true'], ['Content-Type' => 'application/json']], + ['success'], + ], + ]; + + list($expectedArgument, $return) = array_shift($expected); + + $this->assertSame($expectedArgument, $args); + + return $return; + }); + + (new Manager($httpClient, 'optimole_key', ['dashboard_api_url' => 'https://dashboard_api_url', 'upload_api_url' => 'https://upload_api_url']))->deleteImage('image_id'); + } +} diff --git a/tests/Unit/OptimoleTest.php b/tests/Unit/OptimoleTest.php index 6d266cd..f823451 100644 --- a/tests/Unit/OptimoleTest.php +++ b/tests/Unit/OptimoleTest.php @@ -15,6 +15,7 @@ use Optimole\Sdk\Exception\BadMethodCallException; use Optimole\Sdk\Exception\RuntimeException; +use Optimole\Sdk\Offload\Manager; use Optimole\Sdk\Optimole; use Optimole\Sdk\Resource\Asset; use Optimole\Sdk\Resource\Image; @@ -102,6 +103,13 @@ public function testImageUsesDomainOption(): void $this->assertSame('https://foo/https://example.com/image.jpg', (string) Optimole::image('https://example.com/image.jpg')); } + public function testOffloadReturnsOffloadManagerObject(): void + { + Optimole::init('key'); + + $this->assertInstanceOf(Manager::class, Optimole::offload()); + } + public function testThrowsExceptionIfMethodDoesNotExist(): void { $this->expectException(BadMethodCallException::class); diff --git a/tests/Unit/ValueObject/OffloadUsageTest.php b/tests/Unit/ValueObject/OffloadUsageTest.php new file mode 100644 index 0000000..0dda966 --- /dev/null +++ b/tests/Unit/ValueObject/OffloadUsageTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Sdk\Tests\Unit\ValueObject; + +use Optimole\Sdk\ValueObject\OffloadUsage; +use PHPUnit\Framework\TestCase; + +class OffloadUsageTest extends TestCase +{ + public function testGetCurrent() + { + $this->assertSame(10, (new OffloadUsage(10, 100))->getCurrent()); + } + + public function testGetLimit() + { + $this->assertSame(100, (new OffloadUsage(10, 100))->getLimit()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..4babce1 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,15 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +require_once __DIR__.'/../vendor/autoload.php'; +require_once __DIR__.'/../vendor/php-stubs/wordpress-stubs/wordpress-stubs.php';