diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e5921c87..38a7450d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,7 +2,11 @@ name: Tests on: [push, pull_request] jobs: php: - name: PHP ${{ matrix.php-versions }} + name: PHP ${{ matrix.php-versions }}, Guzzle ${{ matrix.guzzle-versions }} + # Same-repo changes run on push; fork PRs run on pull_request. + if: >- + (github.event_name == 'push' && !github.event.pull_request.head.repo.fork) || + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork) runs-on: ubuntu-latest strategy: fail-fast: false @@ -13,6 +17,14 @@ jobs: - '8.2' - '8.3' - '8.4' + guzzle-versions: + - '7' + # Guzzle 8 on lowest and highest PHP only. + include: + - php-versions: '8.0' + guzzle-versions: '8' + - php-versions: '8.4' + guzzle-versions: '8' steps: - name: Checkout uses: actions/checkout@v4 @@ -23,7 +35,9 @@ jobs: extensions: json coverage: xdebug - name: Install Composer dependencies - run: composer update -n + run: | + composer require -n --no-update guzzlehttp/guzzle:^${{ matrix.guzzle-versions }} + composer update -n - name: Run Tests run: | export CLOUDINARY_URL=$(bash tools/get_test_cloud.sh); diff --git a/composer.json b/composer.json index 6dfdcdcc..8f50910f 100644 --- a/composer.json +++ b/composer.json @@ -26,9 +26,9 @@ "require": { "php": ">=8.0.0", "cloudinary/transformation-builder-sdk": "^2", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/promises": "^1.5.3|^2.0", - "guzzlehttp/psr7": "^2.7", + "guzzlehttp/guzzle": "^7.4.5|^8.0", + "guzzlehttp/promises": "^1.5.3|^2.0|^3.0", + "guzzlehttp/psr7": "^2.7|^3.0", "ext-json": "*", "monolog/monolog": "^2|^3", "psr/log" : "^2|^3" diff --git a/src/Api/ApiClient.php b/src/Api/ApiClient.php index a64072fc..78d40ca8 100644 --- a/src/Api/ApiClient.php +++ b/src/Api/ApiClient.php @@ -418,10 +418,12 @@ protected function buildHttpClientConfig(): array $authConfig = [ 'headers' => ['Authorization' => 'Bearer ' . $this->cloud->oauthToken], ]; - } else { + } elseif (! empty($this->cloud->apiKey) && ! empty($this->cloud->apiSecret)) { $authConfig = [ 'auth' => [$this->cloud->apiKey, $this->cloud->apiSecret], ]; + } else { + $authConfig = []; } if (isset($authConfig['headers'])) { diff --git a/src/Api/BaseApiClient.php b/src/Api/BaseApiClient.php index ceb009a9..7190b004 100644 --- a/src/Api/BaseApiClient.php +++ b/src/Api/BaseApiClient.php @@ -33,6 +33,7 @@ use InvalidArgumentException; use JsonSerializable; use Psr\Http\Message\ResponseInterface; +use Throwable; /** * Class BaseApiClient @@ -297,9 +298,11 @@ protected static function finalizeEndPoint(array|string $endPoint): array|string protected function callAsync(string $method, array|string $endPoint, array $options): PromiseInterface { $endPoint = self::finalizeEndPoint($endPoint); - $options['headers'] = ArrayUtils::mergeNonEmpty( - ArrayUtils::get($options, 'headers', []), - ArrayUtils::get($options, 'extra_headers', []) + $options['headers'] = self::normalizeHeaders( + ArrayUtils::mergeNonEmpty( + ArrayUtils::get($options, 'headers', []), + ArrayUtils::get($options, 'extra_headers', []) + ) ); $this->getLogger()->debug("Making async $method request", ['method' => $method, 'endPoint' => $endPoint]); @@ -326,7 +329,7 @@ function (ResponseInterface $response) { return Create::rejectionFor($e); } }, - function (Exception $error) { + function (Throwable $error) { $this->getLogger()->critical( 'Async request failed', [ @@ -343,6 +346,28 @@ function (Exception $error) { ); } + /** + * Keeps named headers only and casts their values to strings. + * + * @param array $headers The headers to normalize. + * + * @internal + */ + private static function normalizeHeaders(array $headers): array + { + $normalized = []; + + foreach ($headers as $name => $value) { + if (! is_string($name)) { + continue; + } + + $normalized[$name] = is_array($value) ? array_map('strval', $value) : (string)$value; + } + + return $normalized; + } + /** * Provides the {@see ApiClient::$userAgent} string that is passed to the Cloudinary servers. * diff --git a/src/Configuration/ApiConfig.php b/src/Configuration/ApiConfig.php index 44e318b9..67efe1e6 100644 --- a/src/Configuration/ApiConfig.php +++ b/src/Configuration/ApiConfig.php @@ -65,7 +65,8 @@ class ApiConfig extends BaseConfigSection /** * Describing the number of seconds to wait while trying to connect to a server. - * Use 0 to wait indefinitely (the default behavior). + * When not set, the HTTP client default applies (Guzzle 8: 60 seconds, Guzzle 7 with cURL: 300 seconds). + * Use 0 to disable the limit on Guzzle 8; on Guzzle 7 with cURL, 0 selects the 300 seconds default. * * @var int|float|null */ diff --git a/tests/Unit/Admin/AssetsTest.php b/tests/Unit/Admin/AssetsTest.php index aace4b19..cffa689d 100644 --- a/tests/Unit/Admin/AssetsTest.php +++ b/tests/Unit/Admin/AssetsTest.php @@ -363,9 +363,16 @@ public function testVisualSearchImageFile() $lastRequest = $mockAdminApi->getMockHandler()->getLastRequest(); self::assertRequestUrl($lastRequest, '/resources/visual_search'); - $body = $lastRequest->getBody()->getContents(); - self::assertStringContainsString('image_file', $body); - self::assertStringContainsString('Content-Length: 5110', $body); + $file = array_values( + array_filter( + $mockAdminApi->getApiClient()->getRequestOptions()['multipart'], + static fn($part) => $part['name'] === 'image_file' + ) + )[0]; + $file['contents']->rewind(); + + self::assertSame(file_get_contents(IntegrationTestCase::TEST_IMAGE_PATH), $file['contents']->getContents()); + self::assertSame('sample.png', $file['filename']); } } diff --git a/tests/Unit/Upload/OAuthTest.php b/tests/Unit/Upload/OAuthTest.php index 78585117..231005f9 100644 --- a/tests/Unit/Upload/OAuthTest.php +++ b/tests/Unit/Upload/OAuthTest.php @@ -112,5 +112,6 @@ public function testMissingCredentialsUnsignedUploadApi() self::API_TEST_PRESET, $uploadApi->getApiClient()->getRequestMultipartOptions()['upload_preset'] ); + self::assertArrayNotHasKey('Authorization', $uploadApi->getApiClient()->getLastRequestHeaders()); } } diff --git a/tests/Unit/Upload/UploadApiTest.php b/tests/Unit/Upload/UploadApiTest.php index 6063095f..c642ef0b 100644 --- a/tests/Unit/Upload/UploadApiTest.php +++ b/tests/Unit/Upload/UploadApiTest.php @@ -205,6 +205,29 @@ public function headersDataProvider() ], [], ], + [ + [ + 'headers' => ['X-Request-ID' => 12345], + 'extra_headers' => ['X-Debug' => [true, 1]], + ], + ['X-Request-ID' => ['12345'], 'X-Debug' => ['1', '1']], + ], ]; } + + /** + * Should send list-form `headers` as an upload parameter only, not as HTTP request headers. + */ + public function testListFormHeadersAreNotSentAsHttpHeaders() + { + $mockUploadApi = new MockUploadApi(); + $mockUploadApi->upload(self::TEST_BASE64_IMAGE, ['headers' => ['X-Robots-Tag: noindex']]); + + self::assertArrayNotHasKey('0', $mockUploadApi->getApiClient()->getLastRequestHeaders()); + self::assertArrayNotHasKey('X-Robots-Tag', $mockUploadApi->getApiClient()->getLastRequestHeaders()); + self::assertSame( + 'X-Robots-Tag: noindex', + $mockUploadApi->getApiClient()->getRequestMultipartOptions()['headers'] + ); + } }