Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand All @@ -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
Expand All @@ -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)"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ tools/dev/sanity/package-lock.json
tools/dev/sanity/results.json
tools/dev/sanity/TransformationSanityTest.php
/.phpunit.result.cache
.scratch/
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
6 changes: 4 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions docs/use-custom-http-client.md
Original file line number Diff line number Diff line change
@@ -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
<?php

require '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;

$httpClient = new RetryableHttpClient(HttpClient::create(['timeout' => 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)
64 changes: 64 additions & 0 deletions examples/use-custom-http-client.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

/**
* Send all SDK requests through your own PSR-18 HTTP client, then upload an image.
*
* This example uses Symfony HttpClient with retries; any PSR-18 client works.
*
* Prerequisites:
* composer require cloudinary/cloudinary_php symfony/http-client
* export CLOUDINARY_URL=cloudinary://<api_key>:<api_secret>@<cloud_name>
*
* 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);
}
14 changes: 14 additions & 0 deletions src/Api/Admin/AdminApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use Cloudinary\Api\ApiClient;
use Cloudinary\Configuration\Configuration;
use Psr\Http\Client\ClientInterface;

/**
* Enables Cloudinary Admin API functionality.
Expand Down Expand Up @@ -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;
}
}
8 changes: 1 addition & 7 deletions src/Api/ApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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));
}

/**
Expand Down
Loading
Loading