connect_rpc_rails 0.1.0 → 0.2.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.
@@ -63,6 +63,9 @@ module ConnectRpcRails
63
63
  UNKNOWN_METHOD_ACTION = "connect_unknown_method"
64
64
  UNKNOWN_METHOD_PARAM = "connect_method"
65
65
 
66
+ # The request compression the transport accepts, advertised when a call uses another.
67
+ SUPPORTED_COMPRESSION = ["identity"].freeze #: Array[String]
68
+
66
69
  # The one `rescue_responses` entry that doesn't become a Connect error: #action_missing
67
70
  # raises ActionNotFound for a name that isn't an RPC, which means the app routed
68
71
  # something to an action this controller doesn't have. That is a misconfiguration for
@@ -85,7 +88,7 @@ module ConnectRpcRails
85
88
  # Registered first, so both the Error handler and anything `map_connect_errors` adds
86
89
  # later take precedence: Rails picks the most recently registered matching handler.
87
90
  install_rescue_response_defaults(base)
88
- base.rescue_from(Error, with: :render_connect_error)
91
+ base.rescue_from(Error, with: :render_connect_exception)
89
92
  base.around_action(:enforce_connect_deadline)
90
93
  base.prepend_before_action(:validate_connect_request)
91
94
  end
@@ -102,7 +105,7 @@ module ConnectRpcRails
102
105
  ActionDispatch::ExceptionWrapper.rescue_responses.each_key do |class_name|
103
106
  next if RESCUE_RESPONSE_EXCLUSIONS.include?(class_name)
104
107
 
105
- base.rescue_from(class_name, with: :render_rescue_response_error)
108
+ base.rescue_from(class_name, with: :render_connect_exception)
106
109
  end
107
110
  end
108
111
 
@@ -140,24 +143,26 @@ module ConnectRpcRails
140
143
  #: (Hash[Class, Symbol]) -> void
141
144
  def map_connect_errors(mapping)
142
145
  self.connect_error_mapping = connect_error_mapping.merge(mapping)
143
- mapping.each_key { |klass| rescue_from(klass, with: :render_mapped_connect_error) }
146
+ mapping.each_key { |klass| rescue_from(klass, with: :render_connect_exception) }
144
147
  end
145
148
  end
146
149
 
147
150
  # Connect actions decode the body themselves and never read `params`, so skip
148
151
  # Rails' lazy body param parsing: it would deserialize a JSON body a second time
149
152
  # (instrumentation reads filtered_parameters) and leak request payloads into
150
- # logs. Query params still parse; instrumentation is otherwise unaffected.
151
- # Runs before instrumentation reads the params, so it decodes the Connect body
152
- # here (exactly once) and reuses it two ways: the typed message drives the action,
153
- # and its hash form populates `request_parameters` — so the standard Rails request
154
- # log, `config.filter_parameters`, and APM see the request without Rails parsing
155
- # the body a second time. Also reports the wire format for logs/instrumentation
156
- # (via the formats header rather than `request.format=`, which would inject a
157
- # :format key into params).
153
+ # logs. Query params other than a GET's Connect parameters still parse; instrumentation
154
+ # is otherwise unaffected.
155
+ # Runs before instrumentation reads the params, so it decodes the Connect message (a
156
+ # POST's body, a GET's `message`) here, exactly once, and reuses it two ways: the
157
+ # typed message drives the action, and its hash form populates `request_parameters`
158
+ # — so the standard Rails request log, `config.filter_parameters`, and APM see the
159
+ # request without Rails parsing it a second time. Also reports the wire format for
160
+ # logs/instrumentation (via the formats header rather than `request.format=`, which
161
+ # would inject a :format key into params).
158
162
  def process_action(*)
159
163
  request.request_parameters = connect_request_params
160
- format = request.content_type.to_s.start_with?("application/proto") ? :proto : :json
164
+ exclude_connect_query_parameters if connect_get?
165
+ format = Codec::Proto == connect_requested_codec ? :proto : :json
161
166
  request.set_header("action_dispatch.request.formats", [Mime[format]])
162
167
  super
163
168
  end
@@ -212,16 +217,18 @@ module ConnectRpcRails
212
217
 
213
218
  @connect_full_method = "#{self.class.connect_registration.service_name}/#{rpc.name}"
214
219
 
215
- # A Connect RPC is POST-only; other verbs are a 405, per the protocol. (Routes
216
- # match all verbs so the wrong-verb case reaches here rather than 404-ing.)
217
- return render(plain: "method not allowed", status: 405) unless request.post?
218
- return render(plain: "unsupported media type", status: 415) unless Codec.for_content_type(request.content_type)
220
+ # A Connect RPC takes POST, and GET too when it is unary with no side effects; other
221
+ # verbs are a 405, per the protocol. (Routes match all verbs so the wrong-verb case reaches here
222
+ # rather than 404-ing.) HTTP requires a 405 to name the verbs the resource takes.
223
+ unless request.post? || connect_get?
224
+ response.headers["allow"] = connect_get_allowed?(rpc) ? "GET, POST" : "POST"
225
+ return render(plain: "method not allowed", status: 405)
226
+ end
227
+ return render(plain: "unsupported media type", status: 415) unless connect_requested_codec
219
228
 
220
229
  reject_unsupported_encoding
221
- # A raw ParseError isn't a ConnectRpcRails::Error, so it would escape rescue_from and
222
- # become a 500. A malformed body is client input: surface it as invalid_argument.
223
- # (Don't echo the decoder message — it can quote payload fragments back.)
224
- raise Error.new(:invalid_argument, "invalid request body") if @connect_decode_error
230
+ reject_unsupported_protocol_version if connect_get?
231
+ raise @connect_decode_error if @connect_decode_error
225
232
  end
226
233
 
227
234
  #: () -> String
@@ -231,20 +238,65 @@ module ConnectRpcRails
231
238
 
232
239
  private def connect_request_params
233
240
  rpc = self.class.connect_rpcs[action_name]
234
- codec = Codec.for_content_type(request.content_type)
235
- return {} unless request.post? && rpc && codec
241
+ codec = connect_requested_codec
242
+ return {} unless (request.post? || connect_get?) && rpc && codec
236
243
 
237
- # Rack 3.1 lets a server omit rack.input for an empty body (Falcon does), and an
238
- # empty message encodes to an empty proto body.
239
- @connect_request = codec.decode(rpc.input_class, request.body&.read || "")
244
+ @connect_request = codec.decode(rpc.input_class, connect_request_bytes)
240
245
  @connect_request.to_h
241
- rescue Google::Protobuf::ParseError => e
242
- # Defer a malformed body to the action, so it flows through instrumentation and
243
- # the normal error path instead of aborting before the request is even logged.
244
- @connect_decode_error = e
246
+ rescue Google::Protobuf::ParseError
247
+ # Deferred to the action, so a malformed message flows through instrumentation and the
248
+ # normal error path. The decoder's message is not sent: it can quote the payload back.
249
+ @connect_decode_error = Error.new(
250
+ :invalid_argument,
251
+ connect_get? ? "invalid message query parameter" : "invalid request body",
252
+ )
253
+ {}
254
+ rescue GetQuery::InvalidMessage => e
255
+ @connect_decode_error = Error.new(:invalid_argument, e.message)
245
256
  {}
246
257
  end
247
258
 
259
+ # The serialized request message: the body of a POST, the `message` parameter of a GET.
260
+ #: () -> String
261
+ private def connect_request_bytes
262
+ return connect_get_query.message if connect_get?
263
+
264
+ # Rack 3.1 lets a server omit rack.input for an empty body (Falcon does), and an
265
+ # empty message encodes to an empty proto body.
266
+ request.body&.read || ""
267
+ end
268
+
269
+ # Connect serves an RPC over GET only when it is unary and its `.proto` declares it free of
270
+ # side effects.
271
+ #: (ServiceRegistration::Rpc) -> bool
272
+ private def connect_get_allowed?(rpc)
273
+ !rpc.streaming && rpc.idempotency_level == :NO_SIDE_EFFECTS
274
+ end
275
+
276
+ # Whether this call is a Connect GET, which carries its request in the query string.
277
+ #: () -> bool
278
+ private def connect_get?
279
+ rpc = self.class.connect_rpcs[action_name]
280
+ !rpc.nil? && request.get? && connect_get_allowed?(rpc)
281
+ end
282
+
283
+ #: () -> GetQuery
284
+ private def connect_get_query
285
+ @connect_get_query ||= GetQuery.parse(request.query_string)
286
+ end
287
+
288
+ # A GET's query parameters are its request, which `request_parameters` already holds
289
+ # decoded, so `params` shows the message once. A query Rails refuses, such as a binary
290
+ # message sent without base64, keeps no parameters.
291
+ private def exclude_connect_query_parameters
292
+ query = begin
293
+ request.query_parameters.except(*GetQuery::PARAMS)
294
+ rescue ActionController::BadRequest
295
+ {} #: Hash[String, untyped]
296
+ end
297
+ request.set_header("action_dispatch.request.query_parameters", query)
298
+ end
299
+
248
300
  # The transport preconditions have all passed by the time this runs (see
249
301
  # #validate_connect_request), so this is the wire round-trip and nothing else. The RPC
250
302
  # runs on this controller instance — the one Rails built for this request — and takes
@@ -260,22 +312,45 @@ module ConnectRpcRails
260
312
  response.headers["content-type"] = connect_codec.content_type
261
313
  end
262
314
 
263
- # The codec for this call's content-type. Never missing once the action runs:
264
- # #validate_connect_request answers a content-type no codec handles with a 415.
315
+ # The codec for this call. Never missing once the action runs: #validate_connect_request
316
+ # answers a codec it has none for with a 415.
265
317
  #: () -> Codec::_Codec
266
318
  private def connect_codec
267
- Codec.for_content_type(request.content_type) ||
268
- raise(Error.new(:internal, "no codec for #{request.content_type}"))
319
+ connect_requested_codec || raise(Error.new(:internal, "no codec for this request"))
269
320
  end
270
321
 
271
- # Connect rejects unsupported request compression with `unimplemented`, advertising
272
- # what it can accept.
322
+ # The codec the caller named: a GET's `encoding` parameter, a POST's content-type. Nil
323
+ # when no codec handles it.
324
+ #: () -> Codec::_Codec?
325
+ private def connect_requested_codec
326
+ return Codec.for_name(connect_get_query.encoding) if connect_get?
327
+
328
+ Codec.for_content_type(request.content_type)
329
+ end
330
+
331
+ # Connect rejects unsupported request compression with `unimplemented`, naming what it
332
+ # can accept in both `accept-encoding` and the message. A GET names its compression in
333
+ # the query.
273
334
  private def reject_unsupported_encoding
274
- encoding = request.headers["Content-Encoding"]
275
- return if encoding.nil? || ["", "identity"].include?(encoding)
335
+ source, encoding = if connect_get?
336
+ ["compression", connect_get_query.compression]
337
+ else
338
+ ["content-encoding", request.headers["Content-Encoding"]]
339
+ end
340
+ return if encoding.nil? || encoding.empty? || SUPPORTED_COMPRESSION.include?(encoding)
276
341
 
277
- response.headers["accept-encoding"] = "identity"
278
- raise Error.new(:unimplemented, "unsupported content-encoding: #{encoding}")
342
+ supported = SUPPORTED_COMPRESSION.join(", ")
343
+ response.headers["accept-encoding"] = supported
344
+ raise Error.new(:unimplemented, "unsupported #{source} #{encoding.inspect}: supported encodings are #{supported}")
345
+ end
346
+
347
+ # `connect=v1` is optional, as the protocol lets a server accept a GET without it. Any
348
+ # other version is a revision of the protocol this server does not speak.
349
+ private def reject_unsupported_protocol_version
350
+ version = connect_get_query.connect
351
+ return if version.nil? || ["", GetQuery::PROTOCOL_VERSION].include?(version)
352
+
353
+ raise Error.new(:invalid_argument, "connect query parameter must be #{GetQuery::PROTOCOL_VERSION}")
279
354
  end
280
355
 
281
356
  # The decoded request message, for the action to read the way an HTTP action reads
@@ -355,32 +430,37 @@ module ConnectRpcRails
355
430
  end
356
431
  end
357
432
 
358
- # The handler `map_connect_errors` installs: the mapping lives on the class, so the
359
- # code for the exception at hand is looked up rather than baked into a closure.
360
- private def render_mapped_connect_error(exception)
433
+ # The handler every `rescue_from` the library installs points at.
434
+ private def render_connect_exception(exception)
435
+ render_connect_error(connect_error_for(exception))
436
+ end
437
+
438
+ # The Connect error to send for a rescued exception. Override to add details every
439
+ # error should carry, the way ExceptionsApp#connect_error_for is overridden for errors
440
+ # that escape the controller.
441
+ #
442
+ # A mapped exception takes its code from `map_connect_errors`, which overrides the
443
+ # `rescue_responses` classification. That status is read off the nearest ancestor the
444
+ # registry names, since `rescue_from` matches subclasses too and a subclass isn't a key
445
+ # (an `ActiveRecord::RecordNotUnique` is classified as the `StatementInvalid` it
446
+ # descends from). Read with `fetch`, because the registry answers anything at all with
447
+ # a default of :internal_server_error — which would stop the walk on the first ancestor
448
+ # and call every subclass a 500.
449
+ #: (Exception) -> Error
450
+ private def connect_error_for(exception)
451
+ return exception if exception.is_a?(Error)
452
+
361
453
  _, code = self.class.connect_error_mapping.find { |klass, _| exception.is_a?(klass) }
362
- # Only classes in the mapping are rescued, so this can't miss; re-raise rather than
363
- # invent a code if it somehow does.
454
+ unless code
455
+ responses = ActionDispatch::ExceptionWrapper.rescue_responses
456
+ status = exception.class.ancestors.lazy.filter_map { |klass| responses.fetch(klass.name, nil) }.first
457
+ code = Error.code_for_http_status(Rack::Utils.status_code(status)) if status
458
+ end
459
+ # Only mapped or classified exceptions are rescued, so this can't miss; re-raise
460
+ # rather than invent a code if it somehow does.
364
461
  raise exception unless code
365
462
 
366
- render_connect_error(Error.new(code, exception.message))
367
- end
368
-
369
- # The handler installed for every `rescue_responses` entry. The status is read off the
370
- # nearest ancestor the registry names, since `rescue_from` matches subclasses too and a
371
- # subclass isn't a key (an `ActiveRecord::RecordNotUnique` is classified as the
372
- # `StatementInvalid` it descends from). Read with `fetch`, because the registry answers
373
- # anything at all with a default of :internal_server_error — which would stop the walk
374
- # on the first ancestor and call every subclass a 500.
375
- private def render_rescue_response_error(exception)
376
- responses = ActionDispatch::ExceptionWrapper.rescue_responses
377
- status = exception.class.ancestors.lazy.filter_map { |klass| responses.fetch(klass.name, nil) }.first
378
- # Only registered names are rescued here, so this can't miss; re-raise rather than
379
- # invent a code if it somehow does.
380
- raise exception unless status
381
-
382
- code = Error.code_for_http_status(Rack::Utils.status_code(status))
383
- render_connect_error(Error.new(code, exception.message))
463
+ Error.new(code, exception.message)
384
464
  end
385
465
 
386
466
  private def render_connect_error(error)
@@ -14,8 +14,9 @@ module ConnectRpcRails
14
14
  #
15
15
  # config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
16
16
  #
17
- # A Connect request is recognized by `connect-protocol-version`, which the protocol
18
- # requires on every unary call; anything else reaches the wrapped app untouched.
17
+ # A Connect request is recognized by the `connect-protocol-version` header Connect clients
18
+ # send on a unary POST, or by the `connect=v1` query parameter they send on a GET; anything
19
+ # else reaches the wrapped app untouched.
19
20
  class ExceptionsApp
20
21
  # `connect-protocol-version`, as Rack names it in the env.
21
22
  PROTOCOL_VERSION_HEADER = "HTTP_CONNECT_PROTOCOL_VERSION"
@@ -28,11 +29,20 @@ module ConnectRpcRails
28
29
  #: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
29
30
  def call(env)
30
31
  exception = env["action_dispatch.exception"]
31
- return @app.call(env) unless exception && env[PROTOCOL_VERSION_HEADER]
32
+ return @app.call(env) unless exception && connect_request?(env)
32
33
 
33
34
  render_connect_error(connect_error_for(exception, env))
34
35
  end
35
36
 
37
+ #: (Hash[String, untyped]) -> bool
38
+ private def connect_request?(env)
39
+ return true if env[PROTOCOL_VERSION_HEADER]
40
+
41
+ # ActionDispatch::ShowExceptions turns every request into a GET before calling this app.
42
+ method = env.fetch("action_dispatch.original_request_method") { env["REQUEST_METHOD"] }
43
+ method == "GET" && GetQuery.parse(env["QUERY_STRING"].to_s).connect == GetQuery::PROTOCOL_VERSION
44
+ end
45
+
36
46
  # The error to send for an escaped exception. The message is the status's own text,
37
47
  # never the exception's, which can quote internals. Override in a subclass to add
38
48
  # details every error should carry.
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 IVRy Inc.
5
+ # SPDX-License-Identifier: Apache-2.0
6
+
7
+ require "base64"
8
+ require "uri"
9
+
10
+ module ConnectRpcRails
11
+ # The query string of a Connect unary GET, which carries the codec, compression and message
12
+ # a POST sends as headers and body:
13
+ #
14
+ # GET /greet.v1.GreetService/SayHello?connect=v1&encoding=json&message=%7B%22name%22%3A%22Ada%22%7D
15
+ class GetQuery
16
+ # The parameters the protocol defines. Any other parameter is allowed and ignored.
17
+ PARAMS = ["connect", "base64", "compression", "encoding", "message"].freeze #: Array[String]
18
+
19
+ # The `connect` value Connect clients send, the GET counterpart of `connect-protocol-version: 1`.
20
+ PROTOCOL_VERSION = "v1" #: String
21
+
22
+ # The RFC 4648 §5 alphabet. Base64.urlsafe_decode64 checks the padding and the length, but
23
+ # also takes "+" and "/" from the standard alphabet.
24
+ URL_SAFE_BASE64 = /\A[A-Za-z0-9_-]*={0,2}\z/ #: Regexp
25
+
26
+ # Raised when the `message` parameter is missing or is not the base64 it claims to be.
27
+ class InvalidMessage < StandardError; end
28
+
29
+ # Each parameter takes its first value. An invalid percent-escape is kept as written, as
30
+ # URI.decode_www_form does, so it fails as an unknown value rather than being dropped. The
31
+ # query is decoded as binary, because decoding as UTF-8 scrubs the bytes of an unencoded
32
+ # binary message; values are then tagged UTF-8 without being checked.
33
+ #: (String) -> GetQuery
34
+ def self.parse(query_string)
35
+ params = {} #: Hash[String, String]
36
+ URI.decode_www_form(query_string, Encoding::BINARY).each do |key, value|
37
+ key = key.force_encoding(Encoding::UTF_8)
38
+ params[key] = value.force_encoding(Encoding::UTF_8) if PARAMS.include?(key) && !params.key?(key)
39
+ end
40
+ new(params)
41
+ end
42
+
43
+ #: (Hash[String, String]) -> void
44
+ def initialize(params)
45
+ @params = params
46
+ end
47
+
48
+ #: () -> String?
49
+ def connect = @params["connect"]
50
+
51
+ #: () -> String?
52
+ def encoding = @params["encoding"]
53
+
54
+ #: () -> String?
55
+ def compression = @params["compression"]
56
+
57
+ # The `message` parameter's bytes. With `base64=1` it is URL-safe base64, padded or not;
58
+ # any other `base64` value is ignored, per the protocol.
59
+ #: () -> String
60
+ def message
61
+ message = @params["message"] || raise(InvalidMessage, "missing message query parameter")
62
+ return message unless @params["base64"] == "1"
63
+ raise InvalidMessage, "invalid message query parameter" unless URL_SAFE_BASE64.match?(message)
64
+
65
+ Base64.urlsafe_decode64(message)
66
+ rescue ArgumentError
67
+ raise InvalidMessage, "invalid message query parameter"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 IVRy Inc.
5
+ # SPDX-License-Identifier: Apache-2.0
6
+
7
+ require "active_support/concern"
8
+ require "google/protobuf/well_known_types"
9
+ require "protovalidate"
10
+
11
+ require "connect_rpc_rails"
12
+
13
+ module ConnectRpcRails
14
+ # Checks a Connect request against the `buf.validate` rules of its message before the RPC
15
+ # runs, and answers a violation the way AIP-193 describes: `invalid_argument`, with a
16
+ # `google.rpc.BadRequest` naming each field.
17
+ #
18
+ # class GreetController < ActionController::API
19
+ # include ConnectRpcRails::Controller
20
+ # include ConnectRpcRails::MessageValidatable
21
+ # include BearerAuthentication
22
+ # connect_service "greet.v1.GreetService"
23
+ #
24
+ # def say_hello = ...
25
+ # end
26
+ #
27
+ # Fields are named in the caller's encoding: `preferredLanguage` for a JSON request,
28
+ # `preferred_language` for a binary one. `google.rpc.BadRequest` is looked up in the
29
+ # descriptor pool rather than required, because googleapis belongs to the application: it
30
+ # generates `google/rpc/error_details.proto` alongside its own protos, or bundles
31
+ # googleapis-common-protos-types.
32
+ #
33
+ # A violation raises ViolationError, which the concern rescues into the Connect error
34
+ # #connect_error_for_violation builds. Override that method to answer differently. Opt a
35
+ # controller out with `skip_before_action :validate_connect_message!` at its top.
36
+ # @rbs module-self ActionController::API
37
+ # @rbs module-self Controller
38
+ module MessageValidatable
39
+ extend ActiveSupport::Concern
40
+
41
+ # Raised when `google.rpc.BadRequest` is not in the descriptor pool.
42
+ class MissingDescriptorError < StandardError; end
43
+
44
+ # Raised when a request breaks a rule its message declares.
45
+ class ViolationError < StandardError
46
+ # @return [Array<Protovalidate::Violation>]
47
+ attr_reader :violations #: Array[untyped]
48
+
49
+ # @return [Object] the request message the violations were found in
50
+ attr_reader :request_message #: untyped
51
+
52
+ #: (Array[untyped] violations, untyped request_message) -> void
53
+ def initialize(violations, request_message)
54
+ @violations = violations
55
+ @request_message = request_message
56
+ super(violations.map(&:to_s).join("; "))
57
+ end
58
+ end
59
+
60
+ BAD_REQUEST = "google.rpc.BadRequest" #: String
61
+
62
+ # AIP-193: a FieldViolation's reason is UPPER_SNAKE_CASE and at most 63 characters.
63
+ REASON_PATTERN = /\A[A-Z][A-Z0-9_]{1,61}[A-Z0-9]\z/ #: Regexp
64
+
65
+ # Published for a rule id that cannot be a reason, such as a CEL rule named in lowercase.
66
+ FALLBACK_REASON = "INVALID_VALUE" #: String
67
+
68
+ # `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
69
+ # way to name.
70
+ # steep:ignore:start
71
+ included do
72
+ # Registered at include time so `skip_before_action` at the top of a controller has an
73
+ # entry to delete. .method_added moves it behind the callbacks declared after this.
74
+ before_action(:validate_connect_message!)
75
+ rescue_from(ViolationError, with: :render_connect_violation)
76
+ end
77
+
78
+ class_methods do
79
+ # Rails appends a re-registered callback to the end of the chain, so defining an RPC
80
+ # moves the validation behind the authentication a controller declares above it. An
81
+ # invalid request answered before an unauthenticated one would tell a caller what a valid
82
+ # request looks like. No entry means the controller skipped it, and it stays skipped.
83
+ def method_added(method_name)
84
+ super
85
+ return unless connect_rpcs&.key?(method_name.to_s)
86
+ return unless _process_action_callbacks.any? { |callback| callback.filter == :validate_connect_message! }
87
+
88
+ before_action(:validate_connect_message!)
89
+ end
90
+ end
91
+ # steep:ignore:end
92
+
93
+ # The violated field as `parts[0].part_name`, each segment resolved against the descriptor
94
+ # so it is spelled the way the caller's encoding spells it. Empty for a message-level rule.
95
+ #: (untyped path, untyped message_class, json_names: bool) -> String
96
+ def self.field_path(path, message_class, json_names:)
97
+ return "" if path.nil?
98
+
99
+ descriptor = message_class.descriptor
100
+ path.elements.map do |element|
101
+ field = descriptor&.lookup(element.field_name)
102
+ descriptor = field_message(field)
103
+ # A segment the descriptor cannot resolve keeps the violation's spelling, rather than
104
+ # being dropped and shifting the rest of the path.
105
+ name = if field.nil?
106
+ element.field_name
107
+ else
108
+ json_names ? field.json_name : field.name
109
+ end
110
+ "#{name}#{subscript(element)}"
111
+ end.join(".")
112
+ end
113
+
114
+ # The message a path continues into after this field: for a map, the value's type rather
115
+ # than the synthetic entry, because the key is the subscript and not a segment.
116
+ #: (untyped field) -> untyped
117
+ def self.field_message(field)
118
+ return unless field&.type == :message
119
+
120
+ entry = field.subtype
121
+ return entry unless entry.options.map_entry
122
+
123
+ value = entry.lookup("value")
124
+ value.type == :message ? value.subtype : nil
125
+ end
126
+
127
+ #: (untyped element) -> String
128
+ def self.subscript(element)
129
+ case element.subscript
130
+ when :index then "[#{element.index}]"
131
+ when :bool_key then "[#{element.bool_key}]"
132
+ when :int_key then "[#{element.int_key}]"
133
+ when :uint_key then "[#{element.uint_key}]"
134
+ when :string_key then "[#{element.string_key.inspect}]"
135
+ else ""
136
+ end
137
+ end
138
+
139
+ # The rule id as a reason constant: `string.min_len` becomes `STRING_MIN_LEN`.
140
+ #: (String rule_id) -> String
141
+ def self.reason(rule_id)
142
+ reason = rule_id.upcase.tr(".", "_")
143
+ REASON_PATTERN.match?(reason) ? reason : FALLBACK_REASON
144
+ end
145
+
146
+ # A googleapis message class, looked up in the descriptor pool.
147
+ #: (String name) -> untyped
148
+ def self.googleapis_class(name)
149
+ descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(name)
150
+ if descriptor.nil?
151
+ raise MissingDescriptorError,
152
+ "#{name} is not in the descriptor pool; generate google/rpc/error_details.proto " \
153
+ "or bundle googleapis-common-protos-types"
154
+ end
155
+
156
+ descriptor.msgclass
157
+ end
158
+
159
+ private_class_method :field_message, :subscript
160
+
161
+ #: () -> void
162
+ private def validate_connect_message!
163
+ return unless self.class.connect_rpcs[action_name]
164
+
165
+ message = connect_request
166
+ return if message.nil?
167
+
168
+ # protovalidate's signatures are not loaded: see rbs_collection.yaml.
169
+ violations = Protovalidate.collect_violations(message) # steep:ignore
170
+ return if violations.empty?
171
+
172
+ raise ViolationError.new(violations, message)
173
+ end
174
+
175
+ # The Connect error a violation is answered with: `invalid_argument` with a
176
+ # `google.rpc.BadRequest`, naming each field the way this request's encoding spells it.
177
+ # Override to answer differently.
178
+ #: (ViolationError error) -> Error
179
+ private def connect_error_for_violation(error)
180
+ bad_request = MessageValidatable.googleapis_class(BAD_REQUEST)
181
+ field_violation = MessageValidatable.googleapis_class("#{BAD_REQUEST}.FieldViolation")
182
+ json_names = Codec::Json == connect_codec
183
+ field_violations = error.violations.map do |violation|
184
+ field_violation.new(
185
+ field: MessageValidatable.field_path(violation.field, error.request_message.class, json_names:),
186
+ reason: MessageValidatable.reason(violation.rule_id),
187
+ description: violation.message,
188
+ )
189
+ end
190
+ detail = field_violations.map { |v| v.field.empty? ? v.description : "#{v.field}: #{v.description}" }
191
+
192
+ Error.new(
193
+ :invalid_argument,
194
+ detail.join("; "),
195
+ details: [Google::Protobuf::Any.pack(bad_request.new(field_violations:))],
196
+ )
197
+ end
198
+
199
+ # Raised rather than only built, so the Connect error carries the violation as its cause for
200
+ # whatever reports it.
201
+ #: (ViolationError error) -> void
202
+ private def render_connect_violation(error)
203
+ raise connect_error_for_violation(error), cause: error
204
+ rescue Error => connect_error
205
+ render_connect_exception(connect_error)
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 IVRy Inc.
5
+ # SPDX-License-Identifier: Apache-2.0
6
+
7
+ # The protovalidate integration, required on its own because protovalidate is not a dependency
8
+ # of connect_rpc_rails: a service that declares no buf.validate rules never loads it.
9
+ #
10
+ # require "connect_rpc_rails/protovalidate"
11
+
12
+ require "connect_rpc_rails/message_validatable"
@@ -30,7 +30,7 @@ module ConnectRpcRails
30
30
  # service but not that method, so it is answered `unimplemented` the way a declared RPC
31
31
  # nobody implements always is.
32
32
  #
33
- # Every RPC the descriptor declares becomes one `POST /<pkg.Service>/<Method>` route to
33
+ # Every RPC the descriptor declares becomes one `/<pkg.Service>/<Method>` route to
34
34
  # the action implementing it (1 RPC = 1 action), so the protobuf definition is the only
35
35
  # place the method list lives. A declared RPC the controller doesn't implement is
36
36
  # answered Connect `unimplemented` by the controller, not by a route. After the RPC
@@ -5,6 +5,8 @@
5
5
  # SPDX-License-Identifier: Apache-2.0
6
6
 
7
7
  require "google/protobuf"
8
+ # MethodDescriptor#options decodes into MethodOptions, which generated code does not load.
9
+ require "google/protobuf/descriptor_pb"
8
10
 
9
11
  module ConnectRpcRails
10
12
  # Reads a protobuf ServiceDescriptor (generated by protoc/buf and present in the
@@ -22,7 +24,9 @@ module ConnectRpcRails
22
24
  # so leftover instance state could leak between callers, whereas Rails builds a
23
25
  # controller per request.
24
26
  class ServiceRegistration
25
- Rpc = Struct.new(:name, :action, :input_class, :output_class, keyword_init: true)
27
+ # `idempotency_level` is the method option's enum symbol: `:NO_SIDE_EFFECTS`, `:IDEMPOTENT`
28
+ # or `:IDEMPOTENCY_UNKNOWN`. `streaming` is true when either side of the RPC streams.
29
+ Rpc = Struct.new(:name, :action, :input_class, :output_class, :idempotency_level, :streaming, keyword_init: true)
26
30
 
27
31
  attr_reader :service_name #: String
28
32
 
@@ -39,6 +43,8 @@ module ConnectRpcRails
39
43
  action: action,
40
44
  input_class: method.input_type.msgclass,
41
45
  output_class: method.output_type.msgclass,
46
+ idempotency_level: method.options.idempotency_level,
47
+ streaming: method.client_streaming || method.server_streaming,
42
48
  )
43
49
  end
44
50
  end
@@ -5,5 +5,5 @@
5
5
  # SPDX-License-Identifier: Apache-2.0
6
6
 
7
7
  module ConnectRpcRails
8
- VERSION = '0.1.0'
8
+ VERSION = '0.2.0'
9
9
  end
@@ -7,6 +7,7 @@
7
7
  require 'connect_rpc_rails/version'
8
8
  require 'connect_rpc_rails/errors'
9
9
  require 'connect_rpc_rails/codec'
10
+ require 'connect_rpc_rails/get_query'
10
11
  require 'connect_rpc_rails/service_registration'
11
12
  require 'connect_rpc_rails/controller'
12
13
  require 'connect_rpc_rails/routing'