diff --git a/CHANGELOG.md b/CHANGELOG.md index c6cf3b23..61ffda6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +* File search through `client.files.search`, including full-text, exact, metadata, tag, range, and image filters, + sorting, highlights, `include=appdata`, and POST-aware pagination * Per-file tags through `client.file_tags` with list, replace, and atomic add/delete operations * Upload-time `tags:` support for direct, batch, URL, and multipart uploads * The `tags` attribute on file resources returned by the REST API diff --git a/README.md b/README.md index 6350d652..1cb4bf7c 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,50 @@ Filters and API parameters can still be passed through: files = client.files.list(stored: true, removed: false, limit: 100) ``` +### Search files + +Search across filenames, file UUIDs, metadata, and detected MIME types: + +```ruby +matches = client.files.search( + query: "invoice", + is_image: false, + sort: ["-datetime_uploaded"], + limit: 20 +) + +puts "Found #{matches.total} files" +matches.each do |file| + puts file.original_filename + puts file.highlight +end +``` + +Search accepts full-text `query` and field-specific `phrase` criteria, exact matches, ranges, and tag filters. All +top-level criteria are combined with AND: + +```ruby +matches = client.files.search( + phrase: { original_filename: "report" }, + exact: { + detected_mime_type: ["application/pdf"], + "metadata[department]" => ["finance"] + }, + datetime_uploaded: { gte: "2026-01-01T00:00:00Z" }, + size: { lte: 10 * 1024 * 1024 }, + tags: { all: ["approved"], none: ["archived"] }, + include: "appdata" +) +``` + +`limit`, `offset`, and `include` are sent as URL query parameters; search criteria are sent in the JSON body. Search +responses are `Uploadcare::Collections::FileSearchResult` objects and support `next_page`, `previous_page`, and `all` +like ordinary file lists. Subsequent pages automatically resend the original search criteria. Full-text values must be +at least four characters; use `exact` for shorter values. A field cannot appear in both `phrase` and `exact`, and every +request needs at least one search condition. `limit` accepts 1–100 results, while `offset + limit` cannot exceed 1,000. +For filter-only searches, pass an explicit `sort` for deterministic ordering. `fuzziness: true` enables typo-tolerant +matching but increases latency. Newly uploaded files may take a short time to appear in the search index. + ### Resource operations ```ruby diff --git a/api_examples/README.md b/api_examples/README.md index c8764c1d..22badafe 100644 --- a/api_examples/README.md +++ b/api_examples/README.md @@ -29,6 +29,7 @@ Verification: | Endpoint | Example file | Notes | | --- | --- | --- | | `GET /files/` | `api_examples/rest_api/get_files.rb` | Uses `client.files.list` | +| `POST /files/search/` | `api_examples/rest_api/post_files_search.rb` | Uses `client.files.search` with a UUID lookup | | `PUT /files/{uuid}/storage/` | `api_examples/rest_api/put_files_uuid_storage.rb` | Uses `file.store` | | `DELETE /files/{uuid}/storage/` | `api_examples/rest_api/delete_files_uuid_storage.rb` | Uses `file.delete` | | `GET /files/{uuid}/` | `api_examples/rest_api/get_files_uuid.rb` | Uses `client.files.find` | diff --git a/api_examples/rest_api/post_files_search.rb b/api_examples/rest_api/post_files_search.rb new file mode 100755 index 00000000..b5b8db83 --- /dev/null +++ b/api_examples/rest_api/post_files_search.rb @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../support/run_rest_example' diff --git a/api_examples/support/example_helper.rb b/api_examples/support/example_helper.rb index cf232552..dd57b480 100755 --- a/api_examples/support/example_helper.rb +++ b/api_examples/support/example_helper.rb @@ -66,6 +66,21 @@ def with_uploaded_files(paths: [fixture_path('kitten.jpeg'), fixture_path('anoth Array(files).each { |file| safe_delete_file(file) } end + def wait_for_file_search(uuid:, timeout: 10, poll_interval: 0.5) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + + loop do + matches = client.files.search(query: uuid, limit: 20) + return matches if matches.any? { |match| match.uuid == uuid } + + raise "Timed out waiting for file #{uuid} to appear in search" if Process.clock_gettime( + Process::CLOCK_MONOTONIC + ) >= deadline + + sleep poll_interval + end + end + def with_fixture_file(name) handle = File.open(fixture_path(name), 'rb') response = yield handle diff --git a/api_examples/support/run_rest_example.rb b/api_examples/support/run_rest_example.rb index 089ffb06..d082cf45 100755 --- a/api_examples/support/run_rest_example.rb +++ b/api_examples/support/run_rest_example.rb @@ -12,6 +12,20 @@ def call client.project.current when 'get_files.rb' client.files.list(limit: 2) + when 'post_files_search.rb' + ApiExamples::ExampleHelper.with_uploaded_file do |file| + matches = ApiExamples::ExampleHelper.wait_for_file_search(uuid: file.uuid) + { + 'total' => matches.total, + 'results' => matches.map do |match| + { + 'uuid' => match.uuid, + 'original_filename' => match.original_filename, + 'highlight' => match.highlight + } + end + } + end when 'get_files_uuid.rb' ApiExamples::ExampleHelper.with_uploaded_file do |file| client.files.find(uuid: file.uuid) diff --git a/context7.json b/context7.json index ee557cff..a9f8b220 100644 --- a/context7.json +++ b/context7.json @@ -55,6 +55,7 @@ "Use `client.files`, `client.groups`, `client.uploads`, `client.project`, `client.webhooks`, `client.file_metadata`, `client.file_tags`, `client.addons`, and `client.conversions` for normal application workflows.", "Use `client.api.rest` and `client.api.upload` when exact REST API or Upload API endpoint parity is needed.", "Use `client.uploads.upload` for automatic upload method selection and `client.uploads.multipart_upload` for explicit multipart uploads.", + "Use `client.files.search` for full-text and structured file search; search pagination resends the original POST criteria automatically.", "Pass `tags: [...]` when uploading, and use `client.file_tags` to list, replace, add, or delete tags on an existing file.", "Use `MIGRATING_V5.md` when upgrading applications from uploadcare-ruby v4.x to v5." ], diff --git a/examples/README.md b/examples/README.md index eba514ab..9d8f0168 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,6 +29,8 @@ mise exec -- ruby examples/simple_upload.rb spec/fixtures/kitten.jpeg Force multipart upload and show throughput details. - `examples/url_upload.rb` Upload a remote URL and show async polling as a follow-up example. +- `examples/file_search.rb` + Search files by text, inspect highlights, and follow the next page. - `examples/file_tags.rb` Upload a file with tags, then list, replace, add, and delete tags. - `examples/group_creation.rb` diff --git a/examples/file_search.rb b/examples/file_search.rb new file mode 100755 index 00000000..59f82e05 --- /dev/null +++ b/examples/file_search.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../lib/uploadcare' +require 'dotenv/load' + +query = ARGV.join(' ') + +if query.length < 4 + puts 'Usage: ruby file_search.rb ' + puts 'Example: ruby file_search.rb invoice' + exit 1 +end + +begin + client = Uploadcare::Client.new( + public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY'), + secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY') + ) + + results = client.files.search(query: query, sort: ['-datetime_uploaded'], limit: 20) + + puts "Found #{results.total} files" + results.each do |file| + puts [file.uuid, file.original_filename, file.highlight].compact.join(' | ') + end + + next_page = results.next_page + puts "Next page contains #{next_page.count} files" if next_page +rescue StandardError => e + warn "File search example failed: #{e.message}" + exit 1 +end diff --git a/lib/uploadcare/api/rest.rb b/lib/uploadcare/api/rest.rb index 68ad1d73..488ba90c 100644 --- a/lib/uploadcare/api/rest.rb +++ b/lib/uploadcare/api/rest.rb @@ -98,14 +98,15 @@ def video_conversions # @param method [Symbol] HTTP method (:get, :post, :put, :patch, :delete) # @param path [String] API endpoint path # @param params [Hash, Array, String] Request parameters + # @param query [Hash] Query parameters for requests that also have a body # @param headers [Hash] Additional request headers # @param request_options [Hash] Request options (timeout, etc.) # @return [Hash, Array, nil] Parsed JSON response body # @raise [Uploadcare::Exception::RequestError] on API errors - def make_request(method:, path:, params: {}, headers: {}, request_options: {}) + def make_request(method:, path:, params: {}, query: {}, headers: {}, request_options: {}) handle_throttling(max_attempts: request_options[:max_throttle_attempts]) do response = connection.public_send(method, path) do |req| - prepare_request(req, method, path, params, headers, request_options) + prepare_request(req, method, path, params, query, headers, request_options) end response.body end @@ -117,11 +118,14 @@ def make_request(method:, path:, params: {}, headers: {}, request_options: {}) # # @param path [String] API endpoint path # @param params [Hash] Request body parameters + # @param query [Hash] Query parameters # @param headers [Hash] Additional request headers # @param request_options [Hash] Request options # @return [Uploadcare::Result] - def post(path:, params: {}, headers: {}, request_options: {}) - request(method: :post, path: path, params: params, headers: headers, request_options: request_options) + def post(path:, params: {}, query: {}, headers: {}, request_options: {}) + request( + method: :post, path: path, params: params, query: query, headers: headers, request_options: request_options + ) end # Make a GET request wrapped in a Result. @@ -173,28 +177,33 @@ def delete(path:, params: {}, headers: {}, request_options: {}) # @param method [Symbol] HTTP method # @param path [String] API path # @param params [Hash] Request parameters + # @param query [Hash] Query parameters for requests that also have a body # @param headers [Hash] Request headers # @param request_options [Hash] Request options # @return [Uploadcare::Result] - def request(method:, path:, params: {}, headers: {}, request_options: {}) + def request(method:, path:, params: {}, query: {}, headers: {}, request_options: {}) Uploadcare::Result.capture do - make_request(method: method, path: path, params: params, headers: headers, request_options: request_options) + make_request( + method: method, path: path, params: params, query: query, headers: headers, request_options: request_options + ) end end private - def prepare_request(req, method, path, params, headers, request_options) + def prepare_request(req, method, path, params, query, headers, request_options) upcase_method_name = method.to_s.upcase - uri = build_request_uri(path, params, upcase_method_name) + query_params = upcase_method_name == HTTP_GET ? params : query + uri = build_request_uri(path, query_params) prepare_headers(req, upcase_method_name, uri, params, headers) prepare_body_or_params(req, upcase_method_name, params) + req.params.update(query) if upcase_method_name != HTTP_GET && !query.nil? && !query.empty? apply_request_options(req, request_options) end - def build_request_uri(path, params, method) - if method == HTTP_GET && !params.nil? && params.is_a?(Hash) && !params.empty? + def build_request_uri(path, params) + if !params.nil? && params.is_a?(Hash) && !params.empty? build_uri(path, params) else path diff --git a/lib/uploadcare/api/rest/files.rb b/lib/uploadcare/api/rest/files.rb index 892156cc..ed620f83 100644 --- a/lib/uploadcare/api/rest/files.rb +++ b/lib/uploadcare/api/rest/files.rb @@ -2,11 +2,12 @@ # REST API endpoint for file operations. # -# Provides methods for listing, retrieving, storing, deleting, and copying files. +# Provides methods for listing, searching, retrieving, storing, deleting, and copying files. # # @example # rest = Uploadcare::Api::Rest.new(config: config) # rest.files.list(params: { limit: 10 }) +# rest.files.search(params: { query: "invoice" }, query: { limit: 20 }) # rest.files.info(uuid: "file-uuid") # # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File @@ -29,6 +30,22 @@ def list(params: {}, request_options: {}) rest.get(path: '/files/', params: params, headers: {}, request_options: request_options) end + # Search files by full-text criteria and structured filters. + # + # Search criteria belong in the JSON request body (`params`). Pagination and + # response expansion options belong in the query string (`query`). + # + # @param params [Hash] Search body (query, phrase, exact, ranges, tags, etc.) + # @param query [Hash] Query parameters (limit, offset, include) + # @param request_options [Hash] Request options + # @return [Uploadcare::Result] Paginated file search results + # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File/operation/searchFiles + def search(params: {}, query: {}, request_options: {}) + rest.post( + path: '/files/search/', params: params, query: query, headers: {}, request_options: request_options + ) + end + # Get file information by UUID. # # @param uuid [String] The file UUID diff --git a/lib/uploadcare/client/files_accessor.rb b/lib/uploadcare/client/files_accessor.rb index 9a82b08a..83697c05 100644 --- a/lib/uploadcare/client/files_accessor.rb +++ b/lib/uploadcare/client/files_accessor.rb @@ -28,6 +28,23 @@ def list(request_options: {}, **options) ) end + # Search files using full-text criteria and structured filters. + # + # @example Search PDFs and inspect highlighted matches + # results = client.files.search( + # query: "invoice", exact: { detected_mime_type: ["application/pdf"] }, limit: 20 + # ) + # results.each { |file| puts file.highlight } + # + # @param request_options [Hash] + # @param options [Hash] Search criteria plus limit, offset, and include + # @return [Uploadcare::Collections::FileSearchResult] + def search(request_options: {}, **options) + Uploadcare::Resources::File.search( + options: options, client: client, request_options: request_options + ) + end + # @param source [IO, Array, String] # @param request_options [Hash] # @param options [Hash] diff --git a/lib/uploadcare/collections/file_search_result.rb b/lib/uploadcare/collections/file_search_result.rb new file mode 100644 index 00000000..af190485 --- /dev/null +++ b/lib/uploadcare/collections/file_search_result.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Paginated results returned by the file search endpoint. +# +# Search pagination uses POST requests, so every subsequent page must resend the +# original JSON search criteria while following the query parameters supplied by +# the API's next or previous URL. +class Uploadcare::Collections::FileSearchResult < Uploadcare::Collections::Paginated + # @return [Hash] JSON search criteria resent for subsequent pages + attr_reader :search_params, :search_query + + def initialize(params = {}) + @search_params = immutable_copy(params[:search_params] || {}) + @search_query = immutable_copy(params[:search_query] || {}) + super + end + + private + + def immutable_copy(value) + case value + when Hash + value.each_with_object({}) do |(key, nested_value), copy| + copy[immutable_copy(key)] = immutable_copy(nested_value) + end.freeze + when Array + value.map { |nested_value| immutable_copy(nested_value) }.freeze + when String + value.dup.freeze + else + value + end + end + + def fetch_response(params) + query = search_query.transform_keys(&:to_s).merge(params) + Uploadcare::Result.unwrap( + api_client.search(params: search_params, query: query, request_options: request_options) + ) + end + + def continuation_options + { search_params: search_params, search_query: search_query } + end +end diff --git a/lib/uploadcare/collections/paginated.rb b/lib/uploadcare/collections/paginated.rb index fa83fb2c..79d99be1 100644 --- a/lib/uploadcare/collections/paginated.rb +++ b/lib/uploadcare/collections/paginated.rb @@ -155,10 +155,16 @@ def build_paginated_collection(response) api_client: api_client, resource_class: resource_class, client: client, - request_options: request_options + request_options: request_options, + **continuation_options ) end + # Extra state that a specialized collection needs to carry to subsequent pages. + def continuation_options + {} + end + def build_resources(results) results.map { |data| resource_class.new(data, client) } end diff --git a/lib/uploadcare/operations/file_search.rb b/lib/uploadcare/operations/file_search.rb new file mode 100644 index 00000000..6e8b88d3 --- /dev/null +++ b/lib/uploadcare/operations/file_search.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Executes a file search and builds its POST-aware paginated result. +class Uploadcare::Operations::FileSearch + QUERY_OPTIONS = %i[limit offset include].freeze + + class << self + def call(options:, client:, resource_class:, request_options: {}) + search_params, query_params = split_options(options) + response = Uploadcare::Result.unwrap( + client.api.rest.files.search( + params: search_params, query: query_params, request_options: request_options + ) + ) + + Uploadcare::Collections::FileSearchResult.new( + resources: response.fetch('results', []).map { |data| resource_class.new(data, client) }, + next_page: response['next'], + previous_page: response['previous'], + per_page: response['per_page'], + total: response['total'], + api_client: client.api.rest.files, + resource_class: resource_class, + client: client, + request_options: request_options, + search_params: search_params, + search_query: query_params + ) + end + + private + + def split_options(options) + body = options.dup + query = QUERY_OPTIONS.to_h do |key| + value = body.key?(key) ? body.delete(key) : body[key.to_s] + body.delete(key.to_s) + [key, value] + end + [body, query.compact] + end + end +end diff --git a/lib/uploadcare/resources/file.rb b/lib/uploadcare/resources/file.rb index 6fe9a4ee..5dfe1f41 100644 --- a/lib/uploadcare/resources/file.rb +++ b/lib/uploadcare/resources/file.rb @@ -19,13 +19,13 @@ class Uploadcare::Resources::File < Uploadcare::Resources::BaseResource # API fields assigned onto file resources. ATTRIBUTES = %i[ datetime_removed datetime_stored datetime_uploaded is_image is_ready mime_type original_file_url - original_filename size url uuid variations content_info metadata tags appdata source + original_filename size url uuid variations content_info metadata tags appdata source highlight ].freeze attr_writer :uuid attr_accessor :datetime_removed, :datetime_stored, :datetime_uploaded, :is_image, :is_ready, :mime_type, :original_file_url, :original_filename, :size, :url, :variations, :content_info, - :metadata, :tags, :appdata, :source + :metadata, :tags, :appdata, :source, :highlight # --- Class methods --- @@ -45,11 +45,6 @@ def self.find(uuid:, params: {}, client: nil, config: Uploadcare.configuration, new(response, resolved_client) end - class << self - alias retrieve find - alias info find - end - # List files with optional filtering and pagination. # # @param options [Hash] Query parameters (limit, ordering, etc.) @@ -78,6 +73,23 @@ def self.list(options: {}, client: nil, config: Uploadcare.configuration, reques ) end + # Search files with full-text criteria and structured filters. + # + # `limit`, `offset`, and `include` are sent as query parameters. All other + # options are sent in the JSON request body as search criteria. + # + # @param options [Hash] Search criteria plus pagination/expansion options + # @param client [Uploadcare::Client, nil] Client instance + # @param config [Uploadcare::Configuration] Configuration fallback + # @param request_options [Hash] Request options + # @return [Uploadcare::Collections::FileSearchResult] + def self.search(options: {}, client: nil, config: Uploadcare.configuration, request_options: {}) + resolved_client = resolve_client(client: client, config: config) + Uploadcare::Operations::FileSearch.call( + options: options, client: resolved_client, resource_class: self, request_options: request_options + ) + end + # Upload a single file. # # @param file [File, IO] File to upload @@ -114,10 +126,6 @@ def self.upload_url(url, client: nil, config: Uploadcare.configuration, request_ resolved_client.uploads.upload_from_url(url: url, request_options: request_options, **options) end - class << self - alias upload_from_url upload_url - end - # Batch store files. # # @param uuids [Array] File UUIDs to store @@ -175,10 +183,6 @@ def self.local_copy(source:, options: {}, client: nil, config: Uploadcare.config new(response['result'], resolved_client) end - class << self - alias copy_to_local local_copy - end - # Copy a file to remote storage (class method). # # @param source [String] CDN URL or UUID @@ -197,6 +201,10 @@ def self.remote_copy(source:, target:, options: {}, client: nil, config: Uploadc end class << self + alias retrieve find + alias info find + alias upload_from_url upload_url + alias copy_to_local local_copy alias copy_to_remote remote_copy end diff --git a/spec/api_examples/example_helper_spec.rb b/spec/api_examples/example_helper_spec.rb new file mode 100644 index 00000000..b437b096 --- /dev/null +++ b/spec/api_examples/example_helper_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../api_examples/support/example_helper' + +RSpec.describe ApiExamples::ExampleHelper do + describe '.wait_for_file_search' do + let(:files) { instance_double(Uploadcare::Client::FilesAccessor) } + let(:client) { instance_double(Uploadcare::Client, files: files) } + let(:match) { instance_double(Uploadcare::Resources::File, uuid: 'file-uuid') } + + before do + allow(described_class).to receive(:client).and_return(client) + allow(described_class).to receive(:sleep) + end + + it 'retries until the uploaded file appears' do + found_matches = [match] + allow(files).to receive(:search) + .with(query: 'file-uuid', limit: 20) + .and_return([], found_matches) + + result = described_class.wait_for_file_search( + uuid: 'file-uuid', timeout: 1, poll_interval: 0 + ) + + expect(result).to equal(found_matches) + expect(files).to have_received(:search).twice + expect(described_class).to have_received(:sleep).with(0).once + end + + it 'raises after the timeout expires' do + allow(files).to receive(:search).and_return([]) + + expect do + described_class.wait_for_file_search(uuid: 'file-uuid', timeout: 0, poll_interval: 0) + end.to raise_error(RuntimeError, 'Timed out waiting for file file-uuid to appear in search') + end + end +end diff --git a/spec/uploadcare/api/rest/files_spec.rb b/spec/uploadcare/api/rest/files_spec.rb index e6bd0cb8..2d0bcdab 100644 --- a/spec/uploadcare/api/rest/files_spec.rb +++ b/spec/uploadcare/api/rest/files_spec.rb @@ -64,6 +64,51 @@ end end + describe '#search' do + it 'posts search criteria in JSON and pagination options in the query string' do + stub = stub_request(:post, 'https://api.uploadcare.com/files/search/') + .with( + query: { limit: '20', offset: '40', include: 'appdata' }, + body: { + query: 'invoice', + phrase: { original_filename: 'report' }, + is_image: false + } + ) + .to_return( + status: 200, + body: { results: [], total: 0, per_page: 20, next: nil, previous: nil }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = files.search( + params: { + query: 'invoice', + phrase: { original_filename: 'report' }, + is_image: false + }, + query: { limit: 20, offset: 40, include: 'appdata' } + ) + + expect(result).to be_success + expect(stub).to have_been_requested + end + + it 'returns validation failures in a Result' do + stub_request(:post, 'https://api.uploadcare.com/files/search/') + .to_return( + status: 400, + body: { non_field_errors: ['At least one search criterion must be specified.'] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = files.search + + expect(result).to be_failure + expect(result.error).to be_a(Uploadcare::Exception::InvalidRequestError) + end + end + describe '#info' do let(:encoded_uuid) { URI.encode_www_form_component(file_uuid) } diff --git a/spec/uploadcare/api/rest_spec.rb b/spec/uploadcare/api/rest_spec.rb index 6f21d418..e5be6082 100644 --- a/spec/uploadcare/api/rest_spec.rb +++ b/spec/uploadcare/api/rest_spec.rb @@ -175,6 +175,67 @@ expect(result.error).to be_a(Uploadcare::Exception::InvalidRequestError) end + it 'sends query params alongside a JSON request body' do + stub = stub_request(:post, 'https://api.uploadcare.com/files/search/') + .with( + query: { limit: '20', offset: '40' }, + body: { query: 'invoice', is_image: false } + ) + .to_return( + status: 200, + body: { results: [], total: 0 }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = rest.post( + path: '/files/search/', + params: { query: 'invoice', is_image: false }, + query: { limit: 20, offset: 40 }, + headers: {}, + request_options: {} + ) + + expect(result).to be_success + expect(stub).to have_been_requested + end + + it 'signs POST URI with the same query encoding used by Faraday' do + authenticator = instance_double(Uploadcare::Internal::Authenticator) + allow(authenticator).to receive(:default_headers).and_return( + { + 'Accept' => 'application/vnd.uploadcare-v0.7+json', + 'Content-Type' => 'application/json' + } + ) + allow(authenticator).to receive(:headers) + .with('POST', '/files/search/?include=appdata&limit=20', { query: 'invoice' }.to_json, 'application/json') + .and_return( + { + 'Accept' => 'application/vnd.uploadcare-v0.7+json', + 'Authorization' => 'Uploadcare.Simple demopublickey:demosecretkey', + 'Content-Type' => 'application/json' + } + ) + rest.instance_variable_set(:@authenticator, authenticator) + + stub_request(:post, 'https://api.uploadcare.com/files/search/?include=appdata&limit=20') + .to_return( + status: 200, + body: { results: [] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = rest.post( + path: '/files/search/', + params: { query: 'invoice' }, + query: { limit: 20, include: 'appdata' }, + headers: {}, + request_options: {} + ) + + expect(result).to be_success + end + it 'uses the resolved Content-Type consistently for signing and request headers' do authenticator = instance_double(Uploadcare::Internal::Authenticator) allow(authenticator).to receive(:default_headers).and_return( diff --git a/spec/uploadcare/client_spec.rb b/spec/uploadcare/client_spec.rb index 5ed062ae..7466fdcf 100644 --- a/spec/uploadcare/client_spec.rb +++ b/spec/uploadcare/client_spec.rb @@ -270,6 +270,19 @@ expect(result).to be_a(Uploadcare::Collections::Paginated) end + it 'delegates search to Resources::File.search' do + allow(rest_files).to receive(:search) + .with(params: { query: 'invoice' }, query: { limit: 10 }, request_options: {}) + .and_return(Uploadcare::Result.success({ + 'results' => [], 'next' => nil, 'previous' => nil, + 'per_page' => 10, 'total' => 0 + })) + + result = client.files.search(query: 'invoice', limit: 10) + + expect(result).to be_a(Uploadcare::Collections::FileSearchResult) + end + it 'delegates batch_store to Resources::File.batch_store' do allow(rest_files).to receive(:batch_store) .and_return(Uploadcare::Result.success({ 'status' => 'ok', 'result' => [], 'problems' => {} })) diff --git a/spec/uploadcare/collections/file_search_result_spec.rb b/spec/uploadcare/collections/file_search_result_spec.rb new file mode 100644 index 00000000..5f1e395b --- /dev/null +++ b/spec/uploadcare/collections/file_search_result_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Collections::FileSearchResult do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'demopublickey', + secret_key: 'demosecretkey', + auth_type: 'Uploadcare.Simple' + ) + end + let(:client) { Uploadcare::Client.new(config: config) } + let(:api_client) { double('api_client') } + let(:resource_class) { Uploadcare::Resources::File } + let(:search_params) do + { + phrase: { original_filename: 'invoice' }, + exact: { 'metadata[camera]' => ['Canon'] }, + is_image: false + } + end + let(:first_file) { resource_class.new({ 'uuid' => 'uuid-1' }, client) } + let(:search_query) { { include: 'appdata', limit: 2 } } + let(:collection) do + described_class.new( + resources: [first_file], + next_page: 'https://api.uploadcare.com/files/search/?limit=2&offset=50', + previous_page: nil, + per_page: 2, + total: 100, + api_client: api_client, + resource_class: resource_class, + client: client, + request_options: { timeout: 5 }, + search_params: search_params, + search_query: search_query + ) + end + + describe '#next_page' do + it 'follows the API URL and resends the original search body' do + response = { + 'results' => [ + { + 'uuid' => 'uuid-2', + 'highlight' => { 'original_filename' => ['invoice.pdf'] } + } + ], + 'next' => nil, + 'previous' => 'https://api.uploadcare.com/files/search/?limit=2&offset=48&include=appdata', + 'per_page' => 2, + 'total' => 100 + } + allow(api_client).to receive(:search) + .with( + params: search_params, + query: { 'include' => 'appdata', 'limit' => '2', 'offset' => '50' }, + request_options: { timeout: 5 } + ) + .and_return(Uploadcare::Result.success(response)) + + page = collection.next_page + + expect(page).to be_a(described_class) + expect(page.resources.first.uuid).to eq('uuid-2') + expect(page.resources.first.highlight).to eq( + 'original_filename' => ['invoice.pdf'] + ) + expect(page.search_params).to eq(search_params) + expect(page.search_query).to eq(search_query) + end + end + + describe '#search_query' do + it 'is copied and frozen so callers cannot alter subsequent page fetches' do + snapshot = collection.search_query + + search_query[:include] = 'other' + + expect(snapshot).to eq(include: 'appdata', limit: 2) + expect(snapshot).to be_frozen + expect(snapshot.fetch(:include)).to be_frozen + end + end + + describe '#search_params' do + it 'is deeply copied and frozen so callers cannot alter subsequent page fetches' do + snapshot = collection.search_params + + search_params.fetch(:phrase)[:original_filename] = 'receipt' + search_params.fetch(:exact).fetch('metadata[camera]') << 'Nikon' + + expect(snapshot).to eq( + phrase: { original_filename: 'invoice' }, + exact: { 'metadata[camera]' => ['Canon'] }, + is_image: false + ) + expect(snapshot).to be_frozen + expect(snapshot.fetch(:phrase)).to be_frozen + expect(snapshot.dig(:phrase, :original_filename)).to be_frozen + expect(snapshot.dig(:exact, 'metadata[camera]')).to be_frozen + end + end + + describe '#all' do + it 'continues through an empty page that still has a next URL' do + empty_page = { + 'results' => [], + 'next' => 'https://api.uploadcare.com/files/search/?limit=2&offset=52', + 'previous' => nil, + 'per_page' => 2, + 'total' => 100 + } + final_page = { + 'results' => [{ 'uuid' => 'uuid-3' }], + 'next' => nil, + 'previous' => nil, + 'per_page' => 2, + 'total' => 100 + } + + allow(api_client).to receive(:search) + .with( + params: search_params, + query: { 'include' => 'appdata', 'limit' => '2', 'offset' => '50' }, + request_options: { timeout: 5 } + ) + .and_return(Uploadcare::Result.success(empty_page)) + allow(api_client).to receive(:search) + .with( + params: search_params, + query: { 'include' => 'appdata', 'limit' => '2', 'offset' => '52' }, + request_options: { timeout: 5 } + ) + .and_return(Uploadcare::Result.success(final_page)) + + expect(collection.all.map(&:uuid)).to eq(%w[uuid-1 uuid-3]) + end + end +end diff --git a/spec/uploadcare/resources/file_spec.rb b/spec/uploadcare/resources/file_spec.rb index b9734ce8..e3eec261 100644 --- a/spec/uploadcare/resources/file_spec.rb +++ b/spec/uploadcare/resources/file_spec.rb @@ -49,7 +49,7 @@ it 'defines expected attributes' do expected = %i[ datetime_removed datetime_stored datetime_uploaded is_image is_ready mime_type original_file_url - original_filename size url uuid variations content_info metadata tags appdata source + original_filename size url uuid variations content_info metadata tags appdata source highlight ] expect(described_class::ATTRIBUTES).to match_array(expected) end @@ -136,6 +136,67 @@ end end + describe '.search' do + let(:search_response) do + { + 'results' => [ + file_attrs.merge( + 'highlight' => { + 'original_filename' => ['photo.jpg'], + 'metadata' => { 'camera' => 'Canon' } + } + ) + ], + 'next' => 'https://api.uploadcare.com/files/search/?limit=20&offset=20&include=appdata', + 'previous' => nil, + 'per_page' => 20, + 'total' => 42 + } + end + + it 'returns file resources with highlights in a FileSearchResult' do + allow(rest_files).to receive(:search) + .with( + params: { query: 'photo' }, + query: { limit: 20, include: 'appdata' }, + request_options: { timeout: 5 } + ) + .and_return(Uploadcare::Result.success(search_response)) + + result = described_class.search( + options: { query: 'photo', limit: 20, include: 'appdata' }, + client: client, + request_options: { timeout: 5 } + ) + + expect(result).to be_a(Uploadcare::Collections::FileSearchResult) + expect(result.total).to eq(42) + expect(result.resources.first).to be_a(described_class) + expect(result.resources.first.highlight).to eq( + 'original_filename' => ['photo.jpg'], + 'metadata' => { 'camera' => 'Canon' } + ) + expect(result.search_params).to eq(query: 'photo') + expect(result.search_query).to eq(limit: 20, include: 'appdata') + end + + it 'supports string keys for URL query options' do + allow(rest_files).to receive(:search) + .with( + params: { 'query' => 'photo' }, + query: { limit: 10, offset: 30 }, + request_options: {} + ) + .and_return(Uploadcare::Result.success(search_response)) + + result = described_class.search( + options: { 'query' => 'photo', 'limit' => 10, 'offset' => 30 }, client: client + ) + + expect(result).to be_a(Uploadcare::Collections::FileSearchResult) + end + end + describe '.upload' do it 'delegates to upload router' do uploads = instance_double(Uploadcare::Operations::UploadRouter)