uploadcare-ruby 4.4.3 → 5.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +7 -0
  3. data/.github/workflows/gem-push.yml +1 -1
  4. data/.github/workflows/ruby.yml +10 -13
  5. data/.gitignore +9 -0
  6. data/.rubocop.yml +95 -8
  7. data/CHANGELOG.md +71 -1
  8. data/Gemfile +23 -6
  9. data/MIGRATING_V5.md +290 -0
  10. data/README.md +422 -671
  11. data/Rakefile +5 -1
  12. data/api_examples/README.md +77 -0
  13. data/api_examples/rest_api/delete_files_storage.rb +3 -5
  14. data/api_examples/rest_api/delete_files_uuid_metadata_key.rb +3 -4
  15. data/api_examples/rest_api/delete_files_uuid_storage.rb +3 -4
  16. data/api_examples/rest_api/delete_groups_uuid.rb +3 -4
  17. data/api_examples/rest_api/delete_webhooks_unsubscribe.rb +3 -4
  18. data/api_examples/rest_api/get_addons_aws_rekognition_detect_labels_execute_status.rb +3 -6
  19. data/api_examples/rest_api/get_addons_aws_rekognition_detect_moderation_labels_execute_status.rb +3 -6
  20. data/api_examples/rest_api/get_addons_remove_bg_execute_status.rb +3 -6
  21. data/api_examples/rest_api/get_addons_uc_clamav_virus_scan_execute_status.rb +3 -6
  22. data/api_examples/rest_api/get_convert_document_status_token.rb +3 -5
  23. data/api_examples/rest_api/get_convert_document_uuid.rb +3 -5
  24. data/api_examples/rest_api/get_convert_video_status_token.rb +3 -5
  25. data/api_examples/rest_api/get_files.rb +3 -5
  26. data/api_examples/rest_api/get_files_uuid.rb +3 -5
  27. data/api_examples/rest_api/get_files_uuid_metadata.rb +3 -5
  28. data/api_examples/rest_api/get_files_uuid_metadata_key.rb +3 -5
  29. data/api_examples/rest_api/get_groups.rb +3 -5
  30. data/api_examples/rest_api/get_groups_uuid.rb +3 -5
  31. data/api_examples/rest_api/get_project.rb +3 -5
  32. data/api_examples/rest_api/get_webhooks.rb +3 -5
  33. data/api_examples/rest_api/post_addons_aws_rekognition_detect_labels_execute.rb +3 -5
  34. data/api_examples/rest_api/post_addons_aws_rekognition_detect_moderation_labels_execute.rb +3 -5
  35. data/api_examples/rest_api/post_addons_remove_bg_execute.rb +3 -5
  36. data/api_examples/rest_api/post_addons_uc_clamav_virus_scan_execute.rb +3 -5
  37. data/api_examples/rest_api/post_convert_document.rb +3 -8
  38. data/api_examples/rest_api/post_convert_video.rb +3 -10
  39. data/api_examples/rest_api/post_files_local_copy.rb +3 -6
  40. data/api_examples/rest_api/post_files_remote_copy.rb +3 -7
  41. data/api_examples/rest_api/post_webhooks.rb +3 -9
  42. data/api_examples/rest_api/put_files_storage.rb +3 -8
  43. data/api_examples/rest_api/put_files_uuid_metadata_key.rb +3 -7
  44. data/api_examples/rest_api/put_files_uuid_storage.rb +3 -5
  45. data/api_examples/rest_api/put_webhooks_id.rb +3 -11
  46. data/api_examples/support/example_helper.rb +250 -0
  47. data/api_examples/support/run_rest_example.rb +161 -0
  48. data/api_examples/support/run_upload_example.rb +88 -0
  49. data/api_examples/upload_api/get_from_url_status.rb +3 -5
  50. data/api_examples/upload_api/get_group_info.rb +3 -6
  51. data/api_examples/upload_api/get_info.rb +3 -6
  52. data/api_examples/upload_api/post_base.rb +3 -5
  53. data/api_examples/upload_api/post_from_url.rb +3 -5
  54. data/api_examples/upload_api/post_group.rb +3 -8
  55. data/api_examples/upload_api/post_multipart_complete.rb +3 -7
  56. data/api_examples/upload_api/post_multipart_start.rb +3 -7
  57. data/api_examples/upload_api/put_multipart_part.rb +4 -0
  58. data/bin/console +1 -1
  59. data/docs/release-notes-5.0.0.rc1.md +34 -0
  60. data/examples/README.md +39 -0
  61. data/examples/batch_upload.rb +54 -0
  62. data/examples/group_creation.rb +88 -0
  63. data/examples/large_file_upload.rb +88 -0
  64. data/examples/simple_upload.rb +39 -0
  65. data/examples/upload_with_progress.rb +84 -0
  66. data/examples/url_upload.rb +56 -0
  67. data/lib/uploadcare/api/rest/addons.rb +107 -0
  68. data/lib/uploadcare/api/rest/document_conversions.rb +65 -0
  69. data/lib/uploadcare/api/rest/file_metadata.rb +71 -0
  70. data/lib/uploadcare/api/rest/files.rb +112 -0
  71. data/lib/uploadcare/api/rest/groups.rb +49 -0
  72. data/lib/uploadcare/api/rest/project.rb +23 -0
  73. data/lib/uploadcare/api/rest/video_conversions.rb +52 -0
  74. data/lib/uploadcare/api/rest/webhooks.rb +74 -0
  75. data/lib/uploadcare/api/rest.rb +254 -0
  76. data/lib/uploadcare/api/upload/files.rb +313 -0
  77. data/lib/uploadcare/api/upload/groups.rb +72 -0
  78. data/lib/uploadcare/api/upload.rb +272 -0
  79. data/lib/uploadcare/client/addons_accessor.rb +85 -0
  80. data/lib/uploadcare/client/api.rb +33 -0
  81. data/lib/uploadcare/client/conversions_accessor.rb +33 -0
  82. data/lib/uploadcare/client/document_conversions_accessor.rb +41 -0
  83. data/lib/uploadcare/client/file_metadata_accessor.rb +46 -0
  84. data/lib/uploadcare/client/files_accessor.rb +82 -0
  85. data/lib/uploadcare/client/groups_accessor.rb +35 -0
  86. data/lib/uploadcare/client/project_accessor.rb +17 -0
  87. data/lib/uploadcare/client/video_conversions_accessor.rb +33 -0
  88. data/lib/uploadcare/client/webhooks_accessor.rb +42 -0
  89. data/lib/uploadcare/client.rb +127 -0
  90. data/lib/uploadcare/cname_generator.rb +68 -0
  91. data/lib/uploadcare/collections/batch_result.rb +35 -0
  92. data/lib/uploadcare/collections/paginated.rb +165 -0
  93. data/lib/uploadcare/configuration.rb +81 -0
  94. data/lib/uploadcare/exception/auth_error.rb +2 -6
  95. data/lib/uploadcare/exception/configuration_error.rb +4 -0
  96. data/lib/uploadcare/exception/conversion_error.rb +2 -6
  97. data/lib/uploadcare/exception/invalid_request_error.rb +4 -0
  98. data/lib/uploadcare/exception/multipart_upload_error.rb +4 -0
  99. data/lib/uploadcare/exception/not_found_error.rb +4 -0
  100. data/lib/uploadcare/exception/request_error.rb +2 -6
  101. data/lib/uploadcare/exception/retry_error.rb +2 -6
  102. data/lib/uploadcare/exception/throttle_error.rb +7 -11
  103. data/lib/uploadcare/exception/unknown_status_error.rb +4 -0
  104. data/lib/uploadcare/exception/upload_error.rb +4 -0
  105. data/lib/uploadcare/exception/upload_timeout_error.rb +4 -0
  106. data/lib/uploadcare/internal/authenticator.rb +101 -0
  107. data/lib/uploadcare/internal/error_handler.rb +102 -0
  108. data/lib/uploadcare/internal/signature_generator.rb +31 -0
  109. data/lib/uploadcare/internal/throttle_handler.rb +36 -0
  110. data/lib/uploadcare/internal/upload_io.rb +110 -0
  111. data/lib/uploadcare/internal/upload_params_generator.rb +86 -0
  112. data/lib/uploadcare/internal/user_agent.rb +22 -0
  113. data/lib/uploadcare/operations/multipart_upload.rb +213 -0
  114. data/lib/uploadcare/operations/upload_router.rb +162 -0
  115. data/lib/uploadcare/resources/addon_execution.rb +97 -0
  116. data/lib/uploadcare/resources/base_resource.rb +61 -0
  117. data/lib/uploadcare/resources/document_conversion.rb +81 -0
  118. data/lib/uploadcare/resources/file.rb +366 -0
  119. data/lib/uploadcare/resources/file_metadata.rb +135 -0
  120. data/lib/uploadcare/resources/group.rb +142 -0
  121. data/lib/uploadcare/resources/project.rb +26 -0
  122. data/lib/uploadcare/resources/video_conversion.rb +59 -0
  123. data/lib/uploadcare/resources/webhook.rb +85 -0
  124. data/lib/uploadcare/result.rb +85 -0
  125. data/lib/uploadcare/signed_url_generators/akamai_generator.rb +50 -51
  126. data/lib/uploadcare/signed_url_generators/base_generator.rb +15 -15
  127. data/lib/uploadcare/version.rb +7 -0
  128. data/lib/uploadcare/webhook_signature_verifier.rb +60 -0
  129. data/lib/uploadcare.rb +84 -50
  130. data/mise.toml +2 -0
  131. data/uploadcare-ruby.gemspec +8 -7
  132. metadata +102 -74
  133. data/api_examples/upload_api/put_presigned_url_x.rb +0 -8
  134. data/lib/uploadcare/api/api.rb +0 -25
  135. data/lib/uploadcare/client/addons_client.rb +0 -69
  136. data/lib/uploadcare/client/conversion/base_conversion_client.rb +0 -60
  137. data/lib/uploadcare/client/conversion/document_conversion_client.rb +0 -45
  138. data/lib/uploadcare/client/conversion/video_conversion_client.rb +0 -46
  139. data/lib/uploadcare/client/file_client.rb +0 -48
  140. data/lib/uploadcare/client/file_list_client.rb +0 -46
  141. data/lib/uploadcare/client/file_metadata_client.rb +0 -36
  142. data/lib/uploadcare/client/group_client.rb +0 -45
  143. data/lib/uploadcare/client/multipart_upload/chunks_client.rb +0 -58
  144. data/lib/uploadcare/client/multipart_upload_client.rb +0 -64
  145. data/lib/uploadcare/client/project_client.rb +0 -20
  146. data/lib/uploadcare/client/rest_client.rb +0 -77
  147. data/lib/uploadcare/client/rest_group_client.rb +0 -43
  148. data/lib/uploadcare/client/upload_client.rb +0 -46
  149. data/lib/uploadcare/client/uploader_client.rb +0 -128
  150. data/lib/uploadcare/client/webhook_client.rb +0 -49
  151. data/lib/uploadcare/concern/error_handler.rb +0 -54
  152. data/lib/uploadcare/concern/throttle_handler.rb +0 -25
  153. data/lib/uploadcare/concern/upload_error_handler.rb +0 -32
  154. data/lib/uploadcare/entity/addons.rb +0 -14
  155. data/lib/uploadcare/entity/conversion/base_converter.rb +0 -43
  156. data/lib/uploadcare/entity/conversion/document_converter.rb +0 -15
  157. data/lib/uploadcare/entity/conversion/video_converter.rb +0 -15
  158. data/lib/uploadcare/entity/decorator/paginator.rb +0 -79
  159. data/lib/uploadcare/entity/entity.rb +0 -18
  160. data/lib/uploadcare/entity/file.rb +0 -103
  161. data/lib/uploadcare/entity/file_list.rb +0 -32
  162. data/lib/uploadcare/entity/file_metadata.rb +0 -30
  163. data/lib/uploadcare/entity/group.rb +0 -49
  164. data/lib/uploadcare/entity/group_list.rb +0 -24
  165. data/lib/uploadcare/entity/project.rb +0 -13
  166. data/lib/uploadcare/entity/uploader.rb +0 -93
  167. data/lib/uploadcare/entity/webhook.rb +0 -14
  168. data/lib/uploadcare/param/authentication_header.rb +0 -37
  169. data/lib/uploadcare/param/conversion/document/processing_job_url_builder.rb +0 -39
  170. data/lib/uploadcare/param/conversion/video/processing_job_url_builder.rb +0 -64
  171. data/lib/uploadcare/param/param.rb +0 -10
  172. data/lib/uploadcare/param/secure_auth_header.rb +0 -51
  173. data/lib/uploadcare/param/simple_auth_header.rb +0 -14
  174. data/lib/uploadcare/param/upload/signature_generator.rb +0 -24
  175. data/lib/uploadcare/param/upload/upload_params_generator.rb +0 -41
  176. data/lib/uploadcare/param/user_agent.rb +0 -21
  177. data/lib/uploadcare/param/webhook_signature_verifier.rb +0 -23
  178. data/lib/uploadcare/ruby/version.rb +0 -5
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+
5
+ # Wraps upload inputs so multipart and direct uploads can treat files and streams uniformly.
6
+ class Uploadcare::Internal::UploadIo
7
+ # Fallback filename used when the source object does not expose one.
8
+ DEFAULT_FILENAME = 'upload.bin'
9
+
10
+ attr_reader :io, :original_filename
11
+
12
+ # Wrap a readable source into a unified upload object.
13
+ #
14
+ # @param source [IO]
15
+ # @param filename [String, nil]
16
+ # @return [Uploadcare::Internal::UploadIo]
17
+ def self.wrap(source, filename: nil)
18
+ raise ArgumentError, 'file must be a readable IO object' unless source.respond_to?(:read)
19
+
20
+ if path_backed?(source)
21
+ new(source, original_filename: filename || extract_filename(source), cleanup: false)
22
+ else
23
+ wrap_stream(source, filename: filename || extract_filename(source))
24
+ end
25
+ end
26
+
27
+ # @param source [IO]
28
+ # @return [Boolean]
29
+ def self.path_backed?(source)
30
+ source.respond_to?(:path) &&
31
+ source.path &&
32
+ ::File.file?(source.path) &&
33
+ ::File.readable?(source.path)
34
+ end
35
+
36
+ # @param source [IO]
37
+ # @return [String]
38
+ def self.extract_filename(source)
39
+ if source.respond_to?(:original_filename) && source.original_filename && !source.original_filename.empty?
40
+ source.original_filename
41
+ elsif path_backed?(source)
42
+ ::File.basename(source.path)
43
+ else
44
+ DEFAULT_FILENAME
45
+ end
46
+ end
47
+
48
+ # Materialize a non-path-backed stream into a tempfile.
49
+ #
50
+ # @param source [IO]
51
+ # @param filename [String]
52
+ # @return [Uploadcare::Internal::UploadIo]
53
+ def self.wrap_stream(source, filename:)
54
+ extension = ::File.extname(filename.to_s)
55
+ tempfile = Tempfile.new(['uploadcare-upload', extension.empty? ? '.bin' : extension])
56
+ tempfile.binmode
57
+
58
+ source.rewind if source.respond_to?(:rewind)
59
+ IO.copy_stream(source, tempfile)
60
+ tempfile.rewind
61
+ source.rewind if source.respond_to?(:rewind)
62
+
63
+ new(tempfile, original_filename: filename, cleanup: true)
64
+ end
65
+
66
+ # @param io [IO]
67
+ # @param original_filename [String]
68
+ # @param cleanup [Boolean]
69
+ def initialize(io, original_filename:, cleanup:)
70
+ @io = io
71
+ @original_filename = original_filename
72
+ @cleanup = cleanup
73
+ end
74
+
75
+ # @return [String]
76
+ def path
77
+ io.path
78
+ end
79
+
80
+ # @return [Integer]
81
+ def size
82
+ return io.size if io.respond_to?(:size)
83
+
84
+ ::File.size(path)
85
+ end
86
+
87
+ # @return [String, nil]
88
+ def read(*args)
89
+ io.read(*args)
90
+ end
91
+
92
+ # @return [Integer]
93
+ def seek(*args)
94
+ io.seek(*args)
95
+ end
96
+
97
+ # @return [void]
98
+ def rewind
99
+ io.rewind
100
+ end
101
+
102
+ # Close and unlink the wrapped tempfile when cleanup is enabled.
103
+ #
104
+ # @return [void]
105
+ def close!
106
+ return unless @cleanup
107
+
108
+ io.close!
109
+ end
110
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generates upload parameters for Upload API requests.
4
+ #
5
+ # Builds the parameter hash needed for file uploads, including public key,
6
+ # store preferences, metadata, and optional signature params.
7
+ class Uploadcare::Internal::UploadParamsGenerator
8
+ class << self
9
+ # Build upload parameters.
10
+ #
11
+ # @param options [Hash] Upload options (:store, :metadata, :signature, :expire)
12
+ # @param config [Uploadcare::Configuration] Configuration with public key and signing settings
13
+ # @return [Hash] Upload parameters hash
14
+ def call(options: {}, config: Uploadcare.configuration)
15
+ params = {
16
+ 'UPLOADCARE_PUB_KEY' => config.public_key
17
+ }
18
+
19
+ store = store_value(options[:store])
20
+ params['UPLOADCARE_STORE'] = store unless store.nil?
21
+
22
+ params.merge!(metadata(options: options))
23
+ params.merge!(signature_params(options: options, config: config))
24
+
25
+ params.compact
26
+ end
27
+
28
+ private
29
+
30
+ # Convert store option to API format.
31
+ #
32
+ # @param store [Boolean, String, Integer, nil] Store option value
33
+ # @return [String, nil] Formatted store value ('0', '1', or nil)
34
+ def store_value(store)
35
+ return nil if store.nil?
36
+
37
+ case store
38
+ when true, '1', 1 then '1'
39
+ when false, '0', 0 then '0'
40
+ else store.to_s
41
+ end
42
+ end
43
+
44
+ # Generate metadata parameters from options hash.
45
+ #
46
+ # @param options [Hash] Options containing :metadata key
47
+ # @return [Hash] Metadata params formatted as "metadata[key]" => "value"
48
+ def metadata(options:)
49
+ return {} if options[:metadata].nil?
50
+ raise ArgumentError, 'metadata must be a hash' unless options[:metadata].is_a?(Hash)
51
+
52
+ options[:metadata].each_with_object({}) do |(k, v), res|
53
+ res["metadata[#{k}]"] = v.to_s
54
+ end
55
+ end
56
+
57
+ # Generate signature parameters for signed uploads.
58
+ #
59
+ # @param options [Hash] Options with optional :signature and :expire keys
60
+ # @param config [Uploadcare::Configuration] Configuration with signing settings
61
+ # @return [Hash] Signature parameters
62
+ def signature_params(options:, config:)
63
+ return explicit_signature_params(options) if options.key?(:signature)
64
+ return {} unless config.sign_uploads
65
+
66
+ signature_data = Uploadcare::Internal::SignatureGenerator.call(config: config)
67
+ return { 'signature' => signature_data } unless signature_data.is_a?(Hash)
68
+
69
+ params = {}
70
+ params['signature'] = signature_data[:signature] || signature_data['signature']
71
+ params['expire'] = signature_data[:expire] || signature_data['expire']
72
+ params.compact
73
+ end
74
+
75
+ # Extract explicit signature params from options.
76
+ #
77
+ # @param options [Hash] Options with :signature and optional :expire
78
+ # @return [Hash] Signature parameters
79
+ def explicit_signature_params(options)
80
+ params = {}
81
+ params['signature'] = options[:signature]
82
+ params['expire'] = options[:expire] if options.key?(:expire)
83
+ params.compact
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # User-Agent string builder for Uploadcare API requests.
4
+ #
5
+ # Generates a standardized User-Agent header that identifies the Ruby gem version,
6
+ # public key, Ruby version, and optional framework information.
7
+ #
8
+ # @example
9
+ # Uploadcare::Internal::UserAgent.call(config: config)
10
+ # # => "UploadcareRuby/5.0.0.rc1/demopublickey (Ruby/3.3.0)"
11
+ class Uploadcare::Internal::UserAgent
12
+ # Build a User-Agent string.
13
+ #
14
+ # @param config [Uploadcare::Configuration] Configuration with public key and framework data
15
+ # @return [String] Formatted User-Agent string
16
+ def self.call(config: Uploadcare.configuration)
17
+ framework_data = config.framework_data.to_s
18
+ framework_suffix = framework_data.empty? ? '' : "; #{framework_data}"
19
+ public_key = config.public_key
20
+ "UploadcareRuby/#{Uploadcare::VERSION}/#{public_key} (Ruby/#{RUBY_VERSION}#{framework_suffix})"
21
+ end
22
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mime/types'
4
+
5
+ # Handles the complete multipart upload workflow.
6
+ #
7
+ # Multipart upload is used for files larger than the multipart threshold (default: 100MB).
8
+ # The process:
9
+ # 1. Start upload → get UUID and presigned URLs
10
+ # 2. Upload file parts to presigned URLs (optionally in parallel)
11
+ # 3. Complete upload → finalize and get file info
12
+ #
13
+ # @example
14
+ # mp = Uploadcare::Operations::MultipartUpload.new(upload_client: upload, config: config)
15
+ # result = mp.upload(file: large_file, store: true, threads: 4)
16
+ class Uploadcare::Operations::MultipartUpload
17
+ CHUNK_SIZE = 5_242_880 # 5MB default chunk size
18
+
19
+ # @return [Uploadcare::Api::Upload] Upload API client
20
+ attr_reader :upload_client
21
+
22
+ # @return [Uploadcare::Configuration] Configuration
23
+ attr_reader :config
24
+
25
+ # @param upload_client [Uploadcare::Api::Upload] Upload API client
26
+ # @param config [Uploadcare::Configuration] Configuration
27
+ def initialize(upload_client:, config:)
28
+ @upload_client = upload_client
29
+ @config = config
30
+ end
31
+
32
+ # Execute the full multipart upload flow.
33
+ #
34
+ # @param file [File, IO] File to upload
35
+ # @param options [Hash] Upload options (:store, :metadata, :threads, :part_size)
36
+ # @param request_options [Hash] Request options
37
+ # @yield [Hash] Progress callback with :uploaded, :total, :part, :total_parts
38
+ # @return [Uploadcare::Result] Result containing { 'uuid' => '...' }
39
+ def upload(file:, request_options: {}, **options, &block)
40
+ Uploadcare::Result.capture do
41
+ prepared_file = Uploadcare::Internal::UploadIo.wrap(file)
42
+ file_size = prepared_file.size
43
+ filename = prepared_file.original_filename
44
+ content_type = MIME::Types.type_for(prepared_file.path).first&.content_type || 'application/octet-stream'
45
+ part_size, threads = normalize_upload_options(options)
46
+
47
+ start_response = Uploadcare::Result.unwrap(
48
+ upload_client.files.multipart_start(
49
+ filename: filename,
50
+ size: file_size,
51
+ content_type: content_type,
52
+ request_options: request_options,
53
+ **options
54
+ )
55
+ )
56
+
57
+ uuid = start_response['uuid']
58
+ presigned_urls = start_response['parts']
59
+
60
+ if threads > 1
61
+ upload_parts_parallel(prepared_file, presigned_urls, part_size, threads, &block)
62
+ else
63
+ upload_parts_sequential(prepared_file, presigned_urls, part_size, &block)
64
+ end
65
+
66
+ Uploadcare::Result.unwrap(
67
+ upload_client.files.multipart_complete(uuid: uuid, request_options: request_options)
68
+ )
69
+
70
+ { 'uuid' => uuid }
71
+ ensure
72
+ prepared_file&.close!
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def normalize_upload_options(options)
79
+ part_size = Integer(options.fetch(:part_size, config.multipart_chunk_size || CHUNK_SIZE))
80
+ max_threads = Integer(config.upload_threads || 1)
81
+ threads = Integer(options.fetch(:threads, max_threads))
82
+
83
+ raise ArgumentError, 'part_size must be > 0' if part_size <= 0
84
+ raise ArgumentError, 'upload_threads must be >= 1' if max_threads < 1
85
+ raise ArgumentError, 'threads must be >= 1' if threads < 1
86
+ raise ArgumentError, "threads must be <= #{max_threads}" if threads > max_threads
87
+
88
+ [part_size, threads]
89
+ end
90
+
91
+ def upload_parts_sequential(file, presigned_urls, part_size, &block)
92
+ total_size = file.respond_to?(:size) ? file.size : ::File.size(file.path)
93
+ uploaded = 0
94
+
95
+ presigned_urls.each_with_index do |presigned_url, index|
96
+ file.seek(index * part_size)
97
+ part_data = file.read(part_size)
98
+ break if part_data.nil? || part_data.empty?
99
+
100
+ upload_part(presigned_url, part_data)
101
+ uploaded += part_data.bytesize
102
+
103
+ block&.call(uploaded: uploaded, total: total_size, part: index + 1, total_parts: presigned_urls.length)
104
+ end
105
+ end
106
+
107
+ def upload_parts_parallel(file, presigned_urls, part_size, threads, &)
108
+ total_size = file.respond_to?(:size) ? file.size : ::File.size(file.path)
109
+ uploaded = { value: 0 }
110
+ mutex = Mutex.new
111
+ queue = Queue.new
112
+ errors = []
113
+ cancel = { value: false }
114
+ file_path = file.path
115
+ total_parts = presigned_urls.length
116
+
117
+ presigned_urls.each_with_index { |url, index| queue << [url, index] }
118
+ threads.times { queue << nil }
119
+
120
+ worker_context = {
121
+ queue: queue,
122
+ file_path: file_path,
123
+ part_size: part_size,
124
+ total_size: total_size,
125
+ total_parts: total_parts,
126
+ mutex: mutex,
127
+ uploaded: uploaded,
128
+ errors: errors,
129
+ cancel: cancel
130
+ }
131
+
132
+ workers = threads.times.map do
133
+ Thread.new do
134
+ run_parallel_worker(worker_context, &)
135
+ end
136
+ end
137
+
138
+ workers.each(&:join)
139
+ raise errors.first if errors.any?
140
+ end
141
+
142
+ def run_parallel_worker(context, &)
143
+ ::File.open(context[:file_path], 'rb') do |worker_file|
144
+ process_parallel_jobs(worker_file, context, &)
145
+ rescue StandardError => e
146
+ record_parallel_error(context, e)
147
+ end
148
+ end
149
+
150
+ def process_parallel_jobs(worker_file, context, &)
151
+ loop do
152
+ job = context[:queue].pop
153
+ break unless process_parallel_job?(worker_file, context, job, &)
154
+ end
155
+ end
156
+
157
+ def process_parallel_job?(worker_file, context, job)
158
+ return false if job.nil? || context[:cancel][:value]
159
+
160
+ presigned_url, index = job
161
+ offset = index * context[:part_size]
162
+ return false if offset >= context[:total_size]
163
+
164
+ worker_file.seek(offset)
165
+ part_data = worker_file.read(context[:part_size])
166
+ return false if part_data.nil? || part_data.empty?
167
+
168
+ upload_part(presigned_url, part_data)
169
+ update_parallel_progress(context, index, part_data.bytesize) { |progress| yield(progress) if block_given? }
170
+ true
171
+ end
172
+
173
+ def update_parallel_progress(context, index, bytesize)
174
+ context[:mutex].synchronize do
175
+ context[:uploaded][:value] += bytesize
176
+ progress = {
177
+ uploaded: context[:uploaded][:value],
178
+ total: context[:total_size],
179
+ part: index + 1,
180
+ total_parts: context[:total_parts]
181
+ }
182
+ yield(progress)
183
+ end
184
+ end
185
+
186
+ def record_parallel_error(context, error)
187
+ context[:mutex].synchronize do
188
+ context[:cancel][:value] = true
189
+ context[:errors] << error
190
+ end
191
+ end
192
+
193
+ def upload_part(presigned_url, part_data)
194
+ upload_client.upload_part_to_url(
195
+ presigned_url,
196
+ part_data,
197
+ max_retries: configured_max_upload_retries,
198
+ timeout: configured_upload_timeout
199
+ )
200
+ end
201
+
202
+ def configured_max_upload_retries
203
+ value = config.max_upload_retries
204
+ value.nil? ? 3 : Integer(value)
205
+ end
206
+
207
+ def configured_upload_timeout
208
+ value = config.upload_timeout
209
+ return nil if value.nil?
210
+
211
+ Integer(value)
212
+ end
213
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Routes upload requests to the appropriate Upload API endpoint.
4
+ #
5
+ # Handles the decision logic for choosing between:
6
+ # - Direct upload (small files < multipart threshold)
7
+ # - Multipart upload (large files >= multipart threshold)
8
+ # - URL upload (string URLs)
9
+ # - Batch upload (arrays of files)
10
+ #
11
+ # @example
12
+ # router = Uploadcare::Operations::UploadRouter.new(client: client)
13
+ # file = router.upload(File.open("image.jpg"))
14
+ # file = router.upload("https://example.com/image.jpg")
15
+ # files = router.upload([file1, file2])
16
+ class Uploadcare::Operations::UploadRouter
17
+ # @return [Uploadcare::Client] Client instance
18
+ attr_reader :client
19
+
20
+ # @param client [Uploadcare::Client] Client instance
21
+ def initialize(client:)
22
+ @client = client
23
+ end
24
+
25
+ # Upload a file, URL, or array of files.
26
+ #
27
+ # Automatically routes to the appropriate upload method based on the source type:
28
+ # - File/IO objects >= multipart threshold → multipart upload
29
+ # - File/IO objects < multipart threshold → direct upload
30
+ # - Arrays → batch direct upload
31
+ # - Strings → URL upload
32
+ #
33
+ # @param source [File, IO, String, Array] Upload source
34
+ # @param options [Hash] Upload options (:store, :metadata, etc.)
35
+ # @param request_options [Hash] Request options
36
+ # @return [Uploadcare::Resources::File, Array<Uploadcare::Resources::File>, Hash]
37
+ # @raise [ArgumentError] if source type is not recognized
38
+ def upload(source, request_options: {}, **options, &block)
39
+ if big_file?(source)
40
+ multipart_upload(file: source, request_options: request_options, **options, &block)
41
+ elsif file?(source)
42
+ upload_file(file: source, request_options: request_options, **options)
43
+ elsif source.is_a?(Array)
44
+ upload_files(files: source, request_options: request_options, **options)
45
+ elsif source.is_a?(String)
46
+ upload_from_url(url: source, request_options: request_options, **options)
47
+ else
48
+ raise ArgumentError, "Expected input to be a File/Array/URL, given: `#{source}`"
49
+ end
50
+ end
51
+
52
+ # Upload a single file directly.
53
+ #
54
+ # @param file [File, IO] File to upload
55
+ # @param options [Hash] Upload options
56
+ # @param request_options [Hash] Request options
57
+ # @return [Uploadcare::Resources::File]
58
+ def upload_file(file:, request_options: {}, **options)
59
+ response = Uploadcare::Result.unwrap(
60
+ client.api.upload.files.direct_many(files: [file], request_options: request_options, **options)
61
+ )
62
+ file_name, uuid = response.first
63
+ Uploadcare::Resources::File.new({ uuid: uuid, original_filename: decode_uploaded_filename(file_name) }, client)
64
+ end
65
+
66
+ # Upload multiple files directly.
67
+ #
68
+ # @param files [Array<File, IO>] Files to upload
69
+ # @param options [Hash] Upload options
70
+ # @param request_options [Hash] Request options
71
+ # @return [Array<Uploadcare::Resources::File>]
72
+ def upload_files(files:, request_options: {}, **options)
73
+ response = Uploadcare::Result.unwrap(
74
+ client.api.upload.files.direct_many(files: files, request_options: request_options, **options)
75
+ )
76
+ response.map do |file_name, uuid|
77
+ Uploadcare::Resources::File.new({ uuid: uuid, original_filename: decode_uploaded_filename(file_name) }, client)
78
+ end
79
+ end
80
+
81
+ # Upload a file from URL.
82
+ #
83
+ # @param url [String] Source URL
84
+ # @param options [Hash] Upload options (:async, :store, :metadata)
85
+ # @param request_options [Hash] Request options
86
+ # @return [Uploadcare::Resources::File, Hash] File resource (sync) or token hash (async)
87
+ def upload_from_url(url:, request_options: {}, **options)
88
+ response = Uploadcare::Result.unwrap(
89
+ client.api.upload.files.from_url(source_url: url, request_options: request_options, **options)
90
+ )
91
+ return response if options[:async]
92
+
93
+ Uploadcare::Resources::File.new(response, client)
94
+ end
95
+
96
+ # Upload a large file using multipart upload.
97
+ #
98
+ # @param file [File, IO] Large file to upload
99
+ # @param options [Hash] Upload options
100
+ # @param request_options [Hash] Request options
101
+ # @return [Uploadcare::Resources::File]
102
+ def multipart_upload(file:, request_options: {}, **options, &block)
103
+ response = Uploadcare::Result.unwrap(
104
+ Uploadcare::Operations::MultipartUpload.new(
105
+ upload_client: client.api.upload,
106
+ config: client.config
107
+ ).upload(file: file, request_options: request_options, **options, &block)
108
+ )
109
+ return response unless response.is_a?(Hash) && response['uuid']
110
+
111
+ Uploadcare::Resources::File.new(response, client)
112
+ end
113
+
114
+ # Get upload-from-URL status.
115
+ #
116
+ # @param token [String] Upload token
117
+ # @param request_options [Hash] Request options
118
+ # @return [Hash] Status response
119
+ def upload_from_url_status(token:, request_options: {})
120
+ Uploadcare::Result.unwrap(
121
+ client.api.upload.files.from_url_status(token: token, request_options: request_options)
122
+ )
123
+ end
124
+
125
+ # Get file info from Upload API (without secret key).
126
+ #
127
+ # @param file_id [String] File UUID
128
+ # @param request_options [Hash] Request options
129
+ # @return [Hash] File information
130
+ def file_info(file_id:, request_options: {})
131
+ Uploadcare::Result.unwrap(
132
+ client.api.upload.files.info(file_id: file_id, request_options: request_options)
133
+ )
134
+ end
135
+
136
+ private
137
+
138
+ def file?(object)
139
+ !object.is_a?(String) && object.respond_to?(:read)
140
+ end
141
+
142
+ def big_file?(object)
143
+ return false unless file?(object)
144
+
145
+ upload_size(object) >= client.config.multipart_size_threshold
146
+ rescue StandardError
147
+ false
148
+ end
149
+
150
+ def upload_size(object)
151
+ return object.size if object.respond_to?(:size)
152
+ return ::File.size(object.path) if object.respond_to?(:path) && object.path && ::File.exist?(object.path)
153
+
154
+ 0
155
+ end
156
+
157
+ def decode_uploaded_filename(file_name)
158
+ encoded_prefix = /\A__uploadcare_form_\d+(?:_\d+)?__(.+)\z/
159
+ match = encoded_prefix.match(file_name.to_s)
160
+ match ? match[1] : file_name
161
+ end
162
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add-on execution resource.
4
+ #
5
+ # Provides a unified interface for executing and checking status of
6
+ # AWS Rekognition, ClamAV, and Remove.bg add-ons.
7
+ #
8
+ # @see https://uploadcare.com/api-refs/rest-api/v0.7.0/#tag/Add-Ons
9
+ class Uploadcare::Resources::AddonExecution < Uploadcare::Resources::BaseResource
10
+ attr_accessor :request_id, :status, :result
11
+
12
+ class << self
13
+ # Execute AWS Rekognition label detection.
14
+ def aws_rekognition_detect_labels(uuid:, client: nil, config: Uploadcare.configuration, request_options: {})
15
+ execute_addon(:aws_rekognition_detect_labels, client: client, config: config,
16
+ request_options: request_options, uuid: uuid)
17
+ end
18
+
19
+ # Check AWS Rekognition label detection status.
20
+ def aws_rekognition_detect_labels_status(request_id:, client: nil, config: Uploadcare.configuration,
21
+ request_options: {})
22
+ check_addon_status(:aws_rekognition_detect_labels_status, client: client, config: config,
23
+ request_options: request_options,
24
+ request_id: request_id)
25
+ end
26
+
27
+ # Execute AWS Rekognition moderation label detection.
28
+ def aws_rekognition_detect_moderation_labels(uuid:, client: nil, config: Uploadcare.configuration,
29
+ request_options: {})
30
+ execute_addon(:aws_rekognition_detect_moderation_labels, client: client, config: config,
31
+ request_options: request_options, uuid: uuid)
32
+ end
33
+
34
+ # Check AWS Rekognition moderation label detection status.
35
+ def aws_rekognition_detect_moderation_labels_status(request_id:, client: nil,
36
+ config: Uploadcare.configuration, request_options: {})
37
+ check_addon_status(:aws_rekognition_detect_moderation_labels_status, client: client, config: config,
38
+ request_options: request_options,
39
+ request_id: request_id)
40
+ end
41
+
42
+ # Execute ClamAV virus scan.
43
+ def uc_clamav_virus_scan(uuid:, params: {}, client: nil, config: Uploadcare.configuration, request_options: {})
44
+ resolved_client = resolve_client(client: client, config: config)
45
+ response = Uploadcare::Result.unwrap(
46
+ resolved_client.api.rest.addons.uc_clamav_virus_scan(
47
+ uuid: uuid, params: params, request_options: request_options
48
+ )
49
+ )
50
+ new(response, resolved_client)
51
+ end
52
+
53
+ # Check ClamAV virus scan status.
54
+ def uc_clamav_virus_scan_status(request_id:, client: nil, config: Uploadcare.configuration,
55
+ request_options: {})
56
+ check_addon_status(:uc_clamav_virus_scan_status, client: client, config: config,
57
+ request_options: request_options, request_id: request_id)
58
+ end
59
+
60
+ # Execute Remove.bg background removal.
61
+ def remove_bg(uuid:, params: {}, client: nil, config: Uploadcare.configuration, request_options: {})
62
+ resolved_client = resolve_client(client: client, config: config)
63
+ response = Uploadcare::Result.unwrap(
64
+ resolved_client.api.rest.addons.remove_bg(
65
+ uuid: uuid, params: params, request_options: request_options
66
+ )
67
+ )
68
+ new(response, resolved_client)
69
+ end
70
+
71
+ # Check Remove.bg execution status.
72
+ def remove_bg_status(request_id:, client: nil, config: Uploadcare.configuration, request_options: {})
73
+ check_addon_status(:remove_bg_status, client: client, config: config,
74
+ request_options: request_options, request_id: request_id)
75
+ end
76
+
77
+ private
78
+
79
+ def execute_addon(method_name, client:, config:, request_options:, uuid:)
80
+ resolved_client = resolve_client(client: client, config: config)
81
+ response = Uploadcare::Result.unwrap(
82
+ resolved_client.api.rest.addons.public_send(method_name, uuid: uuid, request_options: request_options)
83
+ )
84
+ new(response, resolved_client)
85
+ end
86
+
87
+ def check_addon_status(method_name, client:, config:, request_options:, request_id:)
88
+ resolved_client = resolve_client(client: client, config: config)
89
+ response = Uploadcare::Result.unwrap(
90
+ resolved_client.api.rest.addons.public_send(
91
+ method_name, request_id: request_id, request_options: request_options
92
+ )
93
+ )
94
+ new(response, resolved_client)
95
+ end
96
+ end
97
+ end