sirv_rest_api 1.0.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +305 -0
- data/Rakefile +27 -0
- data/docs/index.html +539 -0
- data/examples/basic_usage.rb +68 -0
- data/icon.png +0 -0
- data/lib/sirv_rest_api/client.rb +882 -0
- data/lib/sirv_rest_api/errors.rb +56 -0
- data/lib/sirv_rest_api/models.rb +319 -0
- data/lib/sirv_rest_api/version.rb +5 -0
- data/lib/sirv_rest_api.rb +32 -0
- data/sirv_rest_api.gemspec +43 -0
- metadata +132 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module SirvRestApi
|
|
8
|
+
# Main client for interacting with the Sirv REST API
|
|
9
|
+
class Client
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.sirv.com"
|
|
11
|
+
DEFAULT_TOKEN_REFRESH_BUFFER = 60
|
|
12
|
+
DEFAULT_TIMEOUT = 30
|
|
13
|
+
DEFAULT_MAX_RETRIES = 3
|
|
14
|
+
|
|
15
|
+
attr_reader :config
|
|
16
|
+
|
|
17
|
+
# Initialize a new Sirv API client
|
|
18
|
+
#
|
|
19
|
+
# @param client_id [String] Your Sirv API client ID (required)
|
|
20
|
+
# @param client_secret [String] Your Sirv API client secret (required)
|
|
21
|
+
# @param base_url [String] Base URL for API (default: https://api.sirv.com)
|
|
22
|
+
# @param auto_refresh_token [Boolean] Auto-refresh token before expiry (default: true)
|
|
23
|
+
# @param token_refresh_buffer [Integer] Seconds before token expiry to trigger refresh (default: 60)
|
|
24
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
25
|
+
# @param max_retries [Integer] Maximum number of retries for failed requests (default: 3)
|
|
26
|
+
def initialize(client_id:, client_secret:, base_url: DEFAULT_BASE_URL,
|
|
27
|
+
auto_refresh_token: true, token_refresh_buffer: DEFAULT_TOKEN_REFRESH_BUFFER,
|
|
28
|
+
timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
|
|
29
|
+
raise ArgumentError, "client_id is required" if client_id.nil? || client_id.empty?
|
|
30
|
+
raise ArgumentError, "client_secret is required" if client_secret.nil? || client_secret.empty?
|
|
31
|
+
|
|
32
|
+
@config = {
|
|
33
|
+
client_id: client_id,
|
|
34
|
+
client_secret: client_secret,
|
|
35
|
+
base_url: base_url,
|
|
36
|
+
auto_refresh_token: auto_refresh_token,
|
|
37
|
+
token_refresh_buffer: token_refresh_buffer,
|
|
38
|
+
timeout: timeout,
|
|
39
|
+
max_retries: max_retries
|
|
40
|
+
}
|
|
41
|
+
@token = nil
|
|
42
|
+
@token_expiry = nil
|
|
43
|
+
@mutex = Mutex.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ============================================================================
|
|
47
|
+
# Authentication
|
|
48
|
+
# ============================================================================
|
|
49
|
+
|
|
50
|
+
# Authenticate and obtain a bearer token
|
|
51
|
+
#
|
|
52
|
+
# @param expires_in [Integer, nil] Token expiry time in seconds (5-604800). Default is 1200 (20 minutes).
|
|
53
|
+
# @return [TokenResponse] Token response with token, expiration, and scopes
|
|
54
|
+
def connect(expires_in: nil)
|
|
55
|
+
body = {
|
|
56
|
+
clientId: @config[:client_id],
|
|
57
|
+
clientSecret: @config[:client_secret]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if expires_in
|
|
61
|
+
raise ValidationError.new("expires_in must be between 5 and 604800 seconds") unless expires_in.between?(5, 604800)
|
|
62
|
+
body[:expiresIn] = expires_in
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response = request_without_auth(:post, "/v2/token", body)
|
|
66
|
+
token_response = TokenResponse.new(response)
|
|
67
|
+
|
|
68
|
+
@mutex.synchronize do
|
|
69
|
+
@token = token_response.token
|
|
70
|
+
@token_expiry = Time.now + token_response.expires_in
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
token_response
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Check if client is connected with a valid token
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean] true if connected with valid token
|
|
79
|
+
def connected?
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
!@token.nil? && !token_expired?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get the current access token
|
|
86
|
+
#
|
|
87
|
+
# @return [String, nil] Current token or nil
|
|
88
|
+
def access_token
|
|
89
|
+
@mutex.synchronize { @token }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ============================================================================
|
|
93
|
+
# Account API
|
|
94
|
+
# ============================================================================
|
|
95
|
+
|
|
96
|
+
# Get account information
|
|
97
|
+
#
|
|
98
|
+
# @return [AccountInfo] Account information
|
|
99
|
+
def get_account_info
|
|
100
|
+
response = request(:get, "/v2/account")
|
|
101
|
+
AccountInfo.new(response)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Update account settings
|
|
105
|
+
#
|
|
106
|
+
# @param options [Hash] Account update options
|
|
107
|
+
# @return [void]
|
|
108
|
+
def update_account(options)
|
|
109
|
+
request(:post, "/v2/account", options)
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Get API rate limits
|
|
114
|
+
#
|
|
115
|
+
# @return [AccountLimits] Rate limit information
|
|
116
|
+
def get_account_limits
|
|
117
|
+
response = request(:get, "/v2/account/limits")
|
|
118
|
+
AccountLimits.new(response)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get storage usage information
|
|
122
|
+
#
|
|
123
|
+
# @return [StorageInfo] Storage information
|
|
124
|
+
def get_storage_info
|
|
125
|
+
response = request(:get, "/v2/account/storage")
|
|
126
|
+
StorageInfo.new(response)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get all account users
|
|
130
|
+
#
|
|
131
|
+
# @return [Array<AccountUser>] List of account users
|
|
132
|
+
def get_account_users
|
|
133
|
+
response = request(:get, "/v2/account/users")
|
|
134
|
+
response.map { |user| AccountUser.new(user) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get billing plan details
|
|
138
|
+
#
|
|
139
|
+
# @return [BillingPlan] Billing plan information
|
|
140
|
+
def get_billing_plan
|
|
141
|
+
response = request(:get, "/v2/billing/plan")
|
|
142
|
+
BillingPlan.new(response)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Search account events
|
|
146
|
+
#
|
|
147
|
+
# @param params [Hash] Search parameters (module, type, level, filename, from, to)
|
|
148
|
+
# @return [Array<AccountEvent>] List of events
|
|
149
|
+
def search_events(params = {})
|
|
150
|
+
response = request(:post, "/v2/account/events/search", params)
|
|
151
|
+
response.map { |event| AccountEvent.new(event) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Mark events as seen
|
|
155
|
+
#
|
|
156
|
+
# @param event_ids [Array<String>] Event IDs to mark as seen
|
|
157
|
+
# @return [void]
|
|
158
|
+
def mark_events_seen(event_ids)
|
|
159
|
+
request(:post, "/v2/account/events/seen", event_ids)
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ============================================================================
|
|
164
|
+
# User API
|
|
165
|
+
# ============================================================================
|
|
166
|
+
|
|
167
|
+
# Get user information
|
|
168
|
+
#
|
|
169
|
+
# @param user_id [String, nil] User ID (optional, defaults to current user)
|
|
170
|
+
# @return [UserInfo] User information
|
|
171
|
+
def get_user_info(user_id: nil)
|
|
172
|
+
params = user_id ? { userId: user_id } : {}
|
|
173
|
+
response = request(:get, "/v2/user", params)
|
|
174
|
+
UserInfo.new(response)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ============================================================================
|
|
178
|
+
# Files API - Reading
|
|
179
|
+
# ============================================================================
|
|
180
|
+
|
|
181
|
+
# Get file information
|
|
182
|
+
#
|
|
183
|
+
# @param filename [String] File path
|
|
184
|
+
# @return [FileInfo] File information
|
|
185
|
+
def get_file_info(filename)
|
|
186
|
+
response = request(:get, "/v2/files/stat", { filename: filename })
|
|
187
|
+
FileInfo.new(response)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Read folder contents
|
|
191
|
+
#
|
|
192
|
+
# @param dirname [String] Directory path
|
|
193
|
+
# @param continuation [String, nil] Continuation token for pagination
|
|
194
|
+
# @return [FolderContents] Folder contents with files and continuation token
|
|
195
|
+
def read_folder_contents(dirname, continuation: nil)
|
|
196
|
+
params = { dirname: dirname }
|
|
197
|
+
params[:continuation] = continuation if continuation
|
|
198
|
+
response = request(:get, "/v2/files/readdir", params)
|
|
199
|
+
FolderContents.new(response)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Iterate through all items in a folder (handles pagination automatically)
|
|
203
|
+
#
|
|
204
|
+
# @param dirname [String] Directory path
|
|
205
|
+
# @yield [FileInfo] Each file/folder in the directory
|
|
206
|
+
# @return [Enumerator] If no block given
|
|
207
|
+
def each_folder_item(dirname, &block)
|
|
208
|
+
return enum_for(:each_folder_item, dirname) unless block_given?
|
|
209
|
+
|
|
210
|
+
continuation = nil
|
|
211
|
+
loop do
|
|
212
|
+
result = read_folder_contents(dirname, continuation: continuation)
|
|
213
|
+
result.contents.each(&block)
|
|
214
|
+
break if result.continuation.nil? || result.continuation.empty?
|
|
215
|
+
continuation = result.continuation
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Get folder options
|
|
220
|
+
#
|
|
221
|
+
# @param dirname [String] Directory path
|
|
222
|
+
# @return [Hash] Folder options
|
|
223
|
+
def get_folder_options(dirname)
|
|
224
|
+
request(:get, "/v2/files/options", { dirname: dirname })
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Set folder options
|
|
228
|
+
#
|
|
229
|
+
# @param dirname [String] Directory path
|
|
230
|
+
# @param options [Hash] Folder options (scanSpins, allowListing)
|
|
231
|
+
# @return [void]
|
|
232
|
+
def set_folder_options(dirname, options)
|
|
233
|
+
request(:post, "/v2/files/options", { dirname: dirname }.merge(options))
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Search files
|
|
238
|
+
#
|
|
239
|
+
# @param params [Hash] Search parameters (query, from, size, sort, filters)
|
|
240
|
+
# @return [SearchResult] Search results
|
|
241
|
+
def search_files(params = {})
|
|
242
|
+
response = request(:post, "/v2/files/search", params)
|
|
243
|
+
SearchResult.new(response)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Continue paginated search
|
|
247
|
+
#
|
|
248
|
+
# @param scroll_id [String] Scroll ID from previous search
|
|
249
|
+
# @return [SearchResult] Search results
|
|
250
|
+
def scroll_search(scroll_id)
|
|
251
|
+
response = request(:post, "/v2/files/search/scroll", { scrollId: scroll_id })
|
|
252
|
+
SearchResult.new(response)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Iterate through all search results (handles pagination automatically)
|
|
256
|
+
#
|
|
257
|
+
# @param params [Hash] Search parameters
|
|
258
|
+
# @yield [FileInfo] Each file in search results
|
|
259
|
+
# @return [Enumerator] If no block given
|
|
260
|
+
def each_search_result(params = {}, &block)
|
|
261
|
+
return enum_for(:each_search_result, params) unless block_given?
|
|
262
|
+
|
|
263
|
+
result = search_files(params)
|
|
264
|
+
result.hits.each(&block)
|
|
265
|
+
|
|
266
|
+
while result.scroll_id && !result.hits.empty?
|
|
267
|
+
result = scroll_search(result.scroll_id)
|
|
268
|
+
result.hits.each(&block)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Download a file
|
|
273
|
+
#
|
|
274
|
+
# @param filename [String] File path on Sirv
|
|
275
|
+
# @return [String] File contents as binary string
|
|
276
|
+
def download_file(filename)
|
|
277
|
+
request_raw(:get, "/v2/files/download", { filename: filename })
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Download a file to local path
|
|
281
|
+
#
|
|
282
|
+
# @param filename [String] File path on Sirv
|
|
283
|
+
# @param local_path [String] Local file path to save to
|
|
284
|
+
# @return [void]
|
|
285
|
+
def download_file_to(filename, local_path)
|
|
286
|
+
content = download_file(filename)
|
|
287
|
+
File.binwrite(local_path, content)
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ============================================================================
|
|
292
|
+
# Files API - Writing
|
|
293
|
+
# ============================================================================
|
|
294
|
+
|
|
295
|
+
# Upload a file from content
|
|
296
|
+
#
|
|
297
|
+
# @param target_path [String] Target path on Sirv
|
|
298
|
+
# @param content [String] File content
|
|
299
|
+
# @param content_type [String, nil] Content type (optional)
|
|
300
|
+
# @return [void]
|
|
301
|
+
def upload_file(target_path, content, content_type: nil)
|
|
302
|
+
upload_raw(target_path, content, content_type: content_type)
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Upload a file from local path
|
|
307
|
+
#
|
|
308
|
+
# @param target_path [String] Target path on Sirv
|
|
309
|
+
# @param local_path [String] Local file path
|
|
310
|
+
# @param content_type [String, nil] Content type (optional)
|
|
311
|
+
# @return [void]
|
|
312
|
+
def upload_file_from_path(target_path, local_path, content_type: nil)
|
|
313
|
+
content = File.binread(local_path)
|
|
314
|
+
upload_file(target_path, content, content_type: content_type)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Create a new folder
|
|
318
|
+
#
|
|
319
|
+
# @param dirname [String] Directory path
|
|
320
|
+
# @return [void]
|
|
321
|
+
def create_folder(dirname)
|
|
322
|
+
request(:post, "/v2/files/mkdir", { dirname: dirname })
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Delete a file or empty folder
|
|
327
|
+
#
|
|
328
|
+
# @param filename [String] File or folder path
|
|
329
|
+
# @return [void]
|
|
330
|
+
def delete_file(filename)
|
|
331
|
+
request(:post, "/v2/files/delete", { filename: filename })
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Delete multiple files/folders
|
|
336
|
+
#
|
|
337
|
+
# @param filenames [Array<String>] List of file paths
|
|
338
|
+
# @return [BatchDeleteResult] Batch delete result
|
|
339
|
+
def batch_delete(filenames)
|
|
340
|
+
response = request(:post, "/v2/files/delete", filenames)
|
|
341
|
+
BatchDeleteResult.new(response)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Get batch delete job status
|
|
345
|
+
#
|
|
346
|
+
# @param job_id [String] Job ID
|
|
347
|
+
# @return [BatchDeleteResult] Job status
|
|
348
|
+
def get_batch_delete_status(job_id)
|
|
349
|
+
response = request(:get, "/v2/files/delete/#{job_id}")
|
|
350
|
+
BatchDeleteResult.new(response)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Copy a file
|
|
354
|
+
#
|
|
355
|
+
# @param from [String] Source path
|
|
356
|
+
# @param to [String] Destination path
|
|
357
|
+
# @return [void]
|
|
358
|
+
def copy_file(from:, to:)
|
|
359
|
+
request(:post, "/v2/files/copy", { from: from, to: to })
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Rename or move a file/folder
|
|
364
|
+
#
|
|
365
|
+
# @param from [String] Current path
|
|
366
|
+
# @param to [String] New path
|
|
367
|
+
# @return [void]
|
|
368
|
+
def rename_file(from:, to:)
|
|
369
|
+
request(:post, "/v2/files/rename", { from: from, to: to })
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Fetch file from external URL
|
|
374
|
+
#
|
|
375
|
+
# @param url [String] Source URL
|
|
376
|
+
# @param filename [String] Target filename on Sirv
|
|
377
|
+
# @param wait [Boolean, nil] Wait for completion
|
|
378
|
+
# @return [void]
|
|
379
|
+
def fetch_url(url:, filename:, wait: nil)
|
|
380
|
+
body = { url: url, filename: filename }
|
|
381
|
+
body[:wait] = wait unless wait.nil?
|
|
382
|
+
request(:post, "/v2/files/fetch", body)
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Create ZIP archive from multiple files
|
|
387
|
+
#
|
|
388
|
+
# @param filenames [Array<String>] Files to include
|
|
389
|
+
# @param filename [String] Output ZIP filename
|
|
390
|
+
# @return [BatchZipResult] Batch ZIP result
|
|
391
|
+
def batch_zip(filenames:, filename:)
|
|
392
|
+
response = request(:post, "/v2/files/zip", { filenames: filenames, filename: filename })
|
|
393
|
+
BatchZipResult.new(response)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Get ZIP job status
|
|
397
|
+
#
|
|
398
|
+
# @param job_id [String] Job ID
|
|
399
|
+
# @return [BatchZipResult] Job status
|
|
400
|
+
def get_zip_status(job_id)
|
|
401
|
+
response = request(:get, "/v2/files/zip/#{job_id}")
|
|
402
|
+
BatchZipResult.new(response)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# ============================================================================
|
|
406
|
+
# Metadata API
|
|
407
|
+
# ============================================================================
|
|
408
|
+
|
|
409
|
+
# Get all file metadata
|
|
410
|
+
#
|
|
411
|
+
# @param filename [String] File path
|
|
412
|
+
# @return [FileMeta] File metadata
|
|
413
|
+
def get_file_meta(filename)
|
|
414
|
+
response = request(:get, "/v2/files/meta", { filename: filename })
|
|
415
|
+
FileMeta.new(response)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Set file metadata
|
|
419
|
+
#
|
|
420
|
+
# @param filename [String] File path
|
|
421
|
+
# @param meta [Hash, FileMeta] Metadata to set
|
|
422
|
+
# @return [void]
|
|
423
|
+
def set_file_meta(filename, meta)
|
|
424
|
+
body = meta.is_a?(FileMeta) ? meta.to_h : meta
|
|
425
|
+
request(:post, "/v2/files/meta", { filename: filename }.merge(body))
|
|
426
|
+
nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Get file title
|
|
430
|
+
#
|
|
431
|
+
# @param filename [String] File path
|
|
432
|
+
# @return [String] File title
|
|
433
|
+
def get_file_title(filename)
|
|
434
|
+
response = request(:get, "/v2/files/meta/title", { filename: filename })
|
|
435
|
+
response["title"]
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Set file title
|
|
439
|
+
#
|
|
440
|
+
# @param filename [String] File path
|
|
441
|
+
# @param title [String] New title
|
|
442
|
+
# @return [void]
|
|
443
|
+
def set_file_title(filename, title)
|
|
444
|
+
request(:post, "/v2/files/meta/title", { filename: filename, title: title })
|
|
445
|
+
nil
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Get file description
|
|
449
|
+
#
|
|
450
|
+
# @param filename [String] File path
|
|
451
|
+
# @return [String] File description
|
|
452
|
+
def get_file_description(filename)
|
|
453
|
+
response = request(:get, "/v2/files/meta/description", { filename: filename })
|
|
454
|
+
response["description"]
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Set file description
|
|
458
|
+
#
|
|
459
|
+
# @param filename [String] File path
|
|
460
|
+
# @param description [String] New description
|
|
461
|
+
# @return [void]
|
|
462
|
+
def set_file_description(filename, description)
|
|
463
|
+
request(:post, "/v2/files/meta/description", { filename: filename, description: description })
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Get file tags
|
|
468
|
+
#
|
|
469
|
+
# @param filename [String] File path
|
|
470
|
+
# @return [Array<String>] File tags
|
|
471
|
+
def get_file_tags(filename)
|
|
472
|
+
response = request(:get, "/v2/files/meta/tags", { filename: filename })
|
|
473
|
+
response["tags"] || []
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Add tags to file
|
|
477
|
+
#
|
|
478
|
+
# @param filename [String] File path
|
|
479
|
+
# @param tags [Array<String>] Tags to add
|
|
480
|
+
# @return [void]
|
|
481
|
+
def add_file_tags(filename, tags)
|
|
482
|
+
request(:post, "/v2/files/meta/tags", { filename: filename, tags: tags })
|
|
483
|
+
nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Remove tags from file
|
|
487
|
+
#
|
|
488
|
+
# @param filename [String] File path
|
|
489
|
+
# @param tags [Array<String>] Tags to remove
|
|
490
|
+
# @return [void]
|
|
491
|
+
def remove_file_tags(filename, tags)
|
|
492
|
+
request(:delete, "/v2/files/meta/tags", { filename: filename, tags: tags })
|
|
493
|
+
nil
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Get product metadata
|
|
497
|
+
#
|
|
498
|
+
# @param filename [String] File path
|
|
499
|
+
# @return [ProductMeta] Product metadata
|
|
500
|
+
def get_product_meta(filename)
|
|
501
|
+
response = request(:get, "/v2/files/meta/product", { filename: filename })
|
|
502
|
+
ProductMeta.new(response)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Set product metadata
|
|
506
|
+
#
|
|
507
|
+
# @param filename [String] File path
|
|
508
|
+
# @param meta [Hash, ProductMeta] Product metadata
|
|
509
|
+
# @return [void]
|
|
510
|
+
def set_product_meta(filename, meta)
|
|
511
|
+
body = meta.is_a?(ProductMeta) ? meta.to_h : meta
|
|
512
|
+
request(:post, "/v2/files/meta/product", { filename: filename }.merge(body))
|
|
513
|
+
nil
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Get approval flag
|
|
517
|
+
#
|
|
518
|
+
# @param filename [String] File path
|
|
519
|
+
# @return [Boolean] Approval status
|
|
520
|
+
def get_approval_flag(filename)
|
|
521
|
+
response = request(:get, "/v2/files/meta/approval", { filename: filename })
|
|
522
|
+
response["approved"]
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Set approval flag
|
|
526
|
+
#
|
|
527
|
+
# @param filename [String] File path
|
|
528
|
+
# @param approved [Boolean] Approval status
|
|
529
|
+
# @return [void]
|
|
530
|
+
def set_approval_flag(filename, approved)
|
|
531
|
+
request(:post, "/v2/files/meta/approval", { filename: filename, approved: approved })
|
|
532
|
+
nil
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# ============================================================================
|
|
536
|
+
# JWT API
|
|
537
|
+
# ============================================================================
|
|
538
|
+
|
|
539
|
+
# Generate JWT protected URL
|
|
540
|
+
#
|
|
541
|
+
# @param filename [String] File path
|
|
542
|
+
# @param expires_in [Integer, nil] Expiration time in seconds
|
|
543
|
+
# @param secure_params [Hash, nil] Additional secure parameters
|
|
544
|
+
# @return [JwtResponse] JWT response with URL and token
|
|
545
|
+
def generate_jwt(filename:, expires_in: nil, secure_params: nil)
|
|
546
|
+
body = { filename: filename }
|
|
547
|
+
body[:expiresIn] = expires_in if expires_in
|
|
548
|
+
body[:secureParams] = secure_params if secure_params
|
|
549
|
+
response = request(:post, "/v2/files/jwt", body)
|
|
550
|
+
JwtResponse.new(response)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# ============================================================================
|
|
554
|
+
# Spins/360 API
|
|
555
|
+
# ============================================================================
|
|
556
|
+
|
|
557
|
+
# Convert spin to video
|
|
558
|
+
#
|
|
559
|
+
# @param filename [String] Spin filename
|
|
560
|
+
# @param options [Hash, nil] Conversion options (width, height, loops, format)
|
|
561
|
+
# @return [String] Output video filename
|
|
562
|
+
def spin_to_video(filename, options: nil)
|
|
563
|
+
body = { filename: filename }
|
|
564
|
+
body[:options] = options if options
|
|
565
|
+
response = request(:post, "/v2/files/spin2video", body)
|
|
566
|
+
response["filename"]
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Convert video to spin
|
|
570
|
+
#
|
|
571
|
+
# @param filename [String] Video filename
|
|
572
|
+
# @param target_filename [String, nil] Target spin filename
|
|
573
|
+
# @param options [Hash, nil] Conversion options (frames, start, duration)
|
|
574
|
+
# @return [String] Output spin filename
|
|
575
|
+
def video_to_spin(filename, target_filename: nil, options: nil)
|
|
576
|
+
body = { filename: filename }
|
|
577
|
+
body[:targetFilename] = target_filename if target_filename
|
|
578
|
+
body[:options] = options if options
|
|
579
|
+
response = request(:post, "/v2/files/video2spin", body)
|
|
580
|
+
response["filename"]
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
# Export spin to Amazon
|
|
584
|
+
#
|
|
585
|
+
# @param filename [String] Spin filename
|
|
586
|
+
# @param asin [String, nil] Amazon ASIN
|
|
587
|
+
# @param product_id [String, nil] Product ID
|
|
588
|
+
# @return [void]
|
|
589
|
+
def export_spin_to_amazon(filename:, asin: nil, product_id: nil)
|
|
590
|
+
body = { filename: filename }
|
|
591
|
+
body[:asin] = asin if asin
|
|
592
|
+
body[:productId] = product_id if product_id
|
|
593
|
+
request(:post, "/v2/files/spin/export/amazon", body)
|
|
594
|
+
nil
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# Export spin to Walmart
|
|
598
|
+
#
|
|
599
|
+
# @param filename [String] Spin filename
|
|
600
|
+
# @param product_id [String, nil] Product ID
|
|
601
|
+
# @return [void]
|
|
602
|
+
def export_spin_to_walmart(filename:, product_id: nil)
|
|
603
|
+
body = { filename: filename }
|
|
604
|
+
body[:productId] = product_id if product_id
|
|
605
|
+
request(:post, "/v2/files/spin/export/walmart", body)
|
|
606
|
+
nil
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Export spin to Home Depot
|
|
610
|
+
#
|
|
611
|
+
# @param filename [String] Spin filename
|
|
612
|
+
# @param product_id [String, nil] Product ID
|
|
613
|
+
# @return [void]
|
|
614
|
+
def export_spin_to_home_depot(filename:, product_id: nil)
|
|
615
|
+
body = { filename: filename }
|
|
616
|
+
body[:productId] = product_id if product_id
|
|
617
|
+
request(:post, "/v2/files/spin/export/homedepot", body)
|
|
618
|
+
nil
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# Export spin to Lowe's
|
|
622
|
+
#
|
|
623
|
+
# @param filename [String] Spin filename
|
|
624
|
+
# @param product_id [String, nil] Product ID
|
|
625
|
+
# @return [void]
|
|
626
|
+
def export_spin_to_lowes(filename:, product_id: nil)
|
|
627
|
+
body = { filename: filename }
|
|
628
|
+
body[:productId] = product_id if product_id
|
|
629
|
+
request(:post, "/v2/files/spin/export/lowes", body)
|
|
630
|
+
nil
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Export spin to Grainger
|
|
634
|
+
#
|
|
635
|
+
# @param filename [String] Spin filename
|
|
636
|
+
# @param product_id [String, nil] Product ID
|
|
637
|
+
# @return [void]
|
|
638
|
+
def export_spin_to_grainger(filename:, product_id: nil)
|
|
639
|
+
body = { filename: filename }
|
|
640
|
+
body[:productId] = product_id if product_id
|
|
641
|
+
request(:post, "/v2/files/spin/export/grainger", body)
|
|
642
|
+
nil
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
# ============================================================================
|
|
646
|
+
# Points of Interest API
|
|
647
|
+
# ============================================================================
|
|
648
|
+
|
|
649
|
+
# Get points of interest for a file
|
|
650
|
+
#
|
|
651
|
+
# @param filename [String] File path
|
|
652
|
+
# @return [Array<PointOfInterest>] List of points of interest
|
|
653
|
+
def get_points_of_interest(filename)
|
|
654
|
+
response = request(:get, "/v2/files/poi", { filename: filename })
|
|
655
|
+
response.map { |poi| PointOfInterest.new(poi) }
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Set point of interest
|
|
659
|
+
#
|
|
660
|
+
# @param filename [String] File path
|
|
661
|
+
# @param poi [Hash, PointOfInterest] Point of interest data
|
|
662
|
+
# @return [void]
|
|
663
|
+
def set_point_of_interest(filename, poi)
|
|
664
|
+
body = poi.is_a?(PointOfInterest) ? poi.to_h : poi
|
|
665
|
+
request(:post, "/v2/files/poi", { filename: filename }.merge(body))
|
|
666
|
+
nil
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Delete point of interest
|
|
670
|
+
#
|
|
671
|
+
# @param filename [String] File path
|
|
672
|
+
# @param name [String] POI name
|
|
673
|
+
# @return [void]
|
|
674
|
+
def delete_point_of_interest(filename, name)
|
|
675
|
+
request(:delete, "/v2/files/poi", { filename: filename, name: name })
|
|
676
|
+
nil
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# ============================================================================
|
|
680
|
+
# Statistics API
|
|
681
|
+
# ============================================================================
|
|
682
|
+
|
|
683
|
+
# Get HTTP transfer statistics
|
|
684
|
+
#
|
|
685
|
+
# @param from [String] Start date (ISO format)
|
|
686
|
+
# @param to [String] End date (ISO format)
|
|
687
|
+
# @return [Array<HttpStats>] HTTP statistics
|
|
688
|
+
def get_http_stats(from:, to:)
|
|
689
|
+
response = request(:get, "/v2/stats/http", { from: from, to: to })
|
|
690
|
+
response.map { |stat| HttpStats.new(stat) }
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Get spin views statistics (max 5-day range)
|
|
694
|
+
#
|
|
695
|
+
# @param from [String] Start date (ISO format)
|
|
696
|
+
# @param to [String] End date (ISO format)
|
|
697
|
+
# @return [Array<SpinViewStats>] Spin view statistics
|
|
698
|
+
def get_spin_views_stats(from:, to:)
|
|
699
|
+
response = request(:get, "/v2/stats/spins/views", { from: from, to: to })
|
|
700
|
+
response.map { |stat| SpinViewStats.new(stat) }
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Get storage statistics
|
|
704
|
+
#
|
|
705
|
+
# @param from [String] Start date (ISO format)
|
|
706
|
+
# @param to [String] End date (ISO format)
|
|
707
|
+
# @return [Array<StorageStats>] Storage statistics
|
|
708
|
+
def get_storage_stats(from:, to:)
|
|
709
|
+
response = request(:get, "/v2/stats/storage", { from: from, to: to })
|
|
710
|
+
response.map { |stat| StorageStats.new(stat) }
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
private
|
|
714
|
+
|
|
715
|
+
def token_expired?
|
|
716
|
+
return true if @token_expiry.nil?
|
|
717
|
+
buffer = @config[:auto_refresh_token] ? @config[:token_refresh_buffer] : 0
|
|
718
|
+
Time.now >= (@token_expiry - buffer)
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def ensure_token
|
|
722
|
+
@mutex.synchronize do
|
|
723
|
+
if @token.nil? || token_expired?
|
|
724
|
+
# Release lock during network call
|
|
725
|
+
@mutex.unlock
|
|
726
|
+
begin
|
|
727
|
+
connect
|
|
728
|
+
ensure
|
|
729
|
+
@mutex.lock
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
@token
|
|
733
|
+
end
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def request(method, endpoint, body_or_params = nil, retries = 0)
|
|
737
|
+
token = ensure_token
|
|
738
|
+
|
|
739
|
+
uri = build_uri(endpoint, method == :get ? body_or_params : nil)
|
|
740
|
+
http = build_http(uri)
|
|
741
|
+
req = build_request(method, uri, body_or_params, token)
|
|
742
|
+
|
|
743
|
+
response = http.request(req)
|
|
744
|
+
handle_response(response)
|
|
745
|
+
rescue ApiError => e
|
|
746
|
+
if e.status_code >= 500 && retries < @config[:max_retries]
|
|
747
|
+
sleep(2 ** retries)
|
|
748
|
+
request(method, endpoint, body_or_params, retries + 1)
|
|
749
|
+
else
|
|
750
|
+
raise
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def request_without_auth(method, endpoint, body = nil)
|
|
755
|
+
uri = build_uri(endpoint)
|
|
756
|
+
http = build_http(uri)
|
|
757
|
+
req = build_request(method, uri, body, nil)
|
|
758
|
+
|
|
759
|
+
response = http.request(req)
|
|
760
|
+
handle_response(response)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def request_raw(method, endpoint, params = nil)
|
|
764
|
+
token = ensure_token
|
|
765
|
+
|
|
766
|
+
uri = build_uri(endpoint, params)
|
|
767
|
+
http = build_http(uri)
|
|
768
|
+
req = build_request(method, uri, nil, token)
|
|
769
|
+
|
|
770
|
+
response = http.request(req)
|
|
771
|
+
|
|
772
|
+
if response.code.to_i >= 400
|
|
773
|
+
handle_error_response(response)
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
response.body
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def upload_raw(target_path, content, content_type: nil)
|
|
780
|
+
token = ensure_token
|
|
781
|
+
|
|
782
|
+
uri = URI.parse("#{@config[:base_url]}/v2/files/upload?filename=#{URI.encode_www_form_component(target_path)}")
|
|
783
|
+
http = build_http(uri)
|
|
784
|
+
|
|
785
|
+
boundary = "----RubyFormBoundary#{rand(1_000_000_000)}"
|
|
786
|
+
|
|
787
|
+
body = ""
|
|
788
|
+
body << "--#{boundary}\r\n"
|
|
789
|
+
body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(target_path)}\"\r\n"
|
|
790
|
+
body << "Content-Type: #{content_type || 'application/octet-stream'}\r\n\r\n"
|
|
791
|
+
body << content
|
|
792
|
+
body << "\r\n--#{boundary}--\r\n"
|
|
793
|
+
|
|
794
|
+
req = Net::HTTP::Post.new(uri)
|
|
795
|
+
req["Authorization"] = "Bearer #{token}"
|
|
796
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
797
|
+
req.body = body
|
|
798
|
+
|
|
799
|
+
response = http.request(req)
|
|
800
|
+
|
|
801
|
+
if response.code.to_i >= 400
|
|
802
|
+
handle_error_response(response)
|
|
803
|
+
end
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def build_uri(endpoint, params = nil)
|
|
807
|
+
url = "#{@config[:base_url]}#{endpoint}"
|
|
808
|
+
if params && !params.empty?
|
|
809
|
+
query = params.map { |k, v| "#{URI.encode_www_form_component(k.to_s)}=#{URI.encode_www_form_component(v.to_s)}" }.join("&")
|
|
810
|
+
url = "#{url}?#{query}"
|
|
811
|
+
end
|
|
812
|
+
URI.parse(url)
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def build_http(uri)
|
|
816
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
817
|
+
http.use_ssl = uri.scheme == "https"
|
|
818
|
+
http.read_timeout = @config[:timeout]
|
|
819
|
+
http.open_timeout = @config[:timeout]
|
|
820
|
+
http
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def build_request(method, uri, body, token)
|
|
824
|
+
req_class = case method
|
|
825
|
+
when :get then Net::HTTP::Get
|
|
826
|
+
when :post then Net::HTTP::Post
|
|
827
|
+
when :delete then Net::HTTP::Delete
|
|
828
|
+
when :put then Net::HTTP::Put
|
|
829
|
+
else raise ArgumentError, "Unknown HTTP method: #{method}"
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
req = req_class.new(uri)
|
|
833
|
+
req["Content-Type"] = "application/json"
|
|
834
|
+
req["Authorization"] = "Bearer #{token}" if token
|
|
835
|
+
|
|
836
|
+
if body && method != :get
|
|
837
|
+
req.body = body.to_json
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
req
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
def handle_response(response)
|
|
844
|
+
if response.code.to_i >= 400
|
|
845
|
+
handle_error_response(response)
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
return {} if response.body.nil? || response.body.empty?
|
|
849
|
+
|
|
850
|
+
content_type = response["Content-Type"]
|
|
851
|
+
if content_type && content_type.include?("application/json")
|
|
852
|
+
JSON.parse(response.body)
|
|
853
|
+
else
|
|
854
|
+
response.body
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def handle_error_response(response)
|
|
859
|
+
status_code = response.code.to_i
|
|
860
|
+
|
|
861
|
+
begin
|
|
862
|
+
error_data = JSON.parse(response.body)
|
|
863
|
+
message = error_data["message"] || response.message
|
|
864
|
+
error_code = error_data["error"]
|
|
865
|
+
rescue JSON::ParserError
|
|
866
|
+
message = response.message
|
|
867
|
+
error_code = nil
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
case status_code
|
|
871
|
+
when 401
|
|
872
|
+
raise AuthenticationError.new(message, status_code)
|
|
873
|
+
when 404
|
|
874
|
+
raise NotFoundError.new(message, status_code)
|
|
875
|
+
when 429
|
|
876
|
+
raise RateLimitError.new(message, status_code)
|
|
877
|
+
else
|
|
878
|
+
raise ApiError.new(message, status_code, error_code)
|
|
879
|
+
end
|
|
880
|
+
end
|
|
881
|
+
end
|
|
882
|
+
end
|