uploadcare-ruby 4.4.2 → 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 +78 -0
  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 -6
  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 +60 -56
  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 -59
  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,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Upload API endpoint for file upload operations.
4
+ # rubocop:disable Metrics/ClassLength
5
+ class Uploadcare::Api::Upload::Files
6
+ # @return [Uploadcare::Api::Upload] Parent Upload client
7
+ attr_reader :upload
8
+
9
+ # @param upload [Uploadcare::Api::Upload] Parent Upload client
10
+ def initialize(upload:)
11
+ @upload = upload
12
+ end
13
+
14
+ # Upload a file directly (POST /base/).
15
+ #
16
+ # @param file [File, IO] File object to upload
17
+ # @param options [Hash] Upload options (:store, :metadata, :signature, :expire)
18
+ # @param request_options [Hash] Request options
19
+ # @return [Uploadcare::Result] Upload response with file UUID
20
+ # @raise [ArgumentError] if file is not a valid IO object
21
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/baseUpload
22
+ def direct(file:, request_options: {}, **options)
23
+ Uploadcare::Result.capture do
24
+ prepared_file = Uploadcare::Internal::UploadIo.wrap(file)
25
+ params = build_upload_params(prepared_file, options)
26
+ Uploadcare::Result.unwrap(upload.post(path: 'base/', params: params, request_options: request_options))
27
+ ensure
28
+ prepared_file&.close!
29
+ end
30
+ end
31
+
32
+ # Upload multiple files directly (POST /base/).
33
+ #
34
+ # @param files [Array<File, IO>] Files to upload
35
+ # @param options [Hash] Upload options (:store, :metadata)
36
+ # @param request_options [Hash] Request options
37
+ # @return [Uploadcare::Result] Upload response hash mapping filenames to UUIDs
38
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/baseUpload
39
+ def direct_many(files:, request_options: {}, **options)
40
+ Uploadcare::Result.capture do
41
+ raise ArgumentError, 'files must be an array' unless files.is_a?(Array)
42
+ raise ArgumentError, 'files cannot be empty' if files.empty?
43
+
44
+ prepared_files = []
45
+ params = Uploadcare::Internal::UploadParamsGenerator.call(
46
+ options: options, config: upload.config
47
+ )
48
+ files.each do |file|
49
+ prepared_file = Uploadcare::Internal::UploadIo.wrap(file)
50
+ prepared_files << prepared_file
51
+ form_data_for(prepared_file, params, field_index: prepared_files.length - 1)
52
+ end
53
+ Uploadcare::Result.unwrap(upload.post(path: '/base/', params: params, request_options: request_options))
54
+ ensure
55
+ prepared_files&.each(&:close!)
56
+ end
57
+ end
58
+
59
+ # Upload a file from URL (POST /from_url/).
60
+ #
61
+ # @param source_url [String] URL of the file to upload
62
+ # @param options [Hash] Upload options
63
+ # @option options [Boolean] :async Return immediately with token (default: false)
64
+ # @option options [String, Boolean] :store Whether to store the file
65
+ # @option options [Hash] :metadata Custom metadata
66
+ # @option options [Integer] :poll_interval Polling interval in seconds (default: 1)
67
+ # @option options [Integer] :poll_timeout Max polling time in seconds (default: 300)
68
+ # @param request_options [Hash] Request options
69
+ # @return [Uploadcare::Result] Upload response (file info or token)
70
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/fromUrlUpload
71
+ def from_url(source_url:, request_options: {}, **options)
72
+ Uploadcare::Result.capture do
73
+ validate_url(source_url)
74
+ async_mode = options.fetch(:async, false)
75
+ params = build_from_url_params(source_url, options)
76
+ response = Uploadcare::Result.unwrap(
77
+ upload.post(path: 'from_url/', params: params, request_options: request_options)
78
+ )
79
+ if async_mode
80
+ response
81
+ else
82
+ poll_upload_status(token: response['token'], options: options, request_options: request_options)
83
+ end
84
+ end
85
+ end
86
+
87
+ # Get upload-from-URL status (GET /from_url/status/).
88
+ #
89
+ # @param token [String] Upload token from async upload
90
+ # @param request_options [Hash] Request options
91
+ # @return [Uploadcare::Result] Status response
92
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/fromUrlUploadStatus
93
+ def from_url_status(token:, request_options: {})
94
+ Uploadcare::Result.capture do
95
+ raise ArgumentError, 'token cannot be empty' if token.to_s.strip.empty?
96
+
97
+ Uploadcare::Result.unwrap(
98
+ upload.get(path: 'from_url/status/', params: { token: token }, request_options: request_options)
99
+ )
100
+ end
101
+ end
102
+
103
+ # Start a multipart upload (POST /multipart/start/).
104
+ #
105
+ # @param filename [String] Original filename
106
+ # @param size [Integer] File size in bytes
107
+ # @param content_type [String] MIME type
108
+ # @param options [Hash] Upload options (:store, :metadata)
109
+ # @param request_options [Hash] Request options
110
+ # @return [Uploadcare::Result] Response with UUID and presigned URLs
111
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/multipartUploadStart
112
+ def multipart_start(filename:, size:, content_type:, request_options: {}, **options)
113
+ Uploadcare::Result.capture do
114
+ raise ArgumentError, 'filename cannot be empty' if filename.to_s.strip.empty?
115
+ raise ArgumentError, 'size must be a positive integer' unless size.is_a?(Integer) && size.positive?
116
+ raise ArgumentError, 'content_type cannot be empty' if content_type.to_s.strip.empty?
117
+
118
+ params = build_multipart_start_params(filename, size, content_type, options)
119
+ Uploadcare::Result.unwrap(
120
+ upload.post(path: 'multipart/start/', params: params, request_options: request_options)
121
+ )
122
+ end
123
+ end
124
+
125
+ # Complete a multipart upload (POST /multipart/complete/).
126
+ #
127
+ # @param uuid [String] Upload UUID from multipart_start
128
+ # @param request_options [Hash] Request options
129
+ # @return [Uploadcare::Result] Final file information
130
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/multipartUploadComplete
131
+ def multipart_complete(uuid:, request_options: {})
132
+ Uploadcare::Result.capture do
133
+ raise ArgumentError, 'uuid cannot be empty' if uuid.to_s.strip.empty?
134
+
135
+ params = {
136
+ 'UPLOADCARE_PUB_KEY' => upload.config.public_key,
137
+ 'uuid' => uuid
138
+ }
139
+ Uploadcare::Result.unwrap(
140
+ upload.post(path: 'multipart/complete/', params: params, request_options: request_options)
141
+ )
142
+ end
143
+ end
144
+
145
+ # Get file info from Upload API (GET /info/).
146
+ #
147
+ # @param file_id [String] File UUID
148
+ # @param request_options [Hash] Request options
149
+ # @return [Uploadcare::Result] File information
150
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/filesInfo
151
+ def info(file_id:, request_options: {})
152
+ Uploadcare::Result.capture do
153
+ raise ArgumentError, 'file_id cannot be empty' if file_id.to_s.strip.empty?
154
+
155
+ Uploadcare::Result.unwrap(
156
+ upload.get(path: 'info/', params: { pub_key: upload.config.public_key, file_id: file_id },
157
+ request_options: request_options)
158
+ )
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def validate_url(url)
165
+ raise ArgumentError, 'URL cannot be empty' if url.to_s.strip.empty?
166
+
167
+ uri = URI.parse(url)
168
+ raise ArgumentError, 'URL must be HTTP or HTTPS' unless %w[http https].include?(uri.scheme)
169
+ rescue URI::InvalidURIError => e
170
+ raise ArgumentError, "Invalid URL: #{e.message}"
171
+ end
172
+
173
+ def build_upload_params(file, options)
174
+ params = Uploadcare::Internal::UploadParamsGenerator.call(
175
+ options: options, config: upload.config
176
+ )
177
+ form_data_for(file, params)
178
+ end
179
+
180
+ def form_data_for(file, params, field_index: nil)
181
+ file_path = file.path
182
+ filename = file.respond_to?(:original_filename) ? file.original_filename : ::File.basename(file_path)
183
+ mime = MIME::Types.type_for(file.path).first&.content_type || 'application/octet-stream'
184
+
185
+ field_name = unique_form_field_name(filename, params, field_index)
186
+ params[field_name] = Faraday::Multipart::FilePart.new(file_path, mime, filename)
187
+ params
188
+ end
189
+
190
+ def build_from_url_params(source_url, options)
191
+ params = {
192
+ 'pub_key' => upload.config.public_key,
193
+ 'source_url' => source_url
194
+ }
195
+ store = store_value(options[:store])
196
+ params['store'] = store unless store.nil?
197
+ params['check_URL_duplicates'] = options[:check_URL_duplicates].to_s if options.key?(:check_URL_duplicates)
198
+ params['save_URL_duplicates'] = options[:save_URL_duplicates].to_s if options.key?(:save_URL_duplicates)
199
+ metadata_params = generate_metadata_params(options[:metadata])
200
+ params.merge!(metadata_params) if metadata_params.any?
201
+ params.merge!(signature_params(options))
202
+ params
203
+ end
204
+
205
+ def build_multipart_start_params(filename, size, content_type, options)
206
+ params = {
207
+ 'UPLOADCARE_PUB_KEY' => upload.config.public_key,
208
+ 'filename' => filename,
209
+ 'size' => size.to_s,
210
+ 'content_type' => content_type
211
+ }
212
+ store = store_value(options[:store])
213
+ params['UPLOADCARE_STORE'] = store unless store.nil?
214
+ metadata_params = generate_metadata_params(options[:metadata])
215
+ params.merge!(metadata_params) if metadata_params.any?
216
+ params.merge!(signature_params(options))
217
+ params
218
+ end
219
+
220
+ def poll_upload_status(token:, options: {}, request_options: {})
221
+ initial_poll_interval = options.fetch(:poll_interval, 1).to_f
222
+ max_poll_interval = options.fetch(:poll_max_interval, 10).to_f
223
+ poll_timeout = options.fetch(:poll_timeout, 300)
224
+ poll_attempt = 0
225
+ start_time = Time.now
226
+
227
+ loop do
228
+ status = Uploadcare::Result.unwrap(from_url_status(token: token, request_options: request_options))
229
+
230
+ case status['status']
231
+ when 'success'
232
+ return status
233
+ when 'error'
234
+ raise Uploadcare::Exception::UploadError, "Upload from URL failed: #{status['error']}"
235
+ when 'waiting', 'progress'
236
+ elapsed = Time.now - start_time
237
+ if elapsed > poll_timeout
238
+ raise Uploadcare::Exception::UploadTimeoutError,
239
+ "Upload from URL polling timed out after #{poll_timeout} seconds"
240
+ end
241
+
242
+ sleep_duration = next_poll_sleep(
243
+ initial: initial_poll_interval,
244
+ max_interval: max_poll_interval,
245
+ attempt: poll_attempt
246
+ )
247
+ sleep(sleep_duration)
248
+ poll_attempt += 1
249
+ else
250
+ raise Uploadcare::Exception::UnknownStatusError, "Unknown upload status: #{status['status']}"
251
+ end
252
+ end
253
+ end
254
+
255
+ def store_value(store)
256
+ return nil if store.nil?
257
+
258
+ case store
259
+ when true then '1'
260
+ when false then '0'
261
+ else store.to_s
262
+ end
263
+ end
264
+
265
+ def generate_metadata_params(metadata = nil)
266
+ return {} if metadata.nil? || !metadata.is_a?(Hash)
267
+
268
+ metadata.each_with_object({}) do |(key, value), result|
269
+ result["metadata[#{key}]"] = value.to_s
270
+ end
271
+ end
272
+
273
+ def signature_params(options = {})
274
+ return {} if options.nil?
275
+
276
+ if options[:signature]
277
+ params = { 'signature' => options[:signature] }
278
+ params['expire'] = options[:expire].to_s if options[:expire]
279
+ return params
280
+ end
281
+
282
+ return {} unless upload.config.sign_uploads
283
+
284
+ result = Uploadcare::Internal::SignatureGenerator.call(config: upload.config)
285
+ if result.is_a?(Hash)
286
+ sig = result[:signature] || result['signature']
287
+ exp = result[:expire] || result['expire']
288
+ p = {}
289
+ p['signature'] = sig if sig
290
+ p['expire'] = exp if exp
291
+ p
292
+ else
293
+ { 'signature' => result }
294
+ end
295
+ end
296
+
297
+ def unique_form_field_name(filename, params, field_index)
298
+ return filename unless params.key?(filename)
299
+
300
+ candidate = "__uploadcare_form_#{field_index || 0}__#{filename}"
301
+ suffix = 0
302
+ while params.key?(candidate)
303
+ suffix += 1
304
+ candidate = "__uploadcare_form_#{field_index || 0}_#{suffix}__#{filename}"
305
+ end
306
+ candidate
307
+ end
308
+
309
+ def next_poll_sleep(initial:, max_interval:, attempt:)
310
+ [initial.to_f * (2**attempt), max_interval.to_f].min
311
+ end
312
+ end
313
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Upload API endpoint for group operations.
4
+ #
5
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/createFilesGroup
6
+ class Uploadcare::Api::Upload::Groups
7
+ # @return [Uploadcare::Api::Upload] Parent Upload client
8
+ attr_reader :upload
9
+
10
+ # @param upload [Uploadcare::Api::Upload] Parent Upload client
11
+ def initialize(upload:)
12
+ @upload = upload
13
+ end
14
+
15
+ # Create a file group from UUIDs (POST /group/).
16
+ #
17
+ # @param files [Array<String>] Array of file UUIDs or objects responding to #uuid
18
+ # @param options [Hash] Group creation options (:signature, :expire)
19
+ # @param request_options [Hash] Request options
20
+ # @return [Uploadcare::Result] Group information
21
+ # @raise [ArgumentError] if files is empty or not an array
22
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/createFilesGroup
23
+ def create(files:, request_options: {}, **options)
24
+ Uploadcare::Result.capture do
25
+ raise ArgumentError, 'files must be an array' unless files.is_a?(Array)
26
+ raise ArgumentError, 'files cannot be empty' if files.empty?
27
+
28
+ params = build_group_params(files, options)
29
+ Uploadcare::Result.unwrap(
30
+ upload.post(path: 'group/', params: params, headers: {}, request_options: request_options)
31
+ )
32
+ end
33
+ end
34
+
35
+ # Get group info (GET /group/info/).
36
+ #
37
+ # @param group_id [String] Group UUID
38
+ # @param request_options [Hash] Request options
39
+ # @return [Uploadcare::Result] Group information
40
+ # @raise [ArgumentError] if group_id is empty
41
+ # @see https://uploadcare.com/api-refs/upload-api/#operation/filesGroupInfo
42
+ def info(group_id:, request_options: {})
43
+ Uploadcare::Result.capture do
44
+ raise ArgumentError, 'group_id cannot be empty' if group_id.to_s.strip.empty?
45
+
46
+ Uploadcare::Result.unwrap(
47
+ upload.get(path: 'group/info/',
48
+ params: { pub_key: upload.config.public_key, group_id: group_id },
49
+ request_options: request_options)
50
+ )
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def build_group_params(files, options)
57
+ params = { 'pub_key' => upload.config.public_key }
58
+
59
+ files.each_with_index do |file, index|
60
+ uuid = file.respond_to?(:uuid) ? file.uuid : file.to_s
61
+ params["files[#{index}]"] = uuid
62
+ end
63
+
64
+ if options[:signature] || options['signature']
65
+ params['signature'] =
66
+ (options[:signature] || options['signature']).to_s
67
+ end
68
+ params['expire'] = (options[:expire] || options['expire']).to_s if options[:expire] || options['expire']
69
+
70
+ params
71
+ end
72
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/multipart'
5
+ require 'ipaddr'
6
+ require 'mime/types'
7
+ require 'resolv'
8
+ require 'securerandom'
9
+ require 'uri'
10
+ require 'addressable/uri'
11
+
12
+ # Base client for the Uploadcare Upload API.
13
+ #
14
+ # Provides HTTP methods for upload endpoints using multipart/form-data encoding.
15
+ # Authentication is handled via public key in request parameters (no HMAC signing).
16
+ #
17
+ # Endpoint classes are accessed via lazy-loaded accessors:
18
+ # upload = Uploadcare::Api::Upload.new(config: config)
19
+ # upload.files.direct(file: file_obj)
20
+ # upload.groups.create(files: ["uuid1", "uuid2"])
21
+ #
22
+ # @see https://uploadcare.com/api-refs/upload-api/
23
+ class Uploadcare::Api::Upload
24
+ include Uploadcare::Internal::ErrorHandler
25
+ include Uploadcare::Internal::ThrottleHandler
26
+
27
+ # @return [Uploadcare::Configuration]
28
+ attr_reader :config
29
+
30
+ # @return [Faraday::Connection]
31
+ attr_reader :connection
32
+
33
+ # Initialize a new Upload API client.
34
+ #
35
+ # @param config [Uploadcare::Configuration] Configuration object
36
+ def initialize(config: Uploadcare.configuration)
37
+ @config = config
38
+ @memo_mutex = Mutex.new
39
+ @connection = Faraday.new(url: config.upload_api_root) do |conn|
40
+ conn.request :multipart
41
+ conn.request :url_encoded
42
+ conn.response :json, content_type: /\bjson$/
43
+ conn.response :raise_error
44
+ conn.response :logger, config.logger, bodies: false, headers: false if ENV['DEBUG']
45
+ conn.adapter Faraday.default_adapter
46
+ end
47
+ end
48
+
49
+ # --- Endpoint accessors ---
50
+
51
+ # @return [Uploadcare::Api::Upload::Files] File upload operations
52
+ def files
53
+ memoized(:@files) { Uploadcare::Api::Upload::Files.new(upload: self) }
54
+ end
55
+
56
+ # @return [Uploadcare::Api::Upload::Groups] Group operations via Upload API
57
+ def groups
58
+ memoized(:@groups) { Uploadcare::Api::Upload::Groups.new(upload: self) }
59
+ end
60
+
61
+ # --- HTTP methods ---
62
+
63
+ # Make a GET request to the Upload API wrapped in a Result.
64
+ #
65
+ # @param path [String] API endpoint path
66
+ # @param params [Hash] Query parameters
67
+ # @param headers [Hash] Additional request headers
68
+ # @param request_options [Hash] Request options
69
+ # @return [Uploadcare::Result]
70
+ def get(path:, params: {}, headers: {}, request_options: {})
71
+ Uploadcare::Result.capture do
72
+ make_request(:get, path, params, headers, request_options)
73
+ end
74
+ end
75
+
76
+ # Make a POST request to the Upload API wrapped in a Result.
77
+ #
78
+ # @param path [String] API endpoint path
79
+ # @param params [Hash] Request body parameters
80
+ # @param headers [Hash] Additional request headers
81
+ # @param request_options [Hash] Request options
82
+ # @return [Uploadcare::Result]
83
+ def post(path:, params: {}, headers: {}, request_options: {})
84
+ Uploadcare::Result.capture do
85
+ make_request(:post, path, params, headers, request_options)
86
+ end
87
+ end
88
+
89
+ # Upload binary data to a presigned URL (for multipart uploads).
90
+ #
91
+ # @param presigned_url [String] Presigned URL from multipart_start
92
+ # @param part_data [String, IO] Binary data for this part
93
+ # @param max_retries [Integer] Maximum retry attempts (default: 3)
94
+ # @param timeout [Integer, nil] Request timeout in seconds
95
+ # @param open_timeout [Integer, nil] Open timeout in seconds
96
+ # @return [Boolean] true on success
97
+ # @raise [Uploadcare::Exception::MultipartUploadError] on failure after retries
98
+ def upload_part_to_url(presigned_url, part_data, max_retries: 3, timeout: nil, open_timeout: nil)
99
+ uri = validated_presigned_uri(presigned_url)
100
+ retries = 0
101
+ begin
102
+ conn = Faraday.new(url: "#{uri.scheme}://#{uri.host}") do |f|
103
+ f.adapter Faraday.default_adapter
104
+ end
105
+
106
+ data = part_data.respond_to?(:read) ? part_data.read : part_data
107
+ response = upload_part_request(
108
+ conn: conn, request_uri: uri.request_uri, data: data, timeout: timeout, open_timeout: open_timeout
109
+ )
110
+ raise_multipart_upload_error("Failed to upload part: HTTP #{response.status}") unless success_response?(response)
111
+
112
+ true
113
+ rescue StandardError => e
114
+ retry_part_upload_or_raise!(error: e, retries: retries, max_retries: max_retries)
115
+ retries += 1
116
+ retry
117
+ end
118
+ end
119
+
120
+ protected
121
+
122
+ def make_request(method, path, params = {}, headers = {}, request_options = {})
123
+ handle_throttling(max_attempts: request_options[:max_throttle_attempts]) do
124
+ response = connection.public_send(method, path) do |req|
125
+ prepare_request(req, method, path, params, headers, request_options)
126
+ end
127
+ handle_response(response)
128
+ end
129
+ rescue Faraday::Error => e
130
+ handle_error(e)
131
+ end
132
+
133
+ def handle_response(response)
134
+ return handle_error_response(response) unless success_response?(response)
135
+
136
+ parse_success_response(response)
137
+ rescue JSON::ParserError => e
138
+ handle_json_error(e, response)
139
+ end
140
+
141
+ private
142
+
143
+ def prepare_request(req, method, path, params, headers, request_options)
144
+ upcase_method_name = method.to_s.upcase
145
+ uri = path
146
+ uri = build_request_uri(path, params, upcase_method_name) if upcase_method_name == 'GET'
147
+
148
+ prepare_headers(req, upcase_method_name, uri, headers)
149
+ prepare_body_or_params(req, upcase_method_name, params)
150
+ apply_request_options(req, request_options)
151
+ end
152
+
153
+ def build_request_uri(path, params, method)
154
+ return path unless method == 'GET' && params.is_a?(Hash) && !params.empty?
155
+
156
+ uri = Addressable::URI.parse(path)
157
+ uri.query_values = params
158
+ uri.to_s
159
+ end
160
+
161
+ def prepare_headers(req, _method, _uri, headers)
162
+ req.headers['User-Agent'] ||= Uploadcare::Internal::UserAgent.call(config: config)
163
+ req.headers.merge!(headers)
164
+ end
165
+
166
+ def prepare_body_or_params(req, method, params)
167
+ if method == 'GET'
168
+ req.params.update(params) unless params.empty?
169
+ else
170
+ req.body = params unless params.empty?
171
+ end
172
+ end
173
+
174
+ def apply_request_options(req, request_options)
175
+ return if request_options.nil? || request_options.empty?
176
+
177
+ req.options.timeout = request_options[:timeout] if request_options[:timeout]
178
+ req.options.open_timeout = request_options[:open_timeout] if request_options[:open_timeout]
179
+ end
180
+
181
+ def success_response?(response)
182
+ response.status >= 200 && response.status < 300
183
+ end
184
+
185
+ def handle_error_response(response)
186
+ raise Uploadcare::Exception::UploadError, "Upload API error: #{response.status} #{response.body}"
187
+ end
188
+
189
+ def parse_success_response(response)
190
+ return {} if response.body.nil? || (response.body.is_a?(String) && response.body.strip.empty?)
191
+ return response.body if response.body.is_a?(Hash)
192
+
193
+ JSON.parse(response.body)
194
+ end
195
+
196
+ def handle_json_error(error, response)
197
+ config.logger&.error("Invalid JSON response: #{error.message}")
198
+ success_response?(response) ? {} : response.body
199
+ end
200
+
201
+ def validated_presigned_uri(url)
202
+ uri = URI.parse(url.to_s)
203
+ raise ArgumentError, 'presigned_url must use HTTPS' unless uri.is_a?(URI::HTTPS)
204
+ raise ArgumentError, 'presigned_url host is required' if uri.host.to_s.empty?
205
+ raise ArgumentError, 'presigned_url cannot target localhost' if local_hostname?(uri.host)
206
+ raise ArgumentError, 'presigned_url cannot target a private address' if private_host?(uri.host)
207
+
208
+ uri
209
+ rescue URI::InvalidURIError => e
210
+ raise ArgumentError, "Invalid presigned_url: #{e.message}"
211
+ end
212
+
213
+ def local_hostname?(host)
214
+ normalized_host = host.to_s.downcase
215
+ normalized_host == 'localhost' || normalized_host.end_with?('.localhost', '.local')
216
+ end
217
+
218
+ def private_host?(host)
219
+ return private_ip?(host) if ip_literal?(host)
220
+
221
+ Resolv.getaddresses(host).any? { |address| private_ip?(address) }
222
+ rescue Resolv::ResolvError, SocketError
223
+ false
224
+ end
225
+
226
+ def ip_literal?(host)
227
+ IPAddr.new(host)
228
+ true
229
+ rescue IPAddr::InvalidAddressError
230
+ false
231
+ end
232
+
233
+ def private_ip?(address)
234
+ ip = IPAddr.new(address)
235
+ return true if ip.loopback?
236
+ return true if ip.link_local?
237
+
238
+ ip.private?
239
+ rescue IPAddr::InvalidAddressError
240
+ false
241
+ end
242
+
243
+ def upload_part_request(conn:, request_uri:, data:, timeout:, open_timeout:)
244
+ conn.put(request_uri) do |req|
245
+ req.headers['Content-Type'] = 'application/octet-stream'
246
+ req.options.timeout = timeout if timeout
247
+ req.options.open_timeout = open_timeout if open_timeout
248
+ req.body = data
249
+ end
250
+ end
251
+
252
+ def retry_part_upload_or_raise!(error:, retries:, max_retries:)
253
+ if retries >= max_retries
254
+ raise_multipart_upload_error("Failed to upload part after #{max_retries} retries: #{error.message}")
255
+ end
256
+
257
+ sleep(2**retries)
258
+ end
259
+
260
+ def raise_multipart_upload_error(message)
261
+ raise Uploadcare::Exception::MultipartUploadError, message
262
+ end
263
+
264
+ def memoized(ivar)
265
+ cached = instance_variable_get(ivar)
266
+ return cached if cached
267
+
268
+ @memo_mutex.synchronize do
269
+ instance_variable_get(ivar) || instance_variable_set(ivar, yield)
270
+ end
271
+ end
272
+ end