From fb326f570986ac482d4cb7ab7c393156d5183a51 Mon Sep 17 00:00:00 2001 From: Constantine Nathanson Date: Mon, 19 Oct 2026 22:29:02 +0300 Subject: [PATCH] Add support for custom PSR-18 HTTP clients Let applications send SDK requests through their own PSR-18 client, so their logging, retries, proxy and timeouts apply. - Cloudinary::setHttpClient() sets the client for API objects returned by uploadApi(), adminApi(), searchApi() and searchFoldersApi(). - setHttpClient() on UploadApi, AdminApi, SearchApi, SearchFoldersApi and AccountApi sets it for objects created directly. - Cloudinary::setDefaultHttpClient() sets a process-wide default. - A Guzzle handler (Psr18Handler) forwards each built request to the client and maps PSR-18 network and client errors to Guzzle exceptions, so the public API, including *Async() return types, is unchanged. - With a custom client, timeout, connection_timeout and api_proxy are owned by the client, and async calls run sequentially. - Require psr/http-client; add symfony/http-client to require-dev. - CI: add PHP 8.5; run the suite through the Symfony PSR-18 client on PHP 8.5 with Guzzle 8. - Tests: unit tests for the handler and setters; live scenario tests through Guzzle and Symfony PSR-18 clients; CLD_TEST_HTTP_CLIENT runs integration tests through a PSR-18 client. - Tests: call ReflectionMethod::setAccessible() only before PHP 8.1, as PHP 8.5 deprecates it. - Docs: docs/use-custom-http-client.md, an example, and a README section. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/test.yaml | 16 +- .gitignore | 1 + README.md | 24 + composer.json | 6 +- docs/README.md | 1 + docs/use-custom-http-client.md | 118 ++++ examples/use-custom-http-client.php | 64 +++ src/Api/Admin/AdminApi.php | 14 + src/Api/ApiClient.php | 8 +- src/Api/BaseApiClient.php | 76 +++ src/Api/Provisioning/AccountApi.php | 13 + src/Api/Provisioning/AccountApiClient.php | 10 +- src/Api/Search/SearchApi.php | 13 + src/Api/Upload/UploadApi.php | 13 + src/Cloudinary.php | 57 +- src/HttpClient/HttpClient.php | 3 +- src/HttpClient/Psr18Handler.php | 63 +++ tests/CloudinaryTestCase.php | 8 +- .../HttpClient/Psr18ClientMatrixTest.php | 454 ++++++++++++++++ .../HttpClient/Psr18ClientTest.php | 92 ++++ tests/Integration/IntegrationTestCase.php | 24 + tests/Unit/ApiClientTest.php | 4 +- tests/Unit/Asset/AuthTokenTest.php | 4 +- tests/Unit/HttpClient/Psr18ClientTest.php | 503 ++++++++++++++++++ tests/Unit/HttpClient/Psr18HandlerTest.php | 171 ++++++ 25 files changed, 1732 insertions(+), 28 deletions(-) create mode 100644 docs/use-custom-http-client.md create mode 100644 examples/use-custom-http-client.php create mode 100644 src/HttpClient/Psr18Handler.php create mode 100644 tests/Integration/HttpClient/Psr18ClientMatrixTest.php create mode 100644 tests/Integration/HttpClient/Psr18ClientTest.php create mode 100644 tests/Unit/HttpClient/Psr18ClientTest.php create mode 100644 tests/Unit/HttpClient/Psr18HandlerTest.php diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 38a7450d..fe3a4098 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,7 +2,7 @@ name: Tests on: [push, pull_request] jobs: php: - name: PHP ${{ matrix.php-versions }}, Guzzle ${{ matrix.guzzle-versions }} + name: PHP ${{ matrix.php-versions }}, Guzzle ${{ matrix.guzzle-versions }}, HTTP client ${{ matrix.http-client }} # Same-repo changes run on push; fork PRs run on pull_request. if: >- (github.event_name == 'push' && !github.event.pull_request.head.repo.fork) || @@ -17,14 +17,22 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' guzzle-versions: - '7' - # Guzzle 8 on lowest and highest PHP only. + http-client: + - 'default' + # Guzzle 8 on lowest and highest PHP only; Symfony PSR-18 client on latest only. include: - php-versions: '8.0' guzzle-versions: '8' - - php-versions: '8.4' + http-client: 'default' + - php-versions: '8.5' guzzle-versions: '8' + http-client: 'default' + - php-versions: '8.5' + guzzle-versions: '8' + http-client: 'symfony' steps: - name: Checkout uses: actions/checkout@v4 @@ -39,6 +47,8 @@ jobs: composer require -n --no-update guzzlehttp/guzzle:^${{ matrix.guzzle-versions }} composer update -n - name: Run Tests + env: + CLD_TEST_HTTP_CLIENT: ${{ matrix.http-client }} run: | export CLOUDINARY_URL=$(bash tools/get_test_cloud.sh); echo cloud_name: "$(echo $CLOUDINARY_URL | cut -d'@' -f2)" diff --git a/.gitignore b/.gitignore index c34ed064..8f608afb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ tools/dev/sanity/package-lock.json tools/dev/sanity/results.json tools/dev/sanity/TransformationSanityTest.php /.phpunit.result.cache +.scratch/ diff --git a/README.md b/README.md index 018e3487..248282aa 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Save as `quickstart.php` and run `php quickstart.php`. [Create a free account](h - [Search and manage assets](docs/search-and-manage-assets.md) - [Moderate an upload](docs/moderate-upload.md) - [Use structured metadata](docs/use-structured-metadata.md) +- [Use a custom HTTP client](docs/use-custom-http-client.md) - [Troubleshoot errors](docs/troubleshoot-errors.md) Runnable versions live in [`examples/`](examples/) — each is a complete file you can run directly. @@ -123,6 +124,29 @@ companion link that returns the same page as raw Markdown. Inside `docs/` and `e the links are Markdown-only, since those files are written to be read by coding agents. Either form works for any page: add `.md` for Markdown, drop it for HTML. +## Advanced: use a custom HTTP client + +By default the SDK sends requests with its own Guzzle client. To route them through your +own [PSR-18](https://www.php-fig.org/psr/psr-18/) client, with its logging, retries, +proxy and timeouts: + +```php +use Cloudinary\Cloudinary; +use GuzzleHttp\Psr7\HttpFactory; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; + +$cloudinary = new Cloudinary(); +$cloudinary->setHttpClient(new Psr18Client(HttpClient::create(), new HttpFactory(), new HttpFactory())); +``` + +Any `Psr\Http\Client\ClientInterface` works. API objects created with `new` take the client +through their own `setHttpClient()`, or `Cloudinary::setDefaultHttpClient()` sets it for the +whole process. With a custom client, `timeout`, `connection_timeout` and `api_proxy` are +configured on your client, and `*Async()` calls run one at a time. Every request carries +an `Authorization` header with your API secret: redact it if your client logs headers. +Details: [Use a custom HTTP client](docs/use-custom-http-client.md). + ## For AI coding agents - Contributing to this repo: read [AGENTS.md](AGENTS.md). diff --git a/composer.json b/composer.json index 27a92dac..7f58fae8 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "guzzlehttp/psr7": "^2.7|^3.0", "ext-json": "*", "monolog/monolog": "^2|^3", - "psr/log" : "^2|^3" + "psr/log" : "^2|^3", + "psr/http-client": "^1.0" }, "require-dev": { "symfony/phpunit-bridge": "^7.2", @@ -40,7 +41,8 @@ "friendsofphp/php-cs-fixer": "*", "ext-dom": "*", "ext-libxml": "*", - "ext-zip": "*" + "ext-zip": "*", + "symfony/http-client": "^5.4|^6.4|^7.0|^8.0" }, "autoload": { "classmap": [ diff --git a/docs/README.md b/docs/README.md index 40d651f7..23d2d351 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ versions of these tasks are in `vendor/cloudinary/cloudinary_php/examples/`. - [Search and manage assets](search-and-manage-assets.md) - [Moderate an upload](moderate-upload.md) - [Use structured metadata](use-structured-metadata.md) +- [Use a custom HTTP client](use-custom-http-client.md) - [Troubleshoot errors](troubleshoot-errors.md) ## Security boundary diff --git a/docs/use-custom-http-client.md b/docs/use-custom-http-client.md new file mode 100644 index 00000000..a2ecc32f --- /dev/null +++ b/docs/use-custom-http-client.md @@ -0,0 +1,118 @@ +# Use a custom HTTP client + +## When to use + +Your application already has an HTTP client with its own logging, retries, tracing, +proxy, or timeouts, and you want Cloudinary API calls to go through it. Any +[PSR-18](https://www.php-fig.org/psr/psr-18/) client works: Symfony HttpClient, Guzzle, +and others. + +Without this, the SDK sends requests with its own built-in Guzzle client. + +## Complete flow + +Symfony HttpClient with retries: + +```php + 60]), null, 3); + +$cloudinary = new Cloudinary(); +$cloudinary->setHttpClient(new Psr18Client($httpClient, new HttpFactory(), new HttpFactory())); + +$result = $cloudinary->uploadApi()->upload('https://res.cloudinary.com/demo/image/upload/sample.jpg'); + +echo $result['secure_url'], PHP_EOL; +``` + +`Psr18Client` needs PSR-17 factories. `GuzzleHttp\Psr7\HttpFactory` is already installed +with the SDK, so no extra package is required. + +In a Symfony application, pass the `http_client` service you already configured instead +of `HttpClient::create()`, so its logging, retries and profiler integration apply. + +Runnable version: [`examples/use-custom-http-client.php`](../examples/use-custom-http-client.php). + +## Other PSR-18 clients + +Pass any `Psr\Http\Client\ClientInterface`. For example, a Guzzle client with your own +middleware: + +```php +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + +$stack = HandlerStack::create(); +// $stack->push(...your middleware...); + +$cloudinary->setHttpClient(new Client(['handler' => $stack])); +``` + +## Where to set the client + +| Call | Applies to | +|---|---| +| `$cloudinary->setHttpClient($client)` | API objects returned by `$cloudinary->uploadApi()`, `adminApi()`, `searchApi()` and `searchFoldersApi()` after the call | +| `$api->setHttpClient($client)` | That API object: `UploadApi`, `AdminApi`, `SearchApi`, `SearchFoldersApi` or `AccountApi` (Provisioning) created directly | +| `Cloudinary::setDefaultHttpClient($client)` | Every API object created after the call that has no client of its own | + +An object's own client wins over the default, and the default wins over the built-in +client. Passing `null` to any of them removes that level. The setters on `Cloudinary` and +on API objects return the object, so calls can be chained: + +```php +use Cloudinary\Api\Upload\UploadApi; + +$uploadApi = (new UploadApi())->setHttpClient($client); + +Cloudinary::setDefaultHttpClient($client); // for code that creates API objects directly +``` + +Use the instance setter when you inject the client through a container, or when different +clouds need different clients in one process. Use the default for code that creates API +objects with `new` and has no access to your client. + +## What changes with a custom client + +| Area | Built-in client | Custom PSR-18 client | +|---|---|---| +| `timeout`, `connection_timeout`, `api_proxy` config | Applied by the SDK | Ignored; configure them on your client | +| `*Async()` methods | Requests on the same API object run in parallel | Each call completes before it returns; PSR-18 has no async API | +| Network errors | `GuzzleHttp\Exception\ConnectException` | `GuzzleHttp\Exception\ConnectException` (converted from the client's exception) | +| Other client errors | `GuzzleHttp\Exception\RequestException` | `GuzzleHttp\Exception\RequestException` (converted) | +| API errors (`NotFound`, `BadRequest`, ...) | `Cloudinary\Api\Exception\*` | Unchanged | + +`*Async()` methods still return Guzzle promises, so existing code keeps working. + +## Security: the API secret is in every request + +With `api_key` and `api_secret` configured, every request carries an +`Authorization: Basic` header that encodes your API secret. Your client sees that header. +If your client or its middleware logs request headers (debug logging, tracing, APM), +redact `Authorization` before the logs are stored. Symfony HttpClient's built-in logger +records only the method and URL. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| Requests still use the built-in client | The API object was created before `$cloudinary->setHttpClient()` or `Cloudinary::setDefaultHttpClient()`; get a new one, or call `setHttpClient()` on it. | +| `timeout` or `api_proxy` has no effect | They are ignored with a custom client; set them on the client. | +| `LogicException: You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided` | Pass `new HttpFactory()` twice, as shown above. | +| `ConnectException` | The client could not reach the API host: DNS, proxy, or network. | +| Async calls take as long as running them one by one | Expected with a PSR-18 client. | + +## Related + +- [Configure Cloudinary](configure-cloudinary.md) +- [Troubleshoot errors](troubleshoot-errors.md) +- [Upload a large video](upload-large-video.md) diff --git a/examples/use-custom-http-client.php b/examples/use-custom-http-client.php new file mode 100644 index 00000000..90e98b1c --- /dev/null +++ b/examples/use-custom-http-client.php @@ -0,0 +1,64 @@ +:@ + * + * Run: + * php examples/use-custom-http-client.php + * + * In your own project, the import and instantiation are: + * require 'vendor/autoload.php'; + * use Cloudinary\Cloudinary; + * $cloudinary = new Cloudinary(); + */ + +declare(strict_types=1); + +require __DIR__ . '/../vendor/autoload.php'; + +use Cloudinary\Cloudinary; +use GuzzleHttp\Psr7\HttpFactory; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; +use Symfony\Component\HttpClient\RetryableHttpClient; + +const SOURCE_IMAGE = 'https://res.cloudinary.com/demo/image/upload/sample.jpg'; +const PUBLIC_ID = 'examples/use-custom-http-client'; + +function main(): void +{ + // Timeouts, proxy and retries are configured on your client, not in Cloudinary config. + $httpClient = new RetryableHttpClient(HttpClient::create(['timeout' => 60]), null, 3); + + // API objects returned by this instance send their requests through it. + $cloudinary = new Cloudinary(); + $cloudinary->setHttpClient(new Psr18Client($httpClient, new HttpFactory(), new HttpFactory())); + + $result = $cloudinary->uploadApi()->upload( + SOURCE_IMAGE, + [ + 'public_id' => PUBLIC_ID, + 'tags' => ['example'], + ] + ); + + echo 'Uploaded via PSR-18 client: ', $result['public_id'], PHP_EOL; + echo 'URL: ', $result['secure_url'], PHP_EOL; + + $asset = $cloudinary->adminApi()->asset(PUBLIC_ID); + echo 'Fetched via PSR-18 client: ', $asset['bytes'], ' bytes', PHP_EOL; +} + +try { + main(); +} catch (Throwable $e) { + fwrite(STDERR, 'Request failed: ' . $e->getMessage() . PHP_EOL); + fwrite(STDERR, 'Check that CLOUDINARY_URL is set (Console > Settings > API Keys).' . PHP_EOL); + exit(1); +} diff --git a/src/Api/Admin/AdminApi.php b/src/Api/Admin/AdminApi.php index 1617e630..387d85dc 100644 --- a/src/Api/Admin/AdminApi.php +++ b/src/Api/Admin/AdminApi.php @@ -12,6 +12,7 @@ use Cloudinary\Api\ApiClient; use Cloudinary\Configuration\Configuration; +use Psr\Http\Client\ClientInterface; /** * Enables Cloudinary Admin API functionality. @@ -59,4 +60,17 @@ public function __construct(mixed $configuration = null) $this->apiV2Client = new ApiClient($apiV2Configuration); } + + /** + * Sets a PSR-18 HTTP client for the requests of this API instance, or the default when null. + * + * @param ClientInterface|null $client The PSR-18 client. + */ + public function setHttpClient(?ClientInterface $client): static + { + $this->apiClient->setHttpClient($client); + $this->apiV2Client->setHttpClient($client); + + return $this; + } } diff --git a/src/Api/ApiClient.php b/src/Api/ApiClient.php index 78d40ca8..c783c2c8 100644 --- a/src/Api/ApiClient.php +++ b/src/Api/ApiClient.php @@ -19,7 +19,6 @@ use Cloudinary\FileUtils; use Cloudinary\Utils; use Exception; -use GuzzleHttp\Client; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\LimitStream; @@ -57,11 +56,6 @@ public function __construct($configuration = null) $this->createHttpClient(); } - protected function createHttpClient(): void - { - $this->httpClient = new Client($this->buildHttpClientConfig()); - } - /** * Gets cloud configuration of the current client. * @@ -431,7 +425,7 @@ protected function buildHttpClientConfig(): array unset($authConfig['headers']); } - return array_merge($clientConfig, $authConfig); + return array_merge($clientConfig, $authConfig, self::httpHandlerConfig($this->psrHttpClient)); } /** diff --git a/src/Api/BaseApiClient.php b/src/Api/BaseApiClient.php index 7190b004..3efaef2d 100644 --- a/src/Api/BaseApiClient.php +++ b/src/Api/BaseApiClient.php @@ -21,6 +21,7 @@ use Cloudinary\ArrayUtils; use Cloudinary\Cloudinary; use Cloudinary\Configuration\ApiConfig; +use Cloudinary\HttpClient\Psr18Handler; use Cloudinary\JsonUtils; use Cloudinary\Log\LoggerTrait; use Cloudinary\StringUtils; @@ -28,10 +29,12 @@ use Exception; use GuzzleHttp\Client; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; use InvalidArgumentException; use JsonSerializable; +use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; use Throwable; @@ -104,6 +107,79 @@ class BaseApiClient */ public static string $userPlatform = ''; + /** + * @var ClientInterface|null Process-wide default PSR-18 client for API clients without their own. + */ + private static ?ClientInterface $defaultPsrHttpClient = null; + + /** + * @var ClientInterface|null PSR-18 client of this API client; overrides the process-wide default. + */ + protected ?ClientInterface $psrHttpClient = null; + + /** + * Sets the process-wide default PSR-18 client, or restores the built-in transport when null. + * + * @param ClientInterface|null $client The PSR-18 client. + * + * @internal Use Cloudinary::setDefaultHttpClient(). + */ + public static function setDefaultHttpClient(?ClientInterface $client): void + { + self::$defaultPsrHttpClient = $client; + } + + /** + * Returns the Guzzle handler config for the given or default PSR-18 client, or an empty array. + * + * @param ClientInterface|null $client The PSR-18 client; the process-wide default when null. + * + * @internal + */ + public static function httpHandlerConfig(?ClientInterface $client = null): array + { + $client ??= self::$defaultPsrHttpClient; + + if ($client === null) { + return []; + } + + return ['handler' => HandlerStack::create(new Psr18Handler($client))]; + } + + /** + * Sets the PSR-18 client for this API client, or falls back to the default when null. + * + * @param ClientInterface|null $client The PSR-18 client. + * + * @internal Use setHttpClient() on Cloudinary or on the API classes. + */ + public function setHttpClient(?ClientInterface $client): static + { + $this->psrHttpClient = $client; + $this->createHttpClient(); + + return $this; + } + + /** + * Creates the HTTP client that performs the requests. + */ + protected function createHttpClient(): void + { + $this->httpClient = new Client($this->buildHttpClientConfig()); + } + + /** + * Builds the HTTP client configuration. + * + * @internal + */ + protected function buildHttpClientConfig(): array + { + return self::httpHandlerConfig($this->psrHttpClient); + } + /** * Gets base API url. * diff --git a/src/Api/Provisioning/AccountApi.php b/src/Api/Provisioning/AccountApi.php index fdc93edc..9ea41ec2 100644 --- a/src/Api/Provisioning/AccountApi.php +++ b/src/Api/Provisioning/AccountApi.php @@ -15,6 +15,7 @@ use Cloudinary\Api\Exception\ApiError; use Cloudinary\ArrayUtils; use Cloudinary\Configuration\Provisioning\ProvisioningConfiguration; +use Psr\Http\Client\ClientInterface; /** * Class AccountApi @@ -36,6 +37,18 @@ public function __construct(?ProvisioningConfiguration $configuration = null) $this->accountApiClient = new AccountApiClient($configuration); } + /** + * Sets a PSR-18 HTTP client for the requests of this API instance, or the default when null. + * + * @param ClientInterface|null $client The PSR-18 client. + */ + public function setHttpClient(?ClientInterface $client): static + { + $this->accountApiClient->setHttpClient($client); + + return $this; + } + /** * Gets details of a specific user. * diff --git a/src/Api/Provisioning/AccountApiClient.php b/src/Api/Provisioning/AccountApiClient.php index 79181c1f..51d36f1a 100644 --- a/src/Api/Provisioning/AccountApiClient.php +++ b/src/Api/Provisioning/AccountApiClient.php @@ -14,7 +14,6 @@ use Cloudinary\Configuration\Provisioning\ProvisioningAccountConfig; use Cloudinary\Configuration\Provisioning\ProvisioningConfiguration; use Cloudinary\Exception\ConfigurationException; -use GuzzleHttp\Client; /** * Class AccountApiClient @@ -73,14 +72,9 @@ public function init(?ProvisioningConfiguration $configuration = null): void $this->createHttpClient(); } - protected function createHttpClient(): void - { - $this->httpClient = new Client($this->buildHttpClientConfig()); - } - protected function buildHttpClientConfig(): array { - return [ + $clientConfig = [ 'auth' => [ $this->provisioningAccount->provisioningApiKey, $this->provisioningAccount->provisioningApiSecret, @@ -92,6 +86,8 @@ protected function buildHttpClientConfig(): array 'headers' => ['User-Agent' => self::userAgent()], 'http_errors' => false, // We handle HTTP errors by ourselves and throw corresponding exceptions ]; + + return array_merge($clientConfig, self::httpHandlerConfig($this->psrHttpClient)); } diff --git a/src/Api/Search/SearchApi.php b/src/Api/Search/SearchApi.php index b1e00b18..051ee7f2 100644 --- a/src/Api/Search/SearchApi.php +++ b/src/Api/Search/SearchApi.php @@ -17,6 +17,7 @@ use Cloudinary\Asset\SearchAssetTrait; use GuzzleHttp\Promise\PromiseInterface; use JsonSerializable; +use Psr\Http\Client\ClientInterface; /** * Class SearchApi @@ -70,6 +71,18 @@ public function __construct(mixed $configuration = null) $this->searchAsset = new SearchAsset($configuration); } + /** + * Sets a PSR-18 HTTP client for the requests of this API instance, or the default when null. + * + * @param ClientInterface|null $client The PSR-18 client. + */ + public function setHttpClient(?ClientInterface $client): static + { + $this->apiClient->setHttpClient($client); + + return $this; + } + /** * Sets the Search API endpoint. * diff --git a/src/Api/Upload/UploadApi.php b/src/Api/Upload/UploadApi.php index edadfcaf..4b1b736f 100644 --- a/src/Api/Upload/UploadApi.php +++ b/src/Api/Upload/UploadApi.php @@ -17,6 +17,7 @@ use Cloudinary\Configuration\CloudConfig; use Cloudinary\Utils; use GuzzleHttp\Promise\PromiseInterface; +use Psr\Http\Client\ClientInterface; /** * Class UploadApi for accessing Cloudinary Upload API functionality @@ -51,6 +52,18 @@ public function __construct(mixed $configuration = null) $this->apiClient = new UploadApiClient($configuration); } + /** + * Sets a PSR-18 HTTP client for the requests of this API instance, or the default when null. + * + * @param ClientInterface|null $client The PSR-18 client. + */ + public function setHttpClient(?ClientInterface $client): static + { + $this->apiClient->setHttpClient($client); + + return $this; + } + /** * Gets Upload API end point. * diff --git a/src/Cloudinary.php b/src/Cloudinary.php index 1942dc67..dbb12714 100644 --- a/src/Cloudinary.php +++ b/src/Cloudinary.php @@ -11,6 +11,7 @@ namespace Cloudinary; use Cloudinary\Api\Admin\AdminApi; +use Cloudinary\Api\BaseApiClient; use Cloudinary\Api\Search\SearchFoldersApi; use Cloudinary\Api\Search\SearchApi; use Cloudinary\Api\Upload\UploadApi; @@ -21,6 +22,7 @@ use Cloudinary\Tag\ImageTag; use Cloudinary\Tag\TagBuilder; use Cloudinary\Tag\VideoTag; +use Psr\Http\Client\ClientInterface; /** * Defines the Cloudinary instance. @@ -48,6 +50,11 @@ class Cloudinary */ protected TagBuilder $tagBuilder; + /** + * @var ClientInterface|null $httpClient PSR-18 client for the API objects this instance creates. + */ + protected ?ClientInterface $httpClient = null; + /** * Cloudinary constructor. * @@ -61,6 +68,30 @@ public function __construct(Configuration|array|string|null $config = null) $this->tagBuilder = new TagBuilder($this->configuration); } + /** + * Sets a PSR-18 HTTP client for the API objects this instance creates, or the default when null. + * + * @param ClientInterface|null $client The PSR-18 client. + */ + public function setHttpClient(?ClientInterface $client): static + { + $this->httpClient = $client; + + return $this; + } + + /** + * Sets the process-wide default PSR-18 HTTP client, or restores the built-in client when null. + * + * Applies to API objects created afterwards that have no client of their own. + * + * @param ClientInterface|null $client The PSR-18 client. + */ + public static function setDefaultHttpClient(?ClientInterface $client): void + { + BaseApiClient::setDefaultHttpClient($client); + } + /** * Creates a new Image instance using the current configuration instance. * @@ -135,7 +166,7 @@ public function videoTag(mixed $publicId, ?array $sources = null): VideoTag */ public function adminApi(): AdminApi { - return new AdminApi($this->configuration); + return $this->withHttpClient(new AdminApi($this->configuration)); } /** @@ -144,7 +175,7 @@ public function adminApi(): AdminApi */ public function uploadApi(): UploadApi { - return new UploadApi($this->configuration); + return $this->withHttpClient(new UploadApi($this->configuration)); } /** @@ -153,7 +184,7 @@ public function uploadApi(): UploadApi */ public function searchApi(): SearchApi { - return new SearchApi($this->configuration); + return $this->withHttpClient(new SearchApi($this->configuration)); } /** @@ -162,7 +193,25 @@ public function searchApi(): SearchApi */ public function searchFoldersApi(): SearchFoldersApi { - return new SearchFoldersApi($this->configuration); + return $this->withHttpClient(new SearchFoldersApi($this->configuration)); + } + + /** + * Applies this instance's PSR-18 client to an API object. + * + * @template T of AdminApi|UploadApi|SearchApi + * + * @param T $api The API object. + * + * @return T + */ + protected function withHttpClient(AdminApi|UploadApi|SearchApi $api): AdminApi|UploadApi|SearchApi + { + if ($this->httpClient !== null) { + $api->setHttpClient($this->httpClient); + } + + return $api; } /** diff --git a/src/HttpClient/HttpClient.php b/src/HttpClient/HttpClient.php index cbb55f71..db4e1ab5 100644 --- a/src/HttpClient/HttpClient.php +++ b/src/HttpClient/HttpClient.php @@ -10,6 +10,7 @@ namespace Cloudinary; +use Cloudinary\Api\BaseApiClient; use Cloudinary\Configuration\Configuration; use Cloudinary\Exception\Error; use Cloudinary\Log\LoggerTrait; @@ -38,7 +39,7 @@ class HttpClient */ public function __construct(?Configuration $configuration = null) { - $this->httpClient = new Client(); + $this->httpClient = new Client(BaseApiClient::httpHandlerConfig()); if ($configuration === null) { $configuration = Configuration::instance(); diff --git a/src/HttpClient/Psr18Handler.php b/src/HttpClient/Psr18Handler.php new file mode 100644 index 00000000..2dadad6f --- /dev/null +++ b/src/HttpClient/Psr18Handler.php @@ -0,0 +1,63 @@ +client = $client; + } + + /** + * Sends the request synchronously and returns a settled promise. + * + * @param RequestInterface $request The request to send. + * @param array $options Guzzle request options (transfer options are owned by the PSR-18 client). + */ + public function __invoke(RequestInterface $request, array $options): PromiseInterface + { + try { + return Create::promiseFor($this->client->sendRequest($request)); + } catch (TransferException $e) { + return Create::rejectionFor($e); + } catch (NetworkExceptionInterface $e) { + return Create::rejectionFor(new ConnectException($e->getMessage(), $request, $e)); + } catch (ClientExceptionInterface $e) { + return Create::rejectionFor(new RequestException($e->getMessage(), $request, previous: $e)); + } + } +} diff --git a/tests/CloudinaryTestCase.php b/tests/CloudinaryTestCase.php index bc19f157..4bc0cda1 100644 --- a/tests/CloudinaryTestCase.php +++ b/tests/CloudinaryTestCase.php @@ -184,7 +184,9 @@ public static function invokeNonPublicMethod($class, $methodName, ...$args) return null; } - $method->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } return $method->invoke($classInstance, ...$args); } @@ -230,7 +232,9 @@ public static function assertStrEquals($expected, $actual, $message = '') protected static function assertObjectLoggedMessage($obj, $message, $level) { $reflectionMethod = new ReflectionMethod(get_class($obj), 'getLogger'); - $reflectionMethod->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $reflectionMethod->setAccessible(true); + } $logger = $reflectionMethod->invoke($obj); /** @var TestHandler $testHandler */ $testHandler = $logger->getTestHandler(); diff --git a/tests/Integration/HttpClient/Psr18ClientMatrixTest.php b/tests/Integration/HttpClient/Psr18ClientMatrixTest.php new file mode 100644 index 00000000..99751767 --- /dev/null +++ b/tests/Integration/HttpClient/Psr18ClientMatrixTest.php @@ -0,0 +1,454 @@ + ['guzzle'], + 'symfony' => ['symfony'], + ]; + } + + /** + * Should upload a local file as multipart with tags, context and a custom filename. + * + * @dataProvider clients + */ + public function testMultipartUploadWithFields($client) + { + $this->useClient($client); + $publicId = $this->publicId('multipart', $client); + + $result = $this->upload->upload( + self::TEST_IMAGE_PATH, + [ + 'public_id' => $publicId, + 'tags' => array_merge(self::$ASSET_TAGS, ["psr18_$client"]), + 'context' => ['caption' => "Caption via $client", 'alt' => 'alt text'], + 'filename' => "custom file $client.png", + 'use_filename' => true, + 'headers' => ['X-Robots-Tag: noindex'], + ] + ); + + self::assertSame($publicId, $result['public_id']); + self::assertSame("custom file $client", $result['original_filename']); + self::assertSame(self::SAMPLE_IMAGE_WIDTH, $result['width']); + + $asset = $this->admin->asset($publicId, ['context' => true, 'tags' => true]); + self::assertContains("psr18_$client", $asset['tags']); + self::assertSame("Caption via $client", $asset['context']['custom']['caption']); + self::assertSame('alt text', $asset['context']['custom']['alt']); + } + + /** + * Should upload in-memory content given as a PHP stream, a PSR-7 stream, a data URI and a remote URL. + * + * @dataProvider clients + */ + public function testBlobUploads($client) + { + $this->useClient($client); + $bytes = file_get_contents(self::TEST_IMAGE_PATH); + + $resource = fopen('php://memory', 'r+b'); + fwrite($resource, $bytes); + rewind($resource); + + $sources = [ + 'php-stream' => [$resource, ['filename' => 'blob.png']], + 'psr7-stream' => [Psr7\Utils::streamFor($bytes), ['filename' => 'blob.png']], + 'data-uri' => [self::TEST_BASE64_IMAGE, []], + 'remote-url' => [AssetTestCase::FETCH_IMAGE_URL, []], + ]; + + foreach ($sources as $label => [$file, $options]) { + $result = $this->upload->upload($file, array_merge(['tags' => self::$ASSET_TAGS], $options)); + + self::assertArrayHasKey('public_id', $result, $label); + self::assertGreaterThan(0, $result['width'], $label); + self::assertSame('image', $result['resource_type'], $label); + } + } + + /** + * Should upload a large file in chunks. + * + * @dataProvider clients + */ + public function testChunkedLargeUpload($client) + { + $recorder = self::recordingClient(self::createClient($client)); + $this->upload = (new UploadApi())->setHttpClient($recorder); + + $result = $this->upload->upload( + self::largeImage(), + [ + 'public_id' => $this->publicId('large', $client), + 'filename' => 'large.bmp', + 'tags' => self::$ASSET_TAGS, + 'chunk_size' => self::LARGE_CHUNK_SIZE, + ] + ); + + self::assertSame(self::LARGE_IMAGE_WIDTH, $result['width']); + self::assertSame(self::LARGE_IMAGE_WIDTH, $result['height']); + + $lastByte = self::LARGE_IMAGE_SIZE - 1; + self::assertSame( + [ + 'bytes 0-' . (self::LARGE_CHUNK_SIZE - 1) . '/' . self::LARGE_IMAGE_SIZE, + 'bytes ' . self::LARGE_CHUNK_SIZE . "-$lastByte/" . self::LARGE_IMAGE_SIZE, + ], + array_map(static fn($request) => $request->getHeaderLine('Content-Range'), $recorder->requests) + ); + } + + /** + * Should create a structured metadata field, set and update its value on an asset, and delete the field. + * + * @dataProvider clients + */ + public function testStructuredMetadata($client) + { + $this->useClient($client); + $externalId = 'psr18_field_' . $client . '_' . self::$UNIQUE_TEST_ID; + $publicId = $this->publicId('metadata', $client); + + $field = new StringMetadataField("PSR-18 $client"); + $field->setExternalId($externalId); + $created = $this->admin->addMetadataField($field); + self::assertSame($externalId, $created['external_id']); + + try { + $uploaded = $this->upload->upload( + self::TEST_IMAGE_PATH, + ['public_id' => $publicId, 'tags' => self::$ASSET_TAGS, 'metadata' => [$externalId => 'first']] + ); + self::assertSame('first', $uploaded['metadata'][$externalId]); + + $updated = $this->upload->updateMetadata([$externalId => 'second | with = chars'], [$publicId]); + self::assertSame([$publicId], $updated['public_ids']); + + $asset = $this->admin->asset($publicId); + self::assertSame('second | with = chars', $asset['metadata'][$externalId]); + } finally { + $deleted = $this->admin->deleteMetadataField($externalId); + self::assertSame('ok', $deleted['message']); + } + } + + /** + * Should send array parameters in query strings and page through results. + * + * @dataProvider clients + */ + public function testArrayParametersAndPaging($client) + { + $this->useClient($client); + $tag = 'psr18_paging_' . $client . '_' . self::$UNIQUE_TEST_ID; + + $publicIds = []; + foreach (range(1, 3) as $i) { + $publicIds[] = $this->upload->upload( + self::TEST_IMAGE_PATH, + ['public_id' => $this->publicId("paging_$i", $client), 'tags' => array_merge(self::$ASSET_TAGS, [$tag])] + )['public_id']; + } + + $first = $this->admin->assetsByTag($tag, ['max_results' => 2]); + self::assertCount(2, $first['resources']); + self::assertNotEmpty($first['next_cursor']); + + $second = $this->admin->assetsByTag($tag, ['max_results' => 2, 'next_cursor' => $first['next_cursor']]); + self::assertCount(1, $second['resources']); + + $listed = array_column(array_merge($first['resources'], $second['resources']), 'public_id'); + sort($listed); + sort($publicIds); + self::assertSame($publicIds, $listed); + + $deleted = $this->admin->deleteAssets($publicIds); + self::assertSame(array_fill_keys($publicIds, 'deleted'), $deleted['deleted']); + } + + /** + * Should report a missing asset as NotFound. + * + * @dataProvider clients + */ + public function testNotFound($client) + { + $this->useClient($client); + + $this->expectException(NotFound::class); + + $this->admin->asset($this->publicId('missing', $client)); + } + + /** + * Should report a wrong API secret as AuthorizationRequired on Admin and Upload APIs. + * + * @dataProvider clients + */ + public function testWrongSecret($client) + { + $this->useClient($client); + $config = new Configuration(Configuration::instance()); + $config->cloud->apiSecret = 'wrong_secret'; + + $failures = []; + foreach (['admin' => fn() => (new AdminApi($config))->ping(), + 'upload' => fn() => (new UploadApi($config))->upload(self::TEST_IMAGE_PATH)] as $api => $call) { + try { + $call(); + } catch (AuthorizationRequired $e) { + $failures[] = $api; + } + } + + self::assertSame(['admin', 'upload'], $failures); + } + + /** + * Should report invalid content and an empty file as BadRequest. + * + * @dataProvider clients + */ + public function testBadRequests($client) + { + $this->useClient($client); + + $messages = []; + foreach (['invalid' => 'not an image', 'empty' => ''] as $label => $content) { + try { + $this->upload->upload(Psr7\Utils::streamFor($content), ['filename' => 'bad.png']); + } catch (BadRequest $e) { + $messages[$label] = $e->getMessage(); + } + } + + self::assertSame( + ['invalid' => 'Invalid image file', 'empty' => 'Empty file'], + $messages + ); + } + + /** + * Should report an unresolvable API host as ConnectException. + * + * @dataProvider clients + */ + public function testUnresolvableHost($client) + { + $this->useClient($client); + $config = new Configuration(Configuration::instance()); + $config->api->uploadPrefix = 'https://cloudinary-psr18-test.invalid'; + + $this->expectException(ConnectException::class); + $this->expectExceptionMessage('cloudinary-psr18-test.invalid'); + + (new AdminApi($config))->ping(); + } + + /** + * Should round-trip Unicode public IDs and context values with reserved characters. + * + * @dataProvider clients + */ + public function testUnicodeAndReservedCharacters($client) + { + $this->useClient($client); + $publicId = "psr18_ünïcødé_日本語_{$client}_" . self::$UNIQUE_TEST_ID; + $caption = 'Ünï "quoted" | piped = equals & ampersand'; + + $result = $this->upload->upload( + self::TEST_IMAGE_PATH, + ['public_id' => $publicId, 'tags' => self::$ASSET_TAGS, 'context' => ['caption' => $caption]] + ); + + self::assertSame($publicId, $result['public_id']); + self::assertSame($caption, $result['context']['custom']['caption']); + self::assertSame($publicId, $this->admin->asset($publicId)['public_id']); + } + + /** + * Should expose response headers and rate limits from the client's response. + * + * @dataProvider clients + */ + public function testResponseHeadersAndRateLimits($client) + { + $this->useClient($client); + + $result = $this->admin->ping(); + + self::assertSame('ok', $result['status']); + self::assertNotEmpty($result->headers); + self::assertGreaterThan(0, $result->rateLimitAllowed); + self::assertGreaterThanOrEqual(0, $result->rateLimitRemaining); + } + + /** + * Should resolve async upload and Admin calls. + * + * @dataProvider clients + */ + public function testAsyncCalls($client) + { + $this->useClient($client); + + [$first, $second, $ping] = PromiseUtils::all( + [ + $this->upload->uploadAsync(self::TEST_IMAGE_PATH, ['tags' => self::$ASSET_TAGS]), + $this->upload->uploadAsync(self::TEST_IMAGE_GIF_PATH, ['tags' => self::$ASSET_TAGS]), + $this->admin->pingAsync(), + ] + )->wait(); + + self::assertSame('png', $first['format']); + self::assertSame('gif', $second['format']); + self::assertSame('ok', $ping['status']); + } + + /** + * Creates API objects bound to the named PSR-18 client. + */ + private function useClient($name) + { + $httpClient = self::createClient($name); + + $this->upload = (new UploadApi())->setHttpClient($httpClient); + $this->admin = (new AdminApi())->setHttpClient($httpClient); + } + + /** + * Creates a PSR-18 client by name. + */ + private static function createClient($name): ClientInterface + { + $factory = new HttpFactory(); + + switch ($name) { + case 'guzzle': + return new GuzzleClient(); + default: + return new Psr18Client(HttpClient::create(), $factory, $factory); + } + } + + /** + * Wraps a PSR-18 client to record the requests it sends. + */ + private static function recordingClient(ClientInterface $client) + { + return new class ($client) implements ClientInterface { + public array $requests = []; + + private ClientInterface $client; + + public function __construct(ClientInterface $client) + { + $this->client = $client; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + return $this->client->sendRequest($request); + } + }; + } + + /** + * Builds a public ID unique to this run and client. + */ + private function publicId($scenario, $client) + { + return "psr18_{$scenario}_{$client}_" . self::$UNIQUE_TEST_ID; + } + + /** + * Generates a large BMP image stream. + */ + private static function largeImage() + { + $head = "BMJ\xB9Y\x00\x00\x00\x00\x00\x8A\x00\x00\x00|\x00\x00\x00x\x05\x00\x00x\x05\x00\x00\x01\x00\x18\x00" . + "\x00\x00\x00\x00\xC0\xB8Y\x00a\x0F\x00\x00a\x0F\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF" . + "\x00\x00\xFF\x00\x00\xFF\x00\x00\x00\x00\x00\x00\xFFBGRs\x00\x00\x00\x00\x00\x00\x00\x00T\xB8\x1E" . + "\xFC\x00\x00\x00\x00\x00\x00\x00\x00fff\xFC\x00\x00\x00\x00\x00\x00\x00\x00\xC4\xF5(\xFF\x00\x00\x00" . + "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + + return Psr7\Utils::streamFor($head . str_repeat("\xFF", self::LARGE_IMAGE_SIZE - strlen($head))); + } +} diff --git a/tests/Integration/HttpClient/Psr18ClientTest.php b/tests/Integration/HttpClient/Psr18ClientTest.php new file mode 100644 index 00000000..1593ec0c --- /dev/null +++ b/tests/Integration/HttpClient/Psr18ClientTest.php @@ -0,0 +1,92 @@ +setLogger($logger); + + Cloudinary::setDefaultHttpClient( + new Psr18Client(new RetryableHttpClient($transport, null, 2, $logger), new HttpFactory(), new HttpFactory()) + ); + + $publicId = 'psr18_' . self::$UNIQUE_TEST_ID; + $uploadApi = new UploadApi(); + + $upload = $uploadApi->upload( + self::TEST_IMAGE_PATH, + ['public_id' => $publicId, 'tags' => self::$ASSET_TAGS] + ); + self::assertSame($publicId, $upload['public_id']); + + $asset = (new AdminApi())->asset($publicId); + self::assertSame($upload['asset_id'], $asset['asset_id']); + + $destroy = $uploadApi->destroy($publicId); + self::assertSame('ok', $destroy['result']); + + $cloudName = Configuration::instance()->cloud->cloudName; + $messages = array_map(static fn($record) => $record['message'], $logHandler->getRecords()); + self::assertContains( + "Request: \"POST https://api.cloudinary.com/v1_1/$cloudName/image/upload\"", + $messages + ); + self::assertContains( + "Request: \"GET https://api.cloudinary.com/v1_1/$cloudName/resources/image/upload/$publicId\"", + $messages + ); + } + + /** + * Should call the Admin API through Guzzle used as a plain PSR-18 client. + */ + public function testPingThroughGuzzlePsr18Client() + { + Cloudinary::setDefaultHttpClient(new GuzzleClient()); + + $result = (new AdminApi())->ping(); + + self::assertSame('ok', $result['status']); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 90c27fc9..a4a56d59 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -19,6 +19,7 @@ use Cloudinary\Asset\AssetType; use Cloudinary\Asset\DeliveryType; use Cloudinary\Asset\Media; +use Cloudinary\Cloudinary; use Cloudinary\Configuration\Configuration; use Cloudinary\Configuration\ConfigUtils; use Cloudinary\StringUtils; @@ -32,6 +33,8 @@ use PHPUnit\Framework\Constraint\IsType; use ReflectionClass; use RuntimeException; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpClient\Psr18Client; /** * Class IntegrationTestCase @@ -73,10 +76,31 @@ public static function setUpBeforeClass() $config = array_merge(self::TEST_LOGGING, $config); Configuration::instance()->init($config); + self::useHttpClientFromEnv(); + self::$adminApi = new AdminApi(); self::$uploadApi = new UploadApi(); } + /** + * Sets the PSR-18 client selected by CLD_TEST_HTTP_CLIENT (symfony|guzzle), or the default transport. + */ + protected static function useHttpClientFromEnv() + { + switch (getenv('CLD_TEST_HTTP_CLIENT')) { + case 'symfony': + Cloudinary::setDefaultHttpClient( + new Psr18Client(HttpClient::create(), new Psr7\HttpFactory(), new Psr7\HttpFactory()) + ); + break; + case 'guzzle': + Cloudinary::setDefaultHttpClient(new Client()); + break; + default: + Cloudinary::setDefaultHttpClient(null); + } + } + /** * Should a certain add on be tested? * diff --git a/tests/Unit/ApiClientTest.php b/tests/Unit/ApiClientTest.php index 65f5d06c..92e3ee50 100644 --- a/tests/Unit/ApiClientTest.php +++ b/tests/Unit/ApiClientTest.php @@ -54,7 +54,9 @@ public function testInvalidApiClientParseJsonResponse() { $apiClient = new ApiClient(); $reflectionMethod = new ReflectionMethod(ApiClient::class, 'parseJsonResponse'); - $reflectionMethod->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $reflectionMethod->setAccessible(true); + } $message = null; $expectedExceptionMessage = 'Error parsing server response'; diff --git a/tests/Unit/Asset/AuthTokenTest.php b/tests/Unit/Asset/AuthTokenTest.php index 2e3af62f..4d28131b 100644 --- a/tests/Unit/Asset/AuthTokenTest.php +++ b/tests/Unit/Asset/AuthTokenTest.php @@ -85,7 +85,9 @@ public function testShouldIgnoreUrlIfAclIsProvided() public function testEscapeToLower() { $method = new ReflectionMethod(AuthToken::class, 'escapeToLower'); - $method->setAccessible(true); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } self::assertEquals( 'Encode%20these%20%3a%7e%40%23%25%5e%26%7b%7d%5b%5d%5c%22%27%3b%2f%22,%20but%20not%20those%20$!()_.*', diff --git a/tests/Unit/HttpClient/Psr18ClientTest.php b/tests/Unit/HttpClient/Psr18ClientTest.php new file mode 100644 index 00000000..ef1a6700 --- /dev/null +++ b/tests/Unit/HttpClient/Psr18ClientTest.php @@ -0,0 +1,503 @@ +useSymfonyClient($this->mockTransport([self::jsonResponse(['public_id' => 'sample'])])); + + $result = (new UploadApi())->upload(self::TEST_IMAGE_PATH, ['tags' => ['psr18']]); + + self::assertSame('sample', $result['public_id']); + self::assertCount(1, $this->requests); + + $request = $this->requests[0]; + self::assertSame('POST', $request['method']); + self::assertSame(self::API_BASE_URL . 'image/upload', $request['url']); + self::assertStringStartsWith('multipart/form-data; boundary=', $request['headers']['content-type']); + self::assertStringStartsWith('CloudinaryPHP/', $request['headers']['user-agent']); + self::assertStringContainsString(file_get_contents(self::TEST_IMAGE_PATH), $request['body']); + self::assertStringContainsString('name="signature"', $request['body']); + self::assertStringContainsString('name="tags"', $request['body']); + } + + /** + * Should send Admin API calls with Basic auth and parse the JSON response. + */ + public function testAdminApiThroughPsr18Client() + { + $this->useSymfonyClient( + $this->mockTransport([self::jsonResponse(['public_id' => 'sample', 'bytes' => 3381])]) + ); + + $asset = (new AdminApi())->asset('sample'); + + self::assertSame(3381, $asset['bytes']); + self::assertSame('GET', $this->requests[0]['method']); + self::assertSame(self::API_BASE_URL . 'resources/image/upload/sample', $this->requests[0]['url']); + self::assertSame( + 'Basic ' . base64_encode(self::API_KEY . ':' . self::API_SECRET), + $this->requests[0]['headers']['authorization'] + ); + } + + /** + * Should let the user's retrying client resend a failed upload and log the retry. + */ + public function testUserRetryAndLoggingApplyToUploads() + { + $logHandler = new TestHandler(); + $transport = $this->mockTransport( + [ + self::jsonResponse(['error' => ['message' => 'Service Unavailable']], 503), + self::jsonResponse(['public_id' => 'sample']), + ] + ); + + $this->useSymfonyClient( + new RetryableHttpClient( + $transport, + new GenericRetryStrategy([503], 0), + 1, + new Logger('app', [$logHandler], [new PsrLogMessageProcessor()]) + ) + ); + + $result = (new UploadApi())->upload(self::TEST_IMAGE_PATH); + + self::assertSame('sample', $result['public_id']); + self::assertCount(2, $this->requests); + self::assertSame($this->requests[0]['body'], $this->requests[1]['body']); + self::assertStringContainsString(file_get_contents(self::TEST_IMAGE_PATH), $this->requests[1]['body']); + $messages = array_map(static fn($record) => $record['message'], $logHandler->getRecords()); + self::assertNotEmpty(preg_grep('/^Try #1 after \d+ms, status code: 503$/', $messages)); + } + + /** + * Should send every chunk of a large upload through the PSR-18 client. + */ + public function testChunkedUploadThroughPsr18Client() + { + $fileSize = filesize(self::TEST_IMAGE_PATH); + $chunkSize = (int)ceil($fileSize / 3); + + $this->useSymfonyClient( + $this->mockTransport(array_fill(0, 3, self::jsonResponse(['public_id' => 'sample']))) + ); + + $result = (new UploadApi())->upload(self::TEST_IMAGE_PATH, ['chunk_size' => $chunkSize]); + + self::assertSame('sample', $result['public_id']); + self::assertCount(3, $this->requests); + + $uploadIds = array_unique(array_column(array_column($this->requests, 'headers'), 'x-unique-upload-id')); + self::assertCount(1, $uploadIds); + + $lastByte = $fileSize - 1; + self::assertSame("bytes 0-" . ($chunkSize - 1) . "/$fileSize", $this->requests[0]['headers']['content-range']); + self::assertSame( + 'bytes ' . (2 * $chunkSize) . "-$lastByte/$fileSize", + $this->requests[2]['headers']['content-range'] + ); + } + + /** + * Should map API error responses to SDK exceptions. + */ + public function testApiErrorThroughPsr18Client() + { + $this->useSymfonyClient( + $this->mockTransport([self::jsonResponse(['error' => ['message' => 'Resource not found']], 404)]) + ); + + $this->expectException(NotFound::class); + $this->expectExceptionMessage('Resource not found'); + + (new AdminApi())->asset('missing'); + } + + /** + * Should report PSR-18 network errors as Guzzle ConnectException. + */ + public function testNetworkErrorThroughPsr18Client() + { + $this->useSymfonyClient(new MockHttpClient([new MockResponse('', ['error' => 'Could not resolve host'])])); + + $this->expectException(ConnectException::class); + $this->expectExceptionMessage('Could not resolve host'); + + (new AdminApi())->ping(); + } + + /** + * Should resolve async calls made through the PSR-18 client. + */ + public function testAsyncCallsThroughPsr18Client() + { + $this->useSymfonyClient( + $this->mockTransport( + [ + self::jsonResponse(['public_id' => 'sample']), + self::jsonResponse(['status' => 'ok']), + ] + ) + ); + + [$upload, $ping] = PromiseUtils::all( + [ + (new UploadApi())->uploadAsync(self::TEST_IMAGE_PATH), + (new AdminApi())->pingAsync(), + ] + )->wait(); + + self::assertSame('sample', $upload['public_id']); + self::assertSame('ok', $ping['status']); + } + + /** + * Should send default headers configured on the user's client with every SDK request. + */ + public function testUserClientDefaultHeadersApply() + { + $transport = $this->mockTransport([self::jsonResponse(['status' => 'ok'])]); + + $this->useSymfonyClient($transport->withOptions(['headers' => ['X-Request-Id' => 'req-42']])); + + (new AdminApi())->ping(); + + self::assertSame('req-42', $this->requests[0]['headers']['x-request-id']); + } + + /** + * Should accept Guzzle itself as the PSR-18 client. + */ + public function testGuzzleAsPsr18Client() + { + $history = []; + $stack = HandlerStack::create(new MockHandler([new Response(200, [], '{"status":"ok"}')])); + $stack->push(Middleware::history($history)); + + Cloudinary::setDefaultHttpClient(new GuzzleClient(['handler' => $stack])); + + $result = (new AdminApi())->ping(); + + self::assertSame('ok', $result['status']); + self::assertCount(1, $history); + self::assertSame(self::API_BASE_URL . 'ping', (string)$history[0]['request']->getUri()); + } + + /** + * Should send Provisioning API calls through the PSR-18 client with account credentials. + */ + public function testProvisioningApiThroughPsr18Client() + { + $this->useSymfonyClient($this->mockTransport([self::jsonResponse(['users' => []])])); + + $result = (new AccountApi(new ProvisioningConfiguration('account://accountKey:accountSecret@account123'))) + ->users(); + + self::assertSame([], $result['users']); + self::assertSame('GET', $this->requests[0]['method']); + self::assertStringStartsWith( + 'https://api.cloudinary.com/v1_1/provisioning/accounts/account123/users', + $this->requests[0]['url'] + ); + self::assertSame( + 'Basic ' . base64_encode('accountKey:accountSecret'), + $this->requests[0]['headers']['authorization'] + ); + } + + /** + * Should fetch JSON through the PSR-18 client. + */ + public function testJsonFetchThroughPsr18Client() + { + $this->useSymfonyClient($this->mockTransport([self::jsonResponse(['resources' => [['public_id' => 'a']]])])); + + $json = (new HttpClient())->getJson('https://res.cloudinary.com/test123/image/list/tag.json'); + + self::assertSame('a', $json['resources'][0]['public_id']); + self::assertSame('https://res.cloudinary.com/test123/image/list/tag.json', $this->requests[0]['url']); + } + + /** + * Should send the OAuth bearer token instead of Basic auth through the PSR-18 client. + */ + public function testOAuthTokenThroughPsr18Client() + { + $this->useSymfonyClient($this->mockTransport([self::jsonResponse(['status' => 'ok'])])); + + $config = new Configuration(Configuration::instance()); + $config->cloud->oauthToken('fake_oauth_token'); + + (new AdminApi($config))->ping(); + + self::assertSame('Bearer fake_oauth_token', $this->requests[0]['headers']['authorization']); + } + + /** + * Should restore the default transport when the PSR-18 client is unset. + */ + public function testUnsetRestoresDefaultTransport() + { + Cloudinary::setDefaultHttpClient(new Psr18Client(new MockHttpClient(), new HttpFactory(), new HttpFactory())); + self::assertArrayHasKey('handler', BaseApiClient::httpHandlerConfig()); + + Cloudinary::setDefaultHttpClient(null); + self::assertSame([], BaseApiClient::httpHandlerConfig()); + } + + /** + * Should send every API created by a Cloudinary instance through the instance client. + */ + public function testCloudinaryInstanceClientAppliesToAllApis() + { + $client = self::fakeClient(); + $cloudinary = (new Cloudinary($this->cloudinaryUrl))->setHttpClient($client); + + $cloudinary->uploadApi()->upload(self::TEST_IMAGE_PATH); + $cloudinary->adminApi()->ping(); + $cloudinary->adminApi()->analyze('uri', 'captioning', 'https://example.com/sample.jpg'); + $cloudinary->searchApi()->expression('tags=a')->execute(); + $cloudinary->searchFoldersApi()->expression('path=a')->execute(); + + self::assertSame( + [ + 'v1_1/' . self::CLOUD_NAME . '/image/upload', + 'v1_1/' . self::CLOUD_NAME . '/ping', + 'v2/' . self::CLOUD_NAME . '/analysis/analyze/uri', + 'v1_1/' . self::CLOUD_NAME . '/resources/search', + 'v1_1/' . self::CLOUD_NAME . '/folders/search', + ], + $client->paths() + ); + } + + /** + * Should prefer the instance client over the process-wide default. + */ + public function testInstanceClientOverridesDefault() + { + $default = self::fakeClient(); + $instance = self::fakeClient(); + Cloudinary::setDefaultHttpClient($default); + + (new Cloudinary($this->cloudinaryUrl))->setHttpClient($instance)->adminApi()->ping(); + + self::assertCount(1, $instance->requests); + self::assertCount(0, $default->requests); + } + + /** + * Should keep clients separate for two Cloudinary instances in one process. + */ + public function testTwoInstancesUseTheirOwnClients() + { + $first = self::fakeClient(); + $second = self::fakeClient(); + + (new Cloudinary($this->cloudinaryUrl))->setHttpClient($first)->adminApi()->ping(); + (new Cloudinary('cloudinary://key2:secret2@other_cloud'))->setHttpClient($second)->adminApi()->ping(); + + self::assertSame(['v1_1/' . self::CLOUD_NAME . '/ping'], $first->paths()); + self::assertSame(['v1_1/other_cloud/ping'], $second->paths()); + } + + /** + * Should use the process-wide default for API objects created directly without a client. + */ + public function testDefaultAppliesToDirectlyCreatedApis() + { + $default = self::fakeClient(); + Cloudinary::setDefaultHttpClient($default); + + (new UploadApi())->upload(self::TEST_IMAGE_PATH); + (new AdminApi())->ping(); + + self::assertCount(2, $default->requests); + } + + /** + * Should apply a client set directly on API objects, including the Provisioning API. + */ + public function testApiObjectSetters() + { + $client = self::fakeClient(); + + (new UploadApi())->setHttpClient($client)->upload(self::TEST_IMAGE_PATH); + (new AccountApi(new ProvisioningConfiguration('account://accountKey:accountSecret@account123'))) + ->setHttpClient($client) + ->users(); + + self::assertSame( + ['v1_1/' . self::CLOUD_NAME . '/image/upload', 'v1_1/provisioning/accounts/account123/users'], + $client->paths() + ); + } + + /** + * Should fall back to the process-wide default when an API object's client is unset. + */ + public function testUnsetInstanceClientFallsBackToDefault() + { + $default = self::fakeClient(); + $instance = self::fakeClient(); + Cloudinary::setDefaultHttpClient($default); + + $adminApi = (new AdminApi())->setHttpClient($instance); + $adminApi->ping(); + $adminApi->setHttpClient(null)->ping(); + + self::assertCount(1, $instance->requests); + self::assertCount(1, $default->requests); + } + + /** + * Returns a PSR-18 client that records requests and answers every call with a generic success response. + */ + private static function fakeClient() + { + return new class implements ClientInterface { + public array $requests = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + return new Response( + 200, + ['Content-Type' => 'application/json'], + '{"status":"ok","public_id":"sample","users":[],"resources":[]}' + ); + } + + public function paths(): array + { + return array_map(static fn($request) => ltrim($request->getUri()->getPath(), '/'), $this->requests); + } + }; + } + + /** + * Installs a Symfony PSR-18 client wrapping the given Symfony client. + */ + private function useSymfonyClient($client) + { + Cloudinary::setDefaultHttpClient(new Psr18Client($client, new HttpFactory(), new HttpFactory())); + } + + /** + * Returns a mock transport that records each request and replies with the queued responses. + */ + private function mockTransport(array $responses) + { + return new MockHttpClient( + function ($method, $url, $options) use (&$responses) { + $headers = []; + foreach ($options['normalized_headers'] as $name => $values) { + $headers[$name] = substr($values[0], strlen($name) + 2); + } + + $this->requests[] = [ + 'method' => $method, + 'url' => $url, + 'headers' => $headers, + 'body' => self::readBody($options['body']), + ]; + + return array_shift($responses); + } + ); + } + + /** + * Reads a Symfony request body, given as a string or a chunk-producing closure. + */ + private static function readBody($body) + { + if (! $body instanceof Closure) { + return (string)$body; + } + + $content = ''; + while ('' !== $chunk = $body(16372)) { + $content .= $chunk; + } + + return $content; + } + + /** + * Builds a JSON mock response. + */ + private static function jsonResponse(array $data, $status = 200) + { + return new MockResponse( + json_encode($data), + ['http_code' => $status, 'response_headers' => ['Content-Type: application/json']] + ); + } +} diff --git a/tests/Unit/HttpClient/Psr18HandlerTest.php b/tests/Unit/HttpClient/Psr18HandlerTest.php new file mode 100644 index 00000000..957c3567 --- /dev/null +++ b/tests/Unit/HttpClient/Psr18HandlerTest.php @@ -0,0 +1,171 @@ + 'yes'], '{"ok":true}'); + $client = self::client(static fn() => $response); + + $promise = (new Psr18Handler($client))($request, ['timeout' => 5, 'proxy' => 'http://proxy:8080']); + + self::assertSame(PromiseInterface::FULFILLED, $promise->getState()); + self::assertSame($response, $promise->wait()); + self::assertSame([$request], $client->requests); + } + + /** + * Should return error responses as fulfilled promises, leaving status handling to the SDK. + */ + public function testFulfillsWithErrorResponses() + { + $response = new Response(500, [], '{"error":{"message":"boom"}}'); + + $promise = (new Psr18Handler(self::client(static fn() => $response)))(self::request(), []); + + self::assertSame($response, $promise->wait()); + } + + /** + * Should reject PSR-18 network errors with a Guzzle ConnectException. + */ + public function testNetworkErrorBecomesConnectException() + { + $request = self::request(); + $error = new class ('Could not resolve host') extends RuntimeException implements NetworkExceptionInterface { + public function getRequest(): RequestInterface + { + return new Request('GET', 'https://example.com'); + } + }; + + $rejection = self::rejectionOf((new Psr18Handler(self::client(static fn() => throw $error)))($request, [])); + + self::assertInstanceOf(ConnectException::class, $rejection); + self::assertSame('Could not resolve host', $rejection->getMessage()); + self::assertSame($request, $rejection->getRequest()); + self::assertSame($error, $rejection->getPrevious()); + } + + /** + * Should reject other PSR-18 client errors with a Guzzle RequestException. + */ + public function testClientErrorBecomesRequestException() + { + $request = self::request(); + $error = new class ('Invalid request') extends RuntimeException implements ClientExceptionInterface { + }; + + $rejection = self::rejectionOf((new Psr18Handler(self::client(static fn() => throw $error)))($request, [])); + + self::assertInstanceOf(RequestException::class, $rejection); + self::assertNotInstanceOf(ConnectException::class, $rejection); + self::assertSame($request, $rejection->getRequest()); + self::assertSame($error, $rejection->getPrevious()); + } + + /** + * Should pass Guzzle exceptions through unchanged when Guzzle is the PSR-18 client. + */ + public function testGuzzleExceptionPassesThrough() + { + $error = new ConnectException('Connection refused', self::request()); + + $handler = new Psr18Handler(self::client(static fn() => throw $error)); + $rejection = self::rejectionOf($handler(self::request(), [])); + + self::assertSame($error, $rejection); + } + + /** + * Should not catch exceptions outside the PSR-18 contract. + */ + public function testUnrelatedExceptionIsNotCaught() + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('bug in client'); + + $handler = new Psr18Handler(self::client(static fn() => throw new RuntimeException('bug in client'))); + $handler(self::request(), []); + } + + /** + * Returns a PSR-18 client that records requests and answers with the given callback. + */ + private static function client(callable $respond) + { + return new class ($respond) implements ClientInterface { + public array $requests = []; + + private $respond; + + public function __construct(callable $respond) + { + $this->respond = $respond; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + return ($this->respond)($request); + } + }; + } + + /** + * Returns a sample request. + */ + private static function request() + { + return new Request('GET', 'https://api.cloudinary.com/v1_1/test123/ping'); + } + + /** + * Returns the reason a promise was rejected with. + */ + private static function rejectionOf(PromiseInterface $promise) + { + self::assertSame(PromiseInterface::REJECTED, $promise->getState()); + + try { + $promise->wait(); + } catch (Throwable $e) { + return $e; + } + + self::fail('Promise was not rejected'); + } +}