connectrpc 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/LICENSE +21 -0
- data/README.md +501 -0
- data/docs/design.md +504 -0
- data/docs/development.md +566 -0
- data/docs/grpc-web.md +165 -0
- data/docs/grpc.md +75 -0
- data/docs/rbs.md +469 -0
- data/docs/streaming.md +251 -0
- data/exe/protoc-gen-connectrpc-ruby +23 -0
- data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
- data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
- data/lib/connectrpc/cancellation.rb +220 -0
- data/lib/connectrpc/client.rb +260 -0
- data/lib/connectrpc/compression.rb +120 -0
- data/lib/connectrpc/deadline.rb +81 -0
- data/lib/connectrpc/error.rb +72 -0
- data/lib/connectrpc/generator/names.rb +105 -0
- data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
- data/lib/connectrpc/generator/renderer.rb +115 -0
- data/lib/connectrpc/generator/runtime_names.rb +84 -0
- data/lib/connectrpc/generator/schema.rb +218 -0
- data/lib/connectrpc/generator/streaming_methods.rb +29 -0
- data/lib/connectrpc/generator.rb +42 -0
- data/lib/connectrpc/get_query.rb +159 -0
- data/lib/connectrpc/grpc.rb +200 -0
- data/lib/connectrpc/grpc_client.rb +23 -0
- data/lib/connectrpc/grpc_server.rb +158 -0
- data/lib/connectrpc/grpc_web.rb +131 -0
- data/lib/connectrpc/grpc_web_cors.rb +135 -0
- data/lib/connectrpc/grpc_web_io.rb +156 -0
- data/lib/connectrpc/grpc_web_server.rb +207 -0
- data/lib/connectrpc/instrumentation.rb +216 -0
- data/lib/connectrpc/message_io.rb +47 -0
- data/lib/connectrpc/metadata.rb +105 -0
- data/lib/connectrpc/method.rb +68 -0
- data/lib/connectrpc/protocol.rb +189 -0
- data/lib/connectrpc/server.rb +398 -0
- data/lib/connectrpc/streaming/client.rb +104 -0
- data/lib/connectrpc/streaming/client_execution.rb +87 -0
- data/lib/connectrpc/streaming/client_scope.rb +105 -0
- data/lib/connectrpc/streaming/framing.rb +123 -0
- data/lib/connectrpc/streaming/framing_io.rb +61 -0
- data/lib/connectrpc/streaming/method.rb +33 -0
- data/lib/connectrpc/streaming/server.rb +122 -0
- data/lib/connectrpc/streaming/server_execution.rb +88 -0
- data/lib/connectrpc/streaming/server_terminal.rb +70 -0
- data/lib/connectrpc/streaming/session.rb +143 -0
- data/lib/connectrpc/streaming/session_completion.rb +134 -0
- data/lib/connectrpc/streaming/session_io.rb +107 -0
- data/lib/connectrpc/streaming/session_messages.rb +119 -0
- data/lib/connectrpc/streaming/strategy.rb +95 -0
- data/lib/connectrpc/streaming/streams.rb +116 -0
- data/lib/connectrpc/streaming/terminal.rb +52 -0
- data/lib/connectrpc/transports/net_http.rb +246 -0
- data/lib/connectrpc/version.rb +5 -0
- data/lib/connectrpc.rb +38 -0
- data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
- data/sig/connectrpc.rbs +772 -0
- data/sig/manifest.yaml +2 -0
- data/third_party/README.md +16 -0
- data/third_party/google/LICENSE +201 -0
- data/third_party/google/rpc/status.proto +48 -0
- data/third_party/protobuf-LICENSE +32 -0
- metadata +106 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ConnectRPC
|
|
6
|
+
class Client # rubocop:disable Metrics/ClassLength
|
|
7
|
+
# Configuration validates each independent public bound eagerly.
|
|
8
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
9
|
+
def initialize(base_url:, binding:, transport: nil, codec: :proto, request_limit: Protocol::DEFAULT_LIMIT,
|
|
10
|
+
response_limit: Protocol::DEFAULT_LIMIT, request_wire_limit: request_limit,
|
|
11
|
+
response_wire_limit: response_limit, accept_encoding: nil, use_get: false, get_url_limit: 8192,
|
|
12
|
+
protocol: :connect, mode: :unary, encoding: :identity, instrumentation: nil,
|
|
13
|
+
terminal_wire_limit: 65_536, terminal_limit: 65_536, status_codec: nil,
|
|
14
|
+
grpc_status_wire_limit: 65_536, grpc_status_limit: 65_536)
|
|
15
|
+
Protocol.profile!(protocol, mode, encoding)
|
|
16
|
+
@protocol = protocol
|
|
17
|
+
@grpc_status_wire_limit = Protocol.limit!(grpc_status_wire_limit)
|
|
18
|
+
@grpc_status_limit = Protocol.limit!(grpc_status_limit)
|
|
19
|
+
@status_codec = GRPCProtocol.status_codec!(binding, status_codec) if protocol == :grpc
|
|
20
|
+
raise ArgumentError, "gRPC requires protobuf POST" if protocol == :grpc && (codec != :proto || use_get)
|
|
21
|
+
raise ArgumentError, "use_get must be true or false" unless [true, false].include?(use_get)
|
|
22
|
+
|
|
23
|
+
@terminal_wire_limit = Protocol.limit!(terminal_wire_limit)
|
|
24
|
+
@terminal_limit = Protocol.limit!(terminal_limit)
|
|
25
|
+
@use_get = use_get
|
|
26
|
+
@get_url_limit = Protocol.limit!(get_url_limit)
|
|
27
|
+
@base_url = parse_base_url(base_url)
|
|
28
|
+
@instrumentation = instrumentation
|
|
29
|
+
@binding = binding
|
|
30
|
+
@transport = transport || default_transport
|
|
31
|
+
@codec = Protocol.codec!(codec)
|
|
32
|
+
@encoding = encoding
|
|
33
|
+
@accept_encoding = Protocol.accept_encoding!(accept_encoding, encoding)
|
|
34
|
+
@request_wire_limit = Protocol.limit!(request_wire_limit)
|
|
35
|
+
@response_wire_limit = Protocol.limit!(response_wire_limit)
|
|
36
|
+
@request_limit = Protocol.limit!(request_limit)
|
|
37
|
+
@response_limit = Protocol.limit!(response_limit)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
41
|
+
|
|
42
|
+
def call(method, request, metadata: {}, timeout: 30, cancellation: nil)
|
|
43
|
+
raise ArgumentError, "call requires a unary Method" unless method.is_a?(Method)
|
|
44
|
+
|
|
45
|
+
unless cancellation.nil? || cancellation.is_a?(CancellationToken)
|
|
46
|
+
raise ArgumentError, "cancellation must be a CancellationToken or nil"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if @protocol == :grpc
|
|
50
|
+
raise ArgumentError, "Transport does not support unary gRPC" unless stream_capability?(method)
|
|
51
|
+
|
|
52
|
+
return stream_call(method, request, metadata, timeout, cancellation) { |_| nil }
|
|
53
|
+
end
|
|
54
|
+
return instrumented_call(method, request, metadata, timeout, cancellation) if @instrumentation
|
|
55
|
+
|
|
56
|
+
process_call(method, request, metadata, timeout, cancellation)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
62
|
+
def process_call(method, request, metadata, timeout, cancellation, lifecycle: nil, injection: nil)
|
|
63
|
+
# @type var state: CallCancellation?
|
|
64
|
+
state = nil
|
|
65
|
+
deadline = Deadline.new(timeout) unless timeout.nil?
|
|
66
|
+
active = state = CallCancellation.new(deadline: deadline, cancellation: cancellation)
|
|
67
|
+
active.check!
|
|
68
|
+
metadata = Metadata.new(metadata)
|
|
69
|
+
if lifecycle
|
|
70
|
+
metadata = injected_metadata(metadata, injection)
|
|
71
|
+
lifecycle.request_metadata = Instrumentation.carrier(metadata)
|
|
72
|
+
end
|
|
73
|
+
outgoing = build_request(method, request, metadata, active, lifecycle)
|
|
74
|
+
outgoing.deadline = deadline
|
|
75
|
+
outgoing.cancellation = state
|
|
76
|
+
outgoing.headers["connect-timeout-ms"] = [deadline.timeout_ms] if deadline
|
|
77
|
+
outgoing.lifecycle = lifecycle if lifecycle
|
|
78
|
+
active.check!
|
|
79
|
+
lifecycle.request_bytes = outgoing.body.bytesize if lifecycle && outgoing.http_method != "GET"
|
|
80
|
+
transport = @transport #: _Transport
|
|
81
|
+
response = transport.call(outgoing)
|
|
82
|
+
yield response if block_given?
|
|
83
|
+
active.check!
|
|
84
|
+
Protocol.check_size!(response.body, @response_wire_limit)
|
|
85
|
+
result = read_response(response, method, active, lifecycle)
|
|
86
|
+
active.commit!
|
|
87
|
+
result
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
lifecycle&.record_failure(e)
|
|
90
|
+
state&.commit!
|
|
91
|
+
raise
|
|
92
|
+
ensure
|
|
93
|
+
state&.close
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def instrumented_call(method, request, metadata, timeout, cancellation)
|
|
97
|
+
# @type var injection: Hash[String, String | Array[String]]
|
|
98
|
+
injection = {}
|
|
99
|
+
instrumentation = @instrumentation #: Instrumentation
|
|
100
|
+
lifecycle = instrumentation.start(owner: self, role: :client, method: method.is_a?(Method) ? method : nil,
|
|
101
|
+
carrier: injection, server_address: @base_url.hostname.freeze,
|
|
102
|
+
server_port: @base_url.port)
|
|
103
|
+
lifecycle.begin_processing(self)
|
|
104
|
+
# @type var response: TransportResponse?
|
|
105
|
+
response = nil
|
|
106
|
+
# @type var error: Exception?
|
|
107
|
+
error = nil
|
|
108
|
+
completed = false
|
|
109
|
+
begin
|
|
110
|
+
result = lifecycle.activate do
|
|
111
|
+
process_call(method, request, metadata, timeout, cancellation, lifecycle: lifecycle,
|
|
112
|
+
injection: injection) do |received|
|
|
113
|
+
response = received
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
completed = true
|
|
117
|
+
result
|
|
118
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- observe the actual escape and preserve it
|
|
119
|
+
error = e
|
|
120
|
+
raise
|
|
121
|
+
ensure
|
|
122
|
+
lifecycle.processing_finished(code: error.is_a?(Error) ? error.code : ("OK" if completed),
|
|
123
|
+
error: error, normalized_error: error, http_status: response&.status,
|
|
124
|
+
response_bytes: response&.body&.bytesize,
|
|
125
|
+
response_metadata: response ? Instrumentation.carrier(response.headers) : {})
|
|
126
|
+
lifecycle.finish(delivery: :unobserved)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
131
|
+
|
|
132
|
+
def injected_metadata(metadata, injection)
|
|
133
|
+
instrumentation = @instrumentation #: Instrumentation
|
|
134
|
+
carrier = injection #: metadata_input
|
|
135
|
+
instrumentation.safely do
|
|
136
|
+
validated = Metadata.new(carrier)
|
|
137
|
+
return Metadata.new(metadata.to_h.merge(validated.to_h))
|
|
138
|
+
end
|
|
139
|
+
metadata
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_base_url(base_url)
|
|
143
|
+
uri = URI(base_url)
|
|
144
|
+
unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
|
|
145
|
+
raise ArgumentError, "Base URL must be an HTTP(S) URL without credentials, query or fragment"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
uri
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def default_transport
|
|
152
|
+
require_relative "transports/net_http"
|
|
153
|
+
Transports::NetHTTP.new
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def encode_request(method, request, deadline, lifecycle = nil)
|
|
157
|
+
deadline&.check!
|
|
158
|
+
body = @binding.encode(request, type: method.request_type, codec: @codec)
|
|
159
|
+
deadline&.check!
|
|
160
|
+
Protocol.check_size!(body, @request_limit)
|
|
161
|
+
body
|
|
162
|
+
rescue CodecError => e
|
|
163
|
+
lifecycle&.record_failure(e)
|
|
164
|
+
raise Error.new(:invalid_argument, "Cannot encode request")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_request(method, request, metadata, deadline, lifecycle = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
168
|
+
body = encode_request(method, request, deadline, lifecycle)
|
|
169
|
+
body = compress_request(body, deadline, lifecycle) if @encoding == :gzip
|
|
170
|
+
Protocol.check_size!(body, @request_wire_limit)
|
|
171
|
+
url = @base_url.dup
|
|
172
|
+
base_path = @base_url.path #: String
|
|
173
|
+
url.path = base_path.delete_suffix("/") + method.path
|
|
174
|
+
get = @use_get && method.idempotency == :no_side_effects
|
|
175
|
+
lifecycle.request_bytes = body.bytesize if lifecycle && get
|
|
176
|
+
GetQuery.build(url, body, codec: @codec, encoding: @encoding, limit: @get_url_limit, deadline: deadline) if get
|
|
177
|
+
TransportRequest.new(url: url, http_method: get ? "GET" : "POST",
|
|
178
|
+
headers: request_headers(metadata, nil, get: get), body: get ? "" : body,
|
|
179
|
+
response_limit: @response_wire_limit, deadline: nil)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def request_headers(metadata, deadline, get: false)
|
|
183
|
+
headers = metadata.to_h.merge("content-type" => [Protocol::CONTENT_TYPES.fetch(@codec)],
|
|
184
|
+
"accept-encoding" => [@accept_encoding.join(", ")],
|
|
185
|
+
"connect-protocol-version" => ["1"])
|
|
186
|
+
headers["content-encoding"] = ["gzip"] if @encoding == :gzip
|
|
187
|
+
%w[content-type content-encoding connect-protocol-version].each { |key| headers.delete(key) } if get
|
|
188
|
+
headers["connect-timeout-ms"] = [deadline.timeout_ms] if deadline
|
|
189
|
+
headers
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def compress_request(body, deadline, lifecycle = nil)
|
|
193
|
+
Compression.encode(body, limit: @request_wire_limit, deadline: deadline,
|
|
194
|
+
on_error: ->(error) { lifecycle&.record_failure(error) })
|
|
195
|
+
rescue Zlib::Error => e
|
|
196
|
+
lifecycle&.record_failure(e)
|
|
197
|
+
raise Error.new(:internal, "Cannot compress request")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def response_body(response, headers, deadline, lifecycle = nil)
|
|
201
|
+
encoding = response_encoding!(headers)
|
|
202
|
+
|
|
203
|
+
body = response.body
|
|
204
|
+
body = inflate_response(body, response.status, deadline, lifecycle) if encoding == :gzip && !body.empty?
|
|
205
|
+
Protocol.check_size!(body, @response_limit)
|
|
206
|
+
deadline&.check!
|
|
207
|
+
body
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def inflate_response(body, status, deadline, lifecycle = nil)
|
|
211
|
+
Compression.decode(body, limit: @response_limit, deadline: deadline,
|
|
212
|
+
on_error: ->(error) { lifecycle&.record_failure(error) })
|
|
213
|
+
rescue Compression::Malformed => e
|
|
214
|
+
lifecycle&.record_failure(e)
|
|
215
|
+
raise Error.new(:internal, "Cannot decompress response") if status == 200
|
|
216
|
+
|
|
217
|
+
# Malformed error Bare-Messages use HTTP fallback, preserving metadata.
|
|
218
|
+
""
|
|
219
|
+
rescue Zlib::Error => e
|
|
220
|
+
lifecycle&.record_failure(e)
|
|
221
|
+
raise Error.new(:internal, "Cannot decompress response")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def response_encoding!(headers)
|
|
225
|
+
encoding = Protocol.content_encoding(headers)
|
|
226
|
+
return encoding if encoding && @accept_encoding.include?(encoding)
|
|
227
|
+
|
|
228
|
+
raise Error.new(:internal, "Unsupported response content encoding")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def validate_response!(status, headers, body)
|
|
232
|
+
raise Protocol.parse_error(status, headers, body) unless status == 200
|
|
233
|
+
|
|
234
|
+
content_type = Protocol.single(headers, "content-type")
|
|
235
|
+
return if Protocol.content_codec(content_type) == @codec
|
|
236
|
+
|
|
237
|
+
code = content_type&.downcase&.start_with?("application/") ? :internal : :unknown
|
|
238
|
+
raise Error.new(code, "Unexpected response content type")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def read_response(response, method, deadline, lifecycle = nil)
|
|
242
|
+
headers = Protocol.headers(response.headers)
|
|
243
|
+
body = response_body(response, headers, deadline, lifecycle)
|
|
244
|
+
validate_response!(response.status, headers, body)
|
|
245
|
+
message = decode_response(body, method, lifecycle)
|
|
246
|
+
Response.new(message: message, metadata: Protocol.metadata(headers),
|
|
247
|
+
trailers: Protocol.metadata(headers, trailers: true))
|
|
248
|
+
rescue ArgumentError => e
|
|
249
|
+
lifecycle&.record_failure(e)
|
|
250
|
+
raise Error.new(:internal, "Malformed response metadata")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def decode_response(bytes, method, lifecycle = nil)
|
|
254
|
+
@binding.decode(bytes, type: method.response_type, codec: @codec)
|
|
255
|
+
rescue CodecError => e
|
|
256
|
+
lifecycle&.record_failure(e)
|
|
257
|
+
raise Error.new(:internal, "Cannot decode response")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module ConnectRPC
|
|
6
|
+
# Retains bounded output from native gzip streams. Each native output callback
|
|
7
|
+
# yields at most 16 KiB on the supported default Zlib versions.
|
|
8
|
+
class Compression
|
|
9
|
+
class Malformed < StandardError; end
|
|
10
|
+
|
|
11
|
+
CHUNK_SIZE = 16_384
|
|
12
|
+
|
|
13
|
+
def self.encode(bytes, limit:, deadline: nil, on_error: nil)
|
|
14
|
+
new(limit, deadline, on_error: on_error).encode(bytes)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.decode(bytes, limit:, deadline: nil, on_error: nil)
|
|
18
|
+
new(limit, deadline, on_error: on_error).decode(bytes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(limit, deadline, on_error: nil)
|
|
22
|
+
@limit = limit
|
|
23
|
+
@deadline = deadline
|
|
24
|
+
@on_error = on_error
|
|
25
|
+
@output = +"".b
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def encode(bytes)
|
|
29
|
+
stream = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, Zlib::MAX_WBITS + 16)
|
|
30
|
+
chunks(bytes) { |chunk| step { stream.deflate(chunk) { |output| append(output) } } }
|
|
31
|
+
step { stream.finish { |output| append(output) } }
|
|
32
|
+
@output
|
|
33
|
+
ensure
|
|
34
|
+
# @type var stream: Zlib::Deflate?
|
|
35
|
+
stream&.close
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def decode(bytes)
|
|
39
|
+
cursor = 0
|
|
40
|
+
while cursor < bytes.bytesize
|
|
41
|
+
@deadline&.check!
|
|
42
|
+
cursor = member(bytes, cursor)
|
|
43
|
+
end
|
|
44
|
+
@deadline&.check!
|
|
45
|
+
@output
|
|
46
|
+
rescue Zlib::DataError, Zlib::BufError
|
|
47
|
+
raise Malformed, "Malformed gzip content"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def chunks(bytes)
|
|
53
|
+
cursor = 0
|
|
54
|
+
while cursor < bytes.bytesize
|
|
55
|
+
chunk = bytes.byteslice(cursor, CHUNK_SIZE) #: String
|
|
56
|
+
yield chunk
|
|
57
|
+
cursor += chunk.bytesize
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def member(bytes, cursor)
|
|
62
|
+
stream = Zlib::Inflate.new(Zlib::MAX_WBITS + 16)
|
|
63
|
+
until stream.finished?
|
|
64
|
+
raise Malformed, "Malformed gzip content" if cursor == bytes.bytesize
|
|
65
|
+
|
|
66
|
+
chunk = bytes.byteslice(cursor, CHUNK_SIZE) #: String
|
|
67
|
+
cursor += inflate_chunk(stream, chunk)
|
|
68
|
+
end
|
|
69
|
+
# finish may return raw trailing input. Only inflate's validated block
|
|
70
|
+
# output belongs in the payload; the cursor starts the next gzip member.
|
|
71
|
+
cursor
|
|
72
|
+
ensure
|
|
73
|
+
# @type var stream: Zlib::Inflate?
|
|
74
|
+
stream&.close
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def inflate_chunk(stream, chunk)
|
|
78
|
+
before = stream.total_in
|
|
79
|
+
step { stream.inflate(chunk) { |output| append(output) } }
|
|
80
|
+
consumed = stream.total_in - before
|
|
81
|
+
raise Malformed, "Malformed gzip content" unless consumed.positive?
|
|
82
|
+
|
|
83
|
+
consumed
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def step
|
|
87
|
+
@deadline&.check!
|
|
88
|
+
result = begin
|
|
89
|
+
yield
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
check_after_failure(e)
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
@deadline&.check!
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def check_after_failure(error)
|
|
99
|
+
@deadline&.check!
|
|
100
|
+
rescue StandardError
|
|
101
|
+
# Ordinary gzip normalization belongs to the owner unless this check wins.
|
|
102
|
+
record_failure(error)
|
|
103
|
+
raise
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def record_failure(error)
|
|
107
|
+
@on_error&.call(error)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def append(chunk)
|
|
113
|
+
@deadline&.check!
|
|
114
|
+
raise Error.new(:resource_exhausted, "Message exceeds byte limit") if @output.bytesize + chunk.bytesize > @limit
|
|
115
|
+
|
|
116
|
+
@output << chunk
|
|
117
|
+
@deadline&.check!
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Deadline
|
|
5
|
+
# Bound native time conversions without shortening the logical deadline.
|
|
6
|
+
MAX_NATIVE_WAIT = 9_999_999.999
|
|
7
|
+
|
|
8
|
+
def initialize(timeout, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
9
|
+
unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout.positive?
|
|
10
|
+
raise ArgumentError, "Timeout must be finite positive seconds"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@clock = clock
|
|
14
|
+
@expires_at = @clock.call + timeout
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.expired
|
|
18
|
+
deadline = allocate
|
|
19
|
+
deadline.instance_variable_set(:@clock, -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
20
|
+
deadline.instance_variable_set(:@expires_at, Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
21
|
+
deadline
|
|
22
|
+
end
|
|
23
|
+
private_class_method :expired
|
|
24
|
+
|
|
25
|
+
def remaining
|
|
26
|
+
[@expires_at - @clock.call, 0.0].max
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def expired?
|
|
30
|
+
remaining.zero?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def native_timeout
|
|
34
|
+
[remaining, MAX_NATIVE_WAIT].min
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check!
|
|
38
|
+
raise Error.new(:deadline_exceeded, "Deadline exceeded"), cause: nil if expired?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def timeout_ms
|
|
42
|
+
check!
|
|
43
|
+
[remaining * 1000, 9_999_999_999].min.ceil.clamp(1, 9_999_999_999).to_s
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Context
|
|
48
|
+
attr_reader :request_metadata, :response_metadata, :response_trailers, :deadline
|
|
49
|
+
|
|
50
|
+
def initialize(request_metadata: {}, deadline: nil, lifecycle: nil, cancellation: nil)
|
|
51
|
+
@cancellation = CallCancellation.new(deadline: deadline, cancellation: cancellation)
|
|
52
|
+
@request_metadata = Metadata.new(request_metadata)
|
|
53
|
+
@response_metadata = Metadata.new
|
|
54
|
+
@response_trailers = Metadata.new
|
|
55
|
+
@deadline = deadline
|
|
56
|
+
@lifecycle = lifecycle
|
|
57
|
+
rescue StandardError
|
|
58
|
+
@cancellation&.close
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Framework invokers record escaping application failures before normalizing them.
|
|
63
|
+
def record_failure(error)
|
|
64
|
+
@lifecycle&.record_failure(error)
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cancelled?
|
|
69
|
+
@cancellation.cancelled?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check!
|
|
73
|
+
@cancellation.check!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cancellation_reason = @cancellation.reason
|
|
77
|
+
|
|
78
|
+
def commit! = @cancellation.commit!
|
|
79
|
+
def close = @cancellation.close
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class CodecError < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ErrorDetail
|
|
7
|
+
attr_reader :type, :value
|
|
8
|
+
|
|
9
|
+
def initialize(type:, value:)
|
|
10
|
+
raise ArgumentError, "Invalid detail type" unless type.is_a?(String) && Method::QUALIFIED_NAME.match?(type)
|
|
11
|
+
raise ArgumentError, "Detail value must be binary bytes" unless value.is_a?(String)
|
|
12
|
+
|
|
13
|
+
@type = type.dup.freeze
|
|
14
|
+
@value = value.b.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def decode(binding:, type:)
|
|
19
|
+
binding.decode(value, type: type, codec: :proto)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
{ "type" => type, "value" => Metadata.encode_binary(value) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Error < StandardError
|
|
28
|
+
HTTP_STATUS = {
|
|
29
|
+
"canceled" => 499, "unknown" => 500, "invalid_argument" => 400, "deadline_exceeded" => 504,
|
|
30
|
+
"not_found" => 404, "already_exists" => 409, "permission_denied" => 403, "resource_exhausted" => 429,
|
|
31
|
+
"failed_precondition" => 400, "aborted" => 409, "out_of_range" => 400, "unimplemented" => 501,
|
|
32
|
+
"internal" => 500, "unavailable" => 503, "data_loss" => 500, "unauthenticated" => 401
|
|
33
|
+
}.freeze
|
|
34
|
+
HTTP_FALLBACK = {
|
|
35
|
+
400 => "internal", 401 => "unauthenticated", 403 => "permission_denied", 404 => "unimplemented",
|
|
36
|
+
429 => "unavailable", 502 => "unavailable", 503 => "unavailable", 504 => "unavailable"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :code, :details, :metadata, :trailers
|
|
40
|
+
|
|
41
|
+
def initialize(code, message = "", details: [], metadata: {}, trailers: {})
|
|
42
|
+
code = code.to_s
|
|
43
|
+
raise ArgumentError, "Unknown Connect error code" unless HTTP_STATUS.key?(code)
|
|
44
|
+
unless details.is_a?(Array) && details.all?(ErrorDetail)
|
|
45
|
+
raise ArgumentError, "Details must be ErrorDetail objects"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
super(safe_message(message))
|
|
49
|
+
@code = code.dup.freeze
|
|
50
|
+
@details = details.dup.freeze
|
|
51
|
+
@metadata = Metadata.new(metadata)
|
|
52
|
+
@trailers = Metadata.new(trailers)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def safe_message(message)
|
|
56
|
+
raise ArgumentError, "Error message must be UTF-8 text" unless message.is_a?(String) && message.valid_encoding?
|
|
57
|
+
|
|
58
|
+
message.encode(Encoding::UTF_8)
|
|
59
|
+
rescue EncodingError
|
|
60
|
+
raise ArgumentError, "Error message must be UTF-8 text"
|
|
61
|
+
end
|
|
62
|
+
private :safe_message
|
|
63
|
+
|
|
64
|
+
def http_status
|
|
65
|
+
HTTP_STATUS.fetch(code)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_h
|
|
69
|
+
{ "code" => code, "message" => message, "details" => details.map(&:to_h) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Generator
|
|
5
|
+
module Names
|
|
6
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z_0-9]*\z/
|
|
7
|
+
CONSTANT = /\A[A-Z][A-Za-z_0-9]*\z/
|
|
8
|
+
KEYWORDS = %w[
|
|
9
|
+
BEGIN END alias and begin break case class def defined do else elsif end ensure false for if in module
|
|
10
|
+
next nil not or redo rescue retry return self super then true undef unless until when while yield
|
|
11
|
+
__FILE__ __LINE__ __ENCODING__
|
|
12
|
+
].freeze
|
|
13
|
+
CLIENT_RESERVED = %w[
|
|
14
|
+
initialize handlers raise class clone define_singleton_method display dup enum_for
|
|
15
|
+
extend freeze
|
|
16
|
+
initialize_copy initialize_dup initialize_clone method_missing respond_to_missing
|
|
17
|
+
singleton_method_added singleton_method_removed singleton_method_undefined
|
|
18
|
+
to_str to_ary to_hash to_proc to_io to_path to_int to_sym to_regexp
|
|
19
|
+
to_yaml to_json pretty_print_inspect pretty_print_cycle pretty_print
|
|
20
|
+
pretty_print_instance_variables pretty_inspect
|
|
21
|
+
hash inspect instance_eval instance_exec instance_variable_get
|
|
22
|
+
instance_variable_set instance_variables itself method methods nil object_id private_methods
|
|
23
|
+
protected_methods public_method public_methods public_send remove_instance_variable send
|
|
24
|
+
singleton_class singleton_method singleton_methods tap then to_enum to_s yield_self __id__ __send__
|
|
25
|
+
].freeze
|
|
26
|
+
TOP_LEVEL_RESERVED = %w[
|
|
27
|
+
ARGF ARGV ArgumentError Array BasicObject Binding CROSS_COMPILING Class ClosedQueueError Comparable Complex
|
|
28
|
+
ConditionVariable ConnectRPC Data DidYouMean Dir ENV EOFError Encoding EncodingError Enumerable Enumerator
|
|
29
|
+
Errno ErrorHighlight Exception FalseClass Fiber FiberError File FileTest Float FloatDomainError FrozenError
|
|
30
|
+
GC Gem Google Hash IO IOError IndexError Integer Interrupt JSON Kernel KeyError LoadError LocalJumpError
|
|
31
|
+
Marshal MatchData Math Method Module Monitor MonitorMixin Mutex NameError NilClass NoMatchingPatternError
|
|
32
|
+
NoMatchingPatternKeyError NoMemoryError NoMethodError NotImplementedError Numeric Object ObjectSpace Pathname
|
|
33
|
+
Proc Process Queue RUBYGEMS_ACTIVATION_MONITOR RUBY_COPYRIGHT RUBY_DESCRIPTION RUBY_ENGINE RUBY_ENGINE_VERSION
|
|
34
|
+
RUBY_PATCHLEVEL RUBY_PLATFORM RUBY_RELEASE_DATE RUBY_REVISION RUBY_VERSION Rack Ractor Random Range RangeError
|
|
35
|
+
Rational RbConfig Refinement Regexp RegexpError Ruby RubyVM RuntimeError STDERR STDIN STDOUT ScriptError
|
|
36
|
+
SecurityError Set Signal SignalException SizedQueue StandardError StopIteration String Struct Symbol SyntaxError
|
|
37
|
+
SyntaxSuggest SystemCallError SystemExit SystemStackError TOPLEVEL_BINDING Thread ThreadError ThreadGroup Time
|
|
38
|
+
TracePoint TrueClass TypeError UnboundMethod UncaughtThrowError UnicodeNormalize Warning ZeroDivisionError
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
def self.identifier!(name, description)
|
|
42
|
+
return if IDENTIFIER.match?(name)
|
|
43
|
+
|
|
44
|
+
raise InvalidDescriptor, "Invalid protobuf name #{name.inspect} in #{description}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.constant!(name, description)
|
|
48
|
+
return if CONSTANT.match?(name) && !%w[BEGIN END].include?(name)
|
|
49
|
+
|
|
50
|
+
raise InvalidDescriptor, "Invalid Ruby constant #{name.inspect} for #{description}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Match protoc v36.2 RubifyConstant and PackageToModule, rather than
|
|
54
|
+
# camelizing message/service names (which preserves their underscores).
|
|
55
|
+
def self.rubify(name)
|
|
56
|
+
name.sub(/\A[a-z]/, &:upcase).sub(/\A(?=[^A-Za-z])/, "PB_")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.namespace(file)
|
|
60
|
+
option = file.options
|
|
61
|
+
package = option&.has_ruby_package? ? option.ruby_package : file.package
|
|
62
|
+
return [] if package.empty?
|
|
63
|
+
|
|
64
|
+
parts = if package.include?("::")
|
|
65
|
+
package.split("::", -1)
|
|
66
|
+
else
|
|
67
|
+
package.split(".", -1).map { |part| package_component(part) }
|
|
68
|
+
end
|
|
69
|
+
parts.each { |part| constant!(part, "namespace of #{file.name}") }
|
|
70
|
+
parts
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.package_component(part)
|
|
74
|
+
part.split("_", -1).map { |word| word.sub(/\A[a-z]/, &:upcase) }.join
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# gRPC Ruby's established underscore algorithm.
|
|
78
|
+
def self.rpc(name)
|
|
79
|
+
name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.rpc!(name, description)
|
|
83
|
+
identifier!(name, description)
|
|
84
|
+
ruby = rpc(name)
|
|
85
|
+
if name.start_with?("_") || KEYWORDS.include?(ruby) || CLIENT_RESERVED.include?(ruby) ||
|
|
86
|
+
%w[BEGIN END SERVICE CLIENT].include?(ruby.upcase)
|
|
87
|
+
raise InvalidDescriptor, "Reserved or unsupported Ruby RPC name #{name.inspect} (#{ruby}) in #{description}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
constant!(ruby.upcase, description)
|
|
91
|
+
ruby
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.path!(name)
|
|
95
|
+
parts = name.split("/", -1)
|
|
96
|
+
return if %r{\A[A-Za-z0-9_./-]+\.(?:proto|protodevel)\z}.match?(name) &&
|
|
97
|
+
parts.none? { |part| part.empty? || %w[. ..].include?(part) }
|
|
98
|
+
|
|
99
|
+
raise InvalidDescriptor, "Unsafe relative proto path #{name.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.stem(name) = name.sub(/\.(?:proto|protodevel)\z/, "")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|