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,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