gcs_put 0.1.0
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/.gitignore +9 -0
- data/.standard.yml +3 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/Rakefile +27 -0
- data/gcs_put.gemspec +41 -0
- data/lib/gcs_put/byte_chunker.rb +59 -0
- data/lib/gcs_put/resumable_upload.rb +227 -0
- data/lib/gcs_put/signer.rb +36 -0
- data/lib/gcs_put/transport/faraday.rb +56 -0
- data/lib/gcs_put/transport/net_http.rb +64 -0
- data/lib/gcs_put/transport.rb +38 -0
- data/lib/gcs_put/version.rb +6 -0
- data/lib/gcs_put.rb +62 -0
- data/rbi/gcs_put.rbi +399 -0
- data/sig/gcs_put.rbs +345 -0
- metadata +206 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
# Keeps one connection per host open for the duration of the upload
|
|
6
|
+
class GCSPut::Transport::NetHTTP
|
|
7
|
+
# What Net::HTTP raises when the connection, rather than the request, is at fault
|
|
8
|
+
TRANSIENT_ERRORS = [
|
|
9
|
+
IOError, EOFError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
10
|
+
Net::WriteTimeout, OpenSSL::SSL::SSLError
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
# @param http_options[Hash] forwarded to `Net::HTTP.start` (`open_timeout:`, `read_timeout:`...)
|
|
14
|
+
def initialize(**http_options)
|
|
15
|
+
@http_options = http_options
|
|
16
|
+
@connections = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param uri[URI::Generic]
|
|
20
|
+
# @param body[String]
|
|
21
|
+
# @param headers[Hash{String => String}]
|
|
22
|
+
# @return [GCSPut::Transport::Response]
|
|
23
|
+
# @raise [GCSPut::TransientError] on connection errors and timeouts
|
|
24
|
+
def put(uri, body, headers)
|
|
25
|
+
request(Net::HTTP::Put, uri, body, headers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param uri[URI::Generic]
|
|
29
|
+
# @param body[String]
|
|
30
|
+
# @param headers[Hash{String => String}]
|
|
31
|
+
# @return [GCSPut::Transport::Response]
|
|
32
|
+
# @raise [GCSPut::TransientError] on connection errors and timeouts
|
|
33
|
+
def post(uri, body, headers)
|
|
34
|
+
request(Net::HTTP::Post, uri, body, headers)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Closes the kept connections. Safe to call repeatedly, the next request reopens as needed
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def close
|
|
41
|
+
@connections.each_value do |connection|
|
|
42
|
+
connection.finish if connection.started?
|
|
43
|
+
rescue IOError
|
|
44
|
+
# Already gone, which is what we wanted
|
|
45
|
+
end
|
|
46
|
+
@connections.clear
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def request(request_class, uri, body, headers)
|
|
52
|
+
request = request_class.new(uri, headers)
|
|
53
|
+
request.body = body
|
|
54
|
+
response = connection_for(uri).request(request)
|
|
55
|
+
GCSPut::Transport::Response.new(response.code, response.each_header.to_h, response.body)
|
|
56
|
+
rescue *TRANSIENT_ERRORS => e
|
|
57
|
+
close
|
|
58
|
+
raise GCSPut::TransientError, "#{e.class}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def connection_for(uri)
|
|
62
|
+
@connections[[uri.host, uri.port]] ||= Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", **@http_options)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The uploader needs two HTTP verbs and does not care what performs them. A transport is any
|
|
4
|
+
# object with `put(uri, body, headers)`, `post(uri, body, headers)` and `close`, where the verbs
|
|
5
|
+
# return a `Transport::Response`. Failures worth retrying (connection resets, timeouts and the
|
|
6
|
+
# like) must surface as `GCSPut::TransientError` so the uploader knows it may query
|
|
7
|
+
# the session and carry on. Anything else is allowed to propagate.
|
|
8
|
+
module GCSPut::Transport
|
|
9
|
+
autoload :NetHTTP, "gcs_put/transport/net_http"
|
|
10
|
+
autoload :Faraday, "gcs_put/transport/faraday"
|
|
11
|
+
|
|
12
|
+
# What a transport hands back from `put` and `post`
|
|
13
|
+
class Response
|
|
14
|
+
# @return [Integer] the HTTP status code
|
|
15
|
+
attr_reader :status
|
|
16
|
+
|
|
17
|
+
# @return [Hash{String => String}] the headers, with lowercased names
|
|
18
|
+
attr_reader :headers
|
|
19
|
+
|
|
20
|
+
# @return [String] the body, empty if there was none
|
|
21
|
+
attr_reader :body
|
|
22
|
+
|
|
23
|
+
# @param status[#to_i]
|
|
24
|
+
# @param headers[#to_h] header names in any case
|
|
25
|
+
# @param body[#to_s]
|
|
26
|
+
def initialize(status, headers, body)
|
|
27
|
+
@status = status.to_i
|
|
28
|
+
@headers = headers.to_h.transform_keys { |name| name.to_s.downcase }
|
|
29
|
+
@body = body.to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param name[String] header name in any case
|
|
33
|
+
# @return [String, nil]
|
|
34
|
+
def [](name)
|
|
35
|
+
@headers[name.to_s.downcase]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/gcs_put.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "digest/md5"
|
|
5
|
+
require "forwardable"
|
|
6
|
+
|
|
7
|
+
# The Ruby GCP SDK can only upload things it can measure in advance. GCS itself has supported
|
|
8
|
+
# resumable uploads of unknown size for ages though. This gem gives you a writable object
|
|
9
|
+
# which chops what you write to it into correctly sized chunks and PUTs them into a
|
|
10
|
+
# resumable upload session, so you never need to know the size up front.
|
|
11
|
+
module GCSPut
|
|
12
|
+
# Base class for everything the gem raises
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised by transports for failures worth retrying - connection resets, timeouts and the like
|
|
17
|
+
class TransientError < Error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised when GCS answers a chunk PUT with something we cannot recover from
|
|
21
|
+
class UploadFailed < Error
|
|
22
|
+
# @return [GCSPut::Transport::Response, nil] the response which caused the failure, if there was one
|
|
23
|
+
attr_reader :response
|
|
24
|
+
|
|
25
|
+
# @param message[String]
|
|
26
|
+
# @param response[GCSPut::Transport::Response, nil]
|
|
27
|
+
def initialize(message, response: nil)
|
|
28
|
+
super(message)
|
|
29
|
+
@response = response
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GCS insists that all chunks except the last are sized in multiples of this
|
|
34
|
+
CHUNK_SIZE_UNIT = 256 * 1024
|
|
35
|
+
|
|
36
|
+
# AWS recommend 5MB as the default part size for multipart uploads, and GCP recommend
|
|
37
|
+
# doing "less requests" in general. Since we have to hold a buffer of this size anyway,
|
|
38
|
+
# 5MB seems like a reasonable number for GCP too
|
|
39
|
+
DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
autoload :ByteChunker, "gcs_put/byte_chunker"
|
|
42
|
+
autoload :ResumableUpload, "gcs_put/resumable_upload"
|
|
43
|
+
autoload :Signer, "gcs_put/signer"
|
|
44
|
+
autoload :Transport, "gcs_put/transport"
|
|
45
|
+
|
|
46
|
+
# @!method self.with_gcs_file(gcs_file, content_type: "binary/octet-stream", transport: GCSPut::Transport::NetHTTP.new, signed_url_options: {}, **options, &blk)
|
|
47
|
+
# Shorthand for {GCSPut::ResumableUpload.with_gcs_file}
|
|
48
|
+
# @return [GCSPut::ResumableUpload, Integer]
|
|
49
|
+
# @!method self.with_signed_post_url(signed_post_url, content_type: "binary/octet-stream", transport: GCSPut::Transport::NetHTTP.new, **options, &blk)
|
|
50
|
+
# Shorthand for {GCSPut::ResumableUpload.with_signed_post_url}
|
|
51
|
+
# @return [GCSPut::ResumableUpload, Integer]
|
|
52
|
+
# @!method self.with_session_url(session_url, **options, &blk)
|
|
53
|
+
# Shorthand for {GCSPut::ResumableUpload.with_session_url}
|
|
54
|
+
# @return [GCSPut::ResumableUpload, Integer]
|
|
55
|
+
class << self
|
|
56
|
+
extend Forwardable
|
|
57
|
+
|
|
58
|
+
def_delegators :"GCSPut::ResumableUpload", :with_gcs_file, :with_signed_post_url, :with_session_url
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
require "gcs_put/version"
|
data/rbi/gcs_put.rbi
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# The Ruby GCP SDK can only upload things it can measure in advance. GCS itself has supported
|
|
3
|
+
# resumable uploads of unknown size for ages though. This gem gives you a writable object
|
|
4
|
+
# which chops what you write to it into correctly sized chunks and PUTs them into a
|
|
5
|
+
# resumable upload session, so you never need to know the size up front.
|
|
6
|
+
module GCSPut
|
|
7
|
+
extend Forwardable
|
|
8
|
+
CHUNK_SIZE_UNIT = T.let(256 * 1024, T.untyped)
|
|
9
|
+
DEFAULT_CHUNK_SIZE = T.let(5 * 1024 * 1024, T.untyped)
|
|
10
|
+
VERSION = T.let("0.1.0", T.untyped)
|
|
11
|
+
|
|
12
|
+
# Base class for everything the gem raises
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised by transports for failures worth retrying - connection resets, timeouts and the like
|
|
17
|
+
class TransientError < GCSPut::Error
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised when GCS answers a chunk PUT with something we cannot recover from
|
|
21
|
+
class UploadFailed < GCSPut::Error
|
|
22
|
+
# _@param_ `message`
|
|
23
|
+
#
|
|
24
|
+
# _@param_ `response`
|
|
25
|
+
sig { params(message: String, response: T.nilable(GCSPut::Transport::Response)).void }
|
|
26
|
+
def initialize(message, response: nil); end
|
|
27
|
+
|
|
28
|
+
# _@return_ — the response which caused the failure, if there was one
|
|
29
|
+
sig { returns(T.nilable(GCSPut::Transport::Response)) }
|
|
30
|
+
attr_reader :response
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Supplies the `issuer` and `signer` that `Google::Cloud::Storage::File#signed_url` needs
|
|
34
|
+
# when there is no private key around to sign with
|
|
35
|
+
module Signer
|
|
36
|
+
# When running on GCE, GKE, Cloud Run etc. under a service account there is no private key
|
|
37
|
+
# on the box to sign the URL with. The SDK then needs an `issuer` (the account email) and a
|
|
38
|
+
# `signer` lambda which asks the IAM credentials API to sign for us. For that to be allowed the
|
|
39
|
+
# service account must have `roles/iam.serviceAccountTokenCreator` on itself.
|
|
40
|
+
# Lifted from https://github.com/googleapis/google-cloud-ruby/issues/13307#issuecomment-1894546343
|
|
41
|
+
#
|
|
42
|
+
# _@return_ — either `{issuer:, signer:}` or an empty hash when not on compute engine
|
|
43
|
+
sig { returns(T::Hash[T.untyped, T.untyped]) }
|
|
44
|
+
def self.url_issuer_and_signer; end
|
|
45
|
+
|
|
46
|
+
# _@param_ `service_account_email`
|
|
47
|
+
#
|
|
48
|
+
# _@return_ — a lambda which takes the string to sign and returns the signature
|
|
49
|
+
sig { params(service_account_email: String).returns(Proc) }
|
|
50
|
+
def self.iam_signer_for(service_account_email); end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The uploader needs two HTTP verbs and does not care what performs them. A transport is any
|
|
54
|
+
# object with `put(uri, body, headers)`, `post(uri, body, headers)` and `close`, where the verbs
|
|
55
|
+
# return a `Transport::Response`. Failures worth retrying (connection resets, timeouts and the
|
|
56
|
+
# like) must surface as `GCSPut::TransientError` so the uploader knows it may query
|
|
57
|
+
# the session and carry on. Anything else is allowed to propagate.
|
|
58
|
+
module Transport
|
|
59
|
+
# What a transport hands back from `put` and `post`
|
|
60
|
+
class Response
|
|
61
|
+
# sord duck - #to_i looks like a duck type, replacing with untyped
|
|
62
|
+
# sord duck - #to_h looks like a duck type, replacing with untyped
|
|
63
|
+
# sord duck - #to_s looks like a duck type, replacing with untyped
|
|
64
|
+
# _@param_ `status`
|
|
65
|
+
#
|
|
66
|
+
# _@param_ `headers` — header names in any case
|
|
67
|
+
#
|
|
68
|
+
# _@param_ `body`
|
|
69
|
+
sig { params(status: T.untyped, headers: T.untyped, body: T.untyped).void }
|
|
70
|
+
def initialize(status, headers, body); end
|
|
71
|
+
|
|
72
|
+
# _@param_ `name` — header name in any case
|
|
73
|
+
sig { params(name: String).returns(T.nilable(String)) }
|
|
74
|
+
def [](name); end
|
|
75
|
+
|
|
76
|
+
# _@return_ — the HTTP status code
|
|
77
|
+
sig { returns(Integer) }
|
|
78
|
+
attr_reader :status
|
|
79
|
+
|
|
80
|
+
# _@return_ — the headers, with lowercased names
|
|
81
|
+
sig { returns(T::Hash[String, String]) }
|
|
82
|
+
attr_reader :headers
|
|
83
|
+
|
|
84
|
+
# _@return_ — the body, empty if there was none
|
|
85
|
+
sig { returns(String) }
|
|
86
|
+
attr_reader :body
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Uses a Faraday connection, so you can pick the adapter, timeouts and instrumentation yourself.
|
|
90
|
+
# Check the bytes arrive intact with whatever adapter you choose - we send a `Content-MD5` with
|
|
91
|
+
# every chunk precisely because httpx 1.4.0 used to mangle request bodies,
|
|
92
|
+
# see https://gitlab.com/os85/httpx/-/issues/338
|
|
93
|
+
class Faraday
|
|
94
|
+
TRANSIENT_ERRORS = T.let([::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError].freeze, T.untyped)
|
|
95
|
+
|
|
96
|
+
# sord warn - Faraday::Connection wasn't able to be resolved to a constant in this project
|
|
97
|
+
# _@param_ `connection` — a connection of your own, or `nil` to get a default one
|
|
98
|
+
sig { params(connection: T.nilable(Faraday::Connection)).void }
|
|
99
|
+
def initialize(connection = nil); end
|
|
100
|
+
|
|
101
|
+
# sord warn - URI::Generic wasn't able to be resolved to a constant in this project
|
|
102
|
+
# _@param_ `uri`
|
|
103
|
+
#
|
|
104
|
+
# _@param_ `body`
|
|
105
|
+
#
|
|
106
|
+
# _@param_ `headers`
|
|
107
|
+
sig { params(uri: URI::Generic, body: String, headers: T::Hash[String, String]).returns(GCSPut::Transport::Response) }
|
|
108
|
+
def put(uri, body, headers); end
|
|
109
|
+
|
|
110
|
+
# sord warn - URI::Generic wasn't able to be resolved to a constant in this project
|
|
111
|
+
# _@param_ `uri`
|
|
112
|
+
#
|
|
113
|
+
# _@param_ `body`
|
|
114
|
+
#
|
|
115
|
+
# _@param_ `headers`
|
|
116
|
+
sig { params(uri: URI::Generic, body: String, headers: T::Hash[String, String]).returns(GCSPut::Transport::Response) }
|
|
117
|
+
def post(uri, body, headers); end
|
|
118
|
+
|
|
119
|
+
# A connection you passed in is yours to close, we only close the one we made
|
|
120
|
+
sig { void }
|
|
121
|
+
def close; end
|
|
122
|
+
|
|
123
|
+
# sord omit - no YARD type given for "verb", using untyped
|
|
124
|
+
# sord omit - no YARD type given for "uri", using untyped
|
|
125
|
+
# sord omit - no YARD type given for "body", using untyped
|
|
126
|
+
# sord omit - no YARD type given for "headers", using untyped
|
|
127
|
+
# sord omit - no YARD return type given, using untyped
|
|
128
|
+
sig do
|
|
129
|
+
params(
|
|
130
|
+
verb: T.untyped,
|
|
131
|
+
uri: T.untyped,
|
|
132
|
+
body: T.untyped,
|
|
133
|
+
headers: T.untyped
|
|
134
|
+
).returns(T.untyped)
|
|
135
|
+
end
|
|
136
|
+
def request(verb, uri, body, headers); end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Keeps one connection per host open for the duration of the upload
|
|
140
|
+
class NetHTTP
|
|
141
|
+
TRANSIENT_ERRORS = T.let([
|
|
142
|
+
IOError, EOFError, SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
|
|
143
|
+
Net::WriteTimeout, OpenSSL::SSL::SSLError
|
|
144
|
+
].freeze, T.untyped)
|
|
145
|
+
|
|
146
|
+
# _@param_ `http_options` — forwarded to `Net::HTTP.start` (`open_timeout:`, `read_timeout:`...)
|
|
147
|
+
sig { params(http_options: T::Hash[T.untyped, T.untyped]).void }
|
|
148
|
+
def initialize(**http_options); end
|
|
149
|
+
|
|
150
|
+
# sord warn - URI::Generic wasn't able to be resolved to a constant in this project
|
|
151
|
+
# _@param_ `uri`
|
|
152
|
+
#
|
|
153
|
+
# _@param_ `body`
|
|
154
|
+
#
|
|
155
|
+
# _@param_ `headers`
|
|
156
|
+
sig { params(uri: URI::Generic, body: String, headers: T::Hash[String, String]).returns(GCSPut::Transport::Response) }
|
|
157
|
+
def put(uri, body, headers); end
|
|
158
|
+
|
|
159
|
+
# sord warn - URI::Generic wasn't able to be resolved to a constant in this project
|
|
160
|
+
# _@param_ `uri`
|
|
161
|
+
#
|
|
162
|
+
# _@param_ `body`
|
|
163
|
+
#
|
|
164
|
+
# _@param_ `headers`
|
|
165
|
+
sig { params(uri: URI::Generic, body: String, headers: T::Hash[String, String]).returns(GCSPut::Transport::Response) }
|
|
166
|
+
def post(uri, body, headers); end
|
|
167
|
+
|
|
168
|
+
# Closes the kept connections. Safe to call repeatedly, the next request reopens as needed
|
|
169
|
+
sig { void }
|
|
170
|
+
def close; end
|
|
171
|
+
|
|
172
|
+
# sord omit - no YARD type given for "request_class", using untyped
|
|
173
|
+
# sord omit - no YARD type given for "uri", using untyped
|
|
174
|
+
# sord omit - no YARD type given for "body", using untyped
|
|
175
|
+
# sord omit - no YARD type given for "headers", using untyped
|
|
176
|
+
# sord omit - no YARD return type given, using untyped
|
|
177
|
+
sig do
|
|
178
|
+
params(
|
|
179
|
+
request_class: T.untyped,
|
|
180
|
+
uri: T.untyped,
|
|
181
|
+
body: T.untyped,
|
|
182
|
+
headers: T.untyped
|
|
183
|
+
).returns(T.untyped)
|
|
184
|
+
end
|
|
185
|
+
def request(request_class, uri, body, headers); end
|
|
186
|
+
|
|
187
|
+
# sord omit - no YARD type given for "uri", using untyped
|
|
188
|
+
# sord omit - no YARD return type given, using untyped
|
|
189
|
+
sig { params(uri: T.untyped).returns(T.untyped) }
|
|
190
|
+
def connection_for(uri); end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Chops an arbitrary stream of writes into evenly sized chunks. Every chunk except the last
|
|
195
|
+
# will be exactly `chunk_size` bytes, and the last one can be anything from 0 bytes up to and
|
|
196
|
+
# including `chunk_size`. A chunk which fills up exactly is held back until `finish` so that
|
|
197
|
+
# a stream ending on a chunk boundary still delivers that chunk flagged as the last one.
|
|
198
|
+
#
|
|
199
|
+
# chunker = ByteChunker.new(chunk_size: 3) { |bytes, is_last| puts [bytes, is_last].inspect }
|
|
200
|
+
# chunker << "ab" << "cdefg" # => ["abc", false], ["def", false]
|
|
201
|
+
# chunker.finish # => ["g", true]
|
|
202
|
+
class ByteChunker
|
|
203
|
+
# _@param_ `chunk_size` — the size that every chunk except the last must have
|
|
204
|
+
sig { params(chunk_size: Integer, delivery_proc: T.untyped).void }
|
|
205
|
+
def initialize(chunk_size:, &delivery_proc); end
|
|
206
|
+
|
|
207
|
+
# _@param_ `bin_str` — the bytes to append
|
|
208
|
+
sig { params(bin_str: String).returns(T.self_type) }
|
|
209
|
+
def <<(bin_str); end
|
|
210
|
+
|
|
211
|
+
# _@param_ `bin_str` — the bytes to append
|
|
212
|
+
#
|
|
213
|
+
# _@return_ — the number of bytes appended, like `IO#write`
|
|
214
|
+
sig { params(bin_str: String).returns(Integer) }
|
|
215
|
+
def write(bin_str); end
|
|
216
|
+
|
|
217
|
+
# Delivers whatever is left in the buffer as the last chunk. The last chunk
|
|
218
|
+
# gets delivered even when empty - it is what closes the upload
|
|
219
|
+
sig { void }
|
|
220
|
+
def finish; end
|
|
221
|
+
|
|
222
|
+
sig { void }
|
|
223
|
+
def deliver_full_chunks; end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A writable object which streams what you write into a GCS resumable upload session using
|
|
227
|
+
# ranged PUTs, see https://cloud.google.com/storage/docs/performing-resumable-uploads#chunked-upload
|
|
228
|
+
# You do not need to know the size of the output in advance. You do need to `finish` the upload,
|
|
229
|
+
# since it is the last PUT (with the total size filled in) which closes the GCS object.
|
|
230
|
+
#
|
|
231
|
+
# gcs_file = bucket.file("upload.bin", skip_lookup: true)
|
|
232
|
+
# GCSPut.with_gcs_file(gcs_file) do |io|
|
|
233
|
+
# io.write("Hello resumable")
|
|
234
|
+
# 20.times { io.write(Random.bytes(1024 * 1024)) }
|
|
235
|
+
# end
|
|
236
|
+
#
|
|
237
|
+
# Every chunk is retried from the byte offset GCS reports as persisted, so a chunk which
|
|
238
|
+
# only partially made it over the wire gets topped up rather than resent from the start.
|
|
239
|
+
#
|
|
240
|
+
# Starting the session needs a signed POST URL, and signing needs a private key. Under
|
|
241
|
+
# workload identity there is no key, so we fall back to the IAM signBlob API - which means
|
|
242
|
+
# the service account must hold `roles/iam.serviceAccountTokenCreator` on itself. See
|
|
243
|
+
# https://github.com/googleapis/google-cloud-ruby/issues/13307
|
|
244
|
+
class ResumableUpload
|
|
245
|
+
extend Forwardable
|
|
246
|
+
|
|
247
|
+
# Appends bytes to the upload, sending out a chunk whenever one fills up
|
|
248
|
+
#
|
|
249
|
+
# _@param_ `bin_str`
|
|
250
|
+
#
|
|
251
|
+
# _@return_ — the number of bytes appended, like `IO#write`
|
|
252
|
+
sig { params(bin_str: String).returns(Integer) }
|
|
253
|
+
def write(bin_str); end
|
|
254
|
+
|
|
255
|
+
# Appends bytes to the upload, sending out a chunk whenever one fills up
|
|
256
|
+
#
|
|
257
|
+
# _@param_ `bin_str`
|
|
258
|
+
sig { params(bin_str: String).returns(T.self_type) }
|
|
259
|
+
def <<(bin_str); end
|
|
260
|
+
|
|
261
|
+
# sord warn - Google::Cloud::Storage::File wasn't able to be resolved to a constant in this project
|
|
262
|
+
# sord duck - #put looks like a duck type, replacing with untyped
|
|
263
|
+
# sord duck - #post looks like a duck type, replacing with untyped
|
|
264
|
+
# sord duck - #close looks like a duck type, replacing with untyped
|
|
265
|
+
# Signs a session start URL for the object, starts the session and returns the upload.
|
|
266
|
+
# With a block, yields the upload, finishes it once the block returns and returns the total size.
|
|
267
|
+
#
|
|
268
|
+
# _@param_ `gcs_file` — the object to upload into, does not need to exist yet
|
|
269
|
+
#
|
|
270
|
+
# _@param_ `content_type` — the content type of the resulting object
|
|
271
|
+
#
|
|
272
|
+
# _@param_ `transport` — see `GCSPut::Transport`
|
|
273
|
+
#
|
|
274
|
+
# _@param_ `signed_url_options` — passed to `gcs_file.signed_url`, see `Signer.url_issuer_and_signer`
|
|
275
|
+
#
|
|
276
|
+
# _@param_ `options` — see {#initialize}
|
|
277
|
+
#
|
|
278
|
+
# _@return_ — the upload, or the total size when given a block
|
|
279
|
+
sig do
|
|
280
|
+
params(
|
|
281
|
+
gcs_file: Google::Cloud::Storage::File,
|
|
282
|
+
content_type: String,
|
|
283
|
+
transport: T.untyped,
|
|
284
|
+
signed_url_options: T::Hash[T.untyped, T.untyped],
|
|
285
|
+
options: T::Hash[T.untyped, T.untyped],
|
|
286
|
+
blk: T.untyped
|
|
287
|
+
).returns(T.any(GCSPut::ResumableUpload, Integer))
|
|
288
|
+
end
|
|
289
|
+
def self.with_gcs_file(gcs_file, content_type: "binary/octet-stream", transport: GCSPut::Transport::NetHTTP.new, signed_url_options: {}, **options, &blk); end
|
|
290
|
+
|
|
291
|
+
# sord duck - #put looks like a duck type, replacing with untyped
|
|
292
|
+
# sord duck - #post looks like a duck type, replacing with untyped
|
|
293
|
+
# sord duck - #close looks like a duck type, replacing with untyped
|
|
294
|
+
# Starts a session from a signed POST URL (one with `x-goog-resumable: start` among its signed headers)
|
|
295
|
+
# and returns the upload, see https://cloud.google.com/storage/docs/performing-resumable-uploads#initiate-session
|
|
296
|
+
# With a block, yields the upload, finishes it once the block returns and returns the total size.
|
|
297
|
+
#
|
|
298
|
+
# _@param_ `signed_post_url`
|
|
299
|
+
#
|
|
300
|
+
# _@param_ `content_type` — must match the content type the URL was signed with
|
|
301
|
+
#
|
|
302
|
+
# _@param_ `transport` — see `GCSPut::Transport`
|
|
303
|
+
#
|
|
304
|
+
# _@param_ `options` — see {#initialize}
|
|
305
|
+
#
|
|
306
|
+
# _@return_ — the upload, or the total size when given a block
|
|
307
|
+
sig do
|
|
308
|
+
params(
|
|
309
|
+
signed_post_url: String,
|
|
310
|
+
content_type: String,
|
|
311
|
+
transport: T.untyped,
|
|
312
|
+
options: T::Hash[T.untyped, T.untyped],
|
|
313
|
+
blk: T.untyped
|
|
314
|
+
).returns(T.any(GCSPut::ResumableUpload, Integer))
|
|
315
|
+
end
|
|
316
|
+
def self.with_signed_post_url(signed_post_url, content_type: "binary/octet-stream", transport: GCSPut::Transport::NetHTTP.new, **options, &blk); end
|
|
317
|
+
|
|
318
|
+
# Wraps an already started session. With a block, yields the upload, finishes it once
|
|
319
|
+
# the block returns and returns the total size.
|
|
320
|
+
#
|
|
321
|
+
# _@param_ `session_url` — the `Location` returned by the session start
|
|
322
|
+
#
|
|
323
|
+
# _@param_ `options` — see {#initialize}
|
|
324
|
+
#
|
|
325
|
+
# _@return_ — the upload, or the total size when given a block
|
|
326
|
+
sig { params(session_url: String, options: T::Hash[T.untyped, T.untyped]).returns(T.any(GCSPut::ResumableUpload, Integer)) }
|
|
327
|
+
def self.with_session_url(session_url, **options); end
|
|
328
|
+
|
|
329
|
+
# sord duck - #put looks like a duck type, replacing with untyped
|
|
330
|
+
# sord duck - #post looks like a duck type, replacing with untyped
|
|
331
|
+
# sord duck - #close looks like a duck type, replacing with untyped
|
|
332
|
+
# Prefer the `with_*` factories. This does no HTTP by itself, the first request goes out
|
|
333
|
+
# once a chunk fills up or `finish` gets called
|
|
334
|
+
#
|
|
335
|
+
# _@param_ `session_url` — the `Location` returned by the session start
|
|
336
|
+
#
|
|
337
|
+
# _@param_ `chunk_size` — must be a multiple of 256 KiB
|
|
338
|
+
#
|
|
339
|
+
# _@param_ `content_type` — must match the content type the session was started with
|
|
340
|
+
#
|
|
341
|
+
# _@param_ `max_attempts` — how many times a single chunk may be sent before giving up
|
|
342
|
+
#
|
|
343
|
+
# _@param_ `transport` — see `GCSPut::Transport`
|
|
344
|
+
sig do
|
|
345
|
+
params(
|
|
346
|
+
session_url: String,
|
|
347
|
+
chunk_size: Integer,
|
|
348
|
+
content_type: String,
|
|
349
|
+
max_attempts: Integer,
|
|
350
|
+
transport: T.untyped
|
|
351
|
+
).void
|
|
352
|
+
end
|
|
353
|
+
def initialize(session_url, chunk_size: GCSPut::DEFAULT_CHUNK_SIZE, content_type: "binary/octet-stream", max_attempts: 5, transport: GCSPut::Transport::NetHTTP.new); end
|
|
354
|
+
|
|
355
|
+
# Sends the remaining buffered bytes as the final chunk and closes the GCS object.
|
|
356
|
+
# Also available as `close` so that writers which close their underlying IO, like
|
|
357
|
+
# `Zlib::GzipWriter`, finish the upload for you
|
|
358
|
+
#
|
|
359
|
+
# _@return_ — the total number of bytes uploaded
|
|
360
|
+
sig { returns(Integer) }
|
|
361
|
+
def finish; end
|
|
362
|
+
|
|
363
|
+
# _@param_ `chunk`
|
|
364
|
+
#
|
|
365
|
+
# _@param_ `is_last`
|
|
366
|
+
sig { params(chunk: String, is_last: T::Boolean).void }
|
|
367
|
+
def upload_chunk(chunk, is_last); end
|
|
368
|
+
|
|
369
|
+
# _@param_ `body`
|
|
370
|
+
#
|
|
371
|
+
# _@param_ `from`
|
|
372
|
+
#
|
|
373
|
+
# _@param_ `total` — the total size, or "*" while still unknown
|
|
374
|
+
sig { params(body: String, from: Integer, total: T.any(Integer, String)).returns(GCSPut::Transport::Response) }
|
|
375
|
+
def put_bytes(body, from:, total:); end
|
|
376
|
+
|
|
377
|
+
# Asks GCS how much of the upload it has and updates `bytes_persisted` accordingly, see
|
|
378
|
+
# https://cloud.google.com/storage/docs/performing-resumable-uploads#status-check
|
|
379
|
+
# A status check which fails itself just leaves us with what we already knew
|
|
380
|
+
#
|
|
381
|
+
# _@return_ — whether the session turned out to be finalized already
|
|
382
|
+
sig { returns(T::Boolean) }
|
|
383
|
+
def sync_with_session_finds_it_finalized?; end
|
|
384
|
+
|
|
385
|
+
# The `Range` header is "bytes=0-N" with N being the last persisted byte, and absent if nothing persisted yet
|
|
386
|
+
#
|
|
387
|
+
# _@param_ `response`
|
|
388
|
+
sig { params(response: GCSPut::Transport::Response).returns(Integer) }
|
|
389
|
+
def persisted_offset_from(response); end
|
|
390
|
+
|
|
391
|
+
# _@return_ — the session URL, valid for a week and usable from any process
|
|
392
|
+
sig { returns(String) }
|
|
393
|
+
attr_reader :session_url
|
|
394
|
+
|
|
395
|
+
# _@return_ — the number of bytes GCS has confirmed as persisted so far
|
|
396
|
+
sig { returns(Integer) }
|
|
397
|
+
attr_reader :bytes_persisted
|
|
398
|
+
end
|
|
399
|
+
end
|