Skip to content

Commit 145f567

Browse files
feat(storage): adding checksum header (googleapis#25157)
1 parent 847d4e8 commit 145f567

File tree

2 files changed

+249
-1
lines changed

2 files changed

+249
-1
lines changed

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ def send_upload_command(client)
176176
request_header = header.dup
177177
request_header[CONTENT_RANGE_HEADER] = get_content_range_header current_chunk_size
178178
request_header[CONTENT_LENGTH_HEADER] = current_chunk_size.to_s
179+
last_chunk = remaining_content_size <= current_chunk_size
180+
formatted_string = formatted_checksum_header
181+
request_header['X-Goog-Hash'] = formatted_string if (last_chunk && !formatted_string.empty?)
182+
179183
chunk_body =
180184
if @upload_chunk_size == 0
181185
upload_io
@@ -191,7 +195,7 @@ def send_upload_command(client)
191195
success(result)
192196
rescue => e
193197
logger.warn {
194-
"error occured please use uploadId-#{response.headers['X-GUploader-UploadID']} to resume your upload"
198+
"error occurred please use uploadId-#{response.headers['X-GUploader-UploadID']} to resume your upload"
195199
} unless response.nil?
196200
upload_io.pos = @offset
197201
error(e, rethrow: true)
@@ -290,6 +294,30 @@ def get_content_range_header current_chunk_size
290294
end
291295
sprintf('bytes %s/%d', numerator, upload_io.size)
292296
end
297+
298+
# Generates a formatted checksum header string from the request body.
299+
#
300+
# Parses the body as JSON and extracts checksum values for the keys "crc32c", "md5Hash", and "md5".
301+
# The "md5Hash" key is renamed to "md5" in the output.
302+
# Returns a comma-separated string in the format "key=value" for each present checksum.
303+
#
304+
# @example
305+
# If the body contains:
306+
# { "md5Hash": "1B2M2Y8AsgTpgAmY7PhCfg==",
307+
# "crc32c": "AAAAAA==" }
308+
# The method returns:
309+
# "crc32c=AAAAAA==,md5=1B2M2Y8AsgTpgAmY7PhCfg=="
310+
# @return [String] the formatted checksum header, or an empty string if no relevant keys are present
311+
def formatted_checksum_header
312+
hash_data = body.to_s.empty? ? {} : JSON.parse(body)
313+
target_keys = ["crc32c", "md5Hash", "md5"]
314+
selected_keys = hash_data.slice(*target_keys)
315+
formatted_string = selected_keys.map do |key, value|
316+
output_key = (key == "md5Hash") ? "md5" : key
317+
"#{output_key}=#{value}"
318+
end.join(',')
319+
formatted_string
320+
end
293321
end
294322
end
295323
end

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

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,224 @@
307307
expect { command.execute(client) }.to raise_error Google::Apis::ServerError
308308
end
309309
end
310+
context 'when uploading with md5 checksum' do
311+
312+
let(:file) { StringIO.new(file_content) }
313+
let(:md5_checksum) {"md5_checksum" }
314+
let(:body_with_md5) { { "md5Hash" => md5_checksum }.to_json }
315+
316+
context 'with single shot upload' do
317+
let(:file_content) { "Hello world" }
318+
319+
before(:example) do
320+
command.body = body_with_md5
321+
allow(command).to receive(:formatted_checksum_header).and_call_original
322+
323+
stub_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
324+
.to_return(headers: { 'Location' => 'https://www.googleapis.com/zoo/animals' }, body: %(OK))
325+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
326+
.with { |req| req.headers['X-Goog-Hash'] == "md5=#{md5_checksum}" }
327+
.to_return(body: %(OK))
328+
end
329+
330+
it 'should not include X-Goog-Hash header during initiation' do
331+
command.execute(client)
332+
expect(a_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
333+
.with { |req| req.headers['X-Goog-Hash'] == "md5=#{md5_checksum}" }).to_not have_been_made
334+
end
335+
336+
it 'calls formatted_checksum_header and returns correct value' do
337+
expect(command.formatted_checksum_header).to eq("md5=#{md5_checksum}")
338+
end
339+
end
340+
341+
context 'with chunked upload' do
342+
let(:file_content) { "Hello world" * 2 }
343+
344+
before(:example) do
345+
command.body = body_with_md5
346+
allow(command).to receive(:formatted_checksum_header).and_call_original
347+
348+
stub_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
349+
.to_return(headers: { 'Location' => 'https://www.googleapis.com/zoo/animals' }, body: %(OK))
350+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
351+
.with(headers: { 'Content-Range' => 'bytes 0-10/22' })
352+
.to_return(status: [308, 'Resume Incomplete'])
353+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
354+
.with(headers: {
355+
'Content-Range' => 'bytes 11-21/22',
356+
'X-Goog-Hash' => "md5=#{md5_checksum}"
357+
})
358+
.to_return(body: %(OK))
359+
end
360+
361+
it 'should not include X-Goog-Hash header during initiation' do
362+
command.options.upload_chunk_size = 11
363+
command.execute(client)
364+
expect(a_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
365+
.with { |req| req.headers['X-Goog-Hash'] == "md5=#{md5_checksum}" }).to_not have_been_made
366+
end
367+
368+
it 'includes md5 checksum in X-Goog-Hash header only in the last chunk' do
369+
command.options.upload_chunk_size = 11
370+
command.execute(client)
371+
372+
# First chunk should NOT have the X-Goog-Hash header
373+
expect(a_request(:put, 'https://www.googleapis.com/zoo/animals')
374+
.with { |req| req.headers['Content-Range'] == 'bytes 0-10/22' && !req.headers.key?('X-Goog-Hash') }).to have_been_made
375+
376+
# Last chunk should have the X-Goog-Hash header
377+
expect(a_request(:put, 'https://www.googleapis.com/zoo/animals')
378+
.with { |req| req.headers['Content-Range'] == 'bytes 11-21/22' && req.headers['X-Goog-Hash'] == "md5=#{md5_checksum}" }).to have_been_made
379+
end
380+
end
381+
end
382+
383+
context 'when uploading with crc32c checksum' do
384+
385+
let(:file) { StringIO.new(file_content) }
386+
let(:crc32c_checksum) { "abc_checksum" }
387+
let(:body_with_crc32c) { { "crc32c" => crc32c_checksum }.to_json }
388+
389+
context 'with single shot upload' do
390+
let(:file_content) { "Hello world" }
391+
392+
before(:example) do
393+
command.body = body_with_crc32c
394+
allow(command).to receive(:formatted_checksum_header).and_call_original
395+
396+
stub_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
397+
.to_return(headers: { 'Location' => 'https://www.googleapis.com/zoo/animals' }, body: %(OK))
398+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
399+
.with { |req| req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum}" }
400+
.to_return(body: %(OK))
401+
end
402+
403+
it 'should not include X-Goog-Hash header during initiation' do
404+
command.execute(client)
405+
expect(a_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
406+
.with { |req| req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum}" }).to_not have_been_made
407+
end
408+
409+
it 'calls formatted_checksum_header and returns correct value' do
410+
expect(command.formatted_checksum_header).to eq("crc32c=#{crc32c_checksum}")
411+
end
412+
end
413+
414+
context 'with chunked upload' do
415+
let(:file_content) { "Hello world" * 2 }
416+
417+
before(:example) do
418+
command.body = body_with_crc32c
419+
allow(command).to receive(:formatted_checksum_header).and_call_original
420+
421+
stub_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
422+
.to_return(headers: { 'Location' => 'https://www.googleapis.com/zoo/animals' }, body: %(OK))
423+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
424+
.with(headers: { 'Content-Range' => 'bytes 0-10/22' })
425+
.to_return(status: [308, 'Resume Incomplete'])
426+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
427+
.with(headers: {
428+
'Content-Range' => 'bytes 11-21/22',
429+
'X-Goog-Hash' => "crc32c=#{crc32c_checksum}"
430+
})
431+
.to_return(body: %(OK))
432+
end
433+
434+
it 'should not include X-Goog-Hash header during initiation' do
435+
command.options.upload_chunk_size = 11
436+
command.execute(client)
437+
expect(a_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
438+
.with { |req| req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum}" }).to_not have_been_made
439+
end
440+
441+
it 'includes md5 checksum in X-Goog-Hash header only in the last chunk' do
442+
command.options.upload_chunk_size = 11
443+
command.execute(client)
444+
445+
# First chunk should NOT have the X-Goog-Hash header
446+
expect(a_request(:put, 'https://www.googleapis.com/zoo/animals')
447+
.with { |req| req.headers['Content-Range'] == 'bytes 0-10/22' && !req.headers.key?('X-Goog-Hash') }).to have_been_made
448+
449+
# Last chunk should have the X-Goog-Hash header
450+
expect(a_request(:put, 'https://www.googleapis.com/zoo/animals')
451+
.with { |req| req.headers['Content-Range'] == 'bytes 11-21/22' && req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum}" }).to have_been_made
452+
end
453+
454+
end
455+
end
456+
457+
context 'when uploading with md5 and crc32c checksum' do
458+
let(:file) { StringIO.new(file_content) }
459+
let(:md5_checksum) { "md5_checksum"}
460+
let(:crc32c_checksum) { "crc32c_checksum" }
461+
let(:body_with_md5_crc32c) { { "md5Hash" => md5_checksum, "crc32c" => crc32c_checksum }.to_json }
462+
463+
context 'with single shot upload' do
464+
let(:file_content) { "Hello world" }
465+
466+
before(:example) do
467+
command.body = body_with_md5_crc32c
468+
allow(command).to receive(:formatted_checksum_header).and_call_original
469+
stub_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
470+
.to_return(headers: { 'Location' => 'https://www.googleapis.com/zoo/animals' }, body: %(OK))
471+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
472+
.with { |req| req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum},md5=#{md5_checksum}" }
473+
.to_return(body: %(OK))
474+
end
475+
476+
it 'should not include X-Goog-Hash header during initiation' do
477+
command.execute(client)
478+
expect(a_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
479+
.with { |req| req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum},md5=#{md5_checksum}" }).to_not have_been_made
480+
end
481+
482+
it 'calls formatted_checksum_header and returns correct value' do
483+
expect(command.formatted_checksum_header).to eq("crc32c=#{crc32c_checksum},md5=#{md5_checksum}")
484+
end
485+
end
486+
487+
context 'with chunked upload' do
488+
let(:file_content) { "Hello world" * 2 }
489+
490+
before(:example) do
491+
command.body = body_with_md5_crc32c
492+
allow(command).to receive(:formatted_checksum_header).and_call_original
493+
494+
stub_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
495+
.to_return(headers: { 'Location' => 'https://www.googleapis.com/zoo/animals' }, body: %(OK))
496+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
497+
.with(headers: { 'Content-Range' => 'bytes 0-10/22' })
498+
.to_return(status: [308, 'Resume Incomplete'])
499+
stub_request(:put, 'https://www.googleapis.com/zoo/animals')
500+
.with(headers: {
501+
'Content-Range' => 'bytes 11-21/22',
502+
'X-Goog-Hash' => "crc32c=#{crc32c_checksum},md5=#{md5_checksum}"
503+
})
504+
.to_return(body: %(OK))
505+
end
506+
507+
it 'should not includeX-Goog-Hash header during initiation' do
508+
command.options.upload_chunk_size = 11
509+
command.execute(client)
510+
expect(a_request(:post, 'https://www.googleapis.com/zoo/animals?uploadType=resumable')
511+
.with { |req| req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum},md5=#{md5_checksum}" }).to_not have_been_made
512+
end
513+
514+
it 'includes md5 and crc32c checksum in X-Goog-Hash header only in the last chunk' do
515+
command.options.upload_chunk_size = 11
516+
command.execute(client)
517+
518+
# First chunk should NOT have the X-Goog-Hash header
519+
expect(a_request(:put, 'https://www.googleapis.com/zoo/animals')
520+
.with { |req| req.headers['Content-Range'] == 'bytes 0-10/22' && !req.headers.key?('X-Goog-Hash') }).to have_been_made
521+
522+
# Last chunk should have the X-Goog-Hash header
523+
expect(a_request(:put, 'https://www.googleapis.com/zoo/animals')
524+
.with { |req| req.headers['Content-Range'] == 'bytes 11-21/22' && req.headers['X-Goog-Hash'] == "crc32c=#{crc32c_checksum},md5=#{md5_checksum}" }).to have_been_made
525+
end
526+
527+
end
528+
end
529+
310530
end

0 commit comments

Comments
 (0)