diff --git a/src/Api/Search/SearchApi.php b/src/Api/Search/SearchApi.php
new file mode 100644
index 00000000..8f81c16d
--- /dev/null
+++ b/src/Api/Search/SearchApi.php
@@ -0,0 +1,152 @@
+apiClient = new ApiClient($configuration);
+ $this->searchAsset = new SearchAsset($configuration);
+ }
+
+ /**
+ * Sets the Search API endpoint.
+ *
+ * @param string $endpoint The endpoint for the Search API.
+ *
+ * @return $this
+ */
+ public function endpoint($endpoint)
+ {
+ $this->endpoint = $endpoint;
+
+ return $this;
+ }
+
+ /**
+ * Executes the search API request asynchronously.
+ *
+ * @return PromiseInterface
+ *
+ * @api
+ */
+ public function executeAsync()
+ {
+ return $this->apiClient->postJsonAsync($this->getSearchEndpoint(), $this);
+ }
+
+ /**
+ * Executes the search API request.
+ *
+ * @return ApiResponse
+ *
+ * @throws GeneralError
+ *
+ * @api
+ */
+ public function execute()
+ {
+ return $this->executeAsync()->wait();
+ }
+
+ /**
+ * Creates a signed Search URL that can be used on the client side.
+ *
+ * @param int $ttl The time to live in seconds.
+ * @param string $nextCursor Starting position.
+ *
+ * @return string The resulting search URL.
+ */
+ public function toUrl($ttl = null, $nextCursor = null)
+ {
+ $this->searchAsset->query($this->asArray());
+
+ if ($ttl == null) {
+ $ttl = $this->ttl;
+ }
+
+ return $this->searchAsset->toUrl($ttl, $nextCursor);
+ }
+
+ /**
+ * Serializes to JSON.
+ *
+ * @return array data which can be serialized by json_encode
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize()
+ {
+ return $this->asArray();
+ }
+
+ /**
+ * Returns the search endpoint.
+ *
+ * @return string
+ */
+ private function getSearchEndpoint()
+ {
+ return "{$this->endpoint}/search";
+ }
+}
diff --git a/src/Api/SearchFoldersApi.php b/src/Api/Search/SearchFoldersApi.php
similarity index 100%
rename from src/Api/SearchFoldersApi.php
rename to src/Api/Search/SearchFoldersApi.php
diff --git a/src/Api/Search/SearchQueryInterface.php b/src/Api/Search/SearchQueryInterface.php
new file mode 100644
index 00000000..0f2324a5
--- /dev/null
+++ b/src/Api/Search/SearchQueryInterface.php
@@ -0,0 +1,38 @@
+ [],
];
- /**
- * @var ApiClient $apiClient The HTTP API client instance.
- */
- protected $apiClient;
-
- /**
- * SearchApi constructor.
- *
- * @param mixed $configuration
- */
- public function __construct($configuration = null)
- {
- $this->apiClient = new ApiClient($configuration);
- }
-
- /**
- * Sets the Search API endpoint.
- *
- * @param string $endpoint The endpoint for the Search API.
- *
- * @return $this
- */
- public function endpoint($endpoint)
- {
- $this->endpoint = $endpoint;
-
- return $this;
- }
-
/**
* Sets the query string for filtering the assets in your cloud.
*
@@ -223,29 +130,17 @@ public function withField($value)
}
/**
- * Executes the search API request asynchronously.
+ * Sets the search query.
*
- * @return PromiseInterface
+ * @param array $query The search query.
*
- * @api
+ * @return $this
*/
- public function executeAsync()
+ public function query($query)
{
- return $this->apiClient->postJsonAsync($this->getSearchEndpoint(), $this);
- }
+ $this->query = $query;
- /**
- * Executes the search API request.
- *
- * @return ApiResponse
- *
- * @throws GeneralError
- *
- * @api
- */
- public function execute()
- {
- return $this->executeAsync()->wait();
+ return $this;
}
/**
@@ -270,25 +165,4 @@ static function ($value) {
)
);
}
-
- /**
- * Serializes to JSON.
- *
- * @return array data which can be serialized by json_encode
- */
- #[\ReturnTypeWillChange]
- public function jsonSerialize()
- {
- return $this->asArray();
- }
-
- /**
- * Returns the search endpoint.
- *
- * @return string
- */
- private function getSearchEndpoint()
- {
- return "{$this->endpoint}/search";
- }
}
diff --git a/src/Asset/AssetFinalizerTrait.php b/src/Asset/AssetFinalizerTrait.php
index da14f0b1..1150ad08 100644
--- a/src/Asset/AssetFinalizerTrait.php
+++ b/src/Asset/AssetFinalizerTrait.php
@@ -176,7 +176,7 @@ protected function finalizeAssetType()
$finalAssetType = $suffixSupportedDeliveryTypes[$this->asset->assetType][$this->asset->deliveryType];
} else {
- $finalAssetType = implode('/', [$this->asset->assetType, $this->asset->deliveryType]);
+ $finalAssetType = ArrayUtils::implodeUrl([$this->asset->assetType, $this->asset->deliveryType]);
}
return $finalAssetType;
diff --git a/src/Asset/SearchAsset.php b/src/Asset/SearchAsset.php
new file mode 100644
index 00000000..92a7835d
--- /dev/null
+++ b/src/Asset/SearchAsset.php
@@ -0,0 +1,114 @@
+finalizeUrl(ArrayUtils::implodeUrl($this->prepareSearchUrlParts($ttl, $nextCursor)));
+ }
+
+ /**
+ * Internal pre-serialization helper.
+ *
+ * @return array
+ *
+ * @internal
+ */
+ protected function prepareSearchUrlParts($ttl, $nextCursor)
+ {
+ if ($ttl == null) {
+ $ttl = $this->ttl;
+ }
+
+ $query = $this->asArray();
+
+ $_nextCursor = ArrayUtils::pop($query, 'next_cursor');
+ if ($nextCursor == null) {
+ $nextCursor = $_nextCursor;
+ }
+
+ ksort($query);
+ $b64Query = StringUtils::base64UrlEncode(json_encode($query));
+
+ return [
+ 'distribution' => $this->finalizeDistribution(),
+ 'asset_type' => self::$assetType,
+ 'signature' => $this->finalizeSearchSignature("{$ttl}{$b64Query}"),
+ 'ttl' => $ttl,
+ 'b64query' => $b64Query,
+ 'next_cursor' => $nextCursor,
+ ];
+ }
+
+ /**
+ * Finalizes URL signature.
+ *
+ * @see https://cloudinary.com/documentation/advanced_url_delivery_options#generating_delivery_url_signatures
+ *
+ * @return string
+ * @throws ConfigurationException
+ */
+ private function finalizeSearchSignature($toSign)
+ {
+ if (empty($this->cloud->apiSecret)) {
+ throw new ConfigurationException('Must supply apiSecret');
+ }
+
+ return Utils::sign(
+ $toSign,
+ $this->cloud->apiSecret,
+ false,
+ Utils::ALGO_SHA256
+ );
+ }
+}
diff --git a/src/Asset/SearchAssetTrait.php b/src/Asset/SearchAssetTrait.php
new file mode 100644
index 00000000..b4f91cc5
--- /dev/null
+++ b/src/Asset/SearchAssetTrait.php
@@ -0,0 +1,27 @@
+ttl = $ttl;
+
+ return $this;
+ }
+}
diff --git a/tests/Unit/Asset/SearchAssetTest.php b/tests/Unit/Asset/SearchAssetTest.php
new file mode 100644
index 00000000..5dabf37e
--- /dev/null
+++ b/tests/Unit/Asset/SearchAssetTest.php
@@ -0,0 +1,68 @@
+expression("resource_type:image AND tags=kitten AND uploaded_at>1d AND bytes>1m")
+ ->sortBy("public_id", "desc")
+ ->maxResults(30);
+
+ $b64Query = "eyJleHByZXNzaW9uIjoicmVzb3VyY2VfdHlwZTppbWFnZSBBTkQgdGFncz1raXR0ZW4gQU5EIHVwbG9hZGVkX2F0" .
+ "PjFkIEFORCBieXRlcz4xbSIsIm1heF9yZXN1bHRzIjozMCwic29ydF9ieSI6W3sicHVibGljX2lkIjoiZGVzYyJ9XX0=";
+
+ $ttl300Sig = "431454b74cefa342e2f03e2d589b2e901babb8db6e6b149abf25bc0dd7ab20b7";
+ $ttl1000Sig = "25b91426a37d4f633a9b34383c63889ff8952e7ffecef29a17d600eeb3db0db7";
+
+ $nextCursor = self::NEXT_CURSOR;
+
+ # default usage
+ self::assertAssetUrl(
+ "search/{$ttl300Sig}/300/{$b64Query}",
+ $s
+ );
+
+ # same signature with next cursor
+ self::assertAssetUrl(
+ "search/{$ttl300Sig}/300/{$b64Query}/{$nextCursor}",
+ $s->toUrl(null, self::NEXT_CURSOR)
+ );
+
+ # with custom ttl and next cursor
+ self::assertAssetUrl(
+ "search/{$ttl1000Sig}/1000/{$b64Query}/{$nextCursor}",
+ $s->toUrl(1000, self::NEXT_CURSOR)
+ );
+
+ # ttl and cursor are set from the class
+ self::assertAssetUrl(
+ "search/{$ttl1000Sig}/1000/{$b64Query}/{$nextCursor}",
+ $s->ttl(1000)->nextCursor(self::NEXT_CURSOR)
+ );
+
+ # private cdn
+ self::assertAssetUrl(
+ "search/{$ttl1000Sig}/1000/{$b64Query}/{$nextCursor}",
+ $s->privateCdn(),
+ ['private_cdn' => true]
+ );
+ }
+}
diff --git a/tests/Unit/Search/SearchApiTest.php b/tests/Unit/Search/SearchApiTest.php
index 9daf350f..a98c2443 100644
--- a/tests/Unit/Search/SearchApiTest.php
+++ b/tests/Unit/Search/SearchApiTest.php
@@ -11,9 +11,12 @@
namespace Cloudinary\Test\Unit\Search;
use Cloudinary\Api\Exception\GeneralError;
+use Cloudinary\Asset\SearchAsset;
+use Cloudinary\Configuration\Configuration;
use Cloudinary\Test\Helpers\MockSearchFoldersApi;
use Cloudinary\Test\Helpers\MockSearchApi;
use Cloudinary\Test\Helpers\RequestAssertionsTrait;
+use Cloudinary\Test\Unit\Asset\AssetTestCase;
use Cloudinary\Test\Unit\UnitTestCase;
/**
@@ -87,11 +90,11 @@ public function testShouldNotDuplicateValues()
self::assertRequestJsonBodySubset(
$lastRequest,
[
- 'sort_by' => [
+ 'sort_by' => [
['created_at' => 'desc'],
['public_id' => 'asc'],
],
- 'aggregate' => ['format', 'resource_type'],
+ 'aggregate' => ['format', 'resource_type'],
'with_field' => ['context', 'tags'],
]
);
@@ -112,4 +115,25 @@ public function testShouldSearchFolders()
]
);
}
+
+ public function testShouldBuildSearchUrl()
+ {
+ $config = Configuration::instance();
+ $config->url->privateCdn();
+
+ $mockSearchApi = new MockSearchApi($config);
+ $mockSearchApi->expression("resource_type:image AND tags=kitten AND uploaded_at>1d AND bytes>1m")
+ ->sortBy("public_id", "desc")
+ ->maxResults(30)->ttl(1000)->nextCursor(self::NEXT_CURSOR);
+
+ $searchAsset = new SearchAsset($config);
+ $searchAsset->expression("resource_type:image AND tags=kitten AND uploaded_at>1d AND bytes>1m")
+ ->sortBy("public_id", "desc")
+ ->maxResults(30)->ttl(1000)->nextCursor(self::NEXT_CURSOR);
+
+ self::assertStrEquals(
+ $searchAsset,
+ $mockSearchApi->toUrl()
+ );
+ }
}