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.
- checksums.yaml +4 -4
- data/.env.example +7 -0
- data/.github/workflows/gem-push.yml +1 -1
- data/.github/workflows/ruby.yml +10 -13
- data/.gitignore +9 -0
- data/.rubocop.yml +95 -8
- data/CHANGELOG.md +78 -0
- data/Gemfile +23 -6
- data/MIGRATING_V5.md +290 -0
- data/README.md +422 -671
- data/Rakefile +5 -1
- data/api_examples/README.md +77 -0
- data/api_examples/rest_api/delete_files_storage.rb +3 -5
- data/api_examples/rest_api/delete_files_uuid_metadata_key.rb +3 -4
- data/api_examples/rest_api/delete_files_uuid_storage.rb +3 -4
- data/api_examples/rest_api/delete_groups_uuid.rb +3 -4
- data/api_examples/rest_api/delete_webhooks_unsubscribe.rb +3 -4
- data/api_examples/rest_api/get_addons_aws_rekognition_detect_labels_execute_status.rb +3 -6
- data/api_examples/rest_api/get_addons_aws_rekognition_detect_moderation_labels_execute_status.rb +3 -6
- data/api_examples/rest_api/get_addons_remove_bg_execute_status.rb +3 -6
- data/api_examples/rest_api/get_addons_uc_clamav_virus_scan_execute_status.rb +3 -6
- data/api_examples/rest_api/get_convert_document_status_token.rb +3 -5
- data/api_examples/rest_api/get_convert_document_uuid.rb +3 -5
- data/api_examples/rest_api/get_convert_video_status_token.rb +3 -5
- data/api_examples/rest_api/get_files.rb +3 -5
- data/api_examples/rest_api/get_files_uuid.rb +3 -5
- data/api_examples/rest_api/get_files_uuid_metadata.rb +3 -5
- data/api_examples/rest_api/get_files_uuid_metadata_key.rb +3 -5
- data/api_examples/rest_api/get_groups.rb +3 -5
- data/api_examples/rest_api/get_groups_uuid.rb +3 -5
- data/api_examples/rest_api/get_project.rb +3 -5
- data/api_examples/rest_api/get_webhooks.rb +3 -5
- data/api_examples/rest_api/post_addons_aws_rekognition_detect_labels_execute.rb +3 -5
- data/api_examples/rest_api/post_addons_aws_rekognition_detect_moderation_labels_execute.rb +3 -5
- data/api_examples/rest_api/post_addons_remove_bg_execute.rb +3 -5
- data/api_examples/rest_api/post_addons_uc_clamav_virus_scan_execute.rb +3 -5
- data/api_examples/rest_api/post_convert_document.rb +3 -6
- data/api_examples/rest_api/post_convert_video.rb +3 -10
- data/api_examples/rest_api/post_files_local_copy.rb +3 -6
- data/api_examples/rest_api/post_files_remote_copy.rb +3 -7
- data/api_examples/rest_api/post_webhooks.rb +3 -9
- data/api_examples/rest_api/put_files_storage.rb +3 -8
- data/api_examples/rest_api/put_files_uuid_metadata_key.rb +3 -7
- data/api_examples/rest_api/put_files_uuid_storage.rb +3 -5
- data/api_examples/rest_api/put_webhooks_id.rb +3 -11
- data/api_examples/support/example_helper.rb +250 -0
- data/api_examples/support/run_rest_example.rb +161 -0
- data/api_examples/support/run_upload_example.rb +88 -0
- data/api_examples/upload_api/get_from_url_status.rb +3 -5
- data/api_examples/upload_api/get_group_info.rb +3 -6
- data/api_examples/upload_api/get_info.rb +3 -6
- data/api_examples/upload_api/post_base.rb +3 -5
- data/api_examples/upload_api/post_from_url.rb +3 -5
- data/api_examples/upload_api/post_group.rb +3 -8
- data/api_examples/upload_api/post_multipart_complete.rb +3 -7
- data/api_examples/upload_api/post_multipart_start.rb +3 -7
- data/api_examples/upload_api/put_multipart_part.rb +4 -0
- data/bin/console +1 -1
- data/docs/release-notes-5.0.0.rc1.md +34 -0
- data/examples/README.md +39 -0
- data/examples/batch_upload.rb +54 -0
- data/examples/group_creation.rb +88 -0
- data/examples/large_file_upload.rb +88 -0
- data/examples/simple_upload.rb +39 -0
- data/examples/upload_with_progress.rb +84 -0
- data/examples/url_upload.rb +56 -0
- data/lib/uploadcare/api/rest/addons.rb +107 -0
- data/lib/uploadcare/api/rest/document_conversions.rb +65 -0
- data/lib/uploadcare/api/rest/file_metadata.rb +71 -0
- data/lib/uploadcare/api/rest/files.rb +112 -0
- data/lib/uploadcare/api/rest/groups.rb +49 -0
- data/lib/uploadcare/api/rest/project.rb +23 -0
- data/lib/uploadcare/api/rest/video_conversions.rb +52 -0
- data/lib/uploadcare/api/rest/webhooks.rb +74 -0
- data/lib/uploadcare/api/rest.rb +254 -0
- data/lib/uploadcare/api/upload/files.rb +313 -0
- data/lib/uploadcare/api/upload/groups.rb +72 -0
- data/lib/uploadcare/api/upload.rb +272 -0
- data/lib/uploadcare/client/addons_accessor.rb +85 -0
- data/lib/uploadcare/client/api.rb +33 -0
- data/lib/uploadcare/client/conversions_accessor.rb +33 -0
- data/lib/uploadcare/client/document_conversions_accessor.rb +41 -0
- data/lib/uploadcare/client/file_metadata_accessor.rb +46 -0
- data/lib/uploadcare/client/files_accessor.rb +82 -0
- data/lib/uploadcare/client/groups_accessor.rb +35 -0
- data/lib/uploadcare/client/project_accessor.rb +17 -0
- data/lib/uploadcare/client/video_conversions_accessor.rb +33 -0
- data/lib/uploadcare/client/webhooks_accessor.rb +42 -0
- data/lib/uploadcare/client.rb +127 -0
- data/lib/uploadcare/cname_generator.rb +68 -0
- data/lib/uploadcare/collections/batch_result.rb +35 -0
- data/lib/uploadcare/collections/paginated.rb +165 -0
- data/lib/uploadcare/configuration.rb +81 -0
- data/lib/uploadcare/exception/auth_error.rb +2 -6
- data/lib/uploadcare/exception/configuration_error.rb +4 -0
- data/lib/uploadcare/exception/conversion_error.rb +2 -6
- data/lib/uploadcare/exception/invalid_request_error.rb +4 -0
- data/lib/uploadcare/exception/multipart_upload_error.rb +4 -0
- data/lib/uploadcare/exception/not_found_error.rb +4 -0
- data/lib/uploadcare/exception/request_error.rb +2 -6
- data/lib/uploadcare/exception/retry_error.rb +2 -6
- data/lib/uploadcare/exception/throttle_error.rb +7 -11
- data/lib/uploadcare/exception/unknown_status_error.rb +4 -0
- data/lib/uploadcare/exception/upload_error.rb +4 -0
- data/lib/uploadcare/exception/upload_timeout_error.rb +4 -0
- data/lib/uploadcare/internal/authenticator.rb +101 -0
- data/lib/uploadcare/internal/error_handler.rb +102 -0
- data/lib/uploadcare/internal/signature_generator.rb +31 -0
- data/lib/uploadcare/internal/throttle_handler.rb +36 -0
- data/lib/uploadcare/internal/upload_io.rb +110 -0
- data/lib/uploadcare/internal/upload_params_generator.rb +86 -0
- data/lib/uploadcare/internal/user_agent.rb +22 -0
- data/lib/uploadcare/operations/multipart_upload.rb +213 -0
- data/lib/uploadcare/operations/upload_router.rb +162 -0
- data/lib/uploadcare/resources/addon_execution.rb +97 -0
- data/lib/uploadcare/resources/base_resource.rb +61 -0
- data/lib/uploadcare/resources/document_conversion.rb +81 -0
- data/lib/uploadcare/resources/file.rb +366 -0
- data/lib/uploadcare/resources/file_metadata.rb +135 -0
- data/lib/uploadcare/resources/group.rb +142 -0
- data/lib/uploadcare/resources/project.rb +26 -0
- data/lib/uploadcare/resources/video_conversion.rb +59 -0
- data/lib/uploadcare/resources/webhook.rb +85 -0
- data/lib/uploadcare/result.rb +85 -0
- data/lib/uploadcare/signed_url_generators/akamai_generator.rb +60 -56
- data/lib/uploadcare/signed_url_generators/base_generator.rb +15 -15
- data/lib/uploadcare/version.rb +7 -0
- data/lib/uploadcare/webhook_signature_verifier.rb +60 -0
- data/lib/uploadcare.rb +84 -50
- data/mise.toml +2 -0
- data/uploadcare-ruby.gemspec +8 -7
- metadata +102 -74
- data/api_examples/upload_api/put_presigned_url_x.rb +0 -8
- data/lib/uploadcare/api/api.rb +0 -25
- data/lib/uploadcare/client/addons_client.rb +0 -69
- data/lib/uploadcare/client/conversion/base_conversion_client.rb +0 -59
- data/lib/uploadcare/client/conversion/document_conversion_client.rb +0 -45
- data/lib/uploadcare/client/conversion/video_conversion_client.rb +0 -46
- data/lib/uploadcare/client/file_client.rb +0 -48
- data/lib/uploadcare/client/file_list_client.rb +0 -46
- data/lib/uploadcare/client/file_metadata_client.rb +0 -36
- data/lib/uploadcare/client/group_client.rb +0 -45
- data/lib/uploadcare/client/multipart_upload/chunks_client.rb +0 -58
- data/lib/uploadcare/client/multipart_upload_client.rb +0 -64
- data/lib/uploadcare/client/project_client.rb +0 -20
- data/lib/uploadcare/client/rest_client.rb +0 -77
- data/lib/uploadcare/client/rest_group_client.rb +0 -43
- data/lib/uploadcare/client/upload_client.rb +0 -46
- data/lib/uploadcare/client/uploader_client.rb +0 -128
- data/lib/uploadcare/client/webhook_client.rb +0 -49
- data/lib/uploadcare/concern/error_handler.rb +0 -54
- data/lib/uploadcare/concern/throttle_handler.rb +0 -25
- data/lib/uploadcare/concern/upload_error_handler.rb +0 -32
- data/lib/uploadcare/entity/addons.rb +0 -14
- data/lib/uploadcare/entity/conversion/base_converter.rb +0 -43
- data/lib/uploadcare/entity/conversion/document_converter.rb +0 -15
- data/lib/uploadcare/entity/conversion/video_converter.rb +0 -15
- data/lib/uploadcare/entity/decorator/paginator.rb +0 -79
- data/lib/uploadcare/entity/entity.rb +0 -18
- data/lib/uploadcare/entity/file.rb +0 -103
- data/lib/uploadcare/entity/file_list.rb +0 -32
- data/lib/uploadcare/entity/file_metadata.rb +0 -30
- data/lib/uploadcare/entity/group.rb +0 -49
- data/lib/uploadcare/entity/group_list.rb +0 -24
- data/lib/uploadcare/entity/project.rb +0 -13
- data/lib/uploadcare/entity/uploader.rb +0 -93
- data/lib/uploadcare/entity/webhook.rb +0 -14
- data/lib/uploadcare/param/authentication_header.rb +0 -37
- data/lib/uploadcare/param/conversion/document/processing_job_url_builder.rb +0 -39
- data/lib/uploadcare/param/conversion/video/processing_job_url_builder.rb +0 -64
- data/lib/uploadcare/param/param.rb +0 -10
- data/lib/uploadcare/param/secure_auth_header.rb +0 -51
- data/lib/uploadcare/param/simple_auth_header.rb +0 -14
- data/lib/uploadcare/param/upload/signature_generator.rb +0 -24
- data/lib/uploadcare/param/upload/upload_params_generator.rb +0 -41
- data/lib/uploadcare/param/user_agent.rb +0 -21
- data/lib/uploadcare/param/webhook_signature_verifier.rb +0 -23
- 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
|