Skip to content

Commit 0ee45c8

Browse files
Add support for Guzzle 8
* Add support for Guzzle 8 Allow guzzlehttp/guzzle ^8.0, guzzlehttp/promises ^3.0 and guzzlehttp/psr7 ^3.0 alongside the existing Guzzle 7 constraints (#429). * Fix unsigned upload without `api_key`/`api_secret` (send no Basic auth when credentials are missing) * Cast request header values to strings; skip unnamed (list-form) `headers` entries in HTTP request headers * Handle any `Throwable` rejection from the HTTP client * Test against both Guzzle 7 and Guzzle 8 in CI Notes for Guzzle 8 users: * Connect timeout defaults to 60 seconds when `connection_timeout` is not set * Read timeouts are reported as `NetworkTimeoutException`, which is not a `ConnectException` * Multipart parts no longer carry a per-part `Content-Length` header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Reduce CI test matrix and skip duplicate PR runs Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3d09626 commit 0ee45c8

8 files changed

Lines changed: 87 additions & 14 deletions

File tree

‎.github/workflows/test.yaml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ name: Tests
22
on: [push, pull_request]
33
jobs:
44
php:
5-
name: PHP ${{ matrix.php-versions }}
5+
name: PHP ${{ matrix.php-versions }}, Guzzle ${{ matrix.guzzle-versions }}
6+
# Same-repo changes run on push; fork PRs run on pull_request.
7+
if: >-
8+
(github.event_name == 'push' && !github.event.pull_request.head.repo.fork) ||
9+
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork)
610
runs-on: ubuntu-latest
711
strategy:
812
fail-fast: false
@@ -13,6 +17,14 @@ jobs:
1317
- '8.2'
1418
- '8.3'
1519
- '8.4'
20+
guzzle-versions:
21+
- '7'
22+
# Guzzle 8 on lowest and highest PHP only.
23+
include:
24+
- php-versions: '8.0'
25+
guzzle-versions: '8'
26+
- php-versions: '8.4'
27+
guzzle-versions: '8'
1628
steps:
1729
- name: Checkout
1830
uses: actions/checkout@v4
@@ -23,7 +35,9 @@ jobs:
2335
extensions: json
2436
coverage: xdebug
2537
- name: Install Composer dependencies
26-
run: composer update -n
38+
run: |
39+
composer require -n --no-update guzzlehttp/guzzle:^${{ matrix.guzzle-versions }}
40+
composer update -n
2741
- name: Run Tests
2842
run: |
2943
export CLOUDINARY_URL=$(bash tools/get_test_cloud.sh);

‎composer.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
"require": {
2727
"php": ">=8.0.0",
2828
"cloudinary/transformation-builder-sdk": "^2",
29-
"guzzlehttp/guzzle": "^7.4.5",
30-
"guzzlehttp/promises": "^1.5.3|^2.0",
31-
"guzzlehttp/psr7": "^2.7",
29+
"guzzlehttp/guzzle": "^7.4.5|^8.0",
30+
"guzzlehttp/promises": "^1.5.3|^2.0|^3.0",
31+
"guzzlehttp/psr7": "^2.7|^3.0",
3232
"ext-json": "*",
3333
"monolog/monolog": "^2|^3",
3434
"psr/log" : "^2|^3"

‎src/Api/ApiClient.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,10 +418,12 @@ protected function buildHttpClientConfig(): array
418418
$authConfig = [
419419
'headers' => ['Authorization' => 'Bearer ' . $this->cloud->oauthToken],
420420
];
421-
} else {
421+
} elseif (! empty($this->cloud->apiKey) && ! empty($this->cloud->apiSecret)) {
422422
$authConfig = [
423423
'auth' => [$this->cloud->apiKey, $this->cloud->apiSecret],
424424
];
425+
} else {
426+
$authConfig = [];
425427
}
426428

427429
if (isset($authConfig['headers'])) {

‎src/Api/BaseApiClient.php‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use InvalidArgumentException;
3434
use JsonSerializable;
3535
use Psr\Http\Message\ResponseInterface;
36+
use Throwable;
3637

3738
/**
3839
* Class BaseApiClient
@@ -297,9 +298,11 @@ protected static function finalizeEndPoint(array|string $endPoint): array|string
297298
protected function callAsync(string $method, array|string $endPoint, array $options): PromiseInterface
298299
{
299300
$endPoint = self::finalizeEndPoint($endPoint);
300-
$options['headers'] = ArrayUtils::mergeNonEmpty(
301-
ArrayUtils::get($options, 'headers', []),
302-
ArrayUtils::get($options, 'extra_headers', [])
301+
$options['headers'] = self::normalizeHeaders(
302+
ArrayUtils::mergeNonEmpty(
303+
ArrayUtils::get($options, 'headers', []),
304+
ArrayUtils::get($options, 'extra_headers', [])
305+
)
303306
);
304307
$this->getLogger()->debug("Making async $method request", ['method' => $method, 'endPoint' => $endPoint]);
305308

@@ -326,7 +329,7 @@ function (ResponseInterface $response) {
326329
return Create::rejectionFor($e);
327330
}
328331
},
329-
function (Exception $error) {
332+
function (Throwable $error) {
330333
$this->getLogger()->critical(
331334
'Async request failed',
332335
[
@@ -343,6 +346,28 @@ function (Exception $error) {
343346
);
344347
}
345348

349+
/**
350+
* Keeps named headers only and casts their values to strings.
351+
*
352+
* @param array $headers The headers to normalize.
353+
*
354+
* @internal
355+
*/
356+
private static function normalizeHeaders(array $headers): array
357+
{
358+
$normalized = [];
359+
360+
foreach ($headers as $name => $value) {
361+
if (! is_string($name)) {
362+
continue;
363+
}
364+
365+
$normalized[$name] = is_array($value) ? array_map('strval', $value) : (string)$value;
366+
}
367+
368+
return $normalized;
369+
}
370+
346371
/**
347372
* Provides the {@see ApiClient::$userAgent} string that is passed to the Cloudinary servers.
348373
*

‎src/Configuration/ApiConfig.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ class ApiConfig extends BaseConfigSection
6565

6666
/**
6767
* Describing the number of seconds to wait while trying to connect to a server.
68-
* Use 0 to wait indefinitely (the default behavior).
68+
* When not set, the HTTP client default applies (Guzzle 8: 60 seconds, Guzzle 7 with cURL: 300 seconds).
69+
* Use 0 to disable the limit on Guzzle 8; on Guzzle 7 with cURL, 0 selects the 300 seconds default.
6970
*
7071
* @var int|float|null
7172
*/

‎tests/Unit/Admin/AssetsTest.php‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,16 @@ public function testVisualSearchImageFile()
363363
$lastRequest = $mockAdminApi->getMockHandler()->getLastRequest();
364364

365365
self::assertRequestUrl($lastRequest, '/resources/visual_search');
366-
$body = $lastRequest->getBody()->getContents();
367366

368-
self::assertStringContainsString('image_file', $body);
369-
self::assertStringContainsString('Content-Length: 5110', $body);
367+
$file = array_values(
368+
array_filter(
369+
$mockAdminApi->getApiClient()->getRequestOptions()['multipart'],
370+
static fn($part) => $part['name'] === 'image_file'
371+
)
372+
)[0];
373+
$file['contents']->rewind();
374+
375+
self::assertSame(file_get_contents(IntegrationTestCase::TEST_IMAGE_PATH), $file['contents']->getContents());
376+
self::assertSame('sample.png', $file['filename']);
370377
}
371378
}

‎tests/Unit/Upload/OAuthTest.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,6 @@ public function testMissingCredentialsUnsignedUploadApi()
112112
self::API_TEST_PRESET,
113113
$uploadApi->getApiClient()->getRequestMultipartOptions()['upload_preset']
114114
);
115+
self::assertArrayNotHasKey('Authorization', $uploadApi->getApiClient()->getLastRequestHeaders());
115116
}
116117
}

‎tests/Unit/Upload/UploadApiTest.php‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,29 @@ public function headersDataProvider()
205205
],
206206
[],
207207
],
208+
[
209+
[
210+
'headers' => ['X-Request-ID' => 12345],
211+
'extra_headers' => ['X-Debug' => [true, 1]],
212+
],
213+
['X-Request-ID' => ['12345'], 'X-Debug' => ['1', '1']],
214+
],
208215
];
209216
}
217+
218+
/**
219+
* Should send list-form `headers` as an upload parameter only, not as HTTP request headers.
220+
*/
221+
public function testListFormHeadersAreNotSentAsHttpHeaders()
222+
{
223+
$mockUploadApi = new MockUploadApi();
224+
$mockUploadApi->upload(self::TEST_BASE64_IMAGE, ['headers' => ['X-Robots-Tag: noindex']]);
225+
226+
self::assertArrayNotHasKey('0', $mockUploadApi->getApiClient()->getLastRequestHeaders());
227+
self::assertArrayNotHasKey('X-Robots-Tag', $mockUploadApi->getApiClient()->getLastRequestHeaders());
228+
self::assertSame(
229+
'X-Robots-Tag: noindex',
230+
$mockUploadApi->getApiClient()->getRequestMultipartOptions()['headers']
231+
);
232+
}
210233
}

0 commit comments

Comments
 (0)