connect_rpc_rails 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.
@@ -0,0 +1,402 @@
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 "action_controller"
8
+ require "action_dispatch/middleware/exception_wrapper"
9
+ require "timeout"
10
+
11
+ module ConnectRpcRails
12
+ # Connect unary transport as an ActionController::API mix-in.
13
+ #
14
+ # Include this in an `ActionController::API` subclass and call `connect_service`:
15
+ # each RPC in the descriptor is one Rails *action* on that controller, so every call
16
+ # flows through the normal controller lifecycle. That is the whole point —
17
+ # `process_action.action_controller` fires, so the entire Rails observability
18
+ # ecosystem (Datadog resource naming, Sentry transactions, lograge, the
19
+ # "Completed 200 in Xms" request log) lights up for free. The transport wraps the
20
+ # action rather than generating it: decode the Connect body, call the action, encode the
21
+ # reply. Errors become Connect wire errors in one place (`rescue_from`), the deadline is
22
+ # one `around_action`.
23
+ #
24
+ # class GreetController < ActionController::API
25
+ # include ConnectRpcRails::Controller
26
+ # include BearerAuthentication
27
+ # connect_service "greet.v1.GreetService"
28
+ #
29
+ # def say_hello
30
+ # SayHelloResponse.new(greeting: "Hello, #{connect_request.name}!")
31
+ # end
32
+ # end
33
+ #
34
+ # An action takes no arguments, like every other Rails action: the decoded request is
35
+ # `connect_request`, read the way `params` is read in an HTTP controller. It is an
36
+ # ordinary instance method, so it gets the per-request instance Rails already builds for
37
+ # every action — nothing holding request state outlives the request. There is
38
+ # deliberately no handler object registered on the controller class: that one instance
39
+ # would be shared by every request in the process.
40
+ #
41
+ # A Connect call is an HTTP request, so cross-cutting concerns are Rails callbacks and
42
+ # nothing else: `before_action` for auth (writing an ivar, not a context bag),
43
+ # `around_action` to wrap a call, `rescue_from` (or `map_connect_errors`) to turn a
44
+ # domain exception into a Connect code. The decode happens before the callbacks run, so
45
+ # a `before_action` can already read `connect_request`. There is no interceptor layer:
46
+ # callbacks do the same job with `only:`/`except:`, inheritance and `skip_*` on top.
47
+ #
48
+ # Exceptions the host app already classifies need no mapping at all: Rails keeps that
49
+ # classification in `config.action_dispatch.rescue_responses`, which every railtie and
50
+ # gem registers into (`ActiveRecord::RecordNotFound` is `:not_found` there), so including
51
+ # this module installs a Connect code for each of those entries. `map_connect_errors` is
52
+ # for the ones Rails doesn't know about, and overrides these.
53
+ #
54
+ # Routes are 1 RPC = 1 action, drawn per service from the descriptor (see
55
+ # ConnectRpcRails::Routing). A declared RPC the controller doesn't implement is answered
56
+ # `unimplemented` by #action_missing; a method the descriptor never declared lands on the
57
+ # routes' catch-all, #connect_unknown_method, and is a 404. Routes match every verb, so a
58
+ # wrong-verb request does reach the controller and becomes a Connect-correct 405.
59
+ # @rbs module-self ActionController::API
60
+ # @rbs module-self _ConnectControllerSelf
61
+ module Controller
62
+ # The action and path parameter the routes DSL points its per-service catch-all at.
63
+ UNKNOWN_METHOD_ACTION = "connect_unknown_method"
64
+ UNKNOWN_METHOD_PARAM = "connect_method"
65
+
66
+ # The one `rescue_responses` entry that doesn't become a Connect error: #action_missing
67
+ # raises ActionNotFound for a name that isn't an RPC, which means the app routed
68
+ # something to an action this controller doesn't have. That is a misconfiguration for
69
+ # the host's error handling to surface, not a result to hand a caller.
70
+ RESCUE_RESPONSE_EXCLUSIONS = ["AbstractController::ActionNotFound"].freeze
71
+
72
+ # Raised inside Timeout so it can't be confused with an unrelated Timeout::Error.
73
+ class DeadlineExceeded < StandardError; end
74
+ private_constant :DeadlineExceeded
75
+
76
+ #: (untyped) -> void
77
+ def self.included(base)
78
+ base.extend(ClassMethods)
79
+ no_mapping = {} #: Hash[Class, Symbol]
80
+ base.class_attribute(:connect_error_mapping, default: no_mapping)
81
+ # class_attribute, not a singleton attr_accessor: a base controller declaring the
82
+ # service has to be readable from the subclasses that serve one RPC each.
83
+ base.class_attribute(:connect_registration)
84
+ base.class_attribute(:connect_rpcs)
85
+ # Registered first, so both the Error handler and anything `map_connect_errors` adds
86
+ # later take precedence: Rails picks the most recently registered matching handler.
87
+ install_rescue_response_defaults(base)
88
+ base.rescue_from(Error, with: :render_connect_error)
89
+ base.around_action(:enforce_connect_deadline)
90
+ base.prepend_before_action(:validate_connect_request)
91
+ end
92
+
93
+ # Gives every exception Rails already assigns an HTTP status a Connect code, so an app
94
+ # doesn't restate a mapping the framework ships. Registered by class *name*, which is
95
+ # both what `rescue_responses` is keyed by and what keeps this from loading the classes
96
+ # (`rescue_from` resolves the name when it has to rescue something).
97
+ #
98
+ # This reads `rescue_responses` once, when the controller is loaded — in a Rails app
99
+ # that is after the initializers have merged the app's own entries in.
100
+ #: (untyped) -> void
101
+ def self.install_rescue_response_defaults(base)
102
+ ActionDispatch::ExceptionWrapper.rescue_responses.each_key do |class_name|
103
+ next if RESCUE_RESPONSE_EXCLUSIONS.include?(class_name)
104
+
105
+ base.rescue_from(class_name, with: :render_rescue_response_error)
106
+ end
107
+ end
108
+
109
+ # @rbs module-self Module
110
+ # @rbs module-self _ConnectControllerClass
111
+ module ClassMethods
112
+ # Declares which Connect service this controller serves, named as the `.proto` names
113
+ # it: `connect_service "greet.v1.GreetService"`. The string is looked up in the
114
+ # descriptor pool, so it greps straight to the protobuf definition (and back).
115
+ #
116
+ # Subclasses inherit the declaration, so a service split across a controller per RPC
117
+ # declares it once on their shared base class.
118
+ #: (String | untyped service) -> void
119
+ def connect_service(service)
120
+ self.connect_registration = ServiceRegistration.new(service)
121
+ rpcs = {} #: Hash[String, untyped]
122
+ connect_registration.rpcs.each { |rpc| rpcs[rpc.action] = rpc }
123
+ self.connect_rpcs = rpcs
124
+ end
125
+
126
+ # Turns domain exceptions into Connect errors for every RPC on the controller:
127
+ #
128
+ # map_connect_errors MyDomain::Invalid => :invalid_argument,
129
+ # MyDomain::QuotaReached => :resource_exhausted
130
+ #
131
+ # Only for exceptions Rails doesn't already classify — anything in
132
+ # `config.action_dispatch.rescue_responses` has a code without being named here (see
133
+ # .install_rescue_response_defaults) — or to override the code one of those got.
134
+ #
135
+ # This is `rescue_from` with the conversion filled in — each class gets its own
136
+ # handler, so nothing is blanket-rescued and an unmapped exception still propagates
137
+ # to the host's error middleware. The handler renders rather than re-raising because
138
+ # Rails calls one `rescue_from` handler per exception: an Error raised inside a
139
+ # handler would escape instead of reaching the one that renders the wire error.
140
+ #: (Hash[Class, Symbol]) -> void
141
+ def map_connect_errors(mapping)
142
+ self.connect_error_mapping = connect_error_mapping.merge(mapping)
143
+ mapping.each_key { |klass| rescue_from(klass, with: :render_mapped_connect_error) }
144
+ end
145
+ end
146
+
147
+ # Connect actions decode the body themselves and never read `params`, so skip
148
+ # Rails' lazy body param parsing: it would deserialize a JSON body a second time
149
+ # (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).
158
+ def process_action(*)
159
+ request.request_parameters = connect_request_params
160
+ format = request.content_type.to_s.start_with?("application/proto") ? :proto : :json
161
+ request.set_header("action_dispatch.request.formats", [Mime[format]])
162
+ super
163
+ end
164
+
165
+ # The RPC method is the controller's own method, so the transport wraps dispatch
166
+ # instead of defining the action body: `send_action` is
167
+ # Rails' documented seam for "change how action methods are called".
168
+ private def send_action(action, *args)
169
+ rpc = self.class.connect_rpcs[action]
170
+ return super unless rpc
171
+
172
+ dispatch_connect_rpc(rpc)
173
+ end
174
+
175
+ # The service-prefix catch-all the routes DSL draws after the RPC routes. Every method
176
+ # the descriptor declares has its own route, so reaching here means this one isn't part
177
+ # of the service's contract at all: a 404 whatever the verb — rendered rather than
178
+ # raised as a RoutingError, so it doesn't depend on the host having Rails' exception
179
+ # middleware in the stack. No body is decoded for it either.
180
+ def connect_unknown_method
181
+ @connect_full_method = "#{self.class.connect_registration.service_name}/#{connect_method_param}"
182
+ render(plain: "not found", status: 404)
183
+ end
184
+
185
+ # Every RPC the descriptor declares is routed, whether or not this controller has the
186
+ # method, because what the service serves is the descriptor's business and not the
187
+ # router's. A routed RPC with no action is exactly the case Connect answers
188
+ # `unimplemented`, and Rails' own hook for "this action doesn't exist" is where that is
189
+ # answered. An action name that isn't an RPC at all raises what Rails would have raised
190
+ # had this hook not been defined.
191
+ private def action_missing(name)
192
+ unless self.class.connect_rpcs[name]
193
+ raise AbstractController::ActionNotFound.new(
194
+ "The action '#{name}' could not be found for #{self.class.name}",
195
+ self,
196
+ name,
197
+ )
198
+ end
199
+
200
+ raise Error.new(:unimplemented, "#{@connect_full_method} is not implemented")
201
+ end
202
+
203
+ # Transport preconditions, prepended so they run ahead of any application callback: a
204
+ # wrong verb or a body nothing can read is answered the way the protocol says instead
205
+ # of being handed to auth (or anything else the controller declared) first. Halting
206
+ # with `render` is Rails' own way to stop a callback chain.
207
+ private def validate_connect_request
208
+ rpc = self.class.connect_rpcs[action_name]
209
+ # The catch-all and any non-RPC action have nothing to validate: they answer for
210
+ # themselves (see #connect_unknown_method).
211
+ return unless rpc
212
+
213
+ @connect_full_method = "#{self.class.connect_registration.service_name}/#{rpc.name}"
214
+
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)
219
+
220
+ 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
225
+ end
226
+
227
+ #: () -> String
228
+ private def connect_method_param
229
+ params[UNKNOWN_METHOD_PARAM].to_s
230
+ end
231
+
232
+ private def connect_request_params
233
+ rpc = self.class.connect_rpcs[action_name]
234
+ codec = Codec.for_content_type(request.content_type)
235
+ return {} unless request.post? && rpc && codec
236
+
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 || "")
240
+ @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
245
+ {}
246
+ end
247
+
248
+ # The transport preconditions have all passed by the time this runs (see
249
+ # #validate_connect_request), so this is the wire round-trip and nothing else. The RPC
250
+ # runs on this controller instance — the one Rails built for this request — and takes
251
+ # no arguments, like any other action. Only implemented methods are routed (the routes
252
+ # DSL checks that at boot), so there is no missing-method case.
253
+ private def dispatch_connect_rpc(rpc)
254
+ message = public_send(rpc.action)
255
+
256
+ apply_connect_trailers
257
+ render body: connect_codec.encode(message)
258
+ # Connect wants a bare `application/proto` / `application/json`; drop the
259
+ # charset ActionController appends (conformance rejects it on proto).
260
+ response.headers["content-type"] = connect_codec.content_type
261
+ end
262
+
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.
265
+ #: () -> Codec::_Codec
266
+ private def connect_codec
267
+ Codec.for_content_type(request.content_type) ||
268
+ raise(Error.new(:internal, "no codec for #{request.content_type}"))
269
+ end
270
+
271
+ # Connect rejects unsupported request compression with `unimplemented`, advertising
272
+ # what it can accept.
273
+ private def reject_unsupported_encoding
274
+ encoding = request.headers["Content-Encoding"]
275
+ return if encoding.nil? || ["", "identity"].include?(encoding)
276
+
277
+ response.headers["accept-encoding"] = "identity"
278
+ raise Error.new(:unimplemented, "unsupported content-encoding: #{encoding}")
279
+ end
280
+
281
+ # The decoded request message, for the action to read the way an HTTP action reads
282
+ # `params`. Assigned during decode, so callbacks see it too.
283
+ #: () -> untyped
284
+ private def connect_request
285
+ @connect_request
286
+ end
287
+
288
+ # Request metadata: the request's HTTP headers, downcased and dasherized, which is
289
+ # what Connect metadata is on the wire. Response metadata needs no helper — leading
290
+ # metadata is `response.headers`.
291
+ #: () -> Hash[String, String]
292
+ private def connect_metadata
293
+ @connect_metadata ||= begin
294
+ metadata = {} #: Hash[String, String]
295
+ request.headers.each do |key, value|
296
+ next unless key.is_a?(String) && key.start_with?("HTTP_")
297
+
298
+ metadata[key.delete_prefix("HTTP_").downcase.tr("_", "-")] = value
299
+ end
300
+ metadata
301
+ end
302
+ end
303
+
304
+ # Trailing metadata to send. Unary Connect puts trailers in the response as
305
+ # `trailer-`-prefixed headers, so this collects them and #apply_connect_trailers does
306
+ # the prefixing — a caller writes `connect_trailers["x-audit"] = ["1"]` and doesn't
307
+ # encode the wire form itself.
308
+ #: () -> Hash[String, Array[String]]
309
+ private def connect_trailers
310
+ @connect_trailers ||= {}
311
+ end
312
+
313
+ # Rack joins repeated headers with a newline, which is also how multi-value Connect
314
+ # metadata is sent.
315
+ private def apply_connect_trailers
316
+ connect_trailers.each { |name, values| response.headers["trailer-#{name}"] = Array(values).join("\n") }
317
+ end
318
+
319
+ # The deadline this call must finish by, from `connect-timeout-ms`, or nil when the
320
+ # caller sent no timeout. The library enforces it; an RPC reads it to budget its own
321
+ # downstream calls.
322
+ #: () -> Time?
323
+ private def connect_deadline
324
+ @connect_deadline
325
+ end
326
+
327
+ #: () -> Integer?
328
+ private def connect_timeout_ms
329
+ @connect_timeout_ms
330
+ end
331
+
332
+ # Enforces connect-timeout-ms. Runs as an around_action so the whole action (the
333
+ # callbacks and the RPC) is under the deadline; the Error it raises is rendered by
334
+ # rescue_from.
335
+ #: () { (?) -> untyped } -> untyped
336
+ private def enforce_connect_deadline(&block)
337
+ raw_timeout = connect_metadata["connect-timeout-ms"]
338
+ # connect-timeout-ms is up to 10 ASCII digits per the protocol. String#to_i would
339
+ # coerce "abc" to 0 (an already-expired deadline → 504) and silently truncate
340
+ # "10abc" to 10; a malformed value is client input, so reject it as invalid_argument.
341
+ if raw_timeout && !/\A\d{1,10}\z/.match?(raw_timeout)
342
+ raise Error.new(:invalid_argument, "invalid connect-timeout-ms")
343
+ end
344
+ return yield unless raw_timeout
345
+
346
+ @connect_timeout_ms = Integer(raw_timeout, 10)
347
+ @connect_deadline = Time.now + (@connect_timeout_ms / 1000.0)
348
+ remaining = @connect_deadline - Time.now
349
+ raise Error.new(:deadline_exceeded, "deadline exceeded") if remaining <= 0
350
+
351
+ begin
352
+ Timeout.timeout(remaining, DeadlineExceeded, &block)
353
+ rescue DeadlineExceeded
354
+ raise Error.new(:deadline_exceeded, "deadline exceeded")
355
+ end
356
+ end
357
+
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)
361
+ _, 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.
364
+ raise exception unless code
365
+
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))
384
+ end
385
+
386
+ private def render_connect_error(error)
387
+ # Trailing metadata the RPC set before raising must still be sent. Leading metadata
388
+ # needs nothing here: it was written straight to `response.headers`, which survives
389
+ # the raise.
390
+ apply_connect_trailers
391
+ render json: error.to_wire, status: error.http_status
392
+ end
393
+
394
+ # The official hook for enriching the process_action.action_controller payload
395
+ # (same mechanism lograge/Datadog custom fields use). Adds the fully-qualified
396
+ # Connect method so a trace/log resource can read "pkg.Service/Method".
397
+ private def append_info_to_payload(payload)
398
+ super
399
+ payload[:connect_method] = @connect_full_method if @connect_full_method
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 IVRy Inc.
5
+ # SPDX-License-Identifier: Apache-2.0
6
+
7
+ module ConnectRpcRails
8
+ # A Connect protocol error. The code set and its HTTP status mapping are defined
9
+ # by the spec (https://connectrpc.com/docs/protocol/). Handlers raise these; the
10
+ # transport turns them into the wire error body.
11
+ class Error < StandardError
12
+ CODE_TO_HTTP_STATUS = {
13
+ canceled: 499,
14
+ unknown: 500,
15
+ invalid_argument: 400,
16
+ deadline_exceeded: 504,
17
+ not_found: 404,
18
+ already_exists: 409,
19
+ permission_denied: 403,
20
+ resource_exhausted: 429,
21
+ failed_precondition: 400,
22
+ aborted: 409,
23
+ out_of_range: 400,
24
+ unimplemented: 501,
25
+ internal: 500,
26
+ unavailable: 503,
27
+ data_loss: 500,
28
+ unauthenticated: 401,
29
+ }.freeze #: Hash[Symbol, Integer]
30
+
31
+ # Rails keeps its exception taxonomy in HTTP statuses
32
+ # (`config.action_dispatch.rescue_responses`), so reusing it means reading a status back
33
+ # as a Connect code. This is the inverse of CODE_TO_HTTP_STATUS above — the same
34
+ # code↔status pairing `google.rpc.Code` defines, read the other way.
35
+ #
36
+ # It is *not* gRPC's HTTP-to-status mapping
37
+ # (https://grpc.github.io/grpc/core/md_doc_http-grpc-status-mapping.html), and Connect's
38
+ # equivalent table doesn't apply here either. Those describe a client reading an HTTP
39
+ # response that carries no RPC status at all — a proxy's 502, a load balancer's 404 —
40
+ # where 400 means "an intermediary rejected the request" (`internal`) and 404 means "no
41
+ # such service here" (`unimplemented`). Read that way round, a `RecordNotFound` would go
42
+ # out as `unimplemented`, colliding with the one thing that code means on this server: a
43
+ # routed RPC with no action. The table also stops at seven statuses and sends the rest to
44
+ # `unknown`, which is most of what Active Record raises (409, 422).
45
+ #
46
+ # Where several codes share a status, the entry is the one that fits what Rails raises
47
+ # there: 409 is `aborted` for a `StaleObjectError`'s lost race, not `already_exists`; 400
48
+ # is plain `invalid_argument`. Statuses no code claims (405, 406, 415, 422) take the
49
+ # nearest code by meaning.
50
+ HTTP_STATUS_TO_CODE = {
51
+ 400 => :invalid_argument,
52
+ 401 => :unauthenticated,
53
+ 403 => :permission_denied,
54
+ 404 => :not_found,
55
+ 405 => :unimplemented,
56
+ 406 => :invalid_argument,
57
+ 409 => :aborted,
58
+ 412 => :failed_precondition,
59
+ 415 => :invalid_argument,
60
+ 422 => :invalid_argument,
61
+ 429 => :resource_exhausted,
62
+ 499 => :canceled,
63
+ 500 => :internal,
64
+ 501 => :unimplemented,
65
+ 503 => :unavailable,
66
+ 504 => :deadline_exceeded,
67
+ }.freeze #: Hash[Integer, Symbol]
68
+
69
+ # The Connect code for an HTTP status. Statuses the table doesn't name fall back on
70
+ # their class, so a mapping Rails (or a gem) adds is never left without a code.
71
+ #: (Integer) -> Symbol
72
+ def self.code_for_http_status(status)
73
+ HTTP_STATUS_TO_CODE[status] || (status >= 500 ? :internal : :invalid_argument)
74
+ end
75
+
76
+ attr_reader :code #: Symbol
77
+ attr_reader :details #: Array[untyped]
78
+
79
+ #: (Symbol, ?String?, ?details: Array[untyped]) -> void
80
+ def initialize(code, message = nil, details: [])
81
+ raise ArgumentError, "unknown Connect error code: #{code.inspect}" unless CODE_TO_HTTP_STATUS.key?(code)
82
+
83
+ @code = code
84
+ @details = details
85
+ super(message || code.to_s)
86
+ end
87
+
88
+ #: () -> Integer
89
+ def http_status
90
+ CODE_TO_HTTP_STATUS.fetch(@code)
91
+ end
92
+
93
+ #: () -> Hash[Symbol, untyped]
94
+ def to_wire
95
+ body = {code: @code.to_s, message: message} #: Hash[Symbol, untyped]
96
+ body[:details] = @details.map { |any| encode_detail(any) } unless @details.empty?
97
+ body
98
+ end
99
+
100
+ # A detail is a google.protobuf.Any; its Connect wire form is the bare message
101
+ # type name plus the serialized bytes as unpadded standard base64.
102
+ private def encode_detail(any)
103
+ {
104
+ type: any.type_url.split('/').last,
105
+ value: [any.value].pack('m0').delete('='),
106
+ }
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,64 @@
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 "json"
8
+ require "action_dispatch/middleware/exception_wrapper"
9
+
10
+ module ConnectRpcRails
11
+ # Wraps a Rails `config.exceptions_app` so an exception escaping a Connect call — one
12
+ # raised before dispatch, which no controller `rescue_from` ever sees — is still answered
13
+ # in the protocol's error shape.
14
+ #
15
+ # config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
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.
19
+ class ExceptionsApp
20
+ # `connect-protocol-version`, as Rack names it in the env.
21
+ PROTOCOL_VERSION_HEADER = "HTTP_CONNECT_PROTOCOL_VERSION"
22
+
23
+ #: (untyped app) -> void
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ #: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
29
+ def call(env)
30
+ exception = env["action_dispatch.exception"]
31
+ return @app.call(env) unless exception && env[PROTOCOL_VERSION_HEADER]
32
+
33
+ render_connect_error(connect_error_for(exception, env))
34
+ end
35
+
36
+ # The error to send for an escaped exception. The message is the status's own text,
37
+ # never the exception's, which can quote internals. Override in a subclass to add
38
+ # details every error should carry.
39
+ #: (Exception, Hash[String, untyped]) -> Error
40
+ private def connect_error_for(exception, env)
41
+ return exception if exception.is_a?(Error)
42
+
43
+ # Read the way ActionDispatch::PublicExceptions reads it, so `rescue_responses` stays
44
+ # the one place the app classifies an exception.
45
+ status = ActionDispatch::ExceptionWrapper.new(
46
+ env["action_dispatch.backtrace_cleaner"], exception
47
+ ).status_code
48
+ Error.new(Error.code_for_http_status(status), Rack::Utils::HTTP_STATUS_CODES.fetch(status, "error"))
49
+ end
50
+
51
+ # A unary Connect error is a JSON body whatever the request's codec was, per the
52
+ # protocol.
53
+ #: (Error) -> [Integer, Hash[String, String], Array[String]]
54
+ private def render_connect_error(error)
55
+ body = JSON.generate(error.to_wire)
56
+
57
+ [
58
+ error.http_status,
59
+ {"content-type" => Codec::Json::CONTENT_TYPE, "content-length" => body.bytesize.to_s},
60
+ [body],
61
+ ]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,19 @@
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 "rails/railtie"
8
+
9
+ module ConnectRpcRails
10
+ # Hooks the gem into a Rails app at the framework's own boot point rather than by
11
+ # patching Action Dispatch when the gem is required: the initializer runs once the
12
+ # frameworks are loaded and before the routes are drawn, which is all the routes DSL
13
+ # needs.
14
+ class Railtie < ::Rails::Railtie
15
+ initializer "connect_rpc_rails.install" do
16
+ ConnectRpcRails.install!
17
+ end
18
+ end
19
+ end