From 36a0512789d28b3857052326730fc727f546a1c5 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Mon, 17 Aug 2026 09:29:44 +0800 Subject: [PATCH 1/6] Implement /image api --- lib/serpapi/client.rb | 45 ++++++++++++++++++++----- spec/serpapi/client/image_api_spec.rb | 47 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 spec/serpapi/client/image_api_spec.rb diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 0a8f081..b96458d 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -8,6 +8,7 @@ module SerpApi # * async non-block search # * persistent HTTP connection # * search API + # * image API # * location API # * account API # * search archive API @@ -134,6 +135,25 @@ def location(params = {}) get('/locations.json', :json, params) end + # Upload an image using the Image API. + # The returned image ID can be supplied to Search API engines that support + # uploaded images, such as Google Lens. Image IDs expire after 10 minutes. + # + # doc: https://serpapi.com/image-api + # + # @param [String, Pathname, IO] image path or readable image stream + # @param [Hash] params request parameters, such as an API key overriding the client default + # @return [Hash] upload result containing the image ID + def upload_image(image, params = {}) + if image.is_a?(String) || (defined?(Pathname) && image.is_a?(Pathname)) + ::File.open(image, 'rb') do |file| + post('/image', :json, params, image: HTTP::FormData::File.new(file)) + end + else + post('/image', :json, params, image: HTTP::FormData::File.new(image)) + end + end + # Retrieve search result from the Search Archive API # # ```ruby @@ -215,17 +235,26 @@ def persistent? # @param [Hash] params custom search inputs # @return [String|Hash] raw HTML or decoded response as JSON / Hash def get(endpoint, decoder = :json, params = {}) - response = execute_request(endpoint, params) + response = execute_request(:get, endpoint, params: query(params)) handle_response(response, decoder, endpoint, params) end - def execute_request(endpoint, params) - if persistent? - @socket.get(endpoint, params: query(params)) - else - url = "https://#{BACKEND}#{endpoint}" - HTTP.timeout(timeout).get(url, params: query(params)) - end + # Perform an HTTP POST request with a multipart/form-data body + # + # @param [String] endpoint relative SerpApi endpoint, such as "/image" + # @param [Symbol] decoder response decoder, either :json or :html + # @param [Hash] params request parameters + # @param [Hash] form multipart-specific fields, including uploaded files + # @return [String|Hash] raw HTML or decoded response as JSON / Hash + def post(endpoint, decoder = :json, params = {}, form = {}) + response = execute_request(:post, endpoint, form: query(params).merge(form)) + handle_response(response, decoder, endpoint, params) + end + + def execute_request(method, endpoint, options) + client = persistent? ? @socket : HTTP.timeout(timeout) + url = persistent? ? endpoint : "https://#{BACKEND}#{endpoint}" + client.public_send(method, url, **options) end def handle_response(response, decoder, endpoint, params) diff --git a/spec/serpapi/client/image_api_spec.rb b/spec/serpapi/client/image_api_spec.rb new file mode 100644 index 0000000..c89c66a --- /dev/null +++ b/spec/serpapi/client/image_api_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' +require 'stringio' +require 'tempfile' + +describe 'Image API' do + let(:response) do + double( + status: 200, + body: '{"message":"Image uploaded successfully.","image_id":"test-image-id"}' + ) + end + let(:socket) { double } + let(:client) { SerpApi::Client.new(api_key: api_key) } + + before do + allow(HTTP).to receive(:persistent).and_return(socket) + allow(response).to receive(:flush) + end + + it 'uploads an image from a file path' do + Tempfile.create(['image', '.png']) do |image| + expect(socket).to receive(:post) do |endpoint, options| + uploaded_image = options[:form][:image] + + expect(endpoint).to eq('/image') + expect(uploaded_image.filename).to end_with('.png') + response + end + + result = client.upload_image(image.path) + + expect(result[:message]).to eq('Image uploaded successfully.') + expect(result[:image_id]).to eq('test-image-id') + end + end + + it 'uploads an image from an IO object' do + image = StringIO.new('image data') + + expect(socket).to receive(:post) do |endpoint, options| + expect(endpoint).to eq('/image') + response + end + + expect(client.upload_image(image)[:image_id]).to eq('test-image-id') + end +end From 06770e56952db4822e04b4b35f5445fdf8957899 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Mon, 17 Aug 2026 09:43:46 +0800 Subject: [PATCH 2/6] Update readme --- README.md | 17 +++++++++++++++++ README.md.erb | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/README.md b/README.md index 69d5fd4..5b1cb69 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,23 @@ exit 0 * source code: [demo/demo.rb](https://github.com/serpapi/serpapi-ruby/blob/master/demo/demo.rb) ## APIs supported +### Image API + +Upload a JPG/JPEG, PNG, or WebP image and receive an `image_id` for use with Search API engines that support uploaded images, such as Google Lens. Images must be no larger than 500 KB, and the returned ID expires after 10 minutes. + +```ruby +require 'serpapi' + +client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) +upload = client.upload_image('/path/to/image.png') +pp upload[:image_id] + +results = client.search(engine: 'google_lens', image_id: upload[:image_id]) +pp results +``` + +[See Image API documentation](https://serpapi.com/image-api) + ### Location API ```ruby diff --git a/README.md.erb b/README.md.erb index d0bc70a..4a2e9a8 100644 --- a/README.md.erb +++ b/README.md.erb @@ -237,6 +237,23 @@ exit 0 * source code: [demo/demo.rb](https://github.com/serpapi/serpapi-ruby/blob/master/demo/demo.rb) ## APIs supported +### Image API + +Upload a JPG/JPEG, PNG, or WebP image and receive an `image_id` for use with Search API engines that support uploaded images, such as Google Lens. Images must be no larger than 500 KB, and the returned ID expires after 10 minutes. + +```ruby +require 'serpapi' + +client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) +upload = client.upload_image('/path/to/image.png') +pp upload[:image_id] + +results = client.search(engine: 'google_lens', image_id: upload[:image_id]) +pp results +``` + +[See Image API documentation](https://serpapi.com/image-api) + ### Location API ```ruby From a195a4d2aadee5ffd7f6c147784dc87eb78936c0 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Mon, 17 Aug 2026 09:57:24 +0800 Subject: [PATCH 3/6] Update spec --- spec/serpapi/client/image_api_spec.rb | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/spec/serpapi/client/image_api_spec.rb b/spec/serpapi/client/image_api_spec.rb index c89c66a..45489e2 100644 --- a/spec/serpapi/client/image_api_spec.rb +++ b/spec/serpapi/client/image_api_spec.rb @@ -42,6 +42,23 @@ response end - expect(client.upload_image(image)[:image_id]).to eq('test-image-id') + result = client.upload_image(image) + + expect(result[:image_id]).to eq('test-image-id') + end + + it 'raises an error when image is rejected' do + error_response = double( + status: 400, + body: '{"error":"Invalid image format. Supported format: jpg, jpeg, png, webp"}' + ) + allow(socket).to receive(:post).with('/image', anything).and_return(error_response) + + expect { + client.upload_image(StringIO.new('invalid image data')) + }.to raise_error( + SerpApi::SerpApiError, + /Invalid image format\. Supported format: jpg, jpeg, png, webp/ + ) end end From de1459d1efccce284aa8cf2e91c7bc4759ae04ca Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Mon, 17 Aug 2026 11:19:10 +0800 Subject: [PATCH 4/6] Update error message --- lib/serpapi/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index b96458d..3f42251 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -312,7 +312,7 @@ def raise_http_error(response, data, endpoint, params, explicit_error: nil, deco def raise_parser_error(response, endpoint, params) raise SerpApiError.new( - "JSON parse error: #{response.body} on get url: https://#{BACKEND}#{endpoint}", + "JSON parse error: #{response.body} from url: https://#{BACKEND}#{endpoint}", search_params: params, response_status: response.status, decoder: :json From f48ad5082b402b25808d1fbd8cef1bae9368d442 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Tue, 18 Aug 2026 10:09:34 +0800 Subject: [PATCH 5/6] Rewind IO before upload --- lib/serpapi/client.rb | 2 +- spec/serpapi/client/image_api_spec.rb | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/serpapi/client.rb b/lib/serpapi/client.rb index 3f42251..b37075c 100644 --- a/lib/serpapi/client.rb +++ b/lib/serpapi/client.rb @@ -150,7 +150,7 @@ def upload_image(image, params = {}) post('/image', :json, params, image: HTTP::FormData::File.new(file)) end else - post('/image', :json, params, image: HTTP::FormData::File.new(image)) + post('/image', :json, params, image: HTTP::FormData::File.new(image).tap(&:rewind)) end end diff --git a/spec/serpapi/client/image_api_spec.rb b/spec/serpapi/client/image_api_spec.rb index 45489e2..0fc4f5d 100644 --- a/spec/serpapi/client/image_api_spec.rb +++ b/spec/serpapi/client/image_api_spec.rb @@ -34,11 +34,13 @@ end end - it 'uploads an image from an IO object' do + it 'rewinds and uploads an image from an IO object' do image = StringIO.new('image data') + image.read(5) expect(socket).to receive(:post) do |endpoint, options| expect(endpoint).to eq('/image') + expect(image.pos).to eq(0) response end From a550066cac135c89bdaceee7d3346c83f65bd0b8 Mon Sep 17 00:00:00 2001 From: Terry Tan Date: Tue, 18 Aug 2026 13:18:29 +0800 Subject: [PATCH 6/6] Update README --- README.md | 17 ++++++++++++++++- README.md.erb | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 200bc5d..ef4cd3d 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ client.close - [Asynchronous searches](./demo/demo_async.rb) for submitting non-blocking jobs and retrieving completed results from the Search Archive API. - [Persistent connections and connection pooling](./demo/demo_thread_pool.rb) for reusing HTTP connections across searches. - JSON responses as Ruby hashes with `search`, or raw search-engine HTML with `html`. -- SDK methods for the [Location API](https://serpapi.com/locations-api), [Search Archive API](https://serpapi.com/search-archive-api), and [Account API](https://serpapi.com/account-api). +- SDK methods for the [Image API](https://serpapi.com/image-api), [Location API](https://serpapi.com/locations-api), [Search Archive API](https://serpapi.com/search-archive-api), and [Account API](https://serpapi.com/account-api). - Configurable HTTP timeouts and symbolized or string JSON keys. ## Configuration @@ -186,6 +186,21 @@ pp results[:images_results] A [light variant](https://serpapi.com/google-images-light-api) engine called `google_images_light` is also available for faster, lower-cost image searches. +### Google Lens with File Upload + +Upload an image with the Image API, then use its image ID in a Google Lens search. Uploaded image IDs expire after 10 minutes. + +```ruby +require 'serpapi' + +client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) +upload = client.upload_image('./image.jpg') +results = client.search(engine: 'google_lens', image_id: upload[:image_id]) +pp results[:visual_matches] +``` + +[See Image API documentation](https://serpapi.com/image-api) · [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) + ### Google Trends Track search interest over time and compare the popularity of search terms. diff --git a/README.md.erb b/README.md.erb index 200bc5d..ef4cd3d 100644 --- a/README.md.erb +++ b/README.md.erb @@ -56,7 +56,7 @@ client.close - [Asynchronous searches](./demo/demo_async.rb) for submitting non-blocking jobs and retrieving completed results from the Search Archive API. - [Persistent connections and connection pooling](./demo/demo_thread_pool.rb) for reusing HTTP connections across searches. - JSON responses as Ruby hashes with `search`, or raw search-engine HTML with `html`. -- SDK methods for the [Location API](https://serpapi.com/locations-api), [Search Archive API](https://serpapi.com/search-archive-api), and [Account API](https://serpapi.com/account-api). +- SDK methods for the [Image API](https://serpapi.com/image-api), [Location API](https://serpapi.com/locations-api), [Search Archive API](https://serpapi.com/search-archive-api), and [Account API](https://serpapi.com/account-api). - Configurable HTTP timeouts and symbolized or string JSON keys. ## Configuration @@ -186,6 +186,21 @@ pp results[:images_results] A [light variant](https://serpapi.com/google-images-light-api) engine called `google_images_light` is also available for faster, lower-cost image searches. +### Google Lens with File Upload + +Upload an image with the Image API, then use its image ID in a Google Lens search. Uploaded image IDs expire after 10 minutes. + +```ruby +require 'serpapi' + +client = SerpApi::Client.new(api_key: ENV['SERPAPI_KEY']) +upload = client.upload_image('./image.jpg') +results = client.search(engine: 'google_lens', image_id: upload[:image_id]) +pp results[:visual_matches] +``` + +[See Image API documentation](https://serpapi.com/image-api) · [See Google Lens image upload documentation](https://serpapi.com/google-lens-upload-an-image) + ### Google Trends Track search interest over time and compare the popularity of search terms.