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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +201 -0
- data/README.md +290 -0
- data/lib/connect_rpc_rails/codec.rb +60 -0
- data/lib/connect_rpc_rails/controller.rb +402 -0
- data/lib/connect_rpc_rails/errors.rb +109 -0
- data/lib/connect_rpc_rails/exceptions_app.rb +64 -0
- data/lib/connect_rpc_rails/railtie.rb +19 -0
- data/lib/connect_rpc_rails/routing.rb +178 -0
- data/lib/connect_rpc_rails/service_registration.rb +60 -0
- data/lib/connect_rpc_rails/version.rb +9 -0
- data/lib/connect_rpc_rails.rb +39 -0
- data/sig/generated/connect_rpc_rails/codec.rbs +44 -0
- data/sig/generated/connect_rpc_rails/controller.rbs +228 -0
- data/sig/generated/connect_rpc_rails/errors.rbs +53 -0
- data/sig/generated/connect_rpc_rails/exceptions_app.rbs +33 -0
- data/sig/generated/connect_rpc_rails/railtie.rbs +10 -0
- data/sig/generated/connect_rpc_rails/routing.rbs +101 -0
- data/sig/generated/connect_rpc_rails/service_registration.rbs +46 -0
- data/sig/generated/connect_rpc_rails/version.rbs +5 -0
- data/sig/generated/connect_rpc_rails.rbs +15 -0
- data/sig/manual/controller_self.rbs +32 -0
- data/sig/manual/rails.rbs +25 -0
- metadata +210 -0
|
@@ -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,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
|