Skip to content

Commit 89d5f72

Browse files
feat(storage): Restart & delete resumable upload (googleapis#21896)
1 parent fe0787b commit 89d5f72

File tree

5 files changed

+322
-5
lines changed

5 files changed

+322
-5
lines changed

google-apis-core/lib/google/apis/core/base_service.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,36 @@ def verify_universe_domain!
350350
true
351351
end
352352

353+
# Restarts An interrupted Resumable upload
354+
# @param [String] bucket
355+
# Name of the bucket where the upload is being performed.
356+
# @param [IO, String] upload_source
357+
# IO stream or filename containing content to upload
358+
# @param [IO, String] upload_id
359+
# unique id generated for an ongoing upload
360+
361+
def restart_resumable_upload(bucket, upload_source, upload_id, options: nil)
362+
command = make_storage_upload_command(:put, 'b/{bucket}/o', options)
363+
command.upload_source = upload_source
364+
command.upload_id = upload_id
365+
command.params['bucket'] = bucket unless bucket.nil?
366+
execute_or_queue_command(command)
367+
end
368+
369+
# Deletes An interrupted Resumable upload
370+
# @param [String] bucket
371+
# Name of the bucket where the upload is being performed.
372+
# @param [IO, String] upload_id
373+
# unique id generated for an ongoing upload
374+
375+
def delete_resumable_upload(bucket, upload_id, options: nil)
376+
command = make_storage_upload_command(:delete, 'b/{bucket}/o', options)
377+
command.upload_id = upload_id
378+
command.params['bucket'] = bucket unless bucket.nil?
379+
command.delete_upload = options[:delete_upload] unless options[:delete_upload].nil?
380+
execute_or_queue_command(command)
381+
end
382+
353383
protected
354384

355385
# Create a new upload command.

google-apis-core/lib/google/apis/core/storage_upload.rb

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ class StorageUploadCommand < ApiCommand
4949
# @return [Integer]
5050
attr_accessor :upload_chunk_size
5151

52+
# Unique upload_id of a resumable upload
53+
# @return [String]
54+
attr_accessor :upload_id
55+
56+
# Boolean Value to specify is a resumable upload is to be deleted or not
57+
# @return [Boolean]
58+
attr_accessor :delete_upload
59+
5260
# Ensure the content is readable and wrapped in an IO instance.
5361
#
5462
# @return [void]
@@ -61,7 +69,6 @@ def prepare!
6169
# asserting that it already has a body. Form encoding is never used
6270
# by upload requests.
6371
self.body = '' unless self.body
64-
6572
super
6673
if streamable?(upload_source)
6774
self.upload_io = upload_source
@@ -73,14 +80,16 @@ def prepare!
7380
self.upload_content_type = type&.content_type
7481
end
7582
@close_io_on_finish = true
83+
elsif !upload_id.nil? && delete_upload
84+
@close_io_on_finish = false
7685
else
7786
fail Google::Apis::ClientError, 'Invalid upload source'
7887
end
7988
end
8089

8190
# Close IO stream when command done. Only closes the stream if it was opened by the command.
8291
def release!
83-
upload_io.close if @close_io_on_finish
92+
upload_io.close if @close_io_on_finish && !upload_io.nil?
8493
end
8594

8695
# Execute the command, retrying as necessary
@@ -96,8 +105,16 @@ def execute(client)
96105
prepare!
97106
opencensus_begin_span
98107
@upload_chunk_size = options.upload_chunk_size
108+
if upload_id.nil?
109+
res = do_retry :initiate_resumable_upload, client
110+
elsif delete_upload && !upload_id.nil?
111+
construct_resumable_upload_url upload_id
112+
res = do_retry :cancel_resumable_upload, client
113+
else
114+
construct_resumable_upload_url upload_id
115+
res = do_retry :reinitiate_resumable_upload, client
116+
end
99117

100-
do_retry :initiate_resumable_upload, client
101118
while @upload_incomplete
102119
res = do_retry :send_upload_command, client
103120
end
@@ -131,6 +148,22 @@ def initiate_resumable_upload(client)
131148
error(e, rethrow: true)
132149
end
133150

151+
# Reinitiating resumable upload
152+
def reinitiate_resumable_upload(client)
153+
logger.debug { sprintf('Restarting resumable upload command to %s', url) }
154+
check_resumable_upload client
155+
upload_io.pos = @offset
156+
end
157+
158+
# Making resumable upload url from upload_id
159+
def construct_resumable_upload_url(upload_id)
160+
query_params = query.dup
161+
query_params['uploadType'] = RESUMABLE
162+
query_params['upload_id'] = upload_id
163+
resumable_upload_params = query_params.map { |key, value| "#{key}=#{value}" }.join('&')
164+
@upload_url = "#{url}&#{resumable_upload_params}"
165+
end
166+
134167
# Send the actual content
135168
#
136169
# @param [HTTPClient] client
@@ -160,6 +193,9 @@ def send_upload_command(client)
160193
@offset += current_chunk_size if @upload_incomplete
161194
success(result)
162195
rescue => e
196+
logger.warn {
197+
"error occured please use uploadId-#{response.headers['X-GUploader-UploadID']} to resume your upload"
198+
} unless response.nil?
163199
upload_io.pos = @offset
164200
error(e, rethrow: true)
165201
end
@@ -182,6 +218,59 @@ def process_response(status, header, body)
182218
super(status, header, body)
183219
end
184220

221+
def check_resumable_upload(client)
222+
# Setting up request header
223+
request_header = header.dup
224+
request_header[CONTENT_RANGE_HEADER] = "bytes */#{upload_io.size}"
225+
request_header[CONTENT_LENGTH_HEADER] = '0'
226+
# Initiating call
227+
response = client.put(@upload_url, header: request_header, follow_redirect: true)
228+
handle_resumable_upload_http_response_codes(response)
229+
end
230+
231+
# Cancel resumable upload
232+
def cancel_resumable_upload(client)
233+
# Setting up request header
234+
request_header = header.dup
235+
request_header[CONTENT_LENGTH_HEADER] = '0'
236+
# Initiating call
237+
response = client.delete(@upload_url, header: request_header, follow_redirect: true)
238+
handle_resumable_upload_http_response_codes(response)
239+
240+
if !@upload_incomplete && (400..499).include?(response.code.to_i)
241+
@close_io_on_finish = true
242+
true # method returns true if upload is successfully cancelled
243+
else
244+
logger.debug { sprintf("Failed to cancel upload session. Response: #{response.code} - #{response.body}") }
245+
end
246+
247+
end
248+
249+
def handle_resumable_upload_http_response_codes(response)
250+
code = response.code.to_i
251+
252+
case code
253+
when 308
254+
if response.headers['Range']
255+
range = response.headers['Range']
256+
@offset = range.split('-').last.to_i + 1
257+
logger.debug { sprintf("Upload is incomplete. Bytes uploaded so far: #{range}") }
258+
else
259+
logger.debug { sprintf('No bytes uploaded yet.') }
260+
end
261+
@upload_incomplete = true
262+
when 400..499
263+
# Upload is canceled
264+
@upload_incomplete = false
265+
when 200, 201
266+
# Upload is complete.
267+
@upload_incomplete = false
268+
else
269+
logger.debug { sprintf("Unexpected response: #{response.code} - #{response.body}") }
270+
@upload_incomplete = true
271+
end
272+
end
273+
185274
def streamable?(upload_source)
186275
upload_source.is_a?(IO) || upload_source.is_a?(StringIO) || upload_source.is_a?(Tempfile)
187276
end

google-apis-core/lib/google/apis/options.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ module Apis
4141
:quota_project,
4242
:query,
4343
:add_invocation_id_header,
44-
:upload_chunk_size)
44+
:upload_chunk_size
45+
)
4546

4647
# General client options
4748
class ClientOptions

google-apis-core/spec/google/apis/core/service_spec.rb

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,95 @@
225225
include_examples 'with options'
226226
end
227227

228+
context 'when making restart resumable upload' do
229+
let(:bucket_name) { 'test_bucket' }
230+
let(:file) { StringIO.new('Hello world' * 3) }
231+
232+
let(:upload_id) { 'foo' }
233+
let(:command) do
234+
service.send(
235+
:restart_resumable_upload,
236+
bucket_name, file, upload_id,
237+
options: { upload_chunk_size: 11}
238+
)
239+
end
240+
let(:upload_url) { "https://www.googleapis.com/upload/b/#{bucket_name}/o?uploadType=resumable&upload_id=#{upload_id}"}
241+
context 'should complete the upload' do
242+
before(:example) do
243+
stub_request(:put, upload_url)
244+
.with(
245+
headers: {
246+
'Content-Length' => '0',
247+
'Content-Range' => 'bytes */33'
248+
}
249+
)
250+
.to_return(
251+
status: [308, 'Resume Incomplete'],
252+
headers: { 'Range' => 'bytes=0-21' }
253+
)
254+
end
255+
256+
before(:example) do
257+
stub_request(:put, upload_url)
258+
.with(headers: { 'Content-Range' => 'bytes 22-32/33' })
259+
.to_return(body: %(OK))
260+
end
261+
262+
it 'should send request to upload url multiple times' do
263+
command
264+
expect(a_request(:put, upload_url)).to have_been_made.twice
265+
end
266+
end
267+
context 'not restart resumable upload if upload is completed' do
268+
before(:example) do
269+
stub_request(:put, upload_url)
270+
.with(
271+
headers: {
272+
'Content-Length' => '0',
273+
'Content-Range' => 'bytes */33'
274+
}
275+
)
276+
.to_return(status: 200, headers: { 'Range' => 'bytes=0-32' })
277+
end
278+
279+
before(:example) do
280+
stub_request(:put, upload_url)
281+
.with(headers: { 'Content-Range' => 'bytes */33' })
282+
.to_return(status: 200)
283+
end
284+
285+
it 'should not restart a upload' do
286+
command
287+
expect(a_request(:put, upload_url)).to have_been_made
288+
end
289+
end
290+
end
291+
292+
context 'delete resumable upload with upload_id' do
293+
let(:bucket_name) { 'test_bucket' }
294+
let(:upload_id) { 'foo' }
295+
let(:command) do
296+
service.send(
297+
:delete_resumable_upload,
298+
bucket_name, upload_id,
299+
options: { upload_chunk_size: 11, delete_upload: true }
300+
)
301+
end
302+
303+
let(:upload_url) { "https://www.googleapis.com/upload/b/#{bucket_name}/o?uploadType=resumable&upload_id=#{upload_id}" }
304+
before(:example) do
305+
stub_request(:delete, upload_url)
306+
.with(headers: { 'Content-Length' => '0' })
307+
.to_return(status: [499])
308+
end
309+
310+
it 'should cancel a resumable upload' do
311+
command
312+
expect(a_request(:delete, upload_url)).to have_been_made
313+
expect(command).to be_truthy
314+
end
315+
end
316+
228317
context 'with batch' do
229318
before(:example) do
230319
response = <<EOF.gsub(/\n/, "\r\n")

0 commit comments

Comments
 (0)