Skip to content

Commit

Permalink
Add support for Search URL
Browse files Browse the repository at this point in the history
  • Loading branch information
const-cloudinary authored Jul 4, 2023
1 parent f459265 commit 960dd60
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 21 deletions.
43 changes: 42 additions & 1 deletion lib/cloudinary/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ class Cloudinary::Search
WITH_FIELD = :with_field
KEYS_WITH_UNIQUE_VALUES = [SORT_BY, AGGREGATE, WITH_FIELD].freeze

TTL = 300 # Used for search URLs

def initialize
@query_hash = {
SORT_BY => {},
Expand All @@ -14,6 +16,8 @@ def initialize
}

@endpoint = self.class::ENDPOINT

@ttl = self.class::TTL
end

## implicitly generate an instance delegate the method
Expand Down Expand Up @@ -73,11 +77,21 @@ def with_field(value)
self
end

# Sets the time to live of the search URL.
#
# @param [Object] ttl The time to live in seconds.
#
# @return [Cloudinary::Search]
def ttl(ttl)
@ttl = ttl
self
end

# Returns the query as an hash.
#
# @return [Hash]
def to_h
@query_hash.each_with_object({}) do |(key, value), query|
@query_hash.sort.each_with_object({}) do |(key, value), query|
next if value.nil? || ((value.is_a?(Array) || value.is_a?(Hash)) && value.blank?)

query[key] = KEYS_WITH_UNIQUE_VALUES.include?(key) ? value.values : value
Expand All @@ -90,6 +104,33 @@ def execute(options = {})
Cloudinary::Api.call_api(:post, uri, to_h, options)
end

# Creates a signed Search URL that can be used on the client side.
#
# @param [Integer] ttl The time to live in seconds.
# @param [String] next_cursor Starting position.
# @param [Hash] options Additional url delivery options.
#
# @return [String] The resulting Search URL
def to_url(ttl = nil, next_cursor = nil, options = {})
api_secret = options[:api_secret] || Cloudinary.config.api_secret || raise(CloudinaryException, "Must supply api_secret")

ttl = ttl || @ttl
query = self.to_h

_next_cursor = query.delete(:next_cursor)
next_cursor = _next_cursor if next_cursor.nil?

b64query = Base64.urlsafe_encode64(JSON.generate(query))

prefix = Cloudinary::Utils.build_distribution_domain(options)

signature = Cloudinary::Utils.hash("#{ttl}#{b64query}#{api_secret}", :sha256, :hexdigest)

next_cursor = "/#{next_cursor}" if !next_cursor.nil? && !next_cursor.empty?

"#{prefix}/search/#{signature}/#{ttl}/#{b64query}#{next_cursor}"
end

# Sets the API endpoint.
#
# @param [String] endpoint the endpoint to set.
Expand Down
34 changes: 21 additions & 13 deletions lib/cloudinary/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -524,18 +524,10 @@ def self.unsigned_download_url(source, options = {})
version = options.delete(:version)
force_version = config_option_consume(options, :force_version, true)
format = options.delete(:format)
cloud_name = config_option_consume(options, :cloud_name) || raise(CloudinaryException, "Must supply cloud_name in tag or in configuration")

secure = options.delete(:secure)
ssl_detected = options.delete(:ssl_detected)
secure = ssl_detected || Cloudinary.config.secure if secure.nil?
private_cdn = config_option_consume(options, :private_cdn)
secure_distribution = config_option_consume(options, :secure_distribution)
cname = config_option_consume(options, :cname)
shorten = config_option_consume(options, :shorten)
force_remote = options.delete(:force_remote)
cdn_subdomain = config_option_consume(options, :cdn_subdomain)
secure_cdn_subdomain = config_option_consume(options, :secure_cdn_subdomain)

sign_url = config_option_consume(options, :sign_url)
secret = config_option_consume(options, :api_secret)
sign_version = config_option_consume(options, :sign_version) # Deprecated behavior
Expand All @@ -558,7 +550,7 @@ def self.unsigned_download_url(source, options = {})
type = type.to_s unless type.nil?
resource_type ||= "image"
source = source.to_s
if !force_remote
unless force_remote
static_support = Cloudinary.config.static_file_support || Cloudinary.config.static_image_support
return original_source if !static_support && type == "asset"
return original_source if (type.nil? || type == "asset") && source.match(%r(^https?:/)i)
Expand Down Expand Up @@ -594,7 +586,9 @@ def self.unsigned_download_url(source, options = {})
signature = "s--#{signature[0, long_url_signature ? LONG_URL_SIGNATURE_LENGTH : SHORT_URL_SIGNATURE_LENGTH ]}--"
end

prefix = unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname, secure, secure_distribution)
options[:source] = source
prefix = build_distribution_domain(options)

source = [prefix, resource_type, type, signature, transformation, version, source].reject(&:blank?).join("/")
if sign_url && auth_token && !auth_token.empty?
auth_token[:url] = URI.parse(source).path
Expand Down Expand Up @@ -707,6 +701,22 @@ def self.unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdo
prefix
end

def self.build_distribution_domain(options = {})
cloud_name = config_option_consume(options, :cloud_name) || raise(CloudinaryException, "Must supply cloud_name in tag or in configuration")

source = options.delete(:source)
secure = options.delete(:secure)
ssl_detected = options.delete(:ssl_detected)
secure = ssl_detected || Cloudinary.config.secure if secure.nil?
private_cdn = config_option_consume(options, :private_cdn)
secure_distribution = config_option_consume(options, :secure_distribution)
cname = config_option_consume(options, :cname)
cdn_subdomain = config_option_consume(options, :cdn_subdomain)
secure_cdn_subdomain = config_option_consume(options, :secure_cdn_subdomain)

unsigned_download_url_prefix(source, cloud_name, private_cdn, cdn_subdomain, secure_cdn_subdomain, cname, secure, secure_distribution)
end

# Creates a base URL for the cloudinary api
#
# @param [Object] path Resource name
Expand Down Expand Up @@ -1373,6 +1383,4 @@ def self.hash(input, signature_algorithm = nil, hash_method = :digest)
algorithm = ALGORITHM_SIGNATURE[signature_algorithm] || raise("Unsupported algorithm '#{signature_algorithm}'")
algorithm.public_send(hash_method, input)
end

private_class_method :hash
end
67 changes: 60 additions & 7 deletions spec/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@
expected = {
[:url] => /.*\/resources\/search$/,
[:payload] => {
"aggregate" => %w[format resource_type],
"sort_by" => [
{ "created_at" => "desc" },
{ "public_id" => "asc" }
],
"aggregate" => ["format", "resource_type"],
"with_field" => ["context", "tags"]
"with_field" => %w[context tags]
}.to_json
}

Expand All @@ -75,23 +75,76 @@
end
end

context 'unit Search URL' do
include_context "config"
before(:each) do
ENV["CLOUDINARY_URL"] = "cloudinary://key:secret@test123?secure=true"
Cloudinary.reset_config
end

let(:cloud_name) { Cloudinary.config.cloud_name }
let(:root_path) { "https://res.cloudinary.com/#{cloud_name}" }
let(:search_path) { "#{root_path}/search/" }

search = Cloudinary::Search
.expression("resource_type:image AND tags=kitten AND uploaded_at>1d AND bytes>1m")
.sort_by("public_id", "desc")
.max_results(30)

b64query = "eyJleHByZXNzaW9uIjoicmVzb3VyY2VfdHlwZTppbWFnZSBBTkQgdGFncz1raXR0ZW4gQU5EIHVwbG9hZGVkX2F0" +
"PjFkIEFORCBieXRlcz4xbSIsIm1heF9yZXN1bHRzIjozMCwic29ydF9ieSI6W3sicHVibGljX2lkIjoiZGVzYyJ9XX0="

ttl300_sig = "431454b74cefa342e2f03e2d589b2e901babb8db6e6b149abf25bc0dd7ab20b7"
ttl1000_sig = "25b91426a37d4f633a9b34383c63889ff8952e7ffecef29a17d600eeb3db0db7"

it 'should build Search URL using defaults' do
expect(search.to_url).to eq("#{search_path}#{ttl300_sig}/300/#{b64query}")
end

it 'should build Search URL with next cursor' do
expect(search.to_url(nil, NEXT_CURSOR)).to eq("#{search_path}#{ttl300_sig}/300/#{b64query}/#{NEXT_CURSOR}")
end

it 'should build Search URL with custom ttl and next cursor' do
expect(search.to_url(1000, NEXT_CURSOR)).to eq("#{search_path}#{ttl1000_sig}/1000/#{b64query}/#{NEXT_CURSOR}")
end

it 'should build Search URL with custom ttl and next cursor from the class' do
expect(search.ttl(1000).next_cursor(NEXT_CURSOR).to_url)
.to eq("#{search_path}#{ttl1000_sig}/1000/#{b64query}/#{NEXT_CURSOR}")
end

it 'should build Search URL with private_cdn' do
expect(search.to_url(300, "", { private_cdn: true }))
.to eq("https://#{cloud_name}-res.cloudinary.com/search/#{ttl300_sig}/300/#{b64query}")
end

it 'should build Search URL with private_cdn from config' do
Cloudinary.config do |config|
config.private_cdn = true
end
expect(search.to_url(300, ""))
.to eq("https://#{cloud_name}-res.cloudinary.com/search/#{ttl300_sig}/300/#{b64query}")
end
end

context 'integration', :with_retries do
SEARCH_TAG = TIMESTAMP_TAG + "_search"
include_context 'cleanup', SEARCH_TAG
prefix = "api_test_#{SUFFIX}"
test_id_1 = "#{prefix}_1"
prefix = "api_test_#{SUFFIX}"
test_id_1 = "#{prefix}_1"
test_id_2 = "#{prefix}_2"
test_id_3 = "#{prefix}_3"
m_asset_ids = {}

before(:all) do
result = Cloudinary::Uploader.upload(TEST_IMG, public_id: test_id_1, tags: [TEST_TAG, TIMESTAMP_TAG, SEARCH_TAG], context: 'stage=in_review')
result = Cloudinary::Uploader.upload(TEST_IMG, public_id: test_id_1, tags: [TEST_TAG, TIMESTAMP_TAG, SEARCH_TAG], context: 'stage=in_review')
m_asset_ids[test_id_1] = result["asset_id"]

result = Cloudinary::Uploader.upload(TEST_IMG, public_id: test_id_2, tags: [TEST_TAG, TIMESTAMP_TAG, SEARCH_TAG], context: 'stage=new')
result = Cloudinary::Uploader.upload(TEST_IMG, public_id: test_id_2, tags: [TEST_TAG, TIMESTAMP_TAG, SEARCH_TAG], context: 'stage=new')
m_asset_ids[test_id_2] = result["asset_id"]

result = Cloudinary::Uploader.upload(TEST_IMG, public_id: test_id_3, tags: [TEST_TAG, TIMESTAMP_TAG, SEARCH_TAG], context: 'stage=validated')
result = Cloudinary::Uploader.upload(TEST_IMG, public_id: test_id_3, tags: [TEST_TAG, TIMESTAMP_TAG, SEARCH_TAG], context: 'stage=validated')
m_asset_ids[test_id_3] = result["asset_id"]

sleep(1)
Expand Down

0 comments on commit 960dd60

Please sign in to comment.