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,178 @@
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/core_ext/string/inflections"
8
+
9
+ module ConnectRpcRails
10
+ # Routes DSL for one Connect service, installed on the Rails routes mapper. The service
11
+ # is named as the `.proto` names it and mapped to a controller the way Rails' own `to:`
12
+ # names one — as a string, so drawing the routes doesn't load the controller class:
13
+ #
14
+ # Rails.application.routes.draw do
15
+ # connect_service "greet.v1.GreetService" => :greet
16
+ # end
17
+ #
18
+ # A block maps the RPCs to a controller each, for a service whose methods are better off
19
+ # not sharing one class — an RPC then gets its own `before_action`s rather than callbacks
20
+ # the whole service runs with `only:`:
21
+ #
22
+ # connect_service "greet.v1.GreetService" do
23
+ # rpc "SayHello" => :greet_say_hello
24
+ # rpc "SayGoodbye" => :greet_say_goodbye
25
+ # end
26
+ #
27
+ # Every mapped name has to be one the descriptor declares, so a typo or a rename fails at
28
+ # boot rather than drawing a route nothing reaches. The mapping does not have to cover the
29
+ # service: an RPC left out is routed to the first mapped controller, which serves the
30
+ # service but not that method, so it is answered `unimplemented` the way a declared RPC
31
+ # nobody implements always is.
32
+ #
33
+ # Every RPC the descriptor declares becomes one `POST /<pkg.Service>/<Method>` route to
34
+ # the action implementing it (1 RPC = 1 action), so the protobuf definition is the only
35
+ # place the method list lives. A declared RPC the controller doesn't implement is
36
+ # answered Connect `unimplemented` by the controller, not by a route. After the RPC
37
+ # routes comes one catch-all over the service prefix, which is where a method the
38
+ # descriptor never declared becomes a 404.
39
+ module Routing
40
+ # Whether the routed controllers can be resolved right now. Only under eager loading:
41
+ # Rails eager loads before it draws the routes, so the classes are already in memory
42
+ # and constantizing one autoloads nothing. With lazy loading (dev, and a plain Rack
43
+ # host) the class is deliberately left untouched.
44
+ #: () -> bool
45
+ def self.verify_controllers?
46
+ return false unless defined?(::Rails.application)
47
+
48
+ !!::Rails.application&.config&.eager_load
49
+ end
50
+
51
+ # Checks that each routed service resolves to a controller that actually serves it, so
52
+ # a service wired to the wrong controller fails at boot instead of 404-ing in
53
+ # production. Called as the routes are drawn (see .verify_controllers?).
54
+ #: (String, String) -> void
55
+ def self.verify_controller!(service_name, controller_path)
56
+ controller = "#{controller_path.camelize}Controller".constantize
57
+ registration = controller.connect_registration if controller.respond_to?(:connect_registration)
58
+ unless registration
59
+ raise ArgumentError, "#{controller} does not serve a Connect service: it needs `connect_service`"
60
+ end
61
+ unless registration.service_name == service_name
62
+ raise ArgumentError, "#{controller} serves #{registration.service_name}, not #{service_name}"
63
+ end
64
+ end
65
+
66
+ #: (Hash[String | Symbol, String | Symbol] | String | Symbol) ?{ (?MethodMapping) [self: MethodMapping] -> void } -> void
67
+ def connect_service(mapping, &block)
68
+ if block
69
+ unless mapping.is_a?(String) || mapping.is_a?(Symbol)
70
+ raise ArgumentError, "connect_service takes a service name with a block, not a controller mapping"
71
+ end
72
+
73
+ return Service.new(self, mapping.to_s, MethodMapping.collect(&block)).draw
74
+ end
75
+
76
+ unless mapping.is_a?(Hash)
77
+ raise ArgumentError, "connect_service takes a service-to-controller mapping, or a service name with a block"
78
+ end
79
+
80
+ mapping.each do |service_name, controller|
81
+ Service.new(self, service_name.to_s, controller.to_s).draw
82
+ end
83
+ end
84
+
85
+ # Collects the per-RPC controller mapping a `connect_service` block declares. `rpc`
86
+ # takes the method name as the `.proto` spells it, so both ends of the mapping grep to
87
+ # the protobuf definition.
88
+ class MethodMapping
89
+ #: () { (?MethodMapping) [self: MethodMapping] -> void } -> Hash[String, String]
90
+ def self.collect(&block)
91
+ collector = new
92
+ collector.instance_eval(&block)
93
+ collector.mapping
94
+ end
95
+
96
+ attr_reader :mapping #: Hash[String, String]
97
+
98
+ #: () -> void
99
+ def initialize
100
+ @mapping = {} #: Hash[String, String]
101
+ end
102
+
103
+ #: (Hash[String | Symbol, String | Symbol]) -> void
104
+ def rpc(mapping)
105
+ mapping.each { |name, controller| @mapping[name.to_s] = controller.to_s }
106
+ end
107
+ end
108
+
109
+ # Draws the routes for one service: every RPC the descriptor declares, then the
110
+ # catch-all. The RPCs go to one controller, or to the controller each is mapped to.
111
+ class Service
112
+ #: (untyped mapper, String service_name, String | Hash[String, String] controllers) -> void
113
+ def initialize(mapper, service_name, controllers)
114
+ @mapper = mapper
115
+ @controllers = controllers
116
+ @registration = ServiceRegistration.new(service_name)
117
+ verify_mapping! if controllers.is_a?(Hash)
118
+ end
119
+
120
+ #: () -> void
121
+ def draw
122
+ @registration.rpcs.each { |rpc| route(rpc.name, rpc.action, controller_for(rpc.name)) }
123
+ # The catch-all renders a 404 and nothing else, which any controller serving the
124
+ # service answers identically, so it goes to the first one mapped.
125
+ route("*#{Controller::UNKNOWN_METHOD_PARAM}", Controller::UNKNOWN_METHOD_ACTION, controller_paths.first)
126
+
127
+ return unless Routing.verify_controllers?
128
+
129
+ controller_paths.each { |path| Routing.verify_controller!(@registration.service_name, path) }
130
+ end
131
+
132
+ # A mapping is checked against the descriptor as the routes are drawn: a controller
133
+ # mapped to a name the service does not declare is a typo or a rename, and it would
134
+ # otherwise draw a route nothing can reach. An RPC the block leaves out is *not* an
135
+ # error — it is routed too (see #controller_for), because a declared RPC nobody
136
+ # implements is what Connect answers `unimplemented`.
137
+ #: () -> void
138
+ private def verify_mapping!
139
+ raise ArgumentError, "#{@registration.service_name} is mapped to no controller" if @controllers.empty?
140
+
141
+ undeclared = @controllers.keys - @registration.rpcs.map(&:name)
142
+ return if undeclared.empty?
143
+
144
+ raise ArgumentError, "#{@registration.service_name} declares no RPC named #{undeclared.join(", ")}"
145
+ end
146
+
147
+ # An RPC the mapping leaves out still gets a route, at the first mapped controller:
148
+ # it serves the service but not that method, so #action_missing answers it
149
+ # `unimplemented` (501) exactly as the protocol wants — where no route at all would
150
+ # have made it a 404.
151
+ #: (String) -> String
152
+ private def controller_for(rpc_name)
153
+ return @controllers unless @controllers.is_a?(Hash)
154
+
155
+ @controllers.fetch(rpc_name) { controller_paths.first }
156
+ end
157
+
158
+ #: () -> Array[String]
159
+ private def controller_paths
160
+ @controllers.is_a?(Hash) ? @controllers.values.uniq : [@controllers]
161
+ end
162
+
163
+ # Routes match every verb (`via: :all`) so a wrong-verb request reaches the
164
+ # controller and becomes a Connect-correct 405 rather than a router 404.
165
+ # `format: false` keeps the dots in the service name from being parsed as a format
166
+ # suffix.
167
+ #: (String, String, String) -> void
168
+ private def route(path, action, controller_path)
169
+ @mapper.match(
170
+ "/#{@registration.service_name}/#{path}",
171
+ to: "#{controller_path}##{action}",
172
+ via: :all,
173
+ format: false,
174
+ )
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,60 @@
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 "google/protobuf"
8
+
9
+ module ConnectRpcRails
10
+ # Reads a protobuf ServiceDescriptor (generated by protoc/buf and present in the
11
+ # descriptor pool) into the RPC table the transport dispatches from. Dispatch is
12
+ # reflection-based: the action names and the input/output message classes come
13
+ # entirely from the descriptor, so no per-service code generation is required.
14
+ #
15
+ # The service is named the way the `.proto` names it — `"greet.v1.GreetService"` — and
16
+ # looked up in the pool, so the string in the code is greppable from the protobuf
17
+ # definition. A ServiceDescriptor is accepted too, for a host that already holds one.
18
+ #
19
+ # The service is implemented by the controller itself — the RPC methods are ordinary
20
+ # controller instance methods. There is deliberately no handler object: a handler held
21
+ # on the controller class would be one instance shared by every request in the process,
22
+ # so leftover instance state could leak between callers, whereas Rails builds a
23
+ # controller per request.
24
+ class ServiceRegistration
25
+ Rpc = Struct.new(:name, :action, :input_class, :output_class, keyword_init: true)
26
+
27
+ attr_reader :service_name #: String
28
+
29
+ #: (String | untyped service) -> void
30
+ def initialize(service)
31
+ descriptor = service.is_a?(String) ? self.class.lookup!(service) : service
32
+ @service_name = descriptor.name
33
+ @rpcs = {} #: Hash[String, Rpc]
34
+
35
+ descriptor.each do |method|
36
+ action = ConnectRpcRails.underscore(method.name)
37
+ @rpcs[action] = Rpc.new(
38
+ name: method.name,
39
+ action: action,
40
+ input_class: method.input_type.msgclass,
41
+ output_class: method.output_type.msgclass,
42
+ )
43
+ end
44
+ end
45
+
46
+ # The generated `_pb` file has to have been required, since requiring it is what puts
47
+ # the service in the pool. Say so, rather than let a typo and a missing require look
48
+ # the same.
49
+ #: (String) -> untyped
50
+ def self.lookup!(service_name)
51
+ Google::Protobuf::DescriptorPool.generated_pool.lookup(service_name) ||
52
+ raise(ArgumentError, "no service #{service_name.inspect} in the descriptor pool: is its generated _pb file required?")
53
+ end
54
+
55
+ #: () -> Array[Rpc]
56
+ def rpcs
57
+ @rpcs.values
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,9 @@
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
+ VERSION = '0.1.0'
9
+ end
@@ -0,0 +1,39 @@
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 'connect_rpc_rails/version'
8
+ require 'connect_rpc_rails/errors'
9
+ require 'connect_rpc_rails/codec'
10
+ require 'connect_rpc_rails/service_registration'
11
+ require 'connect_rpc_rails/controller'
12
+ require 'connect_rpc_rails/routing'
13
+ require 'connect_rpc_rails/exceptions_app'
14
+
15
+ module ConnectRpcRails
16
+ # Installs what the gem adds to Action Dispatch: the routes DSL, and Connect's binary
17
+ # content-type so `request.format` (and thus the instrumentation payload / request log)
18
+ # reports :proto instead of defaulting to :html. In a Rails app the Railtie calls this
19
+ # during boot; a plain Rack host (or a spec) calls it itself.
20
+ #: () -> void
21
+ def self.install!
22
+ require 'action_dispatch'
23
+
24
+ ActionDispatch::Routing::Mapper.include(Routing)
25
+ Mime::Type.register('application/proto', :proto) unless Mime[:proto]
26
+ end
27
+
28
+ # "SayHello" -> "say_hello". Maps a Connect method name to the controller action
29
+ # implementing it, so dispatch stays reflection-driven (no per-service codegen).
30
+ #: (String) -> String
31
+ def self.underscore(name)
32
+ name.to_s
33
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
34
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
35
+ .downcase
36
+ end
37
+ end
38
+
39
+ require 'connect_rpc_rails/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,44 @@
1
+ # Generated from lib/connect_rpc_rails/codec.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # Encodes/decodes bare unary message bodies. Both codecs delegate to
5
+ # google-protobuf, so serialization is not something this library implements.
6
+ module Codec
7
+ interface _Codec
8
+ def decode: (untyped message_class, String bytes) -> untyped
9
+
10
+ def encode: (untyped message) -> String
11
+
12
+ def content_type: () -> String
13
+ end
14
+
15
+ # : (String?) -> _Codec?
16
+ def self.for_content_type: (String?) -> _Codec?
17
+
18
+ module Json
19
+ CONTENT_TYPE: String
20
+
21
+ # : (untyped, String) -> untyped
22
+ def self.decode: (untyped, String) -> untyped
23
+
24
+ # : (untyped) -> String
25
+ def self.encode: (untyped) -> String
26
+
27
+ # : () -> String
28
+ def self.content_type: () -> String
29
+ end
30
+
31
+ module Proto
32
+ CONTENT_TYPE: String
33
+
34
+ # : (untyped, String) -> untyped
35
+ def self.decode: (untyped, String) -> untyped
36
+
37
+ # : (untyped) -> String
38
+ def self.encode: (untyped) -> String
39
+
40
+ # : () -> String
41
+ def self.content_type: () -> String
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,228 @@
1
+ # Generated from lib/connect_rpc_rails/controller.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # Connect unary transport as an ActionController::API mix-in.
5
+ #
6
+ # Include this in an `ActionController::API` subclass and call `connect_service`:
7
+ # each RPC in the descriptor is one Rails *action* on that controller, so every call
8
+ # flows through the normal controller lifecycle. That is the whole point —
9
+ # `process_action.action_controller` fires, so the entire Rails observability
10
+ # ecosystem (Datadog resource naming, Sentry transactions, lograge, the
11
+ # "Completed 200 in Xms" request log) lights up for free. The transport wraps the
12
+ # action rather than generating it: decode the Connect body, call the action, encode the
13
+ # reply. Errors become Connect wire errors in one place (`rescue_from`), the deadline is
14
+ # one `around_action`.
15
+ #
16
+ # class GreetController < ActionController::API
17
+ # include ConnectRpcRails::Controller
18
+ # include BearerAuthentication
19
+ # connect_service "greet.v1.GreetService"
20
+ #
21
+ # def say_hello
22
+ # SayHelloResponse.new(greeting: "Hello, #{connect_request.name}!")
23
+ # end
24
+ # end
25
+ #
26
+ # An action takes no arguments, like every other Rails action: the decoded request is
27
+ # `connect_request`, read the way `params` is read in an HTTP controller. It is an
28
+ # ordinary instance method, so it gets the per-request instance Rails already builds for
29
+ # every action — nothing holding request state outlives the request. There is
30
+ # deliberately no handler object registered on the controller class: that one instance
31
+ # would be shared by every request in the process.
32
+ #
33
+ # A Connect call is an HTTP request, so cross-cutting concerns are Rails callbacks and
34
+ # nothing else: `before_action` for auth (writing an ivar, not a context bag),
35
+ # `around_action` to wrap a call, `rescue_from` (or `map_connect_errors`) to turn a
36
+ # domain exception into a Connect code. The decode happens before the callbacks run, so
37
+ # a `before_action` can already read `connect_request`. There is no interceptor layer:
38
+ # callbacks do the same job with `only:`/`except:`, inheritance and `skip_*` on top.
39
+ #
40
+ # Exceptions the host app already classifies need no mapping at all: Rails keeps that
41
+ # classification in `config.action_dispatch.rescue_responses`, which every railtie and
42
+ # gem registers into (`ActiveRecord::RecordNotFound` is `:not_found` there), so including
43
+ # this module installs a Connect code for each of those entries. `map_connect_errors` is
44
+ # for the ones Rails doesn't know about, and overrides these.
45
+ #
46
+ # Routes are 1 RPC = 1 action, drawn per service from the descriptor (see
47
+ # ConnectRpcRails::Routing). A declared RPC the controller doesn't implement is answered
48
+ # `unimplemented` by #action_missing; a method the descriptor never declared lands on the
49
+ # routes' catch-all, #connect_unknown_method, and is a 404. Routes match every verb, so a
50
+ # wrong-verb request does reach the controller and becomes a Connect-correct 405.
51
+ # @rbs module-self ActionController::API
52
+ # @rbs module-self _ConnectControllerSelf
53
+ module Controller : ActionController::API, _ConnectControllerSelf
54
+ # The action and path parameter the routes DSL points its per-service catch-all at.
55
+ UNKNOWN_METHOD_ACTION: ::String
56
+
57
+ UNKNOWN_METHOD_PARAM: ::String
58
+
59
+ # The one `rescue_responses` entry that doesn't become a Connect error: #action_missing
60
+ # raises ActionNotFound for a name that isn't an RPC, which means the app routed
61
+ # something to an action this controller doesn't have. That is a misconfiguration for
62
+ # the host's error handling to surface, not a result to hand a caller.
63
+ RESCUE_RESPONSE_EXCLUSIONS: untyped
64
+
65
+ # Raised inside Timeout so it can't be confused with an unrelated Timeout::Error.
66
+ class DeadlineExceeded < StandardError
67
+ end
68
+
69
+ # : (untyped) -> void
70
+ def self.included: (untyped) -> void
71
+
72
+ # Gives every exception Rails already assigns an HTTP status a Connect code, so an app
73
+ # doesn't restate a mapping the framework ships. Registered by class *name*, which is
74
+ # both what `rescue_responses` is keyed by and what keeps this from loading the classes
75
+ # (`rescue_from` resolves the name when it has to rescue something).
76
+ #
77
+ # This reads `rescue_responses` once, when the controller is loaded — in a Rails app
78
+ # that is after the initializers have merged the app's own entries in.
79
+ # : (untyped) -> void
80
+ def self.install_rescue_response_defaults: (untyped) -> void
81
+
82
+ # @rbs module-self Module
83
+ # @rbs module-self _ConnectControllerClass
84
+ module ClassMethods : Module, _ConnectControllerClass
85
+ # Declares which Connect service this controller serves, named as the `.proto` names
86
+ # it: `connect_service "greet.v1.GreetService"`. The string is looked up in the
87
+ # descriptor pool, so it greps straight to the protobuf definition (and back).
88
+ #
89
+ # Subclasses inherit the declaration, so a service split across a controller per RPC
90
+ # declares it once on their shared base class.
91
+ # : (String | untyped service) -> void
92
+ def connect_service: (String | untyped service) -> void
93
+
94
+ # Turns domain exceptions into Connect errors for every RPC on the controller:
95
+ #
96
+ # map_connect_errors MyDomain::Invalid => :invalid_argument,
97
+ # MyDomain::QuotaReached => :resource_exhausted
98
+ #
99
+ # Only for exceptions Rails doesn't already classify — anything in
100
+ # `config.action_dispatch.rescue_responses` has a code without being named here (see
101
+ # .install_rescue_response_defaults) — or to override the code one of those got.
102
+ #
103
+ # This is `rescue_from` with the conversion filled in — each class gets its own
104
+ # handler, so nothing is blanket-rescued and an unmapped exception still propagates
105
+ # to the host's error middleware. The handler renders rather than re-raising because
106
+ # Rails calls one `rescue_from` handler per exception: an Error raised inside a
107
+ # handler would escape instead of reaching the one that renders the wire error.
108
+ # : (Hash[Class, Symbol]) -> void
109
+ def map_connect_errors: (Hash[Class, Symbol]) -> void
110
+ end
111
+
112
+ # Connect actions decode the body themselves and never read `params`, so skip
113
+ # Rails' lazy body param parsing: it would deserialize a JSON body a second time
114
+ # (instrumentation reads filtered_parameters) and leak request payloads into
115
+ # logs. Query params still parse; instrumentation is otherwise unaffected.
116
+ # Runs before instrumentation reads the params, so it decodes the Connect body
117
+ # here (exactly once) and reuses it two ways: the typed message drives the action,
118
+ # and its hash form populates `request_parameters` — so the standard Rails request
119
+ # log, `config.filter_parameters`, and APM see the request without Rails parsing
120
+ # the body a second time. Also reports the wire format for logs/instrumentation
121
+ # (via the formats header rather than `request.format=`, which would inject a
122
+ # :format key into params).
123
+ def process_action: (*untyped) -> untyped
124
+
125
+ # The RPC method is the controller's own method, so the transport wraps dispatch
126
+ # instead of defining the action body: `send_action` is
127
+ # Rails' documented seam for "change how action methods are called".
128
+ private def send_action: (untyped action, *untyped args) -> untyped
129
+
130
+ # The service-prefix catch-all the routes DSL draws after the RPC routes. Every method
131
+ # the descriptor declares has its own route, so reaching here means this one isn't part
132
+ # of the service's contract at all: a 404 whatever the verb — rendered rather than
133
+ # raised as a RoutingError, so it doesn't depend on the host having Rails' exception
134
+ # middleware in the stack. No body is decoded for it either.
135
+ def connect_unknown_method: () -> untyped
136
+
137
+ # Every RPC the descriptor declares is routed, whether or not this controller has the
138
+ # method, because what the service serves is the descriptor's business and not the
139
+ # router's. A routed RPC with no action is exactly the case Connect answers
140
+ # `unimplemented`, and Rails' own hook for "this action doesn't exist" is where that is
141
+ # answered. An action name that isn't an RPC at all raises what Rails would have raised
142
+ # had this hook not been defined.
143
+ private def action_missing: (untyped name) -> untyped
144
+
145
+ # Transport preconditions, prepended so they run ahead of any application callback: a
146
+ # wrong verb or a body nothing can read is answered the way the protocol says instead
147
+ # of being handed to auth (or anything else the controller declared) first. Halting
148
+ # with `render` is Rails' own way to stop a callback chain.
149
+ private def validate_connect_request: () -> untyped
150
+
151
+ # : () -> String
152
+ private def connect_method_param: () -> String
153
+
154
+ private def connect_request_params: () -> untyped
155
+
156
+ # The transport preconditions have all passed by the time this runs (see
157
+ # #validate_connect_request), so this is the wire round-trip and nothing else. The RPC
158
+ # runs on this controller instance — the one Rails built for this request — and takes
159
+ # no arguments, like any other action. Only implemented methods are routed (the routes
160
+ # DSL checks that at boot), so there is no missing-method case.
161
+ private def dispatch_connect_rpc: (untyped rpc) -> untyped
162
+
163
+ # The codec for this call's content-type. Never missing once the action runs:
164
+ # #validate_connect_request answers a content-type no codec handles with a 415.
165
+ # : () -> Codec::_Codec
166
+ private def connect_codec: () -> Codec::_Codec
167
+
168
+ # Connect rejects unsupported request compression with `unimplemented`, advertising
169
+ # what it can accept.
170
+ private def reject_unsupported_encoding: () -> untyped
171
+
172
+ # The decoded request message, for the action to read the way an HTTP action reads
173
+ # `params`. Assigned during decode, so callbacks see it too.
174
+ # : () -> untyped
175
+ private def connect_request: () -> untyped
176
+
177
+ # Request metadata: the request's HTTP headers, downcased and dasherized, which is
178
+ # what Connect metadata is on the wire. Response metadata needs no helper — leading
179
+ # metadata is `response.headers`.
180
+ # : () -> Hash[String, String]
181
+ private def connect_metadata: () -> Hash[String, String]
182
+
183
+ # Trailing metadata to send. Unary Connect puts trailers in the response as
184
+ # `trailer-`-prefixed headers, so this collects them and #apply_connect_trailers does
185
+ # the prefixing — a caller writes `connect_trailers["x-audit"] = ["1"]` and doesn't
186
+ # encode the wire form itself.
187
+ # : () -> Hash[String, Array[String]]
188
+ private def connect_trailers: () -> Hash[String, Array[String]]
189
+
190
+ # Rack joins repeated headers with a newline, which is also how multi-value Connect
191
+ # metadata is sent.
192
+ private def apply_connect_trailers: () -> untyped
193
+
194
+ # The deadline this call must finish by, from `connect-timeout-ms`, or nil when the
195
+ # caller sent no timeout. The library enforces it; an RPC reads it to budget its own
196
+ # downstream calls.
197
+ # : () -> Time?
198
+ private def connect_deadline: () -> Time?
199
+
200
+ # : () -> Integer?
201
+ private def connect_timeout_ms: () -> Integer?
202
+
203
+ # Enforces connect-timeout-ms. Runs as an around_action so the whole action (the
204
+ # callbacks and the RPC) is under the deadline; the Error it raises is rendered by
205
+ # rescue_from.
206
+ # : () { (?) -> untyped } -> untyped
207
+ private def enforce_connect_deadline: () { (?) -> untyped } -> untyped
208
+
209
+ # The handler `map_connect_errors` installs: the mapping lives on the class, so the
210
+ # code for the exception at hand is looked up rather than baked into a closure.
211
+ private def render_mapped_connect_error: (untyped exception) -> untyped
212
+
213
+ # The handler installed for every `rescue_responses` entry. The status is read off the
214
+ # nearest ancestor the registry names, since `rescue_from` matches subclasses too and a
215
+ # subclass isn't a key (an `ActiveRecord::RecordNotUnique` is classified as the
216
+ # `StatementInvalid` it descends from). Read with `fetch`, because the registry answers
217
+ # anything at all with a default of :internal_server_error — which would stop the walk
218
+ # on the first ancestor and call every subclass a 500.
219
+ private def render_rescue_response_error: (untyped exception) -> untyped
220
+
221
+ private def render_connect_error: (untyped error) -> untyped
222
+
223
+ # The official hook for enriching the process_action.action_controller payload
224
+ # (same mechanism lograge/Datadog custom fields use). Adds the fully-qualified
225
+ # Connect method so a trace/log resource can read "pkg.Service/Method".
226
+ private def append_info_to_payload: (untyped payload) -> untyped
227
+ end
228
+ end
@@ -0,0 +1,53 @@
1
+ # Generated from lib/connect_rpc_rails/errors.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # A Connect protocol error. The code set and its HTTP status mapping are defined
5
+ # by the spec (https://connectrpc.com/docs/protocol/). Handlers raise these; the
6
+ # transport turns them into the wire error body.
7
+ class Error < StandardError
8
+ CODE_TO_HTTP_STATUS: Hash[Symbol, Integer]
9
+
10
+ # Rails keeps its exception taxonomy in HTTP statuses
11
+ # (`config.action_dispatch.rescue_responses`), so reusing it means reading a status back
12
+ # as a Connect code. This is the inverse of CODE_TO_HTTP_STATUS above — the same
13
+ # code↔status pairing `google.rpc.Code` defines, read the other way.
14
+ #
15
+ # It is *not* gRPC's HTTP-to-status mapping
16
+ # (https://grpc.github.io/grpc/core/md_doc_http-grpc-status-mapping.html), and Connect's
17
+ # equivalent table doesn't apply here either. Those describe a client reading an HTTP
18
+ # response that carries no RPC status at all — a proxy's 502, a load balancer's 404 —
19
+ # where 400 means "an intermediary rejected the request" (`internal`) and 404 means "no
20
+ # such service here" (`unimplemented`). Read that way round, a `RecordNotFound` would go
21
+ # out as `unimplemented`, colliding with the one thing that code means on this server: a
22
+ # routed RPC with no action. The table also stops at seven statuses and sends the rest to
23
+ # `unknown`, which is most of what Active Record raises (409, 422).
24
+ #
25
+ # Where several codes share a status, the entry is the one that fits what Rails raises
26
+ # there: 409 is `aborted` for a `StaleObjectError`'s lost race, not `already_exists`; 400
27
+ # is plain `invalid_argument`. Statuses no code claims (405, 406, 415, 422) take the
28
+ # nearest code by meaning.
29
+ HTTP_STATUS_TO_CODE: Hash[Integer, Symbol]
30
+
31
+ # The Connect code for an HTTP status. Statuses the table doesn't name fall back on
32
+ # their class, so a mapping Rails (or a gem) adds is never left without a code.
33
+ # : (Integer) -> Symbol
34
+ def self.code_for_http_status: (Integer) -> Symbol
35
+
36
+ attr_reader code: Symbol
37
+
38
+ attr_reader details: Array[untyped]
39
+
40
+ # : (Symbol, ?String?, ?details: Array[untyped]) -> void
41
+ def initialize: (Symbol, ?String?, ?details: Array[untyped]) -> void
42
+
43
+ # : () -> Integer
44
+ def http_status: () -> Integer
45
+
46
+ # : () -> Hash[Symbol, untyped]
47
+ def to_wire: () -> Hash[Symbol, untyped]
48
+
49
+ # A detail is a google.protobuf.Any; its Connect wire form is the bare message
50
+ # type name plus the serialized bytes as unpadded standard base64.
51
+ private def encode_detail: (untyped any) -> untyped
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # Generated from lib/connect_rpc_rails/exceptions_app.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # Wraps a Rails `config.exceptions_app` so an exception escaping a Connect call — one
5
+ # raised before dispatch, which no controller `rescue_from` ever sees — is still answered
6
+ # in the protocol's error shape.
7
+ #
8
+ # config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
9
+ #
10
+ # A Connect request is recognized by `connect-protocol-version`, which the protocol
11
+ # requires on every unary call; anything else reaches the wrapped app untouched.
12
+ class ExceptionsApp
13
+ # `connect-protocol-version`, as Rack names it in the env.
14
+ PROTOCOL_VERSION_HEADER: ::String
15
+
16
+ # : (untyped app) -> void
17
+ def initialize: (untyped app) -> void
18
+
19
+ # : (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
20
+ def call: (Hash[String, untyped]) -> [ Integer, Hash[String, String], Array[String] ]
21
+
22
+ # The error to send for an escaped exception. The message is the status's own text,
23
+ # never the exception's, which can quote internals. Override in a subclass to add
24
+ # details every error should carry.
25
+ # : (Exception, Hash[String, untyped]) -> Error
26
+ private def connect_error_for: (Exception, Hash[String, untyped]) -> Error
27
+
28
+ # A unary Connect error is a JSON body whatever the request's codec was, per the
29
+ # protocol.
30
+ # : (Error) -> [Integer, Hash[String, String], Array[String]]
31
+ private def render_connect_error: (Error) -> [ Integer, Hash[String, String], Array[String] ]
32
+ end
33
+ end
@@ -0,0 +1,10 @@
1
+ # Generated from lib/connect_rpc_rails/railtie.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # Hooks the gem into a Rails app at the framework's own boot point rather than by
5
+ # patching Action Dispatch when the gem is required: the initializer runs once the
6
+ # frameworks are loaded and before the routes are drawn, which is all the routes DSL
7
+ # needs.
8
+ class Railtie < ::Rails::Railtie
9
+ end
10
+ end