Skip to content

Commit 961c085

Browse files
authored
Merge pull request #15 from phac-nml/copy-blob
Add Copy blob to client and use in compose when composing a new blob from a single blob
2 parents 73b6eab + 383fab5 commit 961c085

File tree

6 files changed

+90
-11
lines changed

6 files changed

+90
-11
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
## [Unreleased]
22

3+
- Add `copy_blob`
4+
- Update `compose` to use `copy_blob` if 1 source key and blob is <= 256MiB
5+
36
## [0.5.6] 2025-01-17
47

58
- Fix user delegation key not refreshing (#14)

lib/active_storage/service/azure_blob_service.rb

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,22 @@ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disp
123123
def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
124124
content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
125125

126-
client.create_append_blob(
127-
destination_key,
128-
content_type: content_type,
129-
content_disposition: content_disposition,
130-
metadata: custom_metadata,
131-
)
132-
133-
source_keys.each do |source_key|
134-
stream(source_key) do |chunk|
135-
client.append_blob_block(destination_key, chunk)
126+
# use copy_blob operation if composing a new blob from a single existing blob
127+
# and that single blob is <= 256 MiB which is the upper limit for copy_blob operation
128+
if source_keys.length == 1 && client.get_blob_properties(source_keys[0]).size <= 256.megabytes
129+
client.copy_blob(destination_key, source_keys[0], metadata: custom_metadata)
130+
else
131+
client.create_append_blob(
132+
destination_key,
133+
content_type: content_type,
134+
content_disposition: content_disposition,
135+
metadata: custom_metadata,
136+
)
137+
138+
source_keys.each do |source_key|
139+
stream(source_key) do |chunk|
140+
client.append_blob_block(destination_key, chunk)
141+
end
136142
end
137143
end
138144
end

lib/azure_blob/client.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ def get_blob(key, options = {})
7777
Http.new(uri, headers, signer:).get
7878
end
7979

80+
# Copy a blob
81+
#
82+
# Calls to {Copy Blob From URL}[https://learn.microsoft.com/en-us/rest/api/storageservices/copy-blob-from-url]
83+
#
84+
# Takes a key (path) and a source_key (path).
85+
#
86+
def copy_blob(key, source_key, options = {})
87+
uri = generate_uri("#{container}/#{key}")
88+
89+
source_uri = signed_uri(source_key, permissions: "r", expiry: Time.at(Time.now.to_i + 300).utc.iso8601)
90+
91+
headers = {
92+
"x-ms-copy-source": source_uri.to_s,
93+
"x-ms-requires-sync": "true",
94+
}
95+
96+
Http.new(uri, headers, signer:, **options.slice(:metadata, :tags)).put
97+
end
98+
8099
# Delete a blob
81100
#
82101
# Calls to {Delete Blob}[https://learn.microsoft.com/en-us/rest/api/storageservices/delete-blob]
@@ -202,7 +221,7 @@ def create_container(options = {})
202221
uri = generate_uri(container)
203222
headers = {}
204223
headers[:"x-ms-blob-public-access"] = "blob" if options[:public_access]
205-
headers[:"x-ms-blob-public-access"] = options[:public_access] if ["container","blob"].include?(options[:public_access])
224+
headers[:"x-ms-blob-public-access"] = options[:public_access] if [ "container", "blob" ].include?(options[:public_access])
206225

207226
uri.query = URI.encode_www_form(restype: "container")
208227
response = Http.new(uri, headers, signer:).put

test/client/test_client.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,17 @@ def test_download_404
175175
assert_raises(AzureBlob::Http::FileNotFoundError) { client.get_blob(key) }
176176
end
177177

178+
def test_copy
179+
client.create_block_blob(key, content)
180+
assert_equal content, client.get_blob(key)
181+
182+
copy_key = "#{key}_copy"
183+
184+
client.copy_blob(copy_key, key)
185+
186+
assert_equal content, client.get_blob(copy_key)
187+
end
188+
178189
def test_delete
179190
client.create_block_blob(key, content)
180191
assert_equal content, client.get_blob(key)

test/rails/service/azure_blob_service_test.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,25 @@ class ActiveStorage::Service::AzureBlobServiceTest < ActiveSupport::TestCase
116116
ensure
117117
@service.delete(key)
118118
end
119+
120+
test "composing a blob from one source blob" do
121+
key = SecureRandom.base58(24)
122+
data = "Something else entirely!"
123+
124+
Tempfile.open do |file|
125+
file.write(data)
126+
file.rewind
127+
@service.upload(key, file)
128+
end
129+
130+
assert_equal data, @service.download(key)
131+
132+
copy_key = SecureRandom.base58(24)
133+
@service.compose([ key ], copy_key)
134+
135+
assert_equal data, @service.download(copy_key)
136+
ensure
137+
@service.delete key
138+
@service.delete copy_key
139+
end
119140
end

test/rails/service/shared_service_tests.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,5 +158,24 @@ module ActiveStorage::Service::SharedServiceTests
158158

159159
assert_equal "Together", @service.download(destination_key)
160160
end
161+
162+
test "compose from single blob" do
163+
keys = [ SecureRandom.base58(24) ]
164+
data = %w[Together]
165+
keys.zip(data).each do |key, data|
166+
@service.upload(
167+
key,
168+
StringIO.new(data),
169+
checksum: Digest::MD5.base64digest(data),
170+
disposition: :attachment,
171+
filename: ActiveStorage::Filename.new("test.html"),
172+
content_type: "text/html",
173+
)
174+
end
175+
destination_key = SecureRandom.base58(24)
176+
@service.compose(keys, destination_key)
177+
178+
assert_equal "Together", @service.download(destination_key)
179+
end
161180
end
162181
end

0 commit comments

Comments
 (0)