Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions api_examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions api_examples/rest_api/post_files_search.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative '../support/run_rest_example'
15 changes: 15 additions & 0 deletions api_examples/support/example_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions api_examples/support/run_rest_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
],
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
33 changes: 33 additions & 0 deletions examples/file_search.rb
Original file line number Diff line number Diff line change
@@ -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 <query of at least four characters>'
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
29 changes: 19 additions & 10 deletions lib/uploadcare/api/rest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion lib/uploadcare/api/rest/files.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions lib/uploadcare/client/files_accessor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<IO>, String]
# @param request_options [Hash]
# @param options [Hash]
Expand Down
45 changes: 45 additions & 0 deletions lib/uploadcare/collections/file_search_result.rb
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
8 changes: 7 additions & 1 deletion lib/uploadcare/collections/paginated.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading