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/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 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