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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +501 -0
  4. data/docs/design.md +504 -0
  5. data/docs/development.md +566 -0
  6. data/docs/grpc-web.md +165 -0
  7. data/docs/grpc.md +75 -0
  8. data/docs/rbs.md +469 -0
  9. data/docs/streaming.md +251 -0
  10. data/exe/protoc-gen-connectrpc-ruby +23 -0
  11. data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
  12. data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
  13. data/lib/connectrpc/cancellation.rb +220 -0
  14. data/lib/connectrpc/client.rb +260 -0
  15. data/lib/connectrpc/compression.rb +120 -0
  16. data/lib/connectrpc/deadline.rb +81 -0
  17. data/lib/connectrpc/error.rb +72 -0
  18. data/lib/connectrpc/generator/names.rb +105 -0
  19. data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
  20. data/lib/connectrpc/generator/renderer.rb +115 -0
  21. data/lib/connectrpc/generator/runtime_names.rb +84 -0
  22. data/lib/connectrpc/generator/schema.rb +218 -0
  23. data/lib/connectrpc/generator/streaming_methods.rb +29 -0
  24. data/lib/connectrpc/generator.rb +42 -0
  25. data/lib/connectrpc/get_query.rb +159 -0
  26. data/lib/connectrpc/grpc.rb +200 -0
  27. data/lib/connectrpc/grpc_client.rb +23 -0
  28. data/lib/connectrpc/grpc_server.rb +158 -0
  29. data/lib/connectrpc/grpc_web.rb +131 -0
  30. data/lib/connectrpc/grpc_web_cors.rb +135 -0
  31. data/lib/connectrpc/grpc_web_io.rb +156 -0
  32. data/lib/connectrpc/grpc_web_server.rb +207 -0
  33. data/lib/connectrpc/instrumentation.rb +216 -0
  34. data/lib/connectrpc/message_io.rb +47 -0
  35. data/lib/connectrpc/metadata.rb +105 -0
  36. data/lib/connectrpc/method.rb +68 -0
  37. data/lib/connectrpc/protocol.rb +189 -0
  38. data/lib/connectrpc/server.rb +398 -0
  39. data/lib/connectrpc/streaming/client.rb +104 -0
  40. data/lib/connectrpc/streaming/client_execution.rb +87 -0
  41. data/lib/connectrpc/streaming/client_scope.rb +105 -0
  42. data/lib/connectrpc/streaming/framing.rb +123 -0
  43. data/lib/connectrpc/streaming/framing_io.rb +61 -0
  44. data/lib/connectrpc/streaming/method.rb +33 -0
  45. data/lib/connectrpc/streaming/server.rb +122 -0
  46. data/lib/connectrpc/streaming/server_execution.rb +88 -0
  47. data/lib/connectrpc/streaming/server_terminal.rb +70 -0
  48. data/lib/connectrpc/streaming/session.rb +143 -0
  49. data/lib/connectrpc/streaming/session_completion.rb +134 -0
  50. data/lib/connectrpc/streaming/session_io.rb +107 -0
  51. data/lib/connectrpc/streaming/session_messages.rb +119 -0
  52. data/lib/connectrpc/streaming/strategy.rb +95 -0
  53. data/lib/connectrpc/streaming/streams.rb +116 -0
  54. data/lib/connectrpc/streaming/terminal.rb +52 -0
  55. data/lib/connectrpc/transports/net_http.rb +246 -0
  56. data/lib/connectrpc/version.rb +5 -0
  57. data/lib/connectrpc.rb +38 -0
  58. data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
  59. data/sig/connectrpc.rbs +772 -0
  60. data/sig/manifest.yaml +2 -0
  61. data/third_party/README.md +16 -0
  62. data/third_party/google/LICENSE +201 -0
  63. data/third_party/google/rpc/status.proto +48 -0
  64. data/third_party/protobuf-LICENSE +32 -0
  65. 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