Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Loading