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,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
# Private, host-neutral form query processing. Unknown fields are validated
|
|
5
|
+
# without retaining their values; only the five protocol controls survive.
|
|
6
|
+
# Keep byte validation, preflight bounds and cooperative checks adjacent.
|
|
7
|
+
# rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
8
|
+
class GetQuery # rubocop:disable Metrics/ClassLength
|
|
9
|
+
KEYS = %w[connect base64 compression encoding message].freeze
|
|
10
|
+
|
|
11
|
+
def self.parse(query, limit:, wire_limit:, deadline: nil)
|
|
12
|
+
Protocol.check_size!(query, limit)
|
|
13
|
+
new(wire_limit, deadline).parse(query.b)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(wire_limit, deadline)
|
|
17
|
+
@wire_limit = wire_limit
|
|
18
|
+
@deadline = deadline
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse(query)
|
|
22
|
+
values = {} #: Hash[String, String]
|
|
23
|
+
offset = 0
|
|
24
|
+
while offset < query.bytesize
|
|
25
|
+
@deadline&.check!
|
|
26
|
+
ending, separator = field(query, offset)
|
|
27
|
+
name = component(query, offset, separator, 12)
|
|
28
|
+
known = KEYS.include?(name)
|
|
29
|
+
raise Error.new(:invalid_argument, "Repeated GET parameter") if known && values.key?(name)
|
|
30
|
+
|
|
31
|
+
start = [separator + 1, ending].min
|
|
32
|
+
# Controls need only enough bytes to distinguish every recognized value.
|
|
33
|
+
cap = name == "message" ? 4 * ((@wire_limit + 2) / 3) : 16
|
|
34
|
+
value = component(query, start, ending, known ? cap : 0, payload: name == "message")
|
|
35
|
+
values[name] = value if known
|
|
36
|
+
offset = ending + 1
|
|
37
|
+
end
|
|
38
|
+
@deadline&.check!
|
|
39
|
+
values
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.payload(values, limit:, deadline: nil)
|
|
43
|
+
message = values.fetch("message")
|
|
44
|
+
bytes = if values["base64"] == "1"
|
|
45
|
+
decode_base64(message, limit)
|
|
46
|
+
else
|
|
47
|
+
text = message.dup.force_encoding(Encoding::UTF_8)
|
|
48
|
+
raise Error.new(:invalid_argument, "Invalid GET message UTF-8") unless text.valid_encoding?
|
|
49
|
+
|
|
50
|
+
text
|
|
51
|
+
end
|
|
52
|
+
Protocol.check_size!(bytes, limit)
|
|
53
|
+
deadline&.check!
|
|
54
|
+
bytes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.decode_base64(message, limit)
|
|
58
|
+
raise Error.new(:invalid_argument, "Invalid GET base64") unless /\A[A-Za-z0-9_-]*={0,2}\z/.match?(message)
|
|
59
|
+
|
|
60
|
+
unpadded = message.delete_suffix("==").delete_suffix("=")
|
|
61
|
+
length = unpadded.bytesize
|
|
62
|
+
if length % 4 == 1 || (message.include?("=") && message.bytesize % 4 != 0)
|
|
63
|
+
raise Error.new(:invalid_argument, "Invalid GET base64")
|
|
64
|
+
end
|
|
65
|
+
raise Error.new(:resource_exhausted, "Message exceeds byte limit") if length * 3 / 4 > limit
|
|
66
|
+
|
|
67
|
+
padded = message + ("=" * ((4 - (message.bytesize % 4)) % 4))
|
|
68
|
+
bytes = padded.tr("-_", "+/").unpack1("m0") #: String
|
|
69
|
+
unless [bytes].pack("m0").tr("+/", "-_").delete("=") == unpadded
|
|
70
|
+
raise Error.new(:invalid_argument, "Invalid GET base64")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
bytes
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
raise Error.new(:invalid_argument, "Invalid GET base64")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.build(url, bytes, codec:, encoding:, limit:, deadline: nil)
|
|
79
|
+
base64 = codec == :proto || encoding == :gzip
|
|
80
|
+
prefix = +"connect=v1&"
|
|
81
|
+
prefix << "base64=1&" if base64
|
|
82
|
+
prefix << "compression=gzip&" if encoding == :gzip
|
|
83
|
+
prefix << "encoding=#{codec}&message="
|
|
84
|
+
size = base64 ? ((bytes.bytesize * 4) + 2) / 3 : form_size(bytes, deadline)
|
|
85
|
+
if url.to_s.bytesize + 1 + prefix.bytesize + size > limit
|
|
86
|
+
raise Error.new(:resource_exhausted, "GET URL exceeds byte limit")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
deadline&.check!
|
|
90
|
+
message = base64 ? [bytes].pack("m0").tr("+/", "-_").delete("=") : URI.encode_www_form_component(bytes)
|
|
91
|
+
url.query = prefix + message
|
|
92
|
+
deadline&.check!
|
|
93
|
+
url
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.form_size(bytes, deadline)
|
|
97
|
+
size = 0
|
|
98
|
+
bytes.each_byte.with_index do |byte, index|
|
|
99
|
+
deadline&.check! if (index % 1024).zero?
|
|
100
|
+
size += byte == 32 || form_safe?(byte) ? 1 : 3
|
|
101
|
+
end
|
|
102
|
+
size
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.form_safe?(byte)
|
|
106
|
+
byte.between?(65, 90) || byte.between?(97, 122) || byte.between?(48, 57) || [42, 45, 46, 95].include?(byte)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def field(query, offset)
|
|
112
|
+
ending = offset
|
|
113
|
+
separator = nil #: Integer?
|
|
114
|
+
while ending < query.bytesize && query.getbyte(ending) != 38
|
|
115
|
+
@deadline&.check! if ((ending - offset) % 1024).zero?
|
|
116
|
+
separator ||= ending if query.getbyte(ending) == 61
|
|
117
|
+
ending += 1
|
|
118
|
+
end
|
|
119
|
+
[ending, separator || ending]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def component(query, start, ending, cap, payload: false)
|
|
123
|
+
bytes = +"".b
|
|
124
|
+
index = start
|
|
125
|
+
while index < ending
|
|
126
|
+
@deadline&.check! if ((index - start) % 1024).zero?
|
|
127
|
+
byte = query.getbyte(index) #: Integer
|
|
128
|
+
if byte == 37
|
|
129
|
+
raise Error.new(:invalid_argument, "Malformed GET percent encoding") if index + 2 >= ending
|
|
130
|
+
|
|
131
|
+
high = hex(query.getbyte(index + 1))
|
|
132
|
+
low = hex(query.getbyte(index + 2))
|
|
133
|
+
byte = (high * 16) + low
|
|
134
|
+
index += 2
|
|
135
|
+
elsif byte == 43
|
|
136
|
+
byte = 32
|
|
137
|
+
end
|
|
138
|
+
if bytes.bytesize < cap
|
|
139
|
+
bytes << byte
|
|
140
|
+
elsif payload
|
|
141
|
+
raise Error.new(:resource_exhausted, "Message exceeds byte limit")
|
|
142
|
+
end
|
|
143
|
+
index += 1
|
|
144
|
+
end
|
|
145
|
+
bytes
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def hex(byte)
|
|
149
|
+
raise Error.new(:invalid_argument, "Malformed GET percent encoding") unless byte
|
|
150
|
+
|
|
151
|
+
return byte - 48 if byte.between?(48, 57)
|
|
152
|
+
return byte - 55 if byte.between?(65, 70)
|
|
153
|
+
return byte - 87 if byte.between?(97, 102)
|
|
154
|
+
|
|
155
|
+
raise Error.new(:invalid_argument, "Malformed GET percent encoding")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
private_constant :GetQuery
|
|
159
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
# Separate from StreamHead: HTTP/2 END_STREAM is a protocol fact, not body state.
|
|
5
|
+
class GRPCHead
|
|
6
|
+
attr_reader :status, :headers, :end_stream
|
|
7
|
+
|
|
8
|
+
def initialize(status:, headers:, end_stream:)
|
|
9
|
+
@status = status
|
|
10
|
+
@headers = GRPCProtocol.snapshot(headers)
|
|
11
|
+
@end_stream = end_stream
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module GRPCProtocol # rubocop:disable Metrics/ModuleLength
|
|
17
|
+
CONTENT_TYPE = "application/grpc+proto"
|
|
18
|
+
FIELD_LIMIT = 65_536
|
|
19
|
+
UINT32_MAX = 4_294_967_295
|
|
20
|
+
CODES = ["OK", *Error::HTTP_STATUS.keys].freeze
|
|
21
|
+
TIME_UNITS = { "n" => 1e-9, "u" => 1e-6, "m" => 0.001, "S" => 1, "M" => 60, "H" => 3600 }.freeze
|
|
22
|
+
|
|
23
|
+
def self.media?(value)
|
|
24
|
+
!!value&.match?(%r{\Aapplication/grpc(?:\+proto)?\z}i)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.intent?(headers)
|
|
28
|
+
(headers["content-type"] || []).any? { |value| value.downcase.start_with?("application/grpc") }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.snapshot(headers)
|
|
32
|
+
headers.to_h { |key, values| [key.dup.freeze, values.map { |value| value.dup.freeze }.freeze] }.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.field_size(headers)
|
|
36
|
+
headers.sum { |key, values| values.sum { |value| key.bytesize + value.bytesize + 32 } }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.check_fields!(headers, limit = FIELD_LIMIT)
|
|
40
|
+
return unless field_size(headers) > limit
|
|
41
|
+
|
|
42
|
+
raise Error.new(:resource_exhausted, "gRPC field block exceeds byte limit"),
|
|
43
|
+
cause: nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Parse incrementally: the budget bounds spelling length, uint32 bounds value.
|
|
47
|
+
def self.status_number(value)
|
|
48
|
+
return if value.empty?
|
|
49
|
+
|
|
50
|
+
number = 0
|
|
51
|
+
value.each_byte do |byte|
|
|
52
|
+
return nil unless byte.between?(48, 57)
|
|
53
|
+
|
|
54
|
+
digit = byte - 48
|
|
55
|
+
return nil if number > (UINT32_MAX - digit) / 10
|
|
56
|
+
|
|
57
|
+
number = (number * 10) + digit
|
|
58
|
+
end
|
|
59
|
+
number
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.status_value(headers)
|
|
63
|
+
values = headers["grpc-status"]
|
|
64
|
+
return unless values
|
|
65
|
+
raise Error.new(:internal, "Duplicate grpc-status"), cause: nil unless values.size == 1
|
|
66
|
+
|
|
67
|
+
status_number(values.first)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.encode_message(message)
|
|
71
|
+
message.b.each_byte.map { |byte| byte.between?(32, 126) && byte != 37 ? byte.chr : format("%%%02X", byte) }.join
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.decode_message(value)
|
|
75
|
+
value.b.gsub(/%([0-9a-fA-F]{2})/n) { [(Regexp.last_match(1) || "").to_i(16)].pack("C") }.force_encoding(Encoding::UTF_8).scrub
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.parse_status(headers, codec:, http_status: 200, metadata: Metadata.new, # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
79
|
+
wire_limit: FIELD_LIMIT, limit: FIELD_LIMIT, on_error: nil)
|
|
80
|
+
check_fields!(headers, wire_limit)
|
|
81
|
+
trailers = Protocol.metadata(headers).freeze
|
|
82
|
+
number = status_value(headers)
|
|
83
|
+
unless headers.key?("grpc-status")
|
|
84
|
+
failure = status_error(Error::HTTP_FALLBACK.fetch(http_status, "unknown"), "Missing grpc-status", [], metadata,
|
|
85
|
+
trailers)
|
|
86
|
+
return [failure, trailers]
|
|
87
|
+
end
|
|
88
|
+
message_fields = headers["grpc-message"] || []
|
|
89
|
+
raise Error.new(:internal, "Duplicate grpc-message"), cause: nil if message_fields.size > 1
|
|
90
|
+
|
|
91
|
+
details = headers["grpc-status-details-bin"]
|
|
92
|
+
rich = decode_status(details, number, codec, limit, on_error) if details
|
|
93
|
+
return [nil, trailers] if number&.zero?
|
|
94
|
+
|
|
95
|
+
code = (number && CODES[number]) || "unknown"
|
|
96
|
+
message = rich ? rich.message : decode_message(message_fields.first || "")
|
|
97
|
+
[status_error(code, message, rich ? rich.details : [], metadata, trailers), trailers]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.status_error(code, message, details, metadata, trailers)
|
|
101
|
+
error = Error.new(code, message, details: details, metadata: metadata, trailers: trailers)
|
|
102
|
+
error.metadata.freeze
|
|
103
|
+
error.trailers.freeze
|
|
104
|
+
error
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.decode_status(values, number, codec, limit, on_error)
|
|
108
|
+
unless number && number != 0 && values.size == 1 && codec
|
|
109
|
+
raise Error.new(:internal, "Invalid gRPC status details"), cause: nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
bytes = Metadata.decode_binary(values.first)
|
|
113
|
+
Protocol.check_size!(bytes, limit)
|
|
114
|
+
codec.decode_status(bytes, code: number)
|
|
115
|
+
rescue CodecError, ArgumentError => e
|
|
116
|
+
on_error&.call(e)
|
|
117
|
+
raise Error.new(:internal, "Invalid gRPC status details"), cause: nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.status_fields(error, trailers, codec, limit) # rubocop:disable Metrics/AbcSize
|
|
121
|
+
fields = trailers.to_h.merge("grpc-status" => [(error ? CODES.index(error.code) : 0).to_s])
|
|
122
|
+
fields["grpc-message"] = [encode_message(error.message)] if error && !error.message.empty?
|
|
123
|
+
if error && !error.details.empty?
|
|
124
|
+
bytes = codec.encode_status(error)
|
|
125
|
+
Protocol.check_size!(bytes, limit)
|
|
126
|
+
fields["grpc-status-details-bin"] = [Metadata.encode_binary(bytes)]
|
|
127
|
+
end
|
|
128
|
+
fields
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.terminal(error, trailers, codec:, wire_limit:, limit:, on_error: nil) # rubocop:disable Metrics/MethodLength
|
|
132
|
+
fields = status_fields(error, trailers, codec, limit)
|
|
133
|
+
check_fields!(fields, [wire_limit, FIELD_LIMIT].min)
|
|
134
|
+
[snapshot(fields), error]
|
|
135
|
+
rescue CodecError => e
|
|
136
|
+
on_error&.call(e)
|
|
137
|
+
terminal(Error.new(:internal, "Cannot encode gRPC status"), Metadata.new, codec: codec,
|
|
138
|
+
wire_limit: wire_limit, limit: limit)
|
|
139
|
+
rescue Error => e
|
|
140
|
+
raise unless e.code == "resource_exhausted"
|
|
141
|
+
|
|
142
|
+
replacement = Error.new(:resource_exhausted, "Status exceeds byte limit")
|
|
143
|
+
fields = { "grpc-status" => ["8"] }
|
|
144
|
+
check_fields!(fields, [wire_limit, FIELD_LIMIT].min)
|
|
145
|
+
[snapshot(fields), replacement]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def self.request_deadline(headers) # rubocop:disable Metrics/AbcSize
|
|
149
|
+
values = headers["grpc-timeout"]
|
|
150
|
+
return unless values
|
|
151
|
+
unless values.size == 1 && /\A[0-9]{1,8}[HMSmun]\z/.match?(values.first)
|
|
152
|
+
raise Error.new(:internal, "Invalid grpc-timeout"), cause: nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
value = values.first
|
|
156
|
+
seconds = value[0...-1].to_i.to_f * TIME_UNITS.fetch(value[-1] || "").to_f
|
|
157
|
+
seconds.zero? ? Deadline.send(:expired) : Deadline.new(seconds)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def self.timeout(deadline)
|
|
161
|
+
deadline.check!
|
|
162
|
+
seconds = Float(deadline.remaining) #: Float
|
|
163
|
+
TIME_UNITS.each do |unit, scale|
|
|
164
|
+
scaled = seconds / scale
|
|
165
|
+
next unless scaled.finite? && scaled <= 99_999_999
|
|
166
|
+
|
|
167
|
+
amount = scaled.ceil
|
|
168
|
+
return "#{[amount, 1].max}#{unit}" if amount <= 99_999_999
|
|
169
|
+
end
|
|
170
|
+
"99999999H"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.encoding(headers, request: false, enabled: :gzip) # rubocop:disable Metrics/CyclomaticComplexity
|
|
174
|
+
values = headers["grpc-encoding"]
|
|
175
|
+
return :identity unless values
|
|
176
|
+
unless values.size == 1 && /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/.match?(values.first)
|
|
177
|
+
raise Error.new(:internal, "Invalid grpc-encoding"), cause: nil
|
|
178
|
+
end
|
|
179
|
+
return :identity if values.first == "identity"
|
|
180
|
+
return :gzip if values.first == "gzip" && enabled == :gzip
|
|
181
|
+
|
|
182
|
+
raise Error.new(request ? :unimplemented : :internal, "Unsupported grpc-encoding"), cause: nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def self.response_encoding(headers, enabled)
|
|
186
|
+
tokens = (headers["grpc-accept-encoding"] || []).flat_map { |value| value.split(",").map(&:strip) }
|
|
187
|
+
enabled == :gzip && tokens.include?("gzip") ? :gzip : :identity
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.status_codec!(binding, explicit)
|
|
191
|
+
candidate = explicit || binding #: (_Binding | _GRPCStatusCodec) & _GRPCCapabilityProbe
|
|
192
|
+
if candidate.respond_to?(:encode_status) && candidate.respond_to?(:decode_status)
|
|
193
|
+
return explicit || binding #: _GRPCStatusCodec
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "gRPC requires a status codec"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
private_constant :GRPCProtocol
|
|
200
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Client
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def client_stream_strategy(lifecycle = nil)
|
|
8
|
+
return Streaming::ConnectStrategy.new unless @protocol == :grpc
|
|
9
|
+
|
|
10
|
+
GRPCProtocol::Strategy.new(codec: @status_codec, wire_limit: @grpc_status_wire_limit, limit: @grpc_status_limit,
|
|
11
|
+
on_error: ->(error) { lifecycle&.record_failure(error) })
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def grpc_stream_request(url, metadata, deadline, state, lifecycle)
|
|
15
|
+
headers = metadata.to_h.merge("content-type" => [GRPCProtocol::CONTENT_TYPE], "te" => ["trailers"],
|
|
16
|
+
"grpc-accept-encoding" => [@accept_encoding.join(",")])
|
|
17
|
+
headers["grpc-encoding"] = ["gzip"] if @encoding == :gzip
|
|
18
|
+
headers["grpc-timeout"] = [GRPCProtocol.timeout(deadline)] if deadline
|
|
19
|
+
StreamRequest.new(url: url, http_method: "POST", headers: headers, deadline: deadline,
|
|
20
|
+
cancellation: state, lifecycle: lifecycle)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module GRPCProtocol
|
|
5
|
+
class ServerState
|
|
6
|
+
attr_reader :headers, :codec, :publish, :lifecycle, :cancellation
|
|
7
|
+
attr_accessor :context, :session, :encoding, :error, :published
|
|
8
|
+
|
|
9
|
+
def initialize(headers:, codec:, publish:, lifecycle:, cancellation:)
|
|
10
|
+
@headers = headers
|
|
11
|
+
@codec = codec
|
|
12
|
+
@publish = publish
|
|
13
|
+
@lifecycle = lifecycle
|
|
14
|
+
@cancellation = cancellation
|
|
15
|
+
@encoding = :identity
|
|
16
|
+
@published = false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Server # rubocop:disable Metrics/ClassLength
|
|
22
|
+
# Native entry point; portable adapters must never opt into HTTP/2 execution.
|
|
23
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
24
|
+
def call_grpc(path:, http_method:, headers:, input:, output:, publish:, lifecycle: nil, cancellation: nil, # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
25
|
+
http2: false)
|
|
26
|
+
# @type var owned: bool?
|
|
27
|
+
owned = nil
|
|
28
|
+
# @type var error: Exception?
|
|
29
|
+
error = nil
|
|
30
|
+
return bare(415, {}, cancellation: cancellation) unless http2 && @protocol.include?(:grpc)
|
|
31
|
+
return bare(405, { "allow" => ["POST"] }, cancellation: cancellation) unless http_method == "POST"
|
|
32
|
+
|
|
33
|
+
values = Protocol.headers(headers)
|
|
34
|
+
unless GRPCProtocol.media?(Protocol.single(values, "content-type"))
|
|
35
|
+
return bare(415, { "accept-post" => [GRPCProtocol::CONTENT_TYPE] }, cancellation: cancellation)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
lifecycle, owned = begin_native_processing(path, values, lifecycle, http_method)
|
|
39
|
+
work = lambda do
|
|
40
|
+
process_grpc(path, values, input: input, output: output, publish: publish,
|
|
41
|
+
lifecycle: lifecycle, cancellation: cancellation)
|
|
42
|
+
end
|
|
43
|
+
lifecycle ? lifecycle.activate(&work) : work.call
|
|
44
|
+
nil
|
|
45
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- observe the actual escape and preserve it
|
|
46
|
+
error = e
|
|
47
|
+
raise
|
|
48
|
+
ensure
|
|
49
|
+
finish_native_processing(lifecycle, owned, error) unless owned.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def process_grpc(path, headers, input:, output:, publish:, lifecycle:, cancellation:)
|
|
55
|
+
# @type var state: GRPCProtocol::ServerState?
|
|
56
|
+
active = state = GRPCProtocol::ServerState.new(headers: headers, codec: :proto, publish: publish,
|
|
57
|
+
lifecycle: lifecycle, cancellation: cancellation)
|
|
58
|
+
capture_stream_error(active) { execute_grpc_handler(active, path, input, output) }
|
|
59
|
+
render_grpc_terminal(active, output)
|
|
60
|
+
ensure
|
|
61
|
+
state&.session&.close
|
|
62
|
+
state&.context&.close
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def execute_grpc_handler(state, path, input, output) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
66
|
+
GRPCProtocol.check_fields!(state.headers)
|
|
67
|
+
request_encoding = GRPCProtocol.encoding(state.headers, request: true, enabled: @encoding)
|
|
68
|
+
state.encoding = GRPCProtocol.response_encoding(state.headers, @encoding)
|
|
69
|
+
declaration = @methods[path]
|
|
70
|
+
raise Error.new(:unimplemented, "Unknown method"), cause: nil unless declaration
|
|
71
|
+
unless declaration.is_a?(Method) || @handlers[path]
|
|
72
|
+
raise Error.new(:unimplemented, "Unknown streaming method"), cause: nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
context = state.context = grpc_context(state, declaration)
|
|
76
|
+
session = state.session = Streaming::Session.new(
|
|
77
|
+
binding: @binding, method: declaration, codec: :proto, state: context, input: input, output: output,
|
|
78
|
+
request_limit: @request_limit, response_limit: @response_limit,
|
|
79
|
+
request_wire_limit: @request_wire_limit, response_wire_limit: @response_wire_limit,
|
|
80
|
+
request_encoding: request_encoding, response_encoding: state.encoding,
|
|
81
|
+
terminal_wire_limit: @grpc_status_wire_limit, terminal_limit: @grpc_status_limit,
|
|
82
|
+
server: true, context: context, lifecycle: state.lifecycle, publish: -> { publish_grpc_head(state) },
|
|
83
|
+
strategy: GRPCProtocol::Strategy.new(codec: @status_codec, wire_limit: @grpc_status_wire_limit,
|
|
84
|
+
limit: @grpc_status_limit)
|
|
85
|
+
)
|
|
86
|
+
if declaration.is_a?(Method)
|
|
87
|
+
request = single_stream_request(session)
|
|
88
|
+
context.check!
|
|
89
|
+
session.send_message(@invoker.call(declaration, request, context))
|
|
90
|
+
else
|
|
91
|
+
streaming = context #: StreamingContext
|
|
92
|
+
invoke_stream_handler(@handlers.fetch(path), declaration, session, streaming)
|
|
93
|
+
end
|
|
94
|
+
context.check!
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def grpc_context(state, declaration = nil, headers: state.headers)
|
|
98
|
+
metadata = Protocol.metadata(headers)
|
|
99
|
+
state.lifecycle.request_metadata = Instrumentation.carrier(metadata) if state.lifecycle
|
|
100
|
+
options = { request_metadata: metadata, deadline: GRPCProtocol.request_deadline(headers),
|
|
101
|
+
cancellation: state.cancellation, lifecycle: state.lifecycle }
|
|
102
|
+
return Context.new(**options) if declaration.nil? || declaration.is_a?(Method)
|
|
103
|
+
|
|
104
|
+
StreamingContext.new(publish: ->(snapshot) { publish_grpc_snapshot(state, snapshot) }, **options)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def publish_grpc_snapshot(state, metadata)
|
|
108
|
+
state.publish.call(grpc_head(metadata, state.encoding))
|
|
109
|
+
state.published = true
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def publish_grpc_head(state)
|
|
114
|
+
return if state.published
|
|
115
|
+
|
|
116
|
+
context = state.context #: Context
|
|
117
|
+
if context.is_a?(StreamingContext)
|
|
118
|
+
context.send(:publish_headers, check: false)
|
|
119
|
+
else
|
|
120
|
+
context.response_metadata.freeze
|
|
121
|
+
publish_grpc_snapshot(state, context.response_metadata)
|
|
122
|
+
end
|
|
123
|
+
raise(state.error || Error.new(:internal, "gRPC headers were not published")) unless state.published
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def grpc_head(metadata, encoding)
|
|
127
|
+
fields = metadata.to_h.merge("content-type" => [GRPCProtocol::CONTENT_TYPE], "grpc-accept-encoding" => [
|
|
128
|
+
@encoding == :gzip ? "gzip,identity" : "identity"
|
|
129
|
+
])
|
|
130
|
+
fields["grpc-encoding"] = ["gzip"] if encoding == :gzip
|
|
131
|
+
GRPCProtocol.check_fields!(fields.merge(":status" => ["200"]))
|
|
132
|
+
GRPCHead.new(status: 200, headers: fields, end_stream: false)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def grpc_terminal_fields(state, context)
|
|
136
|
+
merge_stream_error_metadata(state.error, context)
|
|
137
|
+
codec = @status_codec #: _GRPCStatusCodec
|
|
138
|
+
GRPCProtocol.terminal(
|
|
139
|
+
state.error, stream_trailers(state.error, context),
|
|
140
|
+
codec: codec,
|
|
141
|
+
wire_limit: @grpc_status_wire_limit, limit: @grpc_status_limit,
|
|
142
|
+
on_error: lambda { |error|
|
|
143
|
+
state.lifecycle&.record_failure(error)
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def render_grpc_terminal(state, output)
|
|
149
|
+
context = state.context ||= grpc_context(state, headers: {})
|
|
150
|
+
fields, effective = grpc_terminal_fields(state, context)
|
|
151
|
+
observe_stream_result(state, effective) if state.error
|
|
152
|
+
publish_grpc_head(state)
|
|
153
|
+
output.finish(headers: fields)
|
|
154
|
+
context.commit! unless state.error
|
|
155
|
+
observe_stream_result(state, effective) unless state.error
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
# Server-only wire policy. Native gRPC's final HEADERS and client completion
|
|
5
|
+
# rules do not apply to a Web body terminal.
|
|
6
|
+
module GRPCWeb
|
|
7
|
+
# @type var types: Hash[representation, String]
|
|
8
|
+
types = { binary: "application/grpc-web+proto", text: "application/grpc-web-text+proto" }
|
|
9
|
+
CONTENT_TYPES = types.freeze
|
|
10
|
+
ACCEPT_LIMIT = 8192
|
|
11
|
+
ACCEPT_ENTRIES = 32
|
|
12
|
+
|
|
13
|
+
def self.intent?(headers)
|
|
14
|
+
(headers["content-type"] || []).any? { |value| value.downcase.start_with?("application/grpc-web") }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.media(value)
|
|
18
|
+
return :binary if value&.match?(%r{\Aapplication/grpc-web(?:\+proto)?\z}i)
|
|
19
|
+
return :text if value&.match?(%r{\Aapplication/grpc-web-text(?:\+proto)?\z}i)
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.representation(headers, request) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
25
|
+
values = headers.fetch("accept", [])
|
|
26
|
+
return if values.sum(&:bytesize) > ACCEPT_LIMIT
|
|
27
|
+
return request if values.empty?
|
|
28
|
+
|
|
29
|
+
entries = values.flat_map { |value| value.empty? ? [""] : value.split(",", -1) }
|
|
30
|
+
return if entries.size > ACCEPT_ENTRIES
|
|
31
|
+
|
|
32
|
+
entries.reject! { |entry| entry.strip.empty? }
|
|
33
|
+
return request if entries.empty?
|
|
34
|
+
|
|
35
|
+
parsed = entries.map { |entry| accept_entry(entry) }
|
|
36
|
+
return if parsed.any?(&:nil?)
|
|
37
|
+
|
|
38
|
+
entries = parsed.compact
|
|
39
|
+
other = (request == :text ? :binary : :text) #: representation
|
|
40
|
+
selected = quality(entries, request) >= quality(entries, other) ? request : other
|
|
41
|
+
selected if quality(entries, selected).positive?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.accept_entry(entry) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
45
|
+
token, *parameters = entry.strip.downcase.split(/\s*;\s*/, -1)
|
|
46
|
+
return unless token && %r{\A[!#$%&'*+.^_`|~0-9a-z-]+/[!#$%&'*+.^_`|~0-9a-z-]+\z}.match?(token)
|
|
47
|
+
return [token, 0] unless media(token) || %w[application/* */*].include?(token)
|
|
48
|
+
|
|
49
|
+
weights = parameters.grep(/\Aq(?:=|\s|\z)/)
|
|
50
|
+
return if weights.size > 1
|
|
51
|
+
|
|
52
|
+
weight = weights.first
|
|
53
|
+
match = /\Aq=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)\z/.match(weight) if weight
|
|
54
|
+
return if weight && !match
|
|
55
|
+
# Our representations have no media parameters. Other parameters cannot
|
|
56
|
+
# match them, but do not invalidate a separate satisfiable list element.
|
|
57
|
+
return ["", 0] unless parameters.size == weights.size
|
|
58
|
+
|
|
59
|
+
[token, match ? (match[1].to_f * 1000).round : 1000]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.quality(entries, candidate) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
63
|
+
matches = entries.filter_map do |entry|
|
|
64
|
+
token, quality = entry
|
|
65
|
+
specificity = if media(token) == candidate
|
|
66
|
+
2
|
|
67
|
+
elsif token == "application/*"
|
|
68
|
+
1
|
|
69
|
+
elsif token == "*/*"
|
|
70
|
+
0
|
|
71
|
+
end
|
|
72
|
+
[specificity, quality] if specificity
|
|
73
|
+
end
|
|
74
|
+
matches.max&.last || 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.vary(headers, *names)
|
|
78
|
+
tokens = headers.fetch("vary", []).flat_map { |value| value.split(",").map(&:strip) }
|
|
79
|
+
return headers if tokens.include?("*")
|
|
80
|
+
|
|
81
|
+
names.each { |name| tokens << name unless tokens.any? { |token| token.casecmp?(name) } }
|
|
82
|
+
headers["vary"] = tokens
|
|
83
|
+
headers
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.capabilities!(capabilities)
|
|
87
|
+
return if capabilities.is_a?(Array) && !capabilities.empty? && capabilities.uniq.size == capabilities.size &&
|
|
88
|
+
capabilities.all? { |value| %i[unary server_stream].include?(value) }
|
|
89
|
+
|
|
90
|
+
raise ArgumentError, "Web capabilities must be distinct unary/server_stream symbols"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.terminal(error, trailers, codec:, wire_limit:, limit:, on_error:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
94
|
+
fields, effective = GRPCProtocol.terminal(error, trailers, codec: codec, wire_limit: wire_limit, limit: limit,
|
|
95
|
+
on_error: on_error)
|
|
96
|
+
bytes = fields.flat_map { |key, values| values.map { |value| "#{key}: #{value}\r\n" } }.join.b
|
|
97
|
+
if bytes.bytesize > wire_limit
|
|
98
|
+
fields, effective = GRPCProtocol.terminal(Error.new(:resource_exhausted, "Status exceeds byte limit"),
|
|
99
|
+
Metadata.new, codec: codec, wire_limit: wire_limit, limit: limit)
|
|
100
|
+
bytes = "grpc-status: #{fields.fetch('grpc-status').first}\r\n".b
|
|
101
|
+
Protocol.check_size!(bytes, wire_limit)
|
|
102
|
+
end
|
|
103
|
+
[[128, bytes.bytesize].pack("CN") + bytes, effective]
|
|
104
|
+
rescue Error
|
|
105
|
+
raise
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
on_error&.call(e)
|
|
108
|
+
terminal(Error.new(:internal, "Cannot encode gRPC status"), Metadata.new, codec: codec,
|
|
109
|
+
wire_limit: wire_limit, limit: limit,
|
|
110
|
+
on_error: nil)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class Framing < Streaming::Framing
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def validate_flags(flags)
|
|
117
|
+
protocol_error("Invalid Web envelope flags") unless [0, 1].include?(flags)
|
|
118
|
+
raise Error.new(:internal, "Unnegotiated compression"), cause: nil if flags == 1
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
class Strategy
|
|
123
|
+
def grpc? = false
|
|
124
|
+
def framing = Framing
|
|
125
|
+
def wait(timeout) = [timeout || Deadline::MAX_NATIVE_WAIT, Deadline::MAX_NATIVE_WAIT].min
|
|
126
|
+
def single_response?(method) = method.is_a?(Method)
|
|
127
|
+
def decode_error(_server) = :invalid_argument
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
private_constant :GRPCWeb
|
|
131
|
+
end
|