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.
@@ -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