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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ class StreamStateError < StandardError; end
5
+ class StreamNotConsumedError < StreamStateError; end
6
+
7
+ StreamRequest = Struct.new(:url, :http_method, :headers, :deadline, :lifecycle, :cancellation, keyword_init: true)
8
+ StreamHead = Struct.new(:status, :headers, keyword_init: true)
9
+
10
+ class StreamingContext < Context
11
+ def initialize(publish:, request_metadata: {}, deadline: nil, lifecycle: nil, cancellation: nil)
12
+ super(request_metadata: request_metadata, deadline: deadline, lifecycle: lifecycle, cancellation: cancellation)
13
+ @publish = publish
14
+ @headers_sent = false
15
+ @owner_thread = Thread.current
16
+ @owner_scheduler = Fiber.scheduler
17
+ @stream_closed = false
18
+ end
19
+
20
+ def send_headers(metadata: {})
21
+ check_headers_scope!
22
+ raise StreamStateError, "Response headers already published" if @headers_sent
23
+
24
+ check!
25
+ Metadata.new(metadata).each { |key, values| @response_metadata[key] = values }
26
+ @response_metadata.freeze
27
+ @headers_sent = true
28
+ @publish.call(@response_metadata)
29
+ nil
30
+ end
31
+
32
+ def close
33
+ @stream_closed = true
34
+ super
35
+ end
36
+
37
+ private
38
+
39
+ def check_headers_scope!
40
+ raise StreamStateError, "Stream scope is closed" if @stream_closed
41
+ return if Thread.current.equal?(@owner_thread) && Fiber.scheduler.equal?(@owner_scheduler)
42
+
43
+ raise StreamStateError, "Use the stream's owning reactor and thread"
44
+ end
45
+
46
+ def publish_headers(check: true)
47
+ return if @headers_sent
48
+ return send_headers if check
49
+
50
+ @response_metadata.freeze
51
+ @headers_sent = true
52
+ @publish.call(@response_metadata)
53
+ nil
54
+ end
55
+ end
56
+
57
+ class ResponseReader
58
+ def initialize(session)
59
+ @session = session
60
+ end
61
+
62
+ def receive = @session.receive
63
+ def headers = @session.headers
64
+ def trailers = @session.trailers
65
+ def abandon = @session.abandon
66
+
67
+ def each
68
+ raise ArgumentError, "A block is required" unless block_given?
69
+
70
+ while true # rubocop:disable Style/InfiniteLoop -- Kernel.loop swallows application StopIteration.
71
+ case message = receive
72
+ when nil then break
73
+ else yield message
74
+ end
75
+ end
76
+ nil
77
+ end
78
+ end
79
+
80
+ class RequestWriter
81
+ def initialize(session)
82
+ @session = session
83
+ end
84
+
85
+ def send_message(message) = @session.send_message(message)
86
+ def close_send = @session.close_send
87
+ def abandon = @session.abandon
88
+ end
89
+
90
+ class ClientDuplex < ResponseReader
91
+ def send_message(message) = @session.send_message(message)
92
+ def close_send = @session.close_send
93
+ end
94
+
95
+ class RequestReader < ResponseReader; end
96
+
97
+ class ResponseWriter
98
+ def initialize(session, context)
99
+ @session = session
100
+ @context = context
101
+ end
102
+
103
+ def send_message(message) = @session.send_message(message)
104
+ def send_headers(metadata: {}) = @context.send_headers(metadata: metadata)
105
+ end
106
+
107
+ class ServerDuplex < RequestReader
108
+ def initialize(session, context)
109
+ super(session)
110
+ @context = context
111
+ end
112
+
113
+ def send_message(message) = @session.send_message(message)
114
+ def send_headers(metadata: {}) = @context.send_headers(metadata: metadata)
115
+ end
116
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ module Streaming
5
+ def self.parse_terminal(bytes, metadata: Metadata.new, on_error: nil)
6
+ object = JSON.parse(bytes, max_nesting: 64)
7
+ raise ArgumentError unless object.is_a?(Hash)
8
+
9
+ trailers = terminal_metadata(object)
10
+ [terminal_error(object["error"], metadata, trailers), trailers]
11
+ rescue JSON::ParserError, ArgumentError, TypeError, EncodingError => e
12
+ on_error&.call(e)
13
+ raise Error.new(:internal, "Malformed streaming terminal"), cause: nil
14
+ end
15
+
16
+ def self.terminal_metadata(object)
17
+ empty = {} #: headers
18
+ trailers = object.fetch("metadata", empty)
19
+ raise ArgumentError unless trailers.is_a?(Hash)
20
+
21
+ Protocol.metadata(trailers).freeze
22
+ end
23
+
24
+ def self.terminal_error(error, metadata, trailers)
25
+ return if error.nil?
26
+ raise ArgumentError unless Protocol.valid_wire_error?(error)
27
+
28
+ empty = [] #: Array[ErrorDetail]
29
+ details = Protocol.wire_details(error.fetch("details", empty))
30
+ code = Error::HTTP_STATUS.key?(error["code"]) ? error["code"] : "unknown"
31
+ Error.new(code, error.fetch("message", ""), details: details, metadata: metadata, trailers: trailers)
32
+ end
33
+
34
+ def self.terminal_bytes(error, trailers, wire_limit:, limit:)
35
+ # @type var object: Hash[String, headers | Hash[String, String | Array[Hash[String, String]]]]
36
+ object = { "metadata" => trailers.to_h }
37
+ object["error"] = error.to_h if error
38
+ bytes = JSON.generate(object, max_nesting: 64)
39
+ return [bytes, error] if bytes.bytesize <= wire_limit && bytes.bytesize <= limit
40
+
41
+ minimal_terminal(wire_limit, limit)
42
+ end
43
+
44
+ def self.minimal_terminal(wire_limit, limit)
45
+ replacement = Error.new(:resource_exhausted, "Terminal exceeds byte limit")
46
+ bytes = JSON.generate({ "error" => { "code" => replacement.code } })
47
+ raise replacement if bytes.bytesize > wire_limit || bytes.bytesize > limit
48
+
49
+ [bytes, replacement]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require_relative "../../connectrpc"
6
+
7
+ module ConnectRPC
8
+ module Transports
9
+ class NetHTTP # rubocop:disable Metrics/ClassLength
10
+ # Net::HTTP uses the open timeout again for TLS. Recompute it from the
11
+ # original deadline after TCP/DNS, rather than starting another budget.
12
+ class Connection < Net::HTTP
13
+ attr_accessor :deadline, :cancellation, :socket_lifecycle
14
+
15
+ private
16
+
17
+ def check_cancellation!
18
+ cancellation ? cancellation.check! : deadline&.check!
19
+ end
20
+
21
+ def connect
22
+ check_cancellation!
23
+ # begin_transport can reconnect a closed socket even with retries
24
+ # disabled. A second socket would escape the call's watchdog.
25
+ raise IOError, "Owned connection is closed" if @socket
26
+
27
+ super
28
+ end
29
+
30
+ def begin_transport(request)
31
+ check_cancellation!
32
+ super
33
+ request["connect-timeout-ms"] = deadline.timeout_ms if deadline
34
+ end
35
+
36
+ def ssl_socket_connect(socket, _timeout)
37
+ socket_lifecycle&.attach(socket.to_io)
38
+ check_cancellation!
39
+ super(socket, deadline ? deadline.native_timeout : Deadline::MAX_NATIVE_WAIT)
40
+ check_cancellation!
41
+ end
42
+ end
43
+
44
+ # Owns only this call's raw socket. Closing wakes Net::HTTP's blocking
45
+ # read/write waits even when individual header/body reads keep progressing.
46
+ class Watchdog
47
+ def initialize(socket, deadline)
48
+ @mutex = Mutex.new
49
+ @condition = ConditionVariable.new
50
+ @done = false
51
+ @thread = Thread.new { watch(socket, deadline) }
52
+ end
53
+
54
+ def watch(socket, deadline)
55
+ @mutex.synchronize do
56
+ @condition.wait(@mutex, deadline.native_timeout) until @done || deadline.expired?
57
+ close_socket(socket) unless @done
58
+ end
59
+ end
60
+
61
+ def close_socket(socket)
62
+ socket.shutdown(Socket::SHUT_RDWR)
63
+ socket.close
64
+ rescue IOError, SystemCallError
65
+ # Net::HTTP may have closed the same owned socket first.
66
+ nil
67
+ end
68
+
69
+ def stop
70
+ @mutex.synchronize do
71
+ @done = true
72
+ @condition.signal
73
+ end
74
+ @thread.join
75
+ end
76
+ end
77
+
78
+ # Retirement and callback closure share this guard. Unregister alone
79
+ # cannot prevent an already-running callback from touching a reused FD.
80
+ class SocketLifecycle
81
+ def initialize(cancellation)
82
+ @mutex = Mutex.new
83
+ @socket = nil
84
+ @closed = false
85
+ @retired = false
86
+ @registration = cancellation&.on_cancel { close }
87
+ end
88
+
89
+ def attach(socket)
90
+ @mutex.synchronize do
91
+ return if @retired
92
+
93
+ @socket = socket
94
+ close_socket if @closed
95
+ end
96
+ end
97
+
98
+ def close
99
+ @mutex.synchronize do
100
+ return if @retired
101
+
102
+ @closed = true
103
+ close_socket
104
+ end
105
+ end
106
+
107
+ def retire
108
+ @mutex.synchronize do
109
+ @retired = true
110
+ @socket = nil
111
+ end
112
+ @registration&.unregister
113
+ @registration = nil
114
+ end
115
+
116
+ private
117
+
118
+ def close_socket
119
+ @socket&.shutdown(Socket::SHUT_RDWR)
120
+ @socket&.close
121
+ rescue IOError, SystemCallError
122
+ nil
123
+ end
124
+ end
125
+
126
+ def call(request)
127
+ CallCancellation.with_request(request) { |outgoing| perform_call(outgoing) }
128
+ end
129
+
130
+ private
131
+
132
+ def perform_call(request) # rubocop:disable Metrics/MethodLength
133
+ deadline = request.deadline
134
+ outgoing = prepare_request(request)
135
+ http = connection(request.url, deadline)
136
+ http.cancellation = request.cancellation
137
+ with_connection(http, deadline, request.cancellation) { exchange(http, request, outgoing) }
138
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
139
+ SocketError, IOError, SystemCallError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
140
+ OpenSSL::SSL::SSLError => e
141
+ request.lifecycle&.record_failure(e)
142
+ check_request!(request)
143
+ raise Error.new(:unavailable, "HTTP transport failed"), cause: nil
144
+ end
145
+
146
+ def check_request!(request)
147
+ request.cancellation ? request.cancellation.check! : request.deadline&.check!
148
+ end
149
+
150
+ def prepare_request(request)
151
+ check_request!(request)
152
+ outgoing = outgoing_request(request)
153
+ check_request!(request)
154
+ outgoing
155
+ end
156
+
157
+ # Keep startup ownership and terminal retirement in one ensure.
158
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
159
+ def with_connection(http, deadline, cancellation = nil)
160
+ sockets = SocketLifecycle.new(cancellation)
161
+ http.socket_lifecycle = sockets
162
+ cancellation&.check!
163
+ http.start
164
+ sockets.attach(http.instance_variable_get(:@socket).io.to_io)
165
+ cancellation ? cancellation.check! : deadline&.check!
166
+ # Pinned Net::HTTP exposes its BufferedIO; one call owns this raw socket.
167
+ watchdog = Watchdog.new(http.instance_variable_get(:@socket).io.to_io, deadline) if deadline
168
+ yield
169
+ ensure
170
+ watchdog&.stop
171
+ sockets&.retire
172
+ http.finish if http.started?
173
+ end
174
+
175
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
176
+
177
+ def exchange(http, request, outgoing)
178
+ result = nil
179
+ http.request(outgoing) do |response|
180
+ check_request!(request)
181
+ check_response_size!(response, request.response_limit)
182
+ body = read_body(response, request)
183
+ result = TransportResponse.new(status: response.code.to_i, headers: response.to_hash, body: body)
184
+ end
185
+ check_request!(request)
186
+ result
187
+ end
188
+
189
+ def check_response_size!(response, limit)
190
+ length = Protocol.content_length!(response.get_fields("content-length"), invalid_code: :internal)
191
+ raise Error.new(:resource_exhausted, "Response exceeds byte limit") if length && length > limit
192
+ end
193
+
194
+ def outgoing_request(request) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
195
+ headers = request.headers.merge("connection" => ["close"])
196
+ accept = request.headers.fetch("accept-encoding", ["identity"]).join(", ")
197
+ has_body = request.http_method != "GET"
198
+ outgoing = Net::HTTPGenericRequest.new(request.http_method, has_body, true, request.url.request_uri,
199
+ "accept-encoding" => accept)
200
+ headers.each do |key, values|
201
+ next if key == "accept-encoding"
202
+
203
+ values.each { |value| outgoing.add_field(key, value) }
204
+ end
205
+ outgoing.body = request.body unless request.http_method == "GET"
206
+ outgoing
207
+ end
208
+
209
+ def read_body(response, request)
210
+ body = +"".b
211
+ response.read_body do |chunk|
212
+ check_request!(request)
213
+ if body.bytesize + chunk.bytesize > request.response_limit
214
+ raise Error.new(:resource_exhausted, "Response exceeds byte limit")
215
+ end
216
+
217
+ body << chunk
218
+ end
219
+ body
220
+ end
221
+
222
+ def verify_tls!(http)
223
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
224
+ http.verify_hostname = true
225
+ end
226
+
227
+ def connection(uri, deadline)
228
+ http = Connection.new(uri.hostname, uri.port, nil)
229
+ http.deadline = deadline
230
+ http.use_ssl = uri.scheme == "https"
231
+ configure_http(http, deadline)
232
+ http
233
+ end
234
+
235
+ def configure_http(http, deadline)
236
+ verify_tls!(http) if http.use_ssl?
237
+ native_timeout = deadline ? deadline.native_timeout : Deadline::MAX_NATIVE_WAIT
238
+ http.open_timeout = native_timeout
239
+ http.read_timeout = native_timeout
240
+ http.write_timeout = native_timeout
241
+ http.max_retries = 0
242
+ http.ignore_eof = false
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ VERSION = "0.1.0"
5
+ end
data/lib/connectrpc.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connectrpc/version"
4
+ require_relative "connectrpc/method"
5
+ require_relative "connectrpc/streaming/method"
6
+ require_relative "connectrpc/metadata"
7
+ require_relative "connectrpc/error"
8
+ require_relative "connectrpc/cancellation"
9
+ require_relative "connectrpc/deadline"
10
+ require_relative "connectrpc/protocol"
11
+ require_relative "connectrpc/compression"
12
+ require_relative "connectrpc/instrumentation"
13
+ require_relative "connectrpc/get_query"
14
+ require_relative "connectrpc/grpc"
15
+ require_relative "connectrpc/client"
16
+ require_relative "connectrpc/message_io"
17
+ require_relative "connectrpc/server"
18
+ require_relative "connectrpc/streaming/framing"
19
+ require_relative "connectrpc/streaming/framing_io"
20
+ require_relative "connectrpc/streaming/terminal"
21
+ require_relative "connectrpc/streaming/strategy"
22
+ require_relative "connectrpc/streaming/streams"
23
+ require_relative "connectrpc/streaming/session"
24
+ require_relative "connectrpc/streaming/session_messages"
25
+ require_relative "connectrpc/streaming/session_io"
26
+ require_relative "connectrpc/streaming/session_completion"
27
+ require_relative "connectrpc/streaming/client"
28
+ require_relative "connectrpc/streaming/client_scope"
29
+ require_relative "connectrpc/streaming/client_execution"
30
+ require_relative "connectrpc/grpc_client"
31
+ require_relative "connectrpc/streaming/server"
32
+ require_relative "connectrpc/streaming/server_execution"
33
+ require_relative "connectrpc/streaming/server_terminal"
34
+ require_relative "connectrpc/grpc_server"
35
+ require_relative "connectrpc/grpc_web"
36
+ require_relative "connectrpc/grpc_web_io"
37
+ require_relative "connectrpc/grpc_web_cors"
38
+ require_relative "connectrpc/grpc_web_server"
@@ -0,0 +1,41 @@
1
+ # protoc 36.2 / google-protobuf 4.36.2 compatibility shapes only.
2
+ # Official message field signatures remain upstream-owned. No iteration API.
3
+ module Google
4
+ module Protobuf
5
+ class AbstractMessage
6
+ end
7
+ interface _MessageClass[T]
8
+ end
9
+ class RepeatedField[Read, Write]
10
+ def []: (Integer index) -> Read?
11
+ def []=: (Integer index, Write value) -> nil
12
+ def <<: (Write value) -> self
13
+ end
14
+ class Map[KeyRead, ValueRead, KeyWrite, ValueWrite]
15
+ def []: (KeyWrite key) -> ValueRead?
16
+ def []=: (KeyWrite key, ValueWrite value) -> ValueWrite
17
+ end
18
+ interface _EnumModule
19
+ def lookup: (Integer) -> Symbol?
20
+ def resolve: (Symbol) -> Integer?
21
+ end
22
+ interface _SpecificDescriptor[T]
23
+ def msgclass: () -> T
24
+ end
25
+ interface _SpecificEnumDescriptor[T]
26
+ def enummodule: () -> T
27
+ end
28
+ class Descriptor
29
+ def msgclass: () -> Class
30
+ end
31
+ class EnumDescriptor
32
+ def enummodule: () -> Module
33
+ end
34
+ class DescriptorPool
35
+ def self.generated_pool: () -> DescriptorPool
36
+ # Other names can resolve to file/service/extension descriptors. Keep
37
+ # those runtime shapes opaque; upstream literal overloads refine messages.
38
+ def lookup: (String name) -> Object?
39
+ end
40
+ end
41
+ end