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() + ); + } }