azure_file_shares 0.1.5

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,798 @@
1
+ module AzureFileShares
2
+ module Operations
3
+ # Operations for Azure File Shares - File and Directory Operations
4
+ class FileOperations < BaseOperation
5
+ MAX_RANGE_SIZE = 4 * 1024 * 1024 # 4 MB
6
+ # Base URL for Azure File storage API
7
+ # @return [String] File API base URL
8
+ def file_base_url
9
+ "https://#{client.storage_account_name}.file.core.windows.net"
10
+ end
11
+
12
+ # Create a directory in a file share
13
+ # @param [String] share_name Name of the file share
14
+ # @param [String] directory_path Path to the directory to create
15
+ # @param [Hash] options Additional options
16
+ # @return [Boolean] true if successful
17
+ def create_directory(share_name, directory_path, options = {})
18
+ ensure_storage_credentials!
19
+ path = build_file_path(share_name, directory_path)
20
+
21
+ # Use direct approach for directory creation
22
+ url = "#{file_base_url}#{path}?restype=directory"
23
+
24
+ # Create headers with authorization
25
+ headers = {
26
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
27
+ "x-ms-version" => client.api_version,
28
+ "Content-Type" => "application/x-www-form-urlencoded",
29
+ }
30
+
31
+ # Add additional headers from options if provided
32
+ options.each do |key, value|
33
+ headers["x-ms-#{key}"] = value.to_s unless value.nil?
34
+ end
35
+
36
+ # Calculate authorization header with restype query parameter
37
+ auth_header = calculate_authorization_header(:put, path, headers, { restype: "directory" })
38
+ headers["Authorization"] = auth_header if auth_header
39
+
40
+ # Log request details if a logger is available
41
+ if client.logger
42
+ client.logger.debug "Azure File API Create Directory Request: PUT #{url}"
43
+ client.logger.debug "Headers: #{headers.reject { |k, _| k == 'Authorization' }.inspect}"
44
+ end
45
+
46
+ # Create connection and make the request directly
47
+ connection = create_file_connection
48
+
49
+ response = connection.put(url, nil, headers)
50
+
51
+ # Check response
52
+ if response.status >= 200 && response.status < 300
53
+ true
54
+ else
55
+ handle_file_response(response)
56
+ end
57
+ end
58
+
59
+ # List directories and files in a file share or directory
60
+ # @param [String] share_name Name of the file share
61
+ # @param [String] directory_path Path to the directory (empty string for root)
62
+ # @param [Hash] options Additional options
63
+ # @option options [String] :prefix Filter by prefix
64
+ # @option options [Integer] :maxresults Maximum number of results to return
65
+ # @return [Hash] Hash containing directories and files
66
+ def list(share_name, directory_path = "", options = {})
67
+ ensure_storage_credentials!
68
+ directory_path = normalize_path(directory_path)
69
+ path = build_file_path(share_name, directory_path)
70
+
71
+ # Build URL with query parameters
72
+ url = "#{file_base_url}#{path}?restype=directory&comp=list"
73
+
74
+ # Add additional query parameters
75
+ url += "&prefix=#{ERB::Util.url_encode(options[:prefix])}" if options[:prefix]
76
+ url += "&maxresults=#{options[:maxresults]}" if options[:maxresults]
77
+
78
+ # Query parameters for authorization
79
+ query_params = { restype: "directory", comp: "list" }
80
+ query_params[:prefix] = options[:prefix] if options[:prefix]
81
+ query_params[:maxresults] = options[:maxresults] if options[:maxresults]
82
+
83
+ # Create headers with authorization
84
+ headers = {
85
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
86
+ "x-ms-version" => client.api_version,
87
+ }
88
+
89
+ # Calculate authorization header
90
+ auth_header = calculate_authorization_header(:get, path, headers, query_params)
91
+ headers["Authorization"] = auth_header if auth_header
92
+
93
+ # Log request details if a logger is available
94
+ if client.logger
95
+ client.logger.debug "Azure File API Request: GET #{url}"
96
+ client.logger.debug "Headers: #{headers.reject { |k, _| k == 'Authorization' }.inspect}"
97
+ end
98
+
99
+ # Create connection and make the request directly
100
+ connection = create_file_connection
101
+
102
+ response = connection.get(url, nil, headers)
103
+
104
+ # Check response
105
+ if response.status >= 200 && response.status < 300
106
+ # Parse the XML response
107
+ parse_list_response(response.body)
108
+ else
109
+ handle_file_response(response)
110
+ end
111
+ end
112
+
113
+ # Check if a directory exists
114
+ # @param [String] share_name Name of the file share
115
+ # @param [String] directory_path Path to the directory
116
+ # @return [Boolean] true if directory exists
117
+ def directory_exists?(share_name, directory_path)
118
+ ensure_storage_credentials!
119
+ path = build_file_path(share_name, directory_path)
120
+
121
+ # Use direct approach for HEAD requests
122
+ url = "#{file_base_url}#{path}"
123
+
124
+ # Add restype parameter
125
+ url += "?restype=directory"
126
+
127
+ # Create headers with authorization
128
+ headers = {
129
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
130
+ "x-ms-version" => client.api_version,
131
+ }
132
+
133
+ # Calculate authorization header - include restype query parameter
134
+ auth_header = calculate_authorization_header(:head, path, headers, { restype: "directory" })
135
+ headers["Authorization"] = auth_header if auth_header
136
+
137
+ # Create connection and make the request directly
138
+ connection = create_file_connection
139
+
140
+ begin
141
+ response = connection.head(url, nil, headers)
142
+ response.status >= 200 && response.status < 300
143
+ rescue Faraday::Error, AzureFileShares::Errors::ApiError => _e
144
+ false
145
+ end
146
+ end
147
+
148
+ # Delete a directory
149
+ # @param [String] share_name Name of the file share
150
+ # @param [String] directory_path Path to the directory
151
+ # @param [Hash] options Additional options
152
+ # @option options [Boolean] :recursive Whether to delete recursively
153
+ # @return [Boolean] true if successful
154
+ def delete_directory(share_name, directory_path, options = {})
155
+ ensure_storage_credentials!
156
+ path = build_file_path(share_name, directory_path)
157
+
158
+ # Build URL with query parameters
159
+ url = "#{file_base_url}#{path}?restype=directory"
160
+
161
+ # Add recursive parameter if specified
162
+ url += "&recursive=true" if options[:recursive]
163
+
164
+ # Query parameters for authorization
165
+ query_params = { restype: "directory" }
166
+ query_params[:recursive] = "true" if options[:recursive]
167
+
168
+ # Create headers with authorization
169
+ headers = {
170
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
171
+ "x-ms-version" => client.api_version,
172
+ }
173
+
174
+ # Calculate authorization header
175
+ auth_header = calculate_authorization_header(:delete, path, headers, query_params)
176
+ headers["Authorization"] = auth_header if auth_header
177
+
178
+ # Create connection and make the request directly
179
+ connection = create_file_connection
180
+
181
+ response = connection.delete(url, nil, headers)
182
+
183
+ # Check response
184
+ if response.status >= 200 && response.status < 300
185
+ true
186
+ else
187
+ handle_file_response(response)
188
+ end
189
+ end
190
+
191
+ # Upload a file to a file share
192
+ # @param [String] share_name Name of the file share
193
+ # @param [String] directory_path Path to the directory (empty string for root)
194
+ # @param [String] file_name Name of the file
195
+ # @param [String, IO] content File content or IO object
196
+ # @param [Hash] options Additional options
197
+ # @option options [String] :content_type Content type of the file
198
+ # @option options [Hash] :metadata Metadata for the file
199
+ # @return [Boolean] true if successful
200
+ def upload_file(share_name, directory_path, file_name, content, options = {})
201
+ ensure_storage_credentials!
202
+ directory_path = normalize_path(directory_path)
203
+ file_path = File.join(directory_path, file_name)
204
+ path = build_file_path(share_name, file_path)
205
+
206
+ # Get content length and convert to string if needed
207
+ content_length = nil
208
+ if content.is_a?(IO) || content.is_a?(StringIO)
209
+ content_length = content.size
210
+ content.rewind
211
+ content = content.read
212
+ else
213
+ content_length = content.bytesize
214
+ end
215
+
216
+ # 1. Create the file with specified size
217
+ create_url = "#{file_base_url}#{path}"
218
+
219
+ # Create headers for file creation
220
+ create_headers = {
221
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
222
+ "x-ms-version" => client.api_version,
223
+ "x-ms-type" => "file",
224
+ "x-ms-content-length" => content_length.to_s,
225
+ "Content-Type" => "application/x-www-form-urlencoded",
226
+ }
227
+
228
+ # Set content type if provided
229
+ create_headers["x-ms-content-type"] = options[:content_type] || "application/octet-stream"
230
+
231
+ # Add metadata if provided
232
+ if options[:metadata] && !options[:metadata].empty?
233
+ options[:metadata].each do |key, value|
234
+ create_headers["x-ms-meta-#{key.to_s.downcase}"] = value.to_s
235
+ end
236
+ end
237
+
238
+ # Calculate authorization header for file creation
239
+ auth_header = calculate_authorization_header(:put, path, create_headers, {})
240
+ create_headers["Authorization"] = auth_header if auth_header
241
+
242
+ # Log request details if a logger is available
243
+ if client.logger
244
+ client.logger.debug "Azure File API Create File Request: PUT #{create_url}"
245
+ client.logger.debug "Headers: #{create_headers.reject { |k, _| k == 'Authorization' }.inspect}"
246
+ end
247
+
248
+ # Create connection and make the request to create the file
249
+ connection = create_file_connection
250
+ create_response = connection.put(create_url, nil, create_headers)
251
+
252
+ # Check create response
253
+ unless create_response.status >= 200 && create_response.status < 300
254
+ handle_file_response(create_response)
255
+ end
256
+
257
+ # 2. Upload the content (for small files - large files would use ranges)
258
+ range_url = "#{file_base_url}#{path}?comp=range"
259
+
260
+ # Create headers for content upload
261
+ range_headers = {
262
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
263
+ "x-ms-version" => client.api_version,
264
+ "x-ms-write" => "update",
265
+ "x-ms-range" => "bytes=0-#{content_length - 1}",
266
+ "Content-Length" => content_length.to_s,
267
+ "Content-Type" => "application/x-www-form-urlencoded",
268
+ }
269
+
270
+ cursor = 0
271
+ result = nil
272
+ loop do
273
+ # Calculate the range for this chunk
274
+ range_start = cursor
275
+ range_end = [ cursor + MAX_RANGE_SIZE - 1, content_length - 1 ].min
276
+ range_headers["x-ms-range"] = "bytes=#{range_start}-#{range_end}"
277
+
278
+ # Extract the content for this range
279
+ if content.is_a?(IO) || content.is_a?(StringIO)
280
+ content.seek(range_start)
281
+ content_chunk = content.read(range_end - range_start + 1)
282
+ else
283
+ content_chunk = content[range_start..range_end]
284
+ end
285
+
286
+ # Update the content length for this chunk
287
+ range_headers["Content-Length"] = (range_end - range_start + 1).to_s
288
+
289
+ # Calculate authorization header for range upload
290
+ range_auth_header = calculate_authorization_header(:put, path, range_headers, { comp: "range" })
291
+ range_headers["Authorization"] = range_auth_header if range_auth_header
292
+
293
+ # Log the current range being uploaded
294
+ if client.logger
295
+ client.logger.debug "Uploading range: #{range_headers['x-ms-range']}"
296
+ end
297
+
298
+ # Make the request to upload the content chunk
299
+ result = upload_range(connection, range_url, content_chunk, range_headers)
300
+ break unless result
301
+
302
+ cursor += MAX_RANGE_SIZE
303
+ break if cursor >= content_length
304
+ end
305
+
306
+ result
307
+ end
308
+
309
+ # Download a file from a file share
310
+ # @param [String] share_name Name of the file share
311
+ # @param [String] directory_path Path to the directory (empty string for root)
312
+ # @param [String] file_name Name of the file
313
+ # @param [Hash] options Additional options
314
+ # @option options [Range] :range Range of bytes to download
315
+ # @return [String] File content
316
+ def download_file(share_name, directory_path, file_name, options = {})
317
+ ensure_storage_credentials!
318
+ directory_path = normalize_path(directory_path)
319
+ file_path = File.join(directory_path, file_name)
320
+ path = build_file_path(share_name, file_path)
321
+
322
+ # Build URL
323
+ url = "#{file_base_url}#{path}"
324
+
325
+ # Create headers with authorization
326
+ headers = {
327
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
328
+ "x-ms-version" => client.api_version,
329
+ }
330
+
331
+ # Add range header if specified
332
+ if options[:range]
333
+ headers["x-ms-range"] = "bytes=#{options[:range].begin}-#{options[:range].end}"
334
+ end
335
+
336
+ # Calculate authorization header
337
+ auth_header = calculate_authorization_header(:get, path, headers, {})
338
+ headers["Authorization"] = auth_header if auth_header
339
+
340
+ # Log request details if a logger is available
341
+ if client.logger
342
+ client.logger.debug "Azure File API Download Request: GET #{url}"
343
+ client.logger.debug "Headers: #{headers.reject { |k, _| k == 'Authorization' }.inspect}"
344
+ end
345
+
346
+ # Create connection and make the request directly
347
+ connection = create_file_connection
348
+
349
+ response = connection.get(url, nil, headers)
350
+
351
+ # Check response
352
+ if response.status >= 200 && response.status < 300
353
+ response.body
354
+ else
355
+ handle_file_response(response)
356
+ end
357
+ end
358
+
359
+ # Check if a file exists
360
+ # @param [String] share_name Name of the file share
361
+ # @param [String] directory_path Path to the directory (empty string for root)
362
+ # @param [String] file_name Name of the file
363
+ # @return [Boolean] true if file exists
364
+ def file_exists?(share_name, directory_path, file_name)
365
+ ensure_storage_credentials!
366
+ directory_path = normalize_path(directory_path)
367
+ file_path = File.join(directory_path, file_name)
368
+ path = build_file_path(share_name, file_path)
369
+
370
+ # Use the same direct approach as get_file_properties for HEAD requests
371
+ url = "#{file_base_url}#{path}"
372
+
373
+ # Create headers with authorization
374
+ headers = {
375
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
376
+ "x-ms-version" => client.api_version,
377
+ }
378
+
379
+ # Calculate authorization header
380
+ auth_header = calculate_authorization_header(:head, path, headers, {})
381
+ headers["Authorization"] = auth_header if auth_header
382
+
383
+ # Create connection and make the request directly
384
+ connection = create_file_connection
385
+
386
+ begin
387
+ response = connection.head(url, nil, headers)
388
+ response.status >= 200 && response.status < 300
389
+ rescue Faraday::Error, AzureFileShares::Errors::ApiError => _e
390
+ false
391
+ end
392
+ end
393
+
394
+ # Get file properties
395
+ # @param [String] share_name Name of the file share
396
+ # @param [String] directory_path Path to the directory (empty string for root)
397
+ # @param [String] file_name Name of the file
398
+ # @return [Hash] File properties
399
+ def get_file_properties(share_name, directory_path, file_name)
400
+ ensure_storage_credentials!
401
+ directory_path = normalize_path(directory_path)
402
+ file_path = File.join(directory_path, file_name)
403
+ path = build_file_path(share_name, file_path)
404
+
405
+ # For HEAD requests, we need to handle the response directly in file_request
406
+ # to capture the headers but not process the body
407
+ url = "#{file_base_url}#{path}"
408
+
409
+ # Create headers with authorization
410
+ headers = {
411
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
412
+ "x-ms-version" => client.api_version,
413
+ }
414
+
415
+ # Calculate authorization header
416
+ auth_header = calculate_authorization_header(:head, path, headers, {})
417
+ headers["Authorization"] = auth_header if auth_header
418
+
419
+ # Create connection and make the request directly
420
+ connection = create_file_connection
421
+ response = connection.head(url, nil, headers)
422
+
423
+ # If the request failed, raise an error
424
+ unless response.status >= 200 && response.status < 300
425
+ handle_file_response(response)
426
+ end
427
+
428
+ # Extract properties from response headers
429
+ {
430
+ content_length: response.headers["content-length"].to_i,
431
+ content_type: response.headers["content-type"],
432
+ last_modified: response.headers["last-modified"],
433
+ etag: response.headers["etag"],
434
+ metadata: extract_metadata(response.headers),
435
+ }
436
+ end
437
+
438
+ # Delete a file
439
+ # @param [String] share_name Name of the file share
440
+ # @param [String] directory_path Path to the directory (empty string for root)
441
+ # @param [String] file_name Name of the file
442
+ # @return [Boolean] true if successful
443
+ def delete_file(share_name, directory_path, file_name)
444
+ ensure_storage_credentials!
445
+ directory_path = normalize_path(directory_path)
446
+ file_path = File.join(directory_path, file_name)
447
+ path = build_file_path(share_name, file_path)
448
+
449
+ # Build URL
450
+ url = "#{file_base_url}#{path}"
451
+
452
+ # Create headers with authorization
453
+ headers = {
454
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
455
+ "x-ms-version" => client.api_version,
456
+ }
457
+
458
+ # Calculate authorization header
459
+ auth_header = calculate_authorization_header(:delete, path, headers, {})
460
+ headers["Authorization"] = auth_header if auth_header
461
+
462
+ # Log request details if a logger is available
463
+ if client.logger
464
+ client.logger.debug "Azure File API Delete File Request: DELETE #{url}"
465
+ client.logger.debug "Headers: #{headers.reject { |k, _| k == 'Authorization' }.inspect}"
466
+ end
467
+
468
+ # Create connection and make the request directly
469
+ connection = create_file_connection
470
+
471
+ response = connection.delete(url, nil, headers)
472
+
473
+ # Check response
474
+ if response.status >= 200 && response.status < 300
475
+ true
476
+ else
477
+ handle_file_response(response)
478
+ end
479
+ end
480
+
481
+ # Copy a file within the storage account
482
+ # @param [String] source_share_name Source share name
483
+ # @param [String] source_directory_path Source directory path
484
+ # @param [String] source_file_name Source file name
485
+ # @param [String] dest_share_name Destination share name
486
+ # @param [String] dest_directory_path Destination directory path
487
+ # @param [String] dest_file_name Destination file name
488
+ # @return [Boolean] true if successful
489
+ def copy_file(source_share_name, source_directory_path, source_file_name,
490
+ dest_share_name, dest_directory_path, dest_file_name)
491
+ ensure_storage_credentials!
492
+
493
+ # Build source file URL
494
+ source_directory_path = normalize_path(source_directory_path)
495
+ source_file_path = File.join(source_directory_path, source_file_name)
496
+ source_path = build_file_path(source_share_name, source_file_path)
497
+ source_url = "#{file_base_url}#{source_path}"
498
+
499
+ # Build destination path
500
+ dest_directory_path = normalize_path(dest_directory_path)
501
+ dest_file_path = File.join(dest_directory_path, dest_file_name)
502
+ dest_path = build_file_path(dest_share_name, dest_file_path)
503
+
504
+ # Build URL
505
+ url = "#{file_base_url}#{dest_path}"
506
+
507
+ # Create headers with authorization
508
+ headers = {
509
+ "x-ms-date" => Time.now.utc.strftime("%a, %d %b %Y %H:%M:%S GMT"),
510
+ "x-ms-version" => client.api_version,
511
+ "x-ms-copy-source" => source_url,
512
+ "Content-Type" => "application/x-www-form-urlencoded",
513
+ # "Content-Length" => "0",
514
+ }
515
+
516
+ # Calculate authorization header
517
+ auth_header = calculate_authorization_header(:put, dest_path, headers, {})
518
+ headers["Authorization"] = auth_header if auth_header
519
+
520
+ # Log request details if a logger is available
521
+ if client.logger
522
+ client.logger.debug "Azure File API Copy File Request: PUT #{url}"
523
+ client.logger.debug "Headers: #{headers.reject { |k, _| k == 'Authorization' }.inspect}"
524
+ client.logger.debug "Source URL: #{source_url}"
525
+ end
526
+
527
+ # Create connection and make the request directly
528
+ connection = create_file_connection
529
+
530
+ response = connection.put(url, nil, headers)
531
+
532
+ # Check response
533
+ if response.status >= 200 && response.status < 300
534
+ true
535
+ else
536
+ handle_file_response(response)
537
+ end
538
+ end
539
+
540
+ private
541
+
542
+ # Ensure storage credentials are set
543
+ def ensure_storage_credentials!
544
+ unless client.storage_account_name
545
+ raise AzureFileShares::Errors::ConfigurationError, "Storage account name is required"
546
+ end
547
+
548
+ unless client.storage_account_key
549
+ raise AzureFileShares::Errors::ConfigurationError, "Storage account key is required for file operations"
550
+ end
551
+ end
552
+
553
+ # Normalize a directory path by removing leading/trailing slashes
554
+ # @param [String] path Directory path
555
+ # @return [String] Normalized path
556
+ def normalize_path(path)
557
+ return "" if path.nil? || path.empty? || path == "/"
558
+ path = path.start_with?("/") ? path[1..-1] : path
559
+ path = path.end_with?("/") ? path[0..-2] : path
560
+ path.split("/").map { |seg| ERB::Util.url_encode(seg) }.join("/")
561
+ end
562
+
563
+ # Build a file API path
564
+ # @param [String] share_name Share name
565
+ # @param [String] path File or directory path
566
+ # @return [String] Full path
567
+ def build_file_path(share_name, path)
568
+ path = normalize_path(path)
569
+ "/#{share_name}/#{path}"
570
+ end
571
+
572
+ # Format metadata for request headers
573
+ # @param [Hash] metadata Metadata hash
574
+ # @return [Hash] Formatted metadata headers
575
+ def format_metadata(metadata)
576
+ return {} unless metadata && !metadata.empty?
577
+
578
+ formatted = {}
579
+ metadata.each do |key, value|
580
+ formatted["x-ms-meta-#{key.to_s.downcase}"] = value.to_s
581
+ end
582
+ formatted
583
+ end
584
+
585
+ # Extract metadata from response headers
586
+ # @param [Hash] headers Response headers
587
+ # @return [Hash] Extracted metadata
588
+ def extract_metadata(headers)
589
+ metadata = {}
590
+ headers.each do |key, value|
591
+ if key.to_s.downcase.start_with?("x-ms-meta-")
592
+ metadata_key = key.to_s.downcase.sub("x-ms-meta-", "")
593
+ metadata[metadata_key] = value
594
+ end
595
+ end
596
+ metadata
597
+ end
598
+
599
+ # Parse the list directories and files response
600
+ # @param [String] response XML response
601
+ # @return [Hash] Parsed directories and files
602
+ def parse_list_response(response)
603
+ require "nokogiri"
604
+
605
+ xml = Nokogiri::XML(response)
606
+
607
+ # Extract directories
608
+ directories = xml.xpath("//Entries/Directory").map do |dir|
609
+ {
610
+ name: dir.at_xpath("Name").text,
611
+ properties: {
612
+ last_modified: dir.at_xpath("Properties/Last-Modified")&.text,
613
+ etag: dir.at_xpath("Properties/Etag")&.text,
614
+ },
615
+ }
616
+ end
617
+
618
+ # Extract files
619
+ files = xml.xpath("//Entries/File").map do |file|
620
+ {
621
+ name: file.at_xpath("Name").text,
622
+ properties: {
623
+ content_length: file.at_xpath("Properties/Content-Length")&.text.to_i,
624
+ content_type: file.at_xpath("Properties/Content-Type")&.text,
625
+ last_modified: file.at_xpath("Properties/Last-Modified")&.text,
626
+ etag: file.at_xpath("Properties/Etag")&.text,
627
+ },
628
+ }
629
+ end
630
+
631
+ { directories: directories, files: files }
632
+ end
633
+
634
+ # Calculate authorization header for Azure Storage REST API
635
+ # @param [Symbol] method HTTP method
636
+ # @param [String] path API endpoint path
637
+ # @param [Hash] headers Request headers
638
+ # @param [Hash] query_params Query parameters from the request
639
+ # @return [String] Authorization header value
640
+ def calculate_authorization_header(method, path, headers, query_params = {})
641
+ # Normalize headers
642
+ headers = headers.transform_keys(&:downcase)
643
+
644
+ # Ensure required headers
645
+ headers["x-ms-date"] ||= Time.now.httpdate
646
+ headers["x-ms-version"] ||= "2025-01-05"
647
+ # headers["content-length"] = headers["content-length"].to_s == "0" ? "0" : headers["content-length"].to_s
648
+
649
+ # Merge with actual headers
650
+ canonicalized_headers = headers
651
+ .select { |k, _| k.downcase.start_with?("x-ms-") }
652
+ .map { |k, v| "#{k.downcase.strip}:#{v.strip}" }
653
+ .sort
654
+ .join("\n")
655
+
656
+ # Canonicalized resource
657
+ canonicalized_resource = "/#{client.storage_account_name}#{path}"
658
+ if query_params.any?
659
+ query_string = query_params
660
+ .group_by { |k, _| k.downcase }
661
+ .transform_values { |v| v.map { |_, val| val }.flatten }
662
+ .sort
663
+ .map { |k, v| "#{k}:#{Array(v).sort.join(',')}" }
664
+ .join("\n")
665
+
666
+ canonicalized_resource += "\n#{query_string}"
667
+ end
668
+
669
+ string_to_sign = [
670
+ method.to_s.upcase,
671
+ headers["content-encoding"] || "",
672
+ headers["content-language"] || "",
673
+ headers["content-length"] || "",
674
+ headers["content-md5"] || "",
675
+ headers["content-type"] || "",
676
+ "", # Date (empty because x-ms-date is used instead)
677
+ headers["if-modified-since"] || "",
678
+ headers["if-match"] || "",
679
+ headers["if-none-match"] || "",
680
+ headers["if-unmodified-since"] || "",
681
+ headers["range"] || "",
682
+ canonicalized_headers,
683
+ canonicalized_resource,
684
+ ].join("\n")
685
+
686
+ # Log the string to sign for debugging
687
+ if client.logger
688
+ client.logger.debug "String-to-sign line count: #{string_to_sign.lines.count}"
689
+ client.logger.debug "String to sign for authorization (with escapes): #{string_to_sign.inspect}"
690
+ end
691
+
692
+ decoded_key = Base64.decode64(client.storage_account_key)
693
+ signature = OpenSSL::HMAC.digest("sha256", decoded_key, string_to_sign.encode("utf-8"))
694
+ encoded_signature = Base64.strict_encode64(signature)
695
+
696
+ "SharedKey #{client.storage_account_name}:#{encoded_signature}"
697
+ end
698
+
699
+ # Create a connection for file operations
700
+ # @return [Faraday::Connection] Faraday connection
701
+ def create_file_connection
702
+ Faraday.new do |conn|
703
+ conn.options.timeout = client.request_timeout || 60
704
+ conn.options.open_timeout = 10
705
+
706
+ # Enable response logging if a logger is set
707
+ if client.logger
708
+ conn.response :logger, client.logger, { headers: true, bodies: false } do |logger|
709
+ logger.filter(/(Authorization: "Bearer )([^"]+)/, '\1[FILTERED]')
710
+ logger.filter(/(SharedKey [^:]+:)([^"]+)/, '\1[FILTERED]')
711
+ end
712
+ end
713
+
714
+ # Add retry middleware for transient failures
715
+ conn.request :retry, {
716
+ max: 3,
717
+ interval: 0.5,
718
+ interval_randomness: 0.5,
719
+ backoff_factor: 2,
720
+ retry_statuses: [ 408, 429, 500, 502, 503, 504 ],
721
+ methods: [ :get, :head, :put, :delete, :post ],
722
+ exceptions: [
723
+ Faraday::ConnectionFailed,
724
+ Faraday::TimeoutError,
725
+ Errno::ETIMEDOUT,
726
+ "Timeout::Error",
727
+ ],
728
+ }
729
+
730
+ conn.adapter Faraday.default_adapter
731
+ end
732
+ end
733
+
734
+ # Upload a content chunk for a file range
735
+ # @param [Faraday::Connection] connection Faraday connection
736
+ # @param [String] range_url URL for the range upload
737
+ # @param [String] content_chunk Content chunk to upload
738
+ # @param [Hash] range_headers Headers for the range upload
739
+ # @return [Faraday::Response] Response from the upload request
740
+ # @raise [AzureFileShares::Errors::ApiError] If the upload fails
741
+ def upload_range(connection, range_url, content_chunk, range_headers)
742
+ # Log request details if a logger is available
743
+ if client.logger
744
+ client.logger.debug "Azure File API Upload Range Request: PUT #{range_url}"
745
+ client.logger.debug "Headers: #{range_headers.reject { |k, _| k == 'Authorization' }.inspect}"
746
+ client.logger.debug "Content length: #{content_length}"
747
+ end
748
+
749
+ # Make the request to upload the content
750
+ range_response = connection.put(range_url, content_chunk, range_headers)
751
+
752
+ # Check range response
753
+ if range_response.status >= 200 && range_response.status < 300
754
+ true
755
+ else
756
+ handle_file_response(range_response)
757
+ end
758
+ end
759
+
760
+ # Handle the file API response
761
+ # @param [Faraday::Response] response HTTP response
762
+ # @return [String, Hash] Response body or parsed response
763
+ # @raise [AzureFileShares::Errors::ApiError] If the request fails
764
+ def handle_file_response(response)
765
+ case response.status
766
+ when 200..299
767
+ return {} if response.body.nil? || response.body.empty?
768
+
769
+ if response.headers["content-type"]&.include?("application/xml")
770
+ response.body # Return XML as string for the caller to parse
771
+ else
772
+ response.body
773
+ end
774
+ else
775
+ error_message = "File API Error (#{response.status}): #{response.reason_phrase}"
776
+
777
+ begin
778
+ if response.headers["content-type"]&.include?("application/xml")
779
+ require "nokogiri"
780
+ xml = Nokogiri::XML(response.body)
781
+ error_code = xml.at_xpath("//Code")&.text
782
+ error_message_text = xml.at_xpath("//Message")&.text
783
+ error_message = "File API Error (#{response.status}): #{error_code} - #{error_message_text}"
784
+ end
785
+ rescue StandardError => _e
786
+ # Ignore parsing errors
787
+ end
788
+
789
+ raise AzureFileShares::Errors::ApiError.new(
790
+ error_message,
791
+ response.status,
792
+ response.body
793
+ )
794
+ end
795
+ end
796
+ end
797
+ end
798
+ end