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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions api_examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions api_examples/rest_api/get_files_uuid_tags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative '../support/run_rest_example'
4 changes: 4 additions & 0 deletions api_examples/rest_api/patch_files_uuid_tags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative '../support/run_rest_example'
4 changes: 4 additions & 0 deletions api_examples/rest_api/put_files_uuid_tags.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require_relative '../support/run_rest_example'
16 changes: 16 additions & 0 deletions api_examples/support/run_rest_example.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lib/uploadcare.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion lib/uploadcare/api/rest.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions lib/uploadcare/api/rest/file_tags.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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<String>, nil] Tags to add
# @param delete [Array<String>, 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
20 changes: 17 additions & 3 deletions lib/uploadcare/api/upload/files.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +32,7 @@ def direct(file:, request_options: {}, **options)
# Upload multiple files directly (POST /base/).
#
# @param files [Array<File, IO>] 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
Expand Down Expand Up @@ -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<String>] :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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?
Comment thread
coderabbitai[bot] marked this conversation as resolved.
params.merge!(signature_params(options))
params
end
Expand All @@ -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
Expand Down Expand Up @@ -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?

Expand Down
7 changes: 7 additions & 0 deletions lib/uploadcare/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
40 changes: 40 additions & 0 deletions lib/uploadcare/client/file_tags_accessor.rb
Original file line number Diff line number Diff line change
@@ -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<String>]
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<String>]
# @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<String>]
# @param delete [Array<String>]
# @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
Loading