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.
- checksums.yaml +7 -0
- data/Readme.md +483 -0
- data/lib/azure_file_shares/auth/token_provider.rb +92 -0
- data/lib/azure_file_shares/client.rb +99 -0
- data/lib/azure_file_shares/configuration.rb +58 -0
- data/lib/azure_file_shares/errors/api_error.rb +18 -0
- data/lib/azure_file_shares/errors/configuration_error.rb +6 -0
- data/lib/azure_file_shares/operations/base_operation.rb +90 -0
- data/lib/azure_file_shares/operations/file_operations.rb +798 -0
- data/lib/azure_file_shares/operations/file_shares_operations.rb +78 -0
- data/lib/azure_file_shares/operations/snapshots_operations.rb +62 -0
- data/lib/azure_file_shares/resources/file_share.rb +80 -0
- data/lib/azure_file_shares/resources/file_share_snapshot.rb +75 -0
- data/lib/azure_file_shares/version.rb +3 -0
- data/lib/azure_file_shares.rb +54 -0
- metadata +198 -0
@@ -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
|