diff --git a/src/Api/ApiClient.php b/src/Api/ApiClient.php index 096f816b..a64072fc 100644 --- a/src/Api/ApiClient.php +++ b/src/Api/ApiClient.php @@ -424,7 +424,12 @@ protected function buildHttpClientConfig(): array ]; } - return array_merge_recursive($clientConfig, $authConfig); + if (isset($authConfig['headers'])) { + $clientConfig['headers'] = array_merge($clientConfig['headers'], $authConfig['headers']); + unset($authConfig['headers']); + } + + return array_merge($clientConfig, $authConfig); } /** diff --git a/src/Asset/AssetFinalizerTrait.php b/src/Asset/AssetFinalizerTrait.php index 069c2f9e..6b8c4b10 100644 --- a/src/Asset/AssetFinalizerTrait.php +++ b/src/Asset/AssetFinalizerTrait.php @@ -186,7 +186,7 @@ protected function finalizeSource(): string { $source = $this->asset->publicId(true); - if (! preg_match('/^https?:\//i', $source)) { + if (stripos($source, 'http:/') !== 0 && stripos($source, 'https:/') !== 0) { $source = rawurldecode($source); } @@ -212,10 +212,14 @@ protected function finalizeVersion(): ?string if (empty($version) && $this->urlConfig->forceVersion && ! empty($this->asset->location) - && ! preg_match('/^https?:\//', $this->asset->publicId()) - && ! preg_match('/^v\d+/', $this->asset->publicId()) ) { - $version = '1'; + $publicId = $this->asset->publicId(); + if (strncmp($publicId, 'http:/', 6) !== 0 + && strncmp($publicId, 'https:/', 7) !== 0 + && ! preg_match('/^v\d+/', $publicId) + ) { + $version = '1'; + } } return $version ? 'v' . $version : null; diff --git a/src/Asset/AuthToken.php b/src/Asset/AuthToken.php index 7115b82e..98655d61 100644 --- a/src/Asset/AuthToken.php +++ b/src/Asset/AuthToken.php @@ -85,7 +85,13 @@ public function isEnabled(): bool */ public function configuration(mixed $configuration): static { - $tempConfiguration = new Configuration($configuration, false); // TODO: improve performance here + if ($configuration instanceof Configuration) { + $this->config = clone $configuration->authToken; + + return $this; + } + + $tempConfiguration = new Configuration($configuration, false); $this->config = $tempConfiguration->authToken; diff --git a/src/Asset/BaseAsset.php b/src/Asset/BaseAsset.php index bfd6ac64..01a8e322 100644 --- a/src/Asset/BaseAsset.php +++ b/src/Asset/BaseAsset.php @@ -266,7 +266,15 @@ public function importJson(array|string $json): static */ public function configuration(Configuration|array $configuration): static { - $tempConfiguration = new Configuration($configuration, true); // TODO: improve performance here + if ($configuration instanceof Configuration) { + $this->cloud = clone $configuration->cloud; + $this->urlConfig = clone $configuration->url; + $this->logging = clone $configuration->logging; + + return $this; + } + + $tempConfiguration = new Configuration($configuration, true); $this->cloud = $tempConfiguration->cloud; $this->urlConfig = $tempConfiguration->url; $this->logging = $tempConfiguration->logging; @@ -372,7 +380,7 @@ public function jsonSerialize(bool $includeEmptyKeys = false, bool $includeEmpty if (! $includeEmptySections && empty(array_values($section)[0])) { continue; } - $json = array_merge($json, $section); + $json += $section; } return $json; diff --git a/src/Asset/Descriptor/AssetDescriptor.php b/src/Asset/Descriptor/AssetDescriptor.php index b048bb8f..d0e09635 100644 --- a/src/Asset/Descriptor/AssetDescriptor.php +++ b/src/Asset/Descriptor/AssetDescriptor.php @@ -162,7 +162,7 @@ public function setSuffix(?string $suffix): static return $this; } - if (preg_match('/[.\/]/', $suffix)) { + if (str_contains($suffix, '.') || str_contains($suffix, '/')) { throw new \UnexpectedValueException(static::class . '::$suffix must not include . or /'); } diff --git a/src/Configuration/Configuration.php b/src/Configuration/Configuration.php index 56895c8f..f350f2bc 100644 --- a/src/Configuration/Configuration.php +++ b/src/Configuration/Configuration.php @@ -331,7 +331,7 @@ public function jsonSerialize( if (! $includeEmptySections && empty(array_values($section)[0])) { continue; } - $json = array_merge($json, $section); + $json += $section; } return $json; diff --git a/tests/Unit/Admin/OAuthTest.php b/tests/Unit/Admin/OAuthTest.php index 89c1a424..2dff7984 100644 --- a/tests/Unit/Admin/OAuthTest.php +++ b/tests/Unit/Admin/OAuthTest.php @@ -43,6 +43,12 @@ public function testOauthTokenAdminApi() 'Authorization' => ['Bearer ' . self::FAKE_OAUTH_TOKEN] ] ); + + self::assertArrayHasKey( + 'User-Agent', + $lastRequest->getHeaders(), + 'User-Agent header must be present alongside Authorization when using OAuth token' + ); } /** diff --git a/tests/Unit/Configuration/ConfigurationTest.php b/tests/Unit/Configuration/ConfigurationTest.php index 1ac56816..a13fe845 100644 --- a/tests/Unit/Configuration/ConfigurationTest.php +++ b/tests/Unit/Configuration/ConfigurationTest.php @@ -10,6 +10,7 @@ namespace Cloudinary\Test\Unit\Configuration; +use Cloudinary\Asset\Image; use Cloudinary\Configuration\Configuration; use Cloudinary\Test\Unit\UnitTestCase; use Cloudinary\Utils; @@ -152,4 +153,28 @@ public function testConfigJsonSerialize() json_encode(Configuration::fromJson($expectedJsonConfig)) ); } + + public function testAssetConfigurationIsIndependentFromGlobalConfig() + { + $globalConfig = Configuration::instance(); + $originalCloudName = $globalConfig->cloud->cloudName; + $originalSecure = $globalConfig->url->secure; + + $image = new Image('sample.png'); + + // Mutating the asset's config sections must not affect the global configuration + $image->cloud->cloudName = 'mutated_cloud'; + $image->urlConfig->secure = ! $originalSecure; + + self::assertEquals( + $originalCloudName, + $globalConfig->cloud->cloudName, + 'Mutating asset cloud config must not affect the global Configuration' + ); + self::assertEquals( + $originalSecure, + $globalConfig->url->secure, + 'Mutating asset url config must not affect the global Configuration' + ); + } } diff --git a/tests/Unit/Upload/OAuthTest.php b/tests/Unit/Upload/OAuthTest.php index f113fac7..78585117 100644 --- a/tests/Unit/Upload/OAuthTest.php +++ b/tests/Unit/Upload/OAuthTest.php @@ -47,6 +47,12 @@ public function testOauthTokenUploadApi() 'Authorization' => ['Bearer ' . self::FAKE_OAUTH_TOKEN] ] ); + + self::assertArrayHasKey( + 'User-Agent', + $lastRequest->getHeaders(), + 'User-Agent header must be present alongside Authorization when using OAuth token' + ); } /** diff --git a/tools/benchmark.php b/tools/benchmark.php new file mode 100644 index 00000000..4a81e9cd --- /dev/null +++ b/tools/benchmark.php @@ -0,0 +1,99 @@ +init(); +Configuration::instance()->url->analytics(false); + +const ITERATIONS = 10_000; +const RUNS = 3; + +// ── Benchmark helpers ───────────────────────────────────────────────────────── + +function bench(string $label, callable $fn): void +{ + $times = []; + + for ($run = 0; $run < RUNS; $run++) { + $start = hrtime(true); + for ($i = 0; $i < ITERATIONS; $i++) { + $fn(); + } + $times[] = (hrtime(true) - $start) / 1e6; // ns → ms + } + + $avg = array_sum($times) / count($times); + $min = min($times); + $max = max($times); + + printf( + " %-50s avg: %7.2f ms min: %7.2f ms max: %7.2f ms\n", + $label, + $avg, + $min, + $max + ); +} + +// ── Scenarios ───────────────────────────────────────────────────────────────── + +echo str_repeat('─', 90) . "\n"; +echo sprintf(" Cloudinary PHP SDK benchmark — %d iterations × %d runs\n", ITERATIONS, RUNS); +echo str_repeat('─', 90) . "\n"; + +// 1. Asset construction (exercises configuration() fast path) +bench('new Image($source)', function () { + $img = new Image('sample/image.jpg'); +}); + +// 2. Asset construction + URL generation (exercises finalizeSource, finalizeVersion) +bench('(string) new Image($source)', function () { + $img = (string) new Image('sample/image.jpg'); +}); + +// 3. URL generation on a pre-built asset (isolates toUrl() overhead) +$image = new Image('sample/image.jpg'); +bench('$image->toUrl() [pre-built asset]', function () use ($image) { + $url = (string) $image->toUrl(); +}); + +// 4. Asset with suffix (exercises setSuffix + finalizeAssetType) +bench('new Image + setSuffix()', function () { + $img = new Image('sample/image.jpg'); + $img->asset->suffix = 'my-seo-name'; + $url = (string) $img->toUrl(); +}); + +// 5. Video asset (different asset type path) +bench('(string) new Video($source)', function () { + $img = (string) new Video('sample/video.mp4'); +}); + +// 6. Configuration::jsonSerialize() (exercises array_merge → += fix) +$config = Configuration::instance(); +bench('Configuration::jsonSerialize()', function () use ($config) { + $config->jsonSerialize(); +}); + +// 7. Asset construction from Configuration array (slow path, for comparison) +$configArray = $config->jsonSerialize(); +bench('new Image($source, $configArray) [array config]', function () use ($configArray) { + $img = new Image('sample/image.jpg', $configArray); +}); + +echo str_repeat('─', 90) . "\n";