From 1e0cfe6ed2ec271dceb89342581d9a785dc9b58a Mon Sep 17 00:00:00 2001 From: Alex Gusev Date: Fri, 7 Aug 2026 14:56:12 +0600 Subject: [PATCH 01/13] File tags --- CHANGELOG.md | 8 ++ README.md | 43 +++++++- api_examples/README.md | 6 +- api_examples/rest_api/get_files_uuid_tags.rb | 4 + .../rest_api/patch_files_uuid_tags.rb | 4 + api_examples/rest_api/put_files_uuid_tags.rb | 4 + api_examples/support/run_rest_example.rb | 16 +++ lib/uploadcare.rb | 2 + lib/uploadcare/api/rest.rb | 18 ++- lib/uploadcare/api/rest/file_tags.rb | 62 +++++++++++ lib/uploadcare/api/upload/files.rb | 20 +++- lib/uploadcare/client.rb | 7 ++ lib/uploadcare/client/file_tags_accessor.rb | 40 +++++++ .../internal/file_tag_normalizer.rb | 61 ++++++++++ .../internal/upload_params_generator.rb | 18 ++- lib/uploadcare/operations/multipart_upload.rb | 2 +- lib/uploadcare/operations/upload_router.rb | 4 +- lib/uploadcare/resources/file.rb | 4 +- lib/uploadcare/resources/file_tags.rb | 92 ++++++++++++++++ spec/uploadcare/api/rest/file_tags_spec.rb | 100 +++++++++++++++++ spec/uploadcare/api/rest_spec.rb | 6 + spec/uploadcare/api/upload/files_spec.rb | 50 +++++++++ spec/uploadcare/client_spec.rb | 51 +++++++++ spec/uploadcare/coverage_boost_spec.rb | 1 + .../internal/file_tag_normalizer_spec.rb | 52 +++++++++ .../internal/upload_params_generator_spec.rb | 21 ++++ spec/uploadcare/multi_account_spec.rb | 1 + spec/uploadcare/resources/file_spec.rb | 4 +- spec/uploadcare/resources/file_tags_spec.rb | 104 ++++++++++++++++++ spec/uploadcare_spec.rb | 4 + 30 files changed, 794 insertions(+), 15 deletions(-) create mode 100755 api_examples/rest_api/get_files_uuid_tags.rb create mode 100755 api_examples/rest_api/patch_files_uuid_tags.rb create mode 100755 api_examples/rest_api/put_files_uuid_tags.rb create mode 100644 lib/uploadcare/api/rest/file_tags.rb create mode 100644 lib/uploadcare/client/file_tags_accessor.rb create mode 100644 lib/uploadcare/internal/file_tag_normalizer.rb create mode 100644 lib/uploadcare/resources/file_tags.rb create mode 100644 spec/uploadcare/api/rest/file_tags_spec.rb create mode 100644 spec/uploadcare/internal/file_tag_normalizer_spec.rb create mode 100644 spec/uploadcare/resources/file_tags_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index f340b393..c6cf3b23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Added + +* 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 + ## 5.0.0 — 2026-05-17 v5 is stable. diff --git a/README.md b/README.md index 2099e13a..6e899528 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The gem is built around: - [Multi-Account Usage](#multi-account-usage) - [Uploads](#uploads) - [Files](#files) +- [File Tags](#file-tags) - [Groups](#groups) - [Project](#project) - [Metadata](#metadata) @@ -87,6 +88,7 @@ This is the default API you should use in applications: - `client.project` - `client.webhooks` - `client.file_metadata` +- `client.file_tags` - `client.addons` - `client.conversions` @@ -262,7 +264,12 @@ remote_file = client.uploads.upload("https://example.com/image.jpg", store: true ```ruby file = File.open("photo.jpg", "rb") do |io| - client.files.upload(io, store: true, metadata: { subsystem: "avatars" }) + client.files.upload( + io, + store: true, + metadata: { subsystem: "avatars" }, + tags: ["avatar", "profile"] + ) end ``` @@ -342,6 +349,7 @@ Common upload options: - `store: true | false | "auto"` - `metadata: { key: value }` +- `tags: ["tag-1", "tag_2"]` - `signature: "..."` - `expire: unix_timestamp` - `async: true` for URL uploads @@ -424,6 +432,39 @@ copied = file.copy_to_local(options: { store: true }) remote_url = file.copy_to_remote(target: "custom_storage") ``` +File responses expose the ordered tag list through `file.tags` when the field is present. + +## File Tags + +Tags can be attached during direct, URL, batch, and multipart uploads with the `tags:` option. The SDK normalizes tags to lowercase, strips surrounding whitespace, removes duplicates while preserving order, and validates the platform limits. + +Read or replace the complete tag list: + +```ruby +tags = client.file_tags.list(uuid: file.uuid) + +change = client.file_tags.replace( + uuid: file.uuid, + tags: ["approved", "Summer"] +) + +puts change.tags +puts change.added +puts change.deleted +``` + +Add and delete tags atomically (deletions are applied first): + +```ruby +change = client.file_tags.update( + uuid: file.uuid, + add: ["featured"], + delete: ["draft"] +) +``` + +Passing an empty array to `replace` clears all tags. Tags may contain Latin letters, digits, hyphens, underscores, and dots; each tag is limited to 100 characters and each file to 50 tags. + ## Groups Create a group: diff --git a/api_examples/README.md b/api_examples/README.md index 61a5d452..8b42ef7d 100644 --- a/api_examples/README.md +++ b/api_examples/README.md @@ -21,8 +21,7 @@ Optional environment variables: Verification: -- Verified against a real Uploadcare demo account on `2026-03-16` -- All canonical scripts in `api_examples/rest_api` and `api_examples/upload_api` executed successfully +- Verified against a real Uploadcare demo account on `2026-08-07` ## REST API 0.7 @@ -40,6 +39,9 @@ Verification: | `GET /files/{uuid}/metadata/{key}/` | `api_examples/rest_api/get_files_uuid_metadata_key.rb` | Uses `client.file_metadata.show` | | `PUT /files/{uuid}/metadata/{key}/` | `api_examples/rest_api/put_files_uuid_metadata_key.rb` | Uses `client.file_metadata.update` | | `DELETE /files/{uuid}/metadata/{key}/` | `api_examples/rest_api/delete_files_uuid_metadata_key.rb` | Uses `client.file_metadata.delete` | +| `GET /files/{uuid}/tags/` | `api_examples/rest_api/get_files_uuid_tags.rb` | Uses `client.file_tags.list` | +| `PUT /files/{uuid}/tags/` | `api_examples/rest_api/put_files_uuid_tags.rb` | Uses `client.file_tags.replace` | +| `PATCH /files/{uuid}/tags/` | `api_examples/rest_api/patch_files_uuid_tags.rb` | Uses `client.file_tags.update` | | `GET /groups/` | `api_examples/rest_api/get_groups.rb` | Uses `client.groups.list` | | `GET /groups/{uuid}/` | `api_examples/rest_api/get_groups_uuid.rb` | Uses `client.groups.find` | | `DELETE /groups/{uuid}/` | `api_examples/rest_api/delete_groups_uuid.rb` | Uses `group.delete` | diff --git a/api_examples/rest_api/get_files_uuid_tags.rb b/api_examples/rest_api/get_files_uuid_tags.rb new file mode 100755 index 00000000..b5b8db83 --- /dev/null +++ b/api_examples/rest_api/get_files_uuid_tags.rb @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../support/run_rest_example' diff --git a/api_examples/rest_api/patch_files_uuid_tags.rb b/api_examples/rest_api/patch_files_uuid_tags.rb new file mode 100755 index 00000000..b5b8db83 --- /dev/null +++ b/api_examples/rest_api/patch_files_uuid_tags.rb @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../support/run_rest_example' diff --git a/api_examples/rest_api/put_files_uuid_tags.rb b/api_examples/rest_api/put_files_uuid_tags.rb new file mode 100755 index 00000000..b5b8db83 --- /dev/null +++ b/api_examples/rest_api/put_files_uuid_tags.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/run_rest_example.rb b/api_examples/support/run_rest_example.rb index 59030d04..089ffb06 100755 --- a/api_examples/support/run_rest_example.rb +++ b/api_examples/support/run_rest_example.rb @@ -93,6 +93,22 @@ def call client.file_metadata.delete(uuid: file.uuid, key: 'color') { 'uuid' => file.uuid, 'key' => 'color', 'deleted' => true } end + when 'get_files_uuid_tags.rb' + ApiExamples::ExampleHelper.with_uploaded_file do |file| + client.file_tags.replace(uuid: file.uuid, tags: %w[cat example]) + client.file_tags.list(uuid: file.uuid) + end + when 'put_files_uuid_tags.rb' + ApiExamples::ExampleHelper.with_uploaded_file do |file| + change = client.file_tags.replace(uuid: file.uuid, tags: %w[approved example]) + { 'tags' => change.tags, 'added' => change.added, 'deleted' => change.deleted } + end + when 'patch_files_uuid_tags.rb' + ApiExamples::ExampleHelper.with_uploaded_file do |file| + client.file_tags.replace(uuid: file.uuid, tags: %w[draft example]) + change = client.file_tags.update(uuid: file.uuid, add: ['featured'], delete: ['draft']) + { 'tags' => change.tags, 'added' => change.added, 'deleted' => change.deleted } + end when 'post_addons_aws_rekognition_detect_labels_execute.rb' ApiExamples::ExampleHelper.with_uploaded_file do |file| client.addons.aws_rekognition_detect_labels(uuid: file.uuid) diff --git a/lib/uploadcare.rb b/lib/uploadcare.rb index f01c4923..1fbcbf6a 100644 --- a/lib/uploadcare.rb +++ b/lib/uploadcare.rb @@ -79,6 +79,8 @@ def eager_load! Webhook = Resources::Webhook # Alias for the file metadata resource. FileMetadata = Resources::FileMetadata + # Alias for the file tags resource. + FileTags = Resources::FileTags # Alias for the add-on execution resource. AddonExecution = Resources::AddonExecution # Alias for the document conversion resource. diff --git a/lib/uploadcare/api/rest.rb b/lib/uploadcare/api/rest.rb index 8db7eb92..29b5f75b 100644 --- a/lib/uploadcare/api/rest.rb +++ b/lib/uploadcare/api/rest.rb @@ -5,7 +5,7 @@ # Base client for the Uploadcare REST API. # -# Provides authenticated HTTP methods (GET, POST, PUT, DELETE) for all REST API +# Provides authenticated HTTP methods (GET, POST, PUT, PATCH, DELETE) for all REST API # endpoints. Includes automatic error handling and throttle retry logic. # # Endpoint classes are accessed via lazy-loaded accessors: @@ -71,6 +71,11 @@ def file_metadata memoized(:@file_metadata) { Uploadcare::Api::Rest::FileMetadata.new(rest: self) } end + # @return [Uploadcare::Api::Rest::FileTags] Per-file tag operations endpoint + def file_tags + memoized(:@file_tags) { Uploadcare::Api::Rest::FileTags.new(rest: self) } + end + # @return [Uploadcare::Api::Rest::Addons] Add-on operations endpoint def addons memoized(:@addons) { Uploadcare::Api::Rest::Addons.new(rest: self) } @@ -141,6 +146,17 @@ def put(path:, params: {}, headers: {}, request_options: {}) request(method: :put, path: path, params: params, headers: headers, request_options: request_options) end + # Make a PATCH request wrapped in a Result. + # + # @param path [String] API endpoint path + # @param params [Hash] Request body parameters + # @param headers [Hash] Additional request headers + # @param request_options [Hash] Request options + # @return [Uploadcare::Result] + def patch(path:, params: {}, headers: {}, request_options: {}) + request(method: :patch, path: path, params: params, headers: headers, request_options: request_options) + end + # Make a DELETE request wrapped in a Result. # # @param path [String] API endpoint path diff --git a/lib/uploadcare/api/rest/file_tags.rb b/lib/uploadcare/api/rest/file_tags.rb new file mode 100644 index 00000000..d1a925b7 --- /dev/null +++ b/lib/uploadcare/api/rest/file_tags.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'uri' + +# REST API endpoint for per-file tag operations. +# +# @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-tags +class Uploadcare::Api::Rest::FileTags + # @return [Uploadcare::Api::Rest] Parent REST client + attr_reader :rest + + # @param rest [Uploadcare::Api::Rest] Parent REST client + def initialize(rest:) + @rest = rest + end + + # Get the ordered list of tags for a file. + # + # @param uuid [String] File UUID + # @param request_options [Hash] Request options + # @return [Uploadcare::Result] Response containing the `tags` array + def list(uuid:, request_options: {}) + rest.get(path: tags_path(uuid), params: {}, headers: {}, request_options: request_options) + end + alias index list + + # Replace all tags for a file. + # + # @param uuid [String] File UUID + # @param tags [Array] Complete replacement tag list + # @param request_options [Hash] Request options + # @return [Uploadcare::Result] Response containing tags, added, and deleted + def replace(uuid:, tags:, request_options: {}) + rest.put( + path: tags_path(uuid), params: { tags: tags }, headers: {}, request_options: request_options + ) + end + + # Atomically add and delete tags for a file. + # + # Deletions are applied before additions by the API. + # + # @param uuid [String] File UUID + # @param add [Array, nil] Tags to add + # @param delete [Array, nil] Tags to delete + # @param request_options [Hash] Request options + # @return [Uploadcare::Result] Response containing tags, added, and deleted + def update(uuid:, add: nil, delete: nil, request_options: {}) + params = {} + params[:add] = add unless add.nil? || add.empty? + params[:delete] = delete unless delete.nil? || delete.empty? + body = params.empty? ? {}.to_json : params + rest.patch(path: tags_path(uuid), params: body, headers: {}, request_options: request_options) + end + + private + + def tags_path(uuid) + encoded_uuid = URI.encode_www_form_component(uuid.to_s) + "/files/#{encoded_uuid}/tags/" + end +end diff --git a/lib/uploadcare/api/upload/files.rb b/lib/uploadcare/api/upload/files.rb index e9e4ea89..b08a4ab1 100644 --- a/lib/uploadcare/api/upload/files.rb +++ b/lib/uploadcare/api/upload/files.rb @@ -14,7 +14,7 @@ def initialize(upload:) # Upload a file directly (POST /base/). # # @param file [File, IO] File object to upload - # @param options [Hash] Upload options (:store, :metadata, :signature, :expire) + # @param options [Hash] Upload options (:store, :metadata, :tags, :signature, :expire) # @param request_options [Hash] Request options # @return [Uploadcare::Result] Upload response with file UUID # @raise [ArgumentError] if file is not a valid IO object @@ -32,7 +32,7 @@ def direct(file:, request_options: {}, **options) # Upload multiple files directly (POST /base/). # # @param files [Array] Files to upload - # @param options [Hash] Upload options (:store, :metadata) + # @param options [Hash] Upload options (:store, :metadata, :tags) # @param request_options [Hash] Request options # @return [Uploadcare::Result] Upload response hash mapping filenames to UUIDs # @see https://uploadcare.com/api-refs/upload-api/#operation/baseUpload @@ -63,6 +63,7 @@ def direct_many(files:, request_options: {}, **options) # @option options [Boolean] :async Return immediately with token (default: false) # @option options [String, Boolean] :store Whether to store the file # @option options [Hash] :metadata Custom metadata + # @option options [Array] :tags Tags to attach to the file # @option options [Integer] :poll_interval Polling interval in seconds (default: 1) # @option options [Integer] :poll_timeout Max polling time in seconds (default: 300) # @param request_options [Hash] Request options @@ -105,7 +106,7 @@ def from_url_status(token:, request_options: {}) # @param filename [String] Original filename # @param size [Integer] File size in bytes # @param content_type [String] MIME type - # @param options [Hash] Upload options (:store, :metadata) + # @param options [Hash] Upload options (:store, :metadata, :tags) # @param request_options [Hash] Request options # @return [Uploadcare::Result] Response with UUID and presigned URLs # @see https://uploadcare.com/api-refs/upload-api/#operation/multipartUploadStart @@ -198,6 +199,8 @@ def build_from_url_params(source_url, options) params['save_URL_duplicates'] = options[:save_URL_duplicates].to_s if options.key?(:save_URL_duplicates) metadata_params = generate_metadata_params(options[:metadata]) params.merge!(metadata_params) if metadata_params.any? + tags_param = generate_tags_param(options[:tags]) + params.merge!(tags_param) if tags_param.any? params.merge!(signature_params(options)) params end @@ -213,6 +216,8 @@ def build_multipart_start_params(filename, size, content_type, options) params['UPLOADCARE_STORE'] = store unless store.nil? metadata_params = generate_metadata_params(options[:metadata]) params.merge!(metadata_params) if metadata_params.any? + tags_param = generate_tags_param(options[:tags]) + params.merge!(tags_param) if tags_param.any? params.merge!(signature_params(options)) params end @@ -270,6 +275,15 @@ def generate_metadata_params(metadata = nil) end end + def generate_tags_param(tags = nil) + return {} if tags.nil? + + normalized = Uploadcare::Internal::FileTagNormalizer.call(tags) + return {} if normalized.empty? + + { 'tags' => normalized.join(',') } + end + def signature_params(options = {}) return {} if options.nil? diff --git a/lib/uploadcare/client.rb b/lib/uploadcare/client.rb index db501c59..4344750d 100644 --- a/lib/uploadcare/client.rb +++ b/lib/uploadcare/client.rb @@ -96,6 +96,13 @@ def file_metadata memoized(:@file_metadata) { FileMetadataAccessor.new(client: self) } end + # Access per-file tag operations. + # + # @return [Uploadcare::Client::FileTagsAccessor] + def file_tags + memoized(:@file_tags) { FileTagsAccessor.new(client: self) } + end + # Access conversion helpers. # # @return [Uploadcare::Client::ConversionsAccessor] diff --git a/lib/uploadcare/client/file_tags_accessor.rb b/lib/uploadcare/client/file_tags_accessor.rb new file mode 100644 index 00000000..342cd7d2 --- /dev/null +++ b/lib/uploadcare/client/file_tags_accessor.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Per-file tag operations scoped to a client instance. +class Uploadcare::Client::FileTagsAccessor + attr_reader :client + + # @param client [Uploadcare::Client] + def initialize(client:) + @client = client + end + + # @param uuid [String] + # @param request_options [Hash] + # @return [Array] + def list(uuid:, request_options: {}) + Uploadcare::Resources::FileTags.list(uuid: uuid, client: client, request_options: request_options) + end + alias index list + + # @param uuid [String] + # @param tags [Array] + # @param request_options [Hash] + # @return [Uploadcare::Resources::FileTags] + def replace(uuid:, tags:, request_options: {}) + Uploadcare::Resources::FileTags.replace( + uuid: uuid, tags: tags, client: client, request_options: request_options + ) + end + + # @param uuid [String] + # @param add [Array] + # @param delete [Array] + # @param request_options [Hash] + # @return [Uploadcare::Resources::FileTags] + def update(uuid:, add: [], delete: [], request_options: {}) + Uploadcare::Resources::FileTags.update( + uuid: uuid, add: add, delete: delete, client: client, request_options: request_options + ) + end +end diff --git a/lib/uploadcare/internal/file_tag_normalizer.rb b/lib/uploadcare/internal/file_tag_normalizer.rb new file mode 100644 index 00000000..a41a3523 --- /dev/null +++ b/lib/uploadcare/internal/file_tag_normalizer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Normalizes and validates file tags before sending them to Uploadcare. +class Uploadcare::Internal::FileTagNormalizer + MAX_LENGTH = 100 + MAX_COUNT = 50 + VALID_PATTERN = /\A[a-z0-9._-]+\z/ + + class << self + # Normalize a list of file tags. + # + # Tags are stripped, lowercased, and deduplicated while preserving their + # first-seen order. + # + # @param tags [Array] + # @param max_count [Integer, nil] Maximum number of tags; nil disables the limit + # @return [Array] + # @raise [ArgumentError] if a tag is invalid + def call(tags, max_count: MAX_COUNT) + raise ArgumentError, 'tags must be an array of strings' unless tags.is_a?(Array) + + normalized = normalize(tags) + validate_count(normalized, max_count) + normalized + end + + private + + def normalize(tags) + seen = {} + + tags.each_with_object([]) do |tag, result| + raise ArgumentError, 'tags must be an array of strings' unless tag.is_a?(String) + + value = tag.strip.downcase + validate_tag(value) + next if seen[value] + + seen[value] = true + result << value + end + end + + def validate_tag(tag) + raise ArgumentError, 'tag may not be blank' if tag.empty? + if tag.length > MAX_LENGTH + raise ArgumentError, "tag is too long: #{tag.length} characters (maximum #{MAX_LENGTH})" + end + return if VALID_PATTERN.match?(tag) + + raise ArgumentError, + 'tag contains invalid characters; allowed: Latin letters, digits, hyphen, underscore, dot' + end + + def validate_count(tags, max_count) + return if max_count.nil? || max_count.zero? || tags.length <= max_count + + raise ArgumentError, "too many tags: #{tags.length} (maximum #{max_count})" + end + end +end diff --git a/lib/uploadcare/internal/upload_params_generator.rb b/lib/uploadcare/internal/upload_params_generator.rb index 520f7b46..460877a8 100644 --- a/lib/uploadcare/internal/upload_params_generator.rb +++ b/lib/uploadcare/internal/upload_params_generator.rb @@ -3,12 +3,12 @@ # Generates upload parameters for Upload API requests. # # Builds the parameter hash needed for file uploads, including public key, -# store preferences, metadata, and optional signature params. +# store preferences, metadata, tags, and optional signature params. class Uploadcare::Internal::UploadParamsGenerator class << self # Build upload parameters. # - # @param options [Hash] Upload options (:store, :metadata, :signature, :expire) + # @param options [Hash] Upload options (:store, :metadata, :tags, :signature, :expire) # @param config [Uploadcare::Configuration] Configuration with public key and signing settings # @return [Hash] Upload parameters hash def call(options: {}, config: Uploadcare.configuration) @@ -20,6 +20,7 @@ def call(options: {}, config: Uploadcare.configuration) params['UPLOADCARE_STORE'] = store unless store.nil? params.merge!(metadata(options: options)) + params.merge!(tags(options: options)) params.merge!(signature_params(options: options, config: config)) params.compact @@ -54,6 +55,19 @@ def metadata(options:) end end + # Generate the comma-separated tags parameter. + # + # @param options [Hash] Options containing :tags + # @return [Hash] + def tags(options:) + return {} if options[:tags].nil? + + normalized = Uploadcare::Internal::FileTagNormalizer.call(options[:tags]) + return {} if normalized.empty? + + { 'tags' => normalized.join(',') } + end + # Generate signature parameters for signed uploads. # # @param options [Hash] Options with optional :signature and :expire keys diff --git a/lib/uploadcare/operations/multipart_upload.rb b/lib/uploadcare/operations/multipart_upload.rb index e8373303..74732868 100644 --- a/lib/uploadcare/operations/multipart_upload.rb +++ b/lib/uploadcare/operations/multipart_upload.rb @@ -32,7 +32,7 @@ def initialize(upload_client:, config:) # Execute the full multipart upload flow. # # @param file [File, IO] File to upload - # @param options [Hash] Upload options (:store, :metadata, :threads, :part_size) + # @param options [Hash] Upload options (:store, :metadata, :tags, :threads, :part_size) # @param request_options [Hash] Request options # @yield [Hash] Progress callback with :uploaded, :total, :part, :total_parts # @return [Uploadcare::Result] Result containing { 'uuid' => '...' } diff --git a/lib/uploadcare/operations/upload_router.rb b/lib/uploadcare/operations/upload_router.rb index 0fc78396..24ff6e85 100644 --- a/lib/uploadcare/operations/upload_router.rb +++ b/lib/uploadcare/operations/upload_router.rb @@ -31,7 +31,7 @@ def initialize(client:) # - Strings → URL upload # # @param source [File, IO, String, Array] Upload source - # @param options [Hash] Upload options (:store, :metadata, etc.) + # @param options [Hash] Upload options (:store, :metadata, :tags, etc.) # @param request_options [Hash] Request options # @return [Uploadcare::Resources::File, Array, Hash] # @raise [ArgumentError] if source type is not recognized @@ -81,7 +81,7 @@ def upload_files(files:, request_options: {}, **options) # Upload a file from URL. # # @param url [String] Source URL - # @param options [Hash] Upload options (:async, :store, :metadata) + # @param options [Hash] Upload options (:async, :store, :metadata, :tags) # @param request_options [Hash] Request options # @return [Uploadcare::Resources::File, Hash] File resource (sync) or token hash (async) def upload_from_url(url:, request_options: {}, **options) diff --git a/lib/uploadcare/resources/file.rb b/lib/uploadcare/resources/file.rb index 7b575c81..6fe9a4ee 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 appdata source + original_filename size url uuid variations content_info metadata tags appdata source ].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, :appdata, :source + :metadata, :tags, :appdata, :source # --- Class methods --- diff --git a/lib/uploadcare/resources/file_tags.rb b/lib/uploadcare/resources/file_tags.rb new file mode 100644 index 00000000..025d7085 --- /dev/null +++ b/lib/uploadcare/resources/file_tags.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Resource for reading and changing the ordered tag list associated with a file. +# +# @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/File-tags +class Uploadcare::Resources::FileTags < Uploadcare::Resources::BaseResource + attr_accessor :uuid, :tags, :added, :deleted + + def initialize(attributes = {}, client_or_config = nil) + @tags = [] + @added = [] + @deleted = [] + super + end + + # Fetch the current tags. + # + # @param request_options [Hash] Request options + # @return [self] + def list(request_options: {}) + response = Uploadcare::Result.unwrap( + client.api.rest.file_tags.list(uuid: uuid, request_options: request_options) + ) + self.tags = response.fetch('tags', []) + self.added = [] + self.deleted = [] + self + end + alias index list + + # Replace the complete tag list. An empty array clears all tags. + # + # @param tags [Array] + # @param request_options [Hash] Request options + # @return [self] + def replace(tags:, request_options: {}) + normalized = Uploadcare::Internal::FileTagNormalizer.call(tags) + response = Uploadcare::Result.unwrap( + client.api.rest.file_tags.replace(uuid: uuid, tags: normalized, request_options: request_options) + ) + assign_attributes(response) + self + end + + # Atomically add and delete tags. Deletions are applied first. + # + # @param add [Array] Tags to add + # @param delete [Array] Tags to delete + # @param request_options [Hash] Request options + # @return [self] + def update(add: [], delete: [], request_options: {}) + normalized_add = Uploadcare::Internal::FileTagNormalizer.call(add) + normalized_delete = Uploadcare::Internal::FileTagNormalizer.call(delete, max_count: nil) + response = Uploadcare::Result.unwrap( + client.api.rest.file_tags.update( + uuid: uuid, add: normalized_add, delete: normalized_delete, request_options: request_options + ) + ) + assign_attributes(response) + self + end + + # Get the current tag list for a file. + # + # @return [Array] + def self.list(uuid:, client: nil, config: Uploadcare.configuration, request_options: {}) + resolved_client = resolve_client(client: client, config: config) + new({ uuid: uuid }, resolved_client).list(request_options: request_options).tags.dup + end + + class << self + alias index list + end + + # Replace the complete tag list for a file. + # + # @return [Uploadcare::Resources::FileTags] + def self.replace(uuid:, tags:, client: nil, config: Uploadcare.configuration, request_options: {}) + resolved_client = resolve_client(client: client, config: config) + new({ uuid: uuid }, resolved_client).replace(tags: tags, request_options: request_options) + end + + # Atomically add and delete tags for a file. + # + # @return [Uploadcare::Resources::FileTags] + def self.update(uuid:, add: [], delete: [], client: nil, config: Uploadcare.configuration, request_options: {}) + resolved_client = resolve_client(client: client, config: config) + new({ uuid: uuid }, resolved_client).update( + add: add, delete: delete, request_options: request_options + ) + end +end diff --git a/spec/uploadcare/api/rest/file_tags_spec.rb b/spec/uploadcare/api/rest/file_tags_spec.rb new file mode 100644 index 00000000..9b96e6be --- /dev/null +++ b/spec/uploadcare/api/rest/file_tags_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Api::Rest::FileTags do + subject(:file_tags) { described_class.new(rest: rest) } + + let(:config) do + Uploadcare::Configuration.new( + public_key: 'demopublickey', + secret_key: 'demosecretkey', + auth_type: 'Uploadcare.Simple' + ) + end + let(:rest) { Uploadcare::Api::Rest.new(config: config) } + let(:file_uuid) { 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' } + let(:tags_url) { "https://api.uploadcare.com/files/#{file_uuid}/tags/" } + + describe '#list' do + it 'gets the ordered tag list' do + stub_request(:get, tags_url) + .to_return( + status: 200, + body: { tags: %w[cat animal] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = file_tags.list(uuid: file_uuid) + + expect(result).to be_success + expect(result.value!['tags']).to eq(%w[cat animal]) + end + + it 'URI-encodes the UUID in the path' do + special_uuid = 'uuid/with spaces' + encoded_uuid = URI.encode_www_form_component(special_uuid) + stub = stub_request(:get, "https://api.uploadcare.com/files/#{encoded_uuid}/tags/") + .to_return( + status: 200, + body: { tags: [] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + file_tags.list(uuid: special_uuid) + + expect(stub).to have_been_requested + end + end + + describe '#replace' do + it 'puts the complete replacement list as JSON' do + stub = stub_request(:put, tags_url) + .with(body: { tags: %w[cat animal] }.to_json) + .to_return( + status: 200, + body: { tags: %w[cat animal], added: %w[animal cat], deleted: ['old'] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = file_tags.replace(uuid: file_uuid, tags: %w[cat animal]) + + expect(result).to be_success + expect(result.value!['deleted']).to eq(['old']) + expect(stub).to have_been_requested + end + end + + describe '#update' do + it 'patches additions and deletions atomically as JSON' do + stub = stub_request(:patch, tags_url) + .with(body: { add: ['summer'], delete: ['draft'] }.to_json) + .to_return( + status: 200, + body: { tags: ['summer'], added: ['summer'], deleted: ['draft'] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = file_tags.update(uuid: file_uuid, add: ['summer'], delete: ['draft']) + + expect(result).to be_success + expect(result.value!['added']).to eq(['summer']) + expect(stub).to have_been_requested + end + + it 'allows an empty update body' do + stub = stub_request(:patch, tags_url) + .with(body: '{}') + .to_return( + status: 200, + body: { tags: [], added: [], deleted: [] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = file_tags.update(uuid: file_uuid) + + expect(result).to be_success + expect(stub).to have_been_requested + end + end +end diff --git a/spec/uploadcare/api/rest_spec.rb b/spec/uploadcare/api/rest_spec.rb index 98bfb5e8..6f21d418 100644 --- a/spec/uploadcare/api/rest_spec.rb +++ b/spec/uploadcare/api/rest_spec.rb @@ -324,6 +324,10 @@ expect(rest.file_metadata).to be_a(Uploadcare::Api::Rest::FileMetadata) end + it 'returns a FileTags endpoint' do + expect(rest.file_tags).to be_a(Uploadcare::Api::Rest::FileTags) + end + it 'returns an Addons endpoint' do expect(rest.addons).to be_a(Uploadcare::Api::Rest::Addons) end @@ -342,6 +346,7 @@ project = rest.project webhooks = rest.webhooks file_metadata = rest.file_metadata + file_tags = rest.file_tags addons = rest.addons document_conversions = rest.document_conversions video_conversions = rest.video_conversions @@ -351,6 +356,7 @@ expect(rest.project).to be(project) expect(rest.webhooks).to be(webhooks) expect(rest.file_metadata).to be(file_metadata) + expect(rest.file_tags).to be(file_tags) expect(rest.addons).to be(addons) expect(rest.document_conversions).to be(document_conversions) expect(rest.video_conversions).to be(video_conversions) diff --git a/spec/uploadcare/api/upload/files_spec.rb b/spec/uploadcare/api/upload/files_spec.rb index 98075bfb..f12f9781 100644 --- a/spec/uploadcare/api/upload/files_spec.rb +++ b/spec/uploadcare/api/upload/files_spec.rb @@ -70,6 +70,23 @@ expect(result).to be_success expect(result.value!).to eq({ 'upload.bin' => 'uploaded-uuid-123' }) end + + it 'sends normalized tags as a comma-separated value' do + stub = stub_request(:post, 'https://upload.uploadcare.com/base/') + .with do |request| + request.body.include?('name="tags"') && request.body.include?('cat,featured') + end + .to_return( + status: 200, + body: { 'test.jpg' => 'uploaded-uuid-123' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + result = files.direct(file: tempfile, tags: [' Cat ', 'FEATURED', 'cat']) + + expect(result).to be_success + expect(stub).to have_been_requested + end end describe '#direct_many' do @@ -186,6 +203,20 @@ def original_filename expect(stub).to have_been_requested end + it 'sends normalized tags as a comma-separated value' do + stub = stub_request(:post, 'https://upload.uploadcare.com/from_url/') + .with(body: hash_including('tags' => 'cat,featured')) + .to_return( + status: 200, + body: { token: 'upload-token' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + files.from_url(source_url: source_url, async: true, tags: [' Cat ', 'FEATURED', 'cat']) + + expect(stub).to have_been_requested + end + it 'computes exponential polling intervals with max cap' do expect(files.send(:next_poll_sleep, initial: 1, max_interval: 2, attempt: 0)).to eq(1.0) expect(files.send(:next_poll_sleep, initial: 1, max_interval: 2, attempt: 1)).to eq(2.0) @@ -305,6 +336,25 @@ def original_filename expect(stub).to have_been_requested end + + it 'sends normalized tags as a comma-separated value' do + stub = stub_request(:post, 'https://upload.uploadcare.com/multipart/start/') + .with(body: hash_including('tags' => 'video,featured')) + .to_return( + status: 200, + body: { uuid: 'mp-uuid', parts: [] }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + files.multipart_start( + filename: 'test.mp4', + size: 100_000_000, + content_type: 'video/mp4', + tags: [' Video ', 'FEATURED'] + ) + + expect(stub).to have_been_requested + end end describe '#multipart_complete' do diff --git a/spec/uploadcare/client_spec.rb b/spec/uploadcare/client_spec.rb index ce952cc4..5ed062ae 100644 --- a/spec/uploadcare/client_spec.rb +++ b/spec/uploadcare/client_spec.rb @@ -154,6 +154,16 @@ end end + describe '#file_tags' do + it 'returns a FileTagsAccessor' do + expect(client.file_tags).to be_a(Uploadcare::Client::FileTagsAccessor) + end + + it 'memoizes the accessor' do + expect(client.file_tags).to equal(client.file_tags) + end + end + describe '#conversions' do it 'returns a ConversionsAccessor' do expect(client.conversions).to be_a(Uploadcare::Client::ConversionsAccessor) @@ -481,4 +491,45 @@ end.not_to raise_error end end + + describe 'FileTagsAccessor delegation' do + let(:rest) { instance_double(Uploadcare::Api::Rest) } + let(:rest_file_tags) { instance_double(Uploadcare::Api::Rest::FileTags) } + let(:api_instance) { instance_double(Uploadcare::Client::Api, rest: rest) } + let(:file_uuid) { 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' } + + before do + allow(client).to receive(:api).and_return(api_instance) + allow(rest).to receive(:file_tags).and_return(rest_file_tags) + end + + it 'lists tags' do + allow(rest_file_tags).to receive(:list) + .and_return(Uploadcare::Result.success({ 'tags' => %w[cat animal] })) + + expect(client.file_tags.list(uuid: file_uuid)).to eq(%w[cat animal]) + end + + it 'replaces tags' do + allow(rest_file_tags).to receive(:replace) + .and_return(Uploadcare::Result.success({ 'tags' => ['cat'], 'added' => ['cat'], 'deleted' => [] })) + + result = client.file_tags.replace(uuid: file_uuid, tags: ['Cat']) + + expect(result).to be_a(Uploadcare::Resources::FileTags) + expect(result.tags).to eq(['cat']) + end + + it 'updates tags atomically' do + allow(rest_file_tags).to receive(:update) + .and_return( + Uploadcare::Result.success({ 'tags' => ['featured'], 'added' => ['featured'], 'deleted' => ['draft'] }) + ) + + result = client.file_tags.update(uuid: file_uuid, add: ['featured'], delete: ['draft']) + + expect(result.added).to eq(['featured']) + expect(result.deleted).to eq(['draft']) + end + end end diff --git a/spec/uploadcare/coverage_boost_spec.rb b/spec/uploadcare/coverage_boost_spec.rb index ff162daa..f77c7932 100644 --- a/spec/uploadcare/coverage_boost_spec.rb +++ b/spec/uploadcare/coverage_boost_spec.rb @@ -369,6 +369,7 @@ def config expect(client.webhooks).to be_a(Uploadcare::Client::WebhooksAccessor) expect(client.addons).to be_a(Uploadcare::Client::AddonsAccessor) expect(client.file_metadata).to be_a(Uploadcare::Client::FileMetadataAccessor) + expect(client.file_tags).to be_a(Uploadcare::Client::FileTagsAccessor) expect(client.conversions).to be_a(Uploadcare::Client::ConversionsAccessor) expect(client.conversions.documents).to be_a(Uploadcare::Client::DocumentConversionsAccessor) expect(client.conversions.videos).to be_a(Uploadcare::Client::VideoConversionsAccessor) diff --git a/spec/uploadcare/internal/file_tag_normalizer_spec.rb b/spec/uploadcare/internal/file_tag_normalizer_spec.rb new file mode 100644 index 00000000..51397f0b --- /dev/null +++ b/spec/uploadcare/internal/file_tag_normalizer_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Internal::FileTagNormalizer do + describe '.call' do + it 'strips, lowercases, and deduplicates tags in first-seen order' do + expect(described_class.call([' Cat ', 'ANIMAL', 'cat', 'v1.0'])).to eq(%w[cat animal v1.0]) + end + + it 'returns an empty array for an empty array' do + expect(described_class.call([])).to eq([]) + end + + it 'accepts all supported characters' do + expect(described_class.call(%w[tag-1 tag_2 v1.0])).to eq(%w[tag-1 tag_2 v1.0]) + end + + it 'rejects non-array tag lists and non-string tags' do + expect { described_class.call(nil) }.to raise_error(ArgumentError, /array of strings/) + expect { described_class.call('cat') }.to raise_error(ArgumentError, /array of strings/) + expect { described_class.call(['cat', 1]) }.to raise_error(ArgumentError, /array of strings/) + end + + it 'rejects blank tags' do + expect { described_class.call([' ']) }.to raise_error(ArgumentError, /may not be blank/) + end + + it 'rejects tags longer than 100 characters' do + expect { described_class.call(['a' * 101]) }.to raise_error(ArgumentError, /too long/) + end + + it 'rejects unsupported characters' do + ['has space', 'c++', 'emoji🐈', 'кот'].each do |tag| + expect { described_class.call([tag]) }.to raise_error(ArgumentError, /invalid characters/) + end + end + + it 'counts tags after normalization and deduplication' do + tags = Array.new(51, 'same') + expect(described_class.call(tags)).to eq(['same']) + + unique_tags = 51.times.map { |index| "tag#{index}" } + expect { described_class.call(unique_tags) }.to raise_error(ArgumentError, /too many tags/) + end + + it 'can disable count validation for deletion lists' do + tags = 51.times.map { |index| "tag#{index}" } + expect(described_class.call(tags, max_count: nil)).to eq(tags) + end + end +end diff --git a/spec/uploadcare/internal/upload_params_generator_spec.rb b/spec/uploadcare/internal/upload_params_generator_spec.rb index 518f7869..50e69815 100644 --- a/spec/uploadcare/internal/upload_params_generator_spec.rb +++ b/spec/uploadcare/internal/upload_params_generator_spec.rb @@ -111,6 +111,25 @@ end end + context 'with tags option' do + it 'normalizes tags into the Upload API CSV format' do + result = described_class.call(options: { tags: [' Cat ', 'ANIMAL', 'cat'] }, config: config) + + expect(result['tags']).to eq('cat,animal') + end + + it 'omits tags when nil or empty' do + expect(described_class.call(options: { tags: nil }, config: config)).not_to have_key('tags') + expect(described_class.call(options: { tags: [] }, config: config)).not_to have_key('tags') + end + + it 'rejects invalid tags' do + expect do + described_class.call(options: { tags: ['has space'] }, config: config) + end.to raise_error(ArgumentError, /invalid characters/) + end + end + context 'with explicit signature options' do it 'uses provided signature and expire' do options = { signature: 'abc123', expire: 9_999_999 } @@ -159,6 +178,7 @@ options = { store: true, metadata: { 'env' => 'test' }, + tags: %w[featured production], signature: 'combo-sig', expire: 12_345 } @@ -166,6 +186,7 @@ expect(result['UPLOADCARE_PUB_KEY']).to eq('test-pub-key') expect(result['UPLOADCARE_STORE']).to eq('1') expect(result['metadata[env]']).to eq('test') + expect(result['tags']).to eq('featured,production') expect(result['signature']).to eq('combo-sig') expect(result['expire']).to eq(12_345) end diff --git a/spec/uploadcare/multi_account_spec.rb b/spec/uploadcare/multi_account_spec.rb index fb6d3103..2d80c022 100644 --- a/spec/uploadcare/multi_account_spec.rb +++ b/spec/uploadcare/multi_account_spec.rb @@ -45,6 +45,7 @@ expect(client_a.webhooks).not_to equal(client_b.webhooks) expect(client_a.addons).not_to equal(client_b.addons) expect(client_a.file_metadata).not_to equal(client_b.file_metadata) + expect(client_a.file_tags).not_to equal(client_b.file_tags) expect(client_a.conversions).not_to equal(client_b.conversions) end end diff --git a/spec/uploadcare/resources/file_spec.rb b/spec/uploadcare/resources/file_spec.rb index 9adfc490..b9734ce8 100644 --- a/spec/uploadcare/resources/file_spec.rb +++ b/spec/uploadcare/resources/file_spec.rb @@ -34,6 +34,7 @@ 'variations' => nil, 'content_info' => {}, 'metadata' => {}, + 'tags' => %w[cat featured], 'appdata' => nil, 'source' => nil } @@ -48,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 appdata source + original_filename size url uuid variations content_info metadata tags appdata source ] expect(described_class::ATTRIBUTES).to match_array(expected) end @@ -63,6 +64,7 @@ expect(file.mime_type).to eq('image/jpeg') expect(file.is_image).to be true expect(file.is_ready).to be true + expect(file.tags).to eq(%w[cat featured]) end it 'stores client reference' do diff --git a/spec/uploadcare/resources/file_tags_spec.rb b/spec/uploadcare/resources/file_tags_spec.rb new file mode 100644 index 00000000..da69f155 --- /dev/null +++ b/spec/uploadcare/resources/file_tags_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploadcare::Resources::FileTags 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(:rest) { instance_double(Uploadcare::Api::Rest) } + let(:rest_file_tags) { instance_double(Uploadcare::Api::Rest::FileTags) } + let(:api) { instance_double(Uploadcare::Client::Api, rest: rest) } + let(:file_uuid) { 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' } + + before do + allow(client).to receive(:api).and_return(api) + allow(rest).to receive(:file_tags).and_return(rest_file_tags) + end + + describe '.list' do + it 'returns the current ordered tag list' do + allow(rest_file_tags).to receive(:list) + .with(uuid: file_uuid, request_options: {}) + .and_return(Uploadcare::Result.success({ 'tags' => %w[cat animal] })) + + expect(described_class.list(uuid: file_uuid, client: client)).to eq(%w[cat animal]) + end + end + + describe '.replace' do + it 'normalizes tags and returns the tag change resource' do + allow(rest_file_tags).to receive(:replace) + .with(uuid: file_uuid, tags: %w[cat animal], request_options: {}) + .and_return( + Uploadcare::Result.success( + { 'tags' => %w[cat animal], 'added' => %w[animal cat], 'deleted' => ['old'] } + ) + ) + + result = described_class.replace(uuid: file_uuid, tags: [' Cat ', 'ANIMAL', 'cat'], client: client) + + expect(result.tags).to eq(%w[cat animal]) + expect(result.added).to eq(%w[animal cat]) + expect(result.deleted).to eq(['old']) + expect(result.uuid).to eq(file_uuid) + end + + it 'sends an empty array to clear all tags' do + allow(rest_file_tags).to receive(:replace) + .with(uuid: file_uuid, tags: [], request_options: {}) + .and_return(Uploadcare::Result.success({ 'tags' => [], 'added' => [], 'deleted' => ['old'] })) + + result = described_class.replace(uuid: file_uuid, tags: [], client: client) + + expect(result.tags).to eq([]) + expect(result.deleted).to eq(['old']) + end + + it 'rejects nil instead of clearing tags' do + expect do + described_class.replace(uuid: file_uuid, tags: nil, client: client) + end.to raise_error(ArgumentError, /array of strings/) + end + end + + describe '.update' do + it 'normalizes additions and deletions and returns actual changes' do + allow(rest_file_tags).to receive(:update) + .with(uuid: file_uuid, add: %w[summer featured], delete: ['draft'], request_options: {}) + .and_return( + Uploadcare::Result.success( + { 'tags' => %w[summer featured], 'added' => %w[summer featured], 'deleted' => ['draft'] } + ) + ) + + result = described_class.update( + uuid: file_uuid, + add: [' Summer ', 'FEATURED', 'summer'], + delete: ['DRAFT'], + client: client + ) + + expect(result.tags).to eq(%w[summer featured]) + expect(result.added).to eq(%w[summer featured]) + expect(result.deleted).to eq(['draft']) + end + end + + describe 'instance operations' do + subject(:resource) { described_class.new({ uuid: file_uuid }, client) } + + it 'refreshes its tag state with #list' do + allow(rest_file_tags).to receive(:list) + .and_return(Uploadcare::Result.success({ 'tags' => ['current'] })) + + expect(resource.list).to equal(resource) + expect(resource.tags).to eq(['current']) + end + end +end diff --git a/spec/uploadcare_spec.rb b/spec/uploadcare_spec.rb index 0796823c..73e975d0 100644 --- a/spec/uploadcare_spec.rb +++ b/spec/uploadcare_spec.rb @@ -123,6 +123,10 @@ expect(Uploadcare::Webhook).to eq(Uploadcare::Resources::Webhook) end + it 'aliases Resources::FileTags as FileTags' do + expect(Uploadcare::FileTags).to eq(Uploadcare::Resources::FileTags) + end + it 'aliases Resources::AddonExecution as AddonExecution' do expect(Uploadcare::AddonExecution).to eq(Uploadcare::Resources::AddonExecution) end From 68bc001beedbfd54d9c798c2d4c9bcf6af6f405b Mon Sep 17 00:00:00 2001 From: Alex Gusev Date: Fri, 7 Aug 2026 16:59:27 +0600 Subject: [PATCH 02/13] Fix flaky multipart upload specs by forcing sequential path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "sequential upload (threads <= 1)" and progress-callback contexts never passed `threads:`, so `normalize_upload_options` fell back to `config.upload_threads`, which defaults to 2. Both contexts therefore ran the parallel path with two workers while asserting strict part ordering, failing intermittently on loaded CI runners. Pin `upload_threads: 1` in those contexts so the sequential path is actually exercised — it had no coverage before. Co-Authored-By: Claude Opus 5 (1M context) --- .../operations/multipart_upload_spec.rb | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/spec/uploadcare/operations/multipart_upload_spec.rb b/spec/uploadcare/operations/multipart_upload_spec.rb index 5c86d045..0a19ce55 100644 --- a/spec/uploadcare/operations/multipart_upload_spec.rb +++ b/spec/uploadcare/operations/multipart_upload_spec.rb @@ -114,6 +114,16 @@ end context 'when performing sequential upload (threads <= 1)' do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'demopublickey', + secret_key: 'demosecretkey', + auth_type: 'Uploadcare.Simple', + multipart_chunk_size: 1024, + upload_threads: 1 + ) + end + before do allow(upload_client).to receive(:upload_part_to_url) allow(upload_files_api).to receive_messages(multipart_start: Uploadcare::Result.success(start_response), multipart_complete: Uploadcare::Result.success({ 'uuid' => 'mp-uuid-123' })) @@ -166,7 +176,8 @@ auth_type: 'Uploadcare.Simple', multipart_chunk_size: 1024, upload_timeout: 45, - max_upload_retries: 7 + max_upload_retries: 7, + upload_threads: 1 ) tuned_uploader = described_class.new(upload_client: upload_client, config: tuned_config) @@ -208,7 +219,7 @@ it 'uses custom part_size from options' do custom_config = Uploadcare::Configuration.new( public_key: 'pk', secret_key: 'sk', auth_type: 'Uploadcare.Simple', - multipart_chunk_size: 2048 + multipart_chunk_size: 2048, upload_threads: 1 ) custom_uploader = described_class.new(upload_client: upload_client, config: custom_config) @@ -233,6 +244,16 @@ end context 'when reporting progress via block callback' do + let(:config) do + Uploadcare::Configuration.new( + public_key: 'demopublickey', + secret_key: 'demosecretkey', + auth_type: 'Uploadcare.Simple', + multipart_chunk_size: 1024, + upload_threads: 1 + ) + end + before do allow(upload_client).to receive(:upload_part_to_url) allow(upload_files_api).to receive_messages(multipart_start: Uploadcare::Result.success(start_response), multipart_complete: Uploadcare::Result.success({ 'uuid' => 'mp-uuid-123' })) @@ -508,7 +529,7 @@ def seek(_pos) = nil allow(upload_client).to receive(:upload_part_to_url) .and_raise(Uploadcare::Exception::MultipartUploadError, 'part upload failed') - result = uploader.upload(file: tempfile) + result = uploader.upload(file: tempfile, threads: 1) expect(result.failure?).to be(true) expect(result.error).to be_a(Uploadcare::Exception::MultipartUploadError) end From d64b6a16b07f870a9e3cc514e9fe9eecc7543c92 Mon Sep 17 00:00:00 2001 From: Alex Gusev Date: Fri, 7 Aug 2026 16:52:52 +0600 Subject: [PATCH 03/13] File search --- CHANGELOG.md | 2 + README.md | 42 ++++++ api_examples/README.md | 3 +- api_examples/rest_api/post_files_search.rb | 4 + api_examples/support/example_helper.rb | 15 +++ api_examples/support/run_rest_example.rb | 14 ++ lib/uploadcare/api/rest.rb | 29 ++-- lib/uploadcare/api/rest/files.rb | 19 ++- lib/uploadcare/client/files_accessor.rb | 11 ++ .../collections/file_search_result.rb | 43 ++++++ lib/uploadcare/collections/paginated.rb | 8 +- lib/uploadcare/operations/file_search.rb | 66 +++++++++ lib/uploadcare/resources/file.rb | 38 +++--- spec/api_examples/example_helper_spec.rb | 40 ++++++ spec/uploadcare/api/rest/files_spec.rb | 45 +++++++ spec/uploadcare/api/rest_spec.rb | 61 +++++++++ spec/uploadcare/client_spec.rb | 13 ++ .../collections/file_search_result_spec.rb | 126 ++++++++++++++++++ spec/uploadcare/resources/file_spec.rb | 62 ++++++++- 19 files changed, 612 insertions(+), 29 deletions(-) create mode 100755 api_examples/rest_api/post_files_search.rb create mode 100644 lib/uploadcare/collections/file_search_result.rb create mode 100644 lib/uploadcare/operations/file_search.rb create mode 100644 spec/api_examples/example_helper_spec.rb create mode 100644 spec/uploadcare/collections/file_search_result_spec.rb 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 6e899528..0feac94b 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,48 @@ Filters and API parameters can still be passed through: files = client.files.list(stored: true, removed: false, limit: 100) ``` +### Search files + +Search across filenames, 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; `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 8b42ef7d..afc5e4c7 100644 --- a/api_examples/README.md +++ b/api_examples/README.md @@ -21,13 +21,14 @@ Optional environment variables: Verification: -- Verified against a real Uploadcare demo account on `2026-08-07` +- Examples were verified against a real Uploadcare demo account on `2026-08-07`. ## REST API 0.7 | 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 d414c9f6..0dc5e593 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/lib/uploadcare/api/rest.rb b/lib/uploadcare/api/rest.rb index 29b5f75b..bec7a750 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, :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..20a717b0 100644 --- a/lib/uploadcare/client/files_accessor.rb +++ b/lib/uploadcare/client/files_accessor.rb @@ -28,6 +28,17 @@ def list(request_options: {}, **options) ) end + # Search files using full-text criteria and structured filters. + # + # @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..78904513 --- /dev/null +++ b/lib/uploadcare/collections/file_search_result.rb @@ -0,0 +1,43 @@ +# 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 + + def initialize(params = {}) + @search_params = immutable_copy(params[:search_params] || {}) + 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) + Uploadcare::Result.unwrap( + api_client.search(params: search_params, query: params, request_options: request_options) + ) + end + + def continuation_options + { search_params: search_params } + 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..e1a39dd6 --- /dev/null +++ b/lib/uploadcare/operations/file_search.rb @@ -0,0 +1,66 @@ +# 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 + + def self.call(options:, client:, resource_class:, request_options: {}) + new(options: options, client: client, resource_class: resource_class, request_options: request_options).call + end + + def initialize(options:, client:, resource_class:, request_options: {}) + @options = options + @client = client + @resource_class = resource_class + @request_options = request_options + end + + def call + response = Uploadcare::Result.unwrap( + client.api.rest.files.search( + params: search_params, query: query_params, request_options: request_options + ) + ) + + build_result(response) + end + + private + + attr_reader :client, :options, :request_options, :resource_class + + def search_params + @search_params ||= split_options.first + end + + def query_params + @query_params ||= split_options.last + end + + def split_options + @split_options ||= begin + 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 + + def build_result(response) + 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 + ) + 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..b7c74322 --- /dev/null +++ b/spec/uploadcare/collections/file_search_result_spec.rb @@ -0,0 +1,126 @@ +# 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(:collection) do + described_class.new( + resources: [first_file], + next_page: 'https://api.uploadcare.com/files/search/?limit=2&offset=50&include=appdata', + 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 + ) + 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: { 'limit' => '2', 'offset' => '50', 'include' => 'appdata' }, + 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) + 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: { 'limit' => '2', 'offset' => '50', 'include' => 'appdata' }, + request_options: { timeout: 5 } + ) + .and_return(Uploadcare::Result.success(empty_page)) + allow(api_client).to receive(:search) + .with( + params: search_params, + query: { '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..e9a6b319 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,66 @@ 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') + 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) From 33478014422eed8ce968bbc9d4885fe2196fb0aa Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:14:45 +0530 Subject: [PATCH 04/13] Fix blank file tag normalization --- README.md | 2 +- lib/uploadcare/internal/file_tag_normalizer.rb | 3 ++- spec/uploadcare/internal/file_tag_normalizer_spec.rb | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6e899528..9b5ba31c 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,7 @@ File responses expose the ordered tag list through `file.tags` when the field is ## File Tags -Tags can be attached during direct, URL, batch, and multipart uploads with the `tags:` option. The SDK normalizes tags to lowercase, strips surrounding whitespace, removes duplicates while preserving order, and validates the platform limits. +Tags can be attached during direct, URL, batch, and multipart uploads with the `tags:` option. The SDK normalizes tags to lowercase, strips surrounding whitespace, discards blank tags, removes duplicates while preserving order, and validates the platform limits. Read or replace the complete tag list: diff --git a/lib/uploadcare/internal/file_tag_normalizer.rb b/lib/uploadcare/internal/file_tag_normalizer.rb index a41a3523..3fb70ea2 100644 --- a/lib/uploadcare/internal/file_tag_normalizer.rb +++ b/lib/uploadcare/internal/file_tag_normalizer.rb @@ -33,6 +33,8 @@ def normalize(tags) raise ArgumentError, 'tags must be an array of strings' unless tag.is_a?(String) value = tag.strip.downcase + next if value.empty? + validate_tag(value) next if seen[value] @@ -42,7 +44,6 @@ def normalize(tags) end def validate_tag(tag) - raise ArgumentError, 'tag may not be blank' if tag.empty? if tag.length > MAX_LENGTH raise ArgumentError, "tag is too long: #{tag.length} characters (maximum #{MAX_LENGTH})" end diff --git a/spec/uploadcare/internal/file_tag_normalizer_spec.rb b/spec/uploadcare/internal/file_tag_normalizer_spec.rb index 51397f0b..14f009a4 100644 --- a/spec/uploadcare/internal/file_tag_normalizer_spec.rb +++ b/spec/uploadcare/internal/file_tag_normalizer_spec.rb @@ -22,8 +22,8 @@ expect { described_class.call(['cat', 1]) }.to raise_error(ArgumentError, /array of strings/) end - it 'rejects blank tags' do - expect { described_class.call([' ']) }.to raise_error(ArgumentError, /may not be blank/) + it 'discards blank tags' do + expect(described_class.call(['cat', '', ' ', 'dog'])).to eq(%w[cat dog]) end it 'rejects tags longer than 100 characters' do From c7f578a9a8042968950086223d3cedac18bc95ee Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:14:45 +0530 Subject: [PATCH 05/13] Fix documentation and RuboCop compatibility --- lib/uploadcare/api/rest.rb | 2 +- lib/uploadcare/api/upload/files.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/uploadcare/api/rest.rb b/lib/uploadcare/api/rest.rb index 29b5f75b..68ad1d73 100644 --- a/lib/uploadcare/api/rest.rb +++ b/lib/uploadcare/api/rest.rb @@ -95,7 +95,7 @@ def video_conversions # Make an HTTP request to the REST API. # - # @param method [Symbol] HTTP method (:get, :post, :put, :delete) + # @param method [Symbol] HTTP method (:get, :post, :put, :patch, :delete) # @param path [String] API endpoint path # @param params [Hash, Array, String] Request parameters # @param headers [Hash] Additional request headers diff --git a/lib/uploadcare/api/upload/files.rb b/lib/uploadcare/api/upload/files.rb index b08a4ab1..5d568344 100644 --- a/lib/uploadcare/api/upload/files.rb +++ b/lib/uploadcare/api/upload/files.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Upload API endpoint for file upload operations. -# rubocop:disable Metrics/ClassLength +# rubocop:disable-next Metrics/ClassLength class Uploadcare::Api::Upload::Files # @return [Uploadcare::Api::Upload] Parent Upload client attr_reader :upload @@ -324,4 +324,3 @@ def next_poll_sleep(initial:, max_interval:, attempt:) [initial.to_f * (2**attempt), max_interval.to_f].min end end -# rubocop:enable Metrics/ClassLength From c9a516b4ae0f676b4348c4604fe04c61bc4ebfaf Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:16:03 +0530 Subject: [PATCH 06/13] Simplify file search operation --- lib/uploadcare/operations/file_search.rb | 68 ++++++++---------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/lib/uploadcare/operations/file_search.rb b/lib/uploadcare/operations/file_search.rb index e1a39dd6..c7ed6959 100644 --- a/lib/uploadcare/operations/file_search.rb +++ b/lib/uploadcare/operations/file_search.rb @@ -4,41 +4,32 @@ class Uploadcare::Operations::FileSearch QUERY_OPTIONS = %i[limit offset include].freeze - def self.call(options:, client:, resource_class:, request_options: {}) - new(options: options, client: client, resource_class: resource_class, request_options: request_options).call - end - - def initialize(options:, client:, resource_class:, request_options: {}) - @options = options - @client = client - @resource_class = resource_class - @request_options = request_options - end - - def call - response = Uploadcare::Result.unwrap( - client.api.rest.files.search( - params: search_params, query: query_params, request_options: request_options + 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 + ) ) - ) - - build_result(response) - end - - private - attr_reader :client, :options, :request_options, :resource_class + 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 + ) + end - def search_params - @search_params ||= split_options.first - end + private - def query_params - @query_params ||= split_options.last - end - - def split_options - @split_options ||= begin + 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] @@ -48,19 +39,4 @@ def split_options [body, query.compact] end end - - def build_result(response) - 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 - ) - end end From 3ab7bb2167fb3362d1ac8c9744c4c231e59b92d9 Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:31:01 +0530 Subject: [PATCH 07/13] Document file tags and upload examples --- README.md | 27 +++++++++++++++---- api_examples/README.md | 6 ++--- api_examples/support/example_helper.rb | 3 ++- api_examples/support/run_upload_example.rb | 10 +++++-- examples/README.md | 2 ++ examples/file_tags.rb | 30 +++++++++++++++++++++ lib/uploadcare/client/file_tags_accessor.rb | 4 +++ 7 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 examples/file_tags.rb diff --git a/README.md b/README.md index 9b5ba31c..6350d652 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ files = [ File.open("photo-2.jpg", "rb") ] -uploaded = client.uploads.upload(files, store: true) +uploaded = client.uploads.upload(files, store: true, tags: ["gallery", "batch"]) files.each(&:close) ``` @@ -291,7 +291,11 @@ files.each(&:close) Synchronous: ```ruby -file = client.files.upload_from_url("https://example.com/image.jpg", store: true) +file = client.files.upload_from_url( + "https://example.com/image.jpg", + store: true, + tags: ["remote", "example"] +) ``` Async: @@ -313,7 +317,12 @@ Polling options for synchronous URL uploads: ```ruby File.open("large-video.mp4", "rb") do |io| - file = client.uploads.multipart_upload(file: io, store: true, threads: 4) do |progress| + file = client.uploads.multipart_upload( + file: io, + store: true, + threads: 4, + tags: ["video", "multipart"] + ) do |progress| uploaded = progress[:uploaded] total = progress[:total] puts "#{uploaded}/#{total}" @@ -437,6 +446,7 @@ File responses expose the ordered tag list through `file.tags` when the field is ## File Tags Tags can be attached during direct, URL, batch, and multipart uploads with the `tags:` option. The SDK normalizes tags to lowercase, strips surrounding whitespace, discards blank tags, removes duplicates while preserving order, and validates the platform limits. +Upload responses do not include tags; use `client.file_tags.list` or reload the file through the REST API to read them. Read or replace the complete tag list: @@ -453,6 +463,9 @@ puts change.added puts change.deleted ``` +Mutation responses expose the resulting ordered `tags`, the tags actually `added`, and the tags actually `deleted`. +Replacing with the same normalized set is safe and reports no additions or deletions. + Add and delete tags atomically (deletions are applied first): ```ruby @@ -649,10 +662,14 @@ Upload API: ```ruby File.open("photo.jpg", "rb") do |io| - client.api.upload.files.direct(file: io, store: true) + client.api.upload.files.direct(file: io, store: true, tags: ["photo", "example"]) end -client.api.upload.files.from_url(source_url: "https://example.com/image.jpg", async: true) +client.api.upload.files.from_url( + source_url: "https://example.com/image.jpg", + async: true, + tags: ["remote", "example"] +) client.api.upload.groups.create(files: ["uuid-1", "uuid-2"]) ``` diff --git a/api_examples/README.md b/api_examples/README.md index 8b42ef7d..b671672a 100644 --- a/api_examples/README.md +++ b/api_examples/README.md @@ -68,11 +68,11 @@ Verification: | Endpoint | Example file | Notes | | --- | --- | --- | -| `POST /base/` | `api_examples/upload_api/post_base.rb` | Uses raw upload API | -| `POST /multipart/start/` | `api_examples/upload_api/post_multipart_start.rb` | Starts and completes a real multipart upload | +| `POST /base/` | `api_examples/upload_api/post_base.rb` | Uses raw upload API with upload-time tags | +| `POST /multipart/start/` | `api_examples/upload_api/post_multipart_start.rb` | Starts and completes a real multipart upload with tags | | `PUT ` | `api_examples/upload_api/put_multipart_part.rb` | Uploads one part via gem multipart helper | | `POST /multipart/complete/` | `api_examples/upload_api/post_multipart_complete.rb` | Completes a real multipart upload | -| `POST /from_url/` | `api_examples/upload_api/post_from_url.rb` | Uses raw upload API | +| `POST /from_url/` | `api_examples/upload_api/post_from_url.rb` | Uses raw upload API with upload-time tags | | `GET /from_url/status/` | `api_examples/upload_api/get_from_url_status.rb` | Starts async upload then checks status | | `GET /info/` | `api_examples/upload_api/get_info.rb` | Uses raw upload API | | `POST /group/` | `api_examples/upload_api/post_group.rb` | Uses raw upload API | diff --git a/api_examples/support/example_helper.rb b/api_examples/support/example_helper.rb index d414c9f6..cf232552 100755 --- a/api_examples/support/example_helper.rb +++ b/api_examples/support/example_helper.rb @@ -124,7 +124,8 @@ def with_multipart_session size: file.size, content_type: 'image/jpeg', part_size: multipart_part_size, - store: true + store: true, + tags: %w[example multipart] ) ) yield file, response diff --git a/api_examples/support/run_upload_example.rb b/api_examples/support/run_upload_example.rb index ff7cd406..d1b9cd8d 100755 --- a/api_examples/support/run_upload_example.rb +++ b/api_examples/support/run_upload_example.rb @@ -71,13 +71,19 @@ def call def run_base_upload(client) ApiExamples::ExampleHelper.with_fixture_file('kitten.jpeg') do |handle| - ApiExamples::ExampleHelper.unwrap(client.api.upload.files.direct(file: handle, store: true)) + ApiExamples::ExampleHelper.unwrap( + client.api.upload.files.direct(file: handle, store: true, tags: %w[example base]) + ) end end def run_url_upload(client) response = ApiExamples::ExampleHelper.unwrap( - client.api.upload.files.from_url(source_url: ApiExamples::ExampleHelper::SAMPLE_IMAGE_URL, store: true) + client.api.upload.files.from_url( + source_url: ApiExamples::ExampleHelper::SAMPLE_IMAGE_URL, + store: true, + tags: %w[example remote] + ) ) response ensure diff --git a/examples/README.md b/examples/README.md index 035c829d..eba514ab 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_tags.rb` + Upload a file with tags, then list, replace, add, and delete tags. - `examples/group_creation.rb` Upload multiple files, create a group, and print group details. diff --git a/examples/file_tags.rb b/examples/file_tags.rb new file mode 100644 index 00000000..bd238e74 --- /dev/null +++ b/examples/file_tags.rb @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../lib/uploadcare' +require 'dotenv/load' + +file_path = ARGV[0] + +unless file_path && File.exist?(file_path) + puts 'Usage: ruby file_tags.rb ' + puts 'Example: ruby file_tags.rb photo.jpg' + exit 1 +end + +client = Uploadcare::Client.new( + public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY'), + secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY') +) + +file = File.open(file_path, 'rb') do |io| + client.files.upload(io, store: true, tags: %w[example draft]) +end + +puts "Uploaded #{file.uuid} with tags: #{client.file_tags.list(uuid: file.uuid).join(', ')}" + +change = client.file_tags.replace(uuid: file.uuid, tags: %w[example approved]) +puts "Replaced tags: #{change.tags.join(', ')}" + +change = client.file_tags.update(uuid: file.uuid, add: ['featured'], delete: ['example']) +puts "Updated tags: #{change.tags.join(', ')}" diff --git a/lib/uploadcare/client/file_tags_accessor.rb b/lib/uploadcare/client/file_tags_accessor.rb index 342cd7d2..4cadb280 100644 --- a/lib/uploadcare/client/file_tags_accessor.rb +++ b/lib/uploadcare/client/file_tags_accessor.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true # Per-file tag operations scoped to a client instance. +# +# @example Replace and update tags +# client.file_tags.replace(uuid: file.uuid, tags: %w[approved summer]) +# client.file_tags.update(uuid: file.uuid, add: ["featured"], delete: ["summer"]) class Uploadcare::Client::FileTagsAccessor attr_reader :client From 3e54ea115ac819d90b61f6850c67dcb426e8d9ec Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:31:34 +0530 Subject: [PATCH 08/13] Document file search workflow --- README.md | 6 ++++-- examples/README.md | 2 ++ examples/file_search.rb | 28 +++++++++++++++++++++++++ lib/uploadcare/client/files_accessor.rb | 6 ++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 examples/file_search.rb diff --git a/README.md b/README.md index c7d05e7e..44f3ba9d 100644 --- a/README.md +++ b/README.md @@ -436,8 +436,10 @@ matches = client.files.search( `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; `fuzziness: true` enables typo-tolerant matching but increases latency. Newly uploaded files -may take a short time to appear in the search index. +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 diff --git a/examples/README.md b/examples/README.md index 035c829d..b729d46f 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/group_creation.rb` Upload multiple files, create a group, and print group details. diff --git a/examples/file_search.rb b/examples/file_search.rb new file mode 100644 index 00000000..4bf650fd --- /dev/null +++ b/examples/file_search.rb @@ -0,0 +1,28 @@ +#!/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 + +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.length} files" if next_page diff --git a/lib/uploadcare/client/files_accessor.rb b/lib/uploadcare/client/files_accessor.rb index 20a717b0..83697c05 100644 --- a/lib/uploadcare/client/files_accessor.rb +++ b/lib/uploadcare/client/files_accessor.rb @@ -30,6 +30,12 @@ def list(request_options: {}, **options) # 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] From 5a6a6d886f9d133ae005886a06f26f8e7f3add2f Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:34:46 +0530 Subject: [PATCH 09/13] Complete file tag documentation guidance --- api_examples/README.md | 3 ++- context7.json | 3 ++- examples/file_tags.rb | 29 +++++++++++++++++------------ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/api_examples/README.md b/api_examples/README.md index b671672a..c8764c1d 100644 --- a/api_examples/README.md +++ b/api_examples/README.md @@ -21,7 +21,8 @@ Optional environment variables: Verification: -- Verified against a real Uploadcare demo account on `2026-08-07` +- The endpoint suite was verified against a real Uploadcare demo account on `2026-08-07`. +- Run changed examples against a disposable project before release; they create and remove temporary resources. ## REST API 0.7 diff --git a/context7.json b/context7.json index d61b65e5..ee557cff 100644 --- a/context7.json +++ b/context7.json @@ -52,9 +52,10 @@ "Prefer explicit `Uploadcare::Client` instances for application code, especially when an app uses more than one Uploadcare project.", "Configure credentials with `Uploadcare::Client.new(public_key: ..., secret_key: ...)`, `Uploadcare.configure`, or the UPLOADCARE_PUBLIC_KEY and UPLOADCARE_SECRET_KEY environment variables.", "Never hardcode real Uploadcare API keys in examples or application code; use environment variables or an application secrets store.", - "Use `client.files`, `client.groups`, `client.uploads`, `client.project`, `client.webhooks`, `client.file_metadata`, `client.addons`, and `client.conversions` for normal application workflows.", + "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.", + "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." ], "previousVersions": [ diff --git a/examples/file_tags.rb b/examples/file_tags.rb index bd238e74..317fee79 100644 --- a/examples/file_tags.rb +++ b/examples/file_tags.rb @@ -12,19 +12,24 @@ exit 1 end -client = Uploadcare::Client.new( - public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY'), - secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY') -) +begin + client = Uploadcare::Client.new( + public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY'), + secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY') + ) -file = File.open(file_path, 'rb') do |io| - client.files.upload(io, store: true, tags: %w[example draft]) -end + file = File.open(file_path, 'rb') do |io| + client.files.upload(io, store: true, tags: %w[example draft]) + end -puts "Uploaded #{file.uuid} with tags: #{client.file_tags.list(uuid: file.uuid).join(', ')}" + puts "Uploaded #{file.uuid} with tags: #{client.file_tags.list(uuid: file.uuid).join(', ')}" -change = client.file_tags.replace(uuid: file.uuid, tags: %w[example approved]) -puts "Replaced tags: #{change.tags.join(', ')}" + change = client.file_tags.replace(uuid: file.uuid, tags: %w[example approved]) + puts "Replaced tags: #{change.tags.join(', ')}" -change = client.file_tags.update(uuid: file.uuid, add: ['featured'], delete: ['example']) -puts "Updated tags: #{change.tags.join(', ')}" + change = client.file_tags.update(uuid: file.uuid, add: ['featured'], delete: ['example']) + puts "Updated tags: #{change.tags.join(', ')}" +rescue StandardError => e + warn "File tag example failed: #{e.message}" + exit 1 +end From 335e20625fc0c8bc78f4708e31c8d75326ff2feb Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:35:20 +0530 Subject: [PATCH 10/13] Make file tag example executable --- examples/file_tags.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 examples/file_tags.rb diff --git a/examples/file_tags.rb b/examples/file_tags.rb old mode 100644 new mode 100755 From ceef639c93b6393df8d3c74d02fcb69b418e1596 Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 18:35:30 +0530 Subject: [PATCH 11/13] Complete file search documentation guidance --- context7.json | 1 + examples/file_search.rb | 27 ++++++++++++++++----------- 2 files changed, 17 insertions(+), 11 deletions(-) mode change 100644 => 100755 examples/file_search.rb diff --git a/context7.json b/context7.json index d61b65e5..798453f0 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.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.", "Use `MIGRATING_V5.md` when upgrading applications from uploadcare-ruby v4.x to v5." ], "previousVersions": [ diff --git a/examples/file_search.rb b/examples/file_search.rb old mode 100644 new mode 100755 index 4bf650fd..f33919d0 --- a/examples/file_search.rb +++ b/examples/file_search.rb @@ -12,17 +12,22 @@ exit 1 end -client = Uploadcare::Client.new( - public_key: ENV.fetch('UPLOADCARE_PUBLIC_KEY'), - secret_key: ENV.fetch('UPLOADCARE_SECRET_KEY') -) +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) + 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 + 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.length} files" if next_page + next_page = results.next_page + puts "Next page contains #{next_page.length} files" if next_page +rescue StandardError => e + warn "File search example failed: #{e.message}" + exit 1 +end From 84993dfd31ee99aa178fa9095e77942f9ee42710 Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 19:11:23 +0530 Subject: [PATCH 12/13] Clean up file tag example uploads --- examples/file_tags.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/file_tags.rb b/examples/file_tags.rb index 317fee79..a19a5677 100755 --- a/examples/file_tags.rb +++ b/examples/file_tags.rb @@ -32,4 +32,6 @@ rescue StandardError => e warn "File tag example failed: #{e.message}" exit 1 +ensure + client.api.rest.files.delete(uuid: file.uuid) if file&.uuid end From f0008ee265df8798c005f4ae4c633d783ef51195 Mon Sep 17 00:00:00 2001 From: Vipul A M Date: Tue, 25 Aug 2026 19:36:06 +0530 Subject: [PATCH 13/13] Fix file search pagination review feedback --- README.md | 2 +- examples/file_search.rb | 2 +- .../collections/file_search_result.rb | 8 +++--- lib/uploadcare/operations/file_search.rb | 3 ++- .../collections/file_search_result_spec.rb | 25 +++++++++++++++---- spec/uploadcare/resources/file_spec.rb | 1 + 6 files changed, 30 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 13a5d1e2..1cb4bf7c 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,7 @@ files = client.files.list(stored: true, removed: false, limit: 100) ### Search files -Search across filenames, metadata, and detected MIME types: +Search across filenames, file UUIDs, metadata, and detected MIME types: ```ruby matches = client.files.search( diff --git a/examples/file_search.rb b/examples/file_search.rb index f33919d0..59f82e05 100755 --- a/examples/file_search.rb +++ b/examples/file_search.rb @@ -26,7 +26,7 @@ end next_page = results.next_page - puts "Next page contains #{next_page.length} files" if 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 diff --git a/lib/uploadcare/collections/file_search_result.rb b/lib/uploadcare/collections/file_search_result.rb index 78904513..af190485 100644 --- a/lib/uploadcare/collections/file_search_result.rb +++ b/lib/uploadcare/collections/file_search_result.rb @@ -7,10 +7,11 @@ # 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 + 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 @@ -32,12 +33,13 @@ def immutable_copy(value) end def fetch_response(params) + query = search_query.transform_keys(&:to_s).merge(params) Uploadcare::Result.unwrap( - api_client.search(params: search_params, query: params, request_options: request_options) + api_client.search(params: search_params, query: query, request_options: request_options) ) end def continuation_options - { search_params: search_params } + { search_params: search_params, search_query: search_query } end end diff --git a/lib/uploadcare/operations/file_search.rb b/lib/uploadcare/operations/file_search.rb index c7ed6959..6e8b88d3 100644 --- a/lib/uploadcare/operations/file_search.rb +++ b/lib/uploadcare/operations/file_search.rb @@ -23,7 +23,8 @@ def call(options:, client:, resource_class:, request_options: {}) resource_class: resource_class, client: client, request_options: request_options, - search_params: search_params + search_params: search_params, + search_query: query_params ) end diff --git a/spec/uploadcare/collections/file_search_result_spec.rb b/spec/uploadcare/collections/file_search_result_spec.rb index b7c74322..5f1e395b 100644 --- a/spec/uploadcare/collections/file_search_result_spec.rb +++ b/spec/uploadcare/collections/file_search_result_spec.rb @@ -21,10 +21,11 @@ } 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&include=appdata', + next_page: 'https://api.uploadcare.com/files/search/?limit=2&offset=50', previous_page: nil, per_page: 2, total: 100, @@ -32,7 +33,8 @@ resource_class: resource_class, client: client, request_options: { timeout: 5 }, - search_params: search_params + search_params: search_params, + search_query: search_query ) end @@ -53,7 +55,7 @@ allow(api_client).to receive(:search) .with( params: search_params, - query: { 'limit' => '2', 'offset' => '50', 'include' => 'appdata' }, + query: { 'include' => 'appdata', 'limit' => '2', 'offset' => '50' }, request_options: { timeout: 5 } ) .and_return(Uploadcare::Result.success(response)) @@ -66,6 +68,19 @@ '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 @@ -108,14 +123,14 @@ allow(api_client).to receive(:search) .with( params: search_params, - query: { 'limit' => '2', 'offset' => '50', 'include' => 'appdata' }, + 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: { 'limit' => '2', 'offset' => '52' }, + query: { 'include' => 'appdata', 'limit' => '2', 'offset' => '52' }, request_options: { timeout: 5 } ) .and_return(Uploadcare::Result.success(final_page)) diff --git a/spec/uploadcare/resources/file_spec.rb b/spec/uploadcare/resources/file_spec.rb index e9a6b319..e3eec261 100644 --- a/spec/uploadcare/resources/file_spec.rb +++ b/spec/uploadcare/resources/file_spec.rb @@ -177,6 +177,7 @@ '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