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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 582638c47c9c929929643e53251f54ec5734f3e0cec8aa2f4fe5041db2d09e83
|
|
4
|
+
data.tar.gz: a32657f0c08c0e8f3bfd92658d3a7fbb8121a2485e3854260b0f91ea84ae7df0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5cbd7087dc4f05689ce9957fa7aad85d4291a6f0c9e69c6af410fab38f80d364062b34e9b835b433fbf1a65fcab72fe0db4a49bd2ab2f89bd240f20f0d0151d5
|
|
7
|
+
data.tar.gz: 9840150ff947a838ca21db9bb2935899bd27a69f445fc6a8e64bc7e0ceca155d2b7c7b5fa93deb776f5e8c2f2c1b7bdb4594db6b433c0929983af0e98ef25b28
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 IVRy Inc.
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# connect_rpc_rails
|
|
2
|
+
|
|
3
|
+
A minimal [Connect](https://connectrpc.com/docs/protocol/) **unary** RPC server for
|
|
4
|
+
Rails, built on `ActionController::API`. It gives you a Connect-for-Ruby layer that is
|
|
5
|
+
small enough to own outright: a service is an ordinary Rails controller, so it reuses
|
|
6
|
+
`google-protobuf` and the observability you already have rather than shipping a parallel
|
|
7
|
+
stack.
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
A Connect service is an `ActionController::API` controller: each RPC in the descriptor
|
|
12
|
+
is **one Rails action on that controller**, so every call flows through the normal
|
|
13
|
+
controller lifecycle. Because `process_action.action_controller` fires, the entire
|
|
14
|
+
Rails observability ecosystem (Datadog resource naming, Sentry transactions, lograge,
|
|
15
|
+
the `Completed 200 in Xms` request log) works with no extra wiring. The RPC method
|
|
16
|
+
holds the domain logic; the library wraps it with the transport.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
caller ──HTTP──▶ Rails router ──▶ GreetController#say_hello
|
|
20
|
+
(ConnectRpcRails::Controller: decode ▸ callbacks ▸ encode)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# app/controllers/greet_controller.rb
|
|
25
|
+
class GreetController < ActionController::API
|
|
26
|
+
include ConnectRpcRails::Controller
|
|
27
|
+
include BearerAuthentication # a concern with a before_action
|
|
28
|
+
|
|
29
|
+
connect_service "greet.v1.GreetService" # the name the .proto gives it
|
|
30
|
+
|
|
31
|
+
# An ordinary action — no arguments, like any other Rails action. The library decodes
|
|
32
|
+
# the request message (`connect_request`, read the way you read `params`) and encodes
|
|
33
|
+
# whatever message you return. `principal` was set by the before_action; authZ lives
|
|
34
|
+
# here.
|
|
35
|
+
def say_hello
|
|
36
|
+
Greet::V1::SayHelloResponse.new(greeting: "Hello, #{connect_request.name}!")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/routes.rb — 1 RPC = 1 route.
|
|
43
|
+
Rails.application.routes.draw do
|
|
44
|
+
connect_service "greet.v1.GreetService" => :greet
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**The RPC runs on a per-request instance.** That is the reason there is no handler
|
|
49
|
+
object to register: an object held on the controller class would be shared by every
|
|
50
|
+
request in the process, so anything one call left in an instance variable would be
|
|
51
|
+
readable by the next caller — the mismatch that bites when gRPC-style handlers (one
|
|
52
|
+
long-lived instance) are mixed into Rails (one instance per request). Here the RPC
|
|
53
|
+
method *is* an action, so Rails' per-request instance is the only lifecycle in play.
|
|
54
|
+
Domain logic that shouldn't live in a controller belongs in an ordinary object the
|
|
55
|
+
action calls, constructed inside the action like anywhere else in Rails.
|
|
56
|
+
|
|
57
|
+
**A service can be one controller or a controller per RPC.** A whole service behind one
|
|
58
|
+
class is the default. When its methods have little in common — different authorization,
|
|
59
|
+
different validation — give each its own controller with a block, and each RPC's callbacks
|
|
60
|
+
are its own rather than the service's with `only:`:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# config/routes.rb
|
|
64
|
+
connect_service "greet.v1.GreetService" do
|
|
65
|
+
rpc "SayHello" => :greet_say_hello
|
|
66
|
+
rpc "SayGoodbye" => :greet_say_goodbye
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every mapped name has to be one the descriptor declares, so a typo or a rename fails at boot
|
|
71
|
+
instead of drawing a route nothing reaches. The mapping does not have to cover the service:
|
|
72
|
+
an RPC left out is still routed — to the first mapped controller, which serves the service
|
|
73
|
+
but not that method, so it is answered `unimplemented` exactly as a declared RPC nobody
|
|
74
|
+
implements always is. The controllers declare the service once on a shared base class —
|
|
75
|
+
`connect_service` is inherited — and the service-prefix catch-all is drawn at the first of
|
|
76
|
+
them, since answering it is a 404 and nothing else.
|
|
77
|
+
|
|
78
|
+
**Routes come from the descriptor, per service.** The routes file names the service the
|
|
79
|
+
way the `.proto` does and points it at a controller as a string, exactly like any other
|
|
80
|
+
Rails route — so drawing the routes doesn't load the controller class, and both ends of the
|
|
81
|
+
mapping grep straight to the protobuf definition. Every method the descriptor declares
|
|
82
|
+
becomes one route to the action implementing it; the method list lives in the `.proto` and
|
|
83
|
+
nowhere else. What the controller actually serves is then its own business: a declared RPC
|
|
84
|
+
with no action is answered Connect `unimplemented` (HTTP 501, not a 404, as the protocol
|
|
85
|
+
wants) through Rails' own `action_missing`, and the single catch-all the DSL draws over the
|
|
86
|
+
service prefix makes a method the descriptor never declared a plain 404.
|
|
87
|
+
|
|
88
|
+
Under eager loading — production, and CI — the DSL also resolves each routed controller as
|
|
89
|
+
the routes are drawn and checks that it serves the service it was wired to, so a
|
|
90
|
+
mis-wired route raises at boot instead of 404-ing in production. Rails eager loads before it
|
|
91
|
+
draws the routes, so that costs no autoloading; with lazy loading (development) the class is
|
|
92
|
+
left untouched.
|
|
93
|
+
|
|
94
|
+
**Why `ActionController::API`, not a bare Rack transport?** A bespoke Rack transport
|
|
95
|
+
would mean going off the controller path and losing everything that hangs off
|
|
96
|
+
`process_action.action_controller` (Datadog/Sentry/lograge/the request log), then
|
|
97
|
+
rebuilding each integration by hand. `ActionController::API` ships exactly the useful
|
|
98
|
+
modules (`Instrumentation`, `Logging`, `Rescue`, `AbstractController::Callbacks`,
|
|
99
|
+
`StrongParameters`) and omits the browser concerns an RPC endpoint never uses (CSRF,
|
|
100
|
+
cookies, flash, view rendering). It also brings the per-request instance lifecycle,
|
|
101
|
+
which is what keeps request state from outliving the request.
|
|
102
|
+
|
|
103
|
+
## Reading the call, and cross-cutting logic
|
|
104
|
+
|
|
105
|
+
A Connect call *is* an HTTP request, so there is no per-call context object to learn:
|
|
106
|
+
|
|
107
|
+
| what you want | where it is |
|
|
108
|
+
|---|---|
|
|
109
|
+
| the decoded request message | `connect_request` — read it the way you read `params` |
|
|
110
|
+
| request metadata | `connect_metadata` (the request's headers, downcased and dasherized), or `request.headers` |
|
|
111
|
+
| leading response metadata | `response.headers` |
|
|
112
|
+
| trailing response metadata | `connect_trailers["x-audit"] = ["1"]` — the helper writes Connect's unary `trailer-` form |
|
|
113
|
+
| the deadline | `connect_deadline` / `connect_timeout_ms`, for budgeting your own downstream calls |
|
|
114
|
+
| anything you computed for this call | an instance variable, as in any controller |
|
|
115
|
+
|
|
116
|
+
**Cross-cutting logic is Rails callbacks, and only that.** There is no interceptor layer:
|
|
117
|
+
`before_action` for auth, `around_action` to wrap a call, `rescue_from` for exception
|
|
118
|
+
mapping. The body is decoded *before* the callbacks run, so a `before_action` can already
|
|
119
|
+
read `connect_request` — which is what makes callbacks a complete replacement rather than a
|
|
120
|
+
partial one. Reuse across services is an `ActiveSupport::Concern` (see
|
|
121
|
+
[`BearerAuthentication`](examples/greet/app/controllers/concerns/bearer_authentication.rb))
|
|
122
|
+
or a shared base controller, and on top of that you get `only:` / `except:`, inheritance
|
|
123
|
+
and `skip_before_action`, none of which an interceptor chain offers.
|
|
124
|
+
|
|
125
|
+
Callbacks halt the Rails way: `render` a response, or raise a `ConnectRpcRails::Error` and
|
|
126
|
+
let the library's `rescue_from` render the wire error.
|
|
127
|
+
|
|
128
|
+
The library's own transport checks (POST-only, media type, undecodable body) run in a
|
|
129
|
+
`prepend_before_action`, so a wrong-verb or unreadable request is answered as the protocol
|
|
130
|
+
requires before any application callback — auth never sees a request that should be a 405.
|
|
131
|
+
|
|
132
|
+
## Error handling
|
|
133
|
+
|
|
134
|
+
`ConnectRpcRails::Error` maps to its Connect code + HTTP status. The controller declares
|
|
135
|
+
`rescue_from ConnectRpcRails::Error` once, so it becomes the wire error body `{code,message,details}`
|
|
136
|
+
in exactly one place. An exception that isn't a `ConnectRpcRails::Error` propagates to the
|
|
137
|
+
host's error middleware, per the "let exceptions propagate" policy.
|
|
138
|
+
|
|
139
|
+
**Exceptions Rails already classifies need no mapping.** Rails keeps that classification in
|
|
140
|
+
`config.action_dispatch.rescue_responses` — the registry every railtie and gem writes into,
|
|
141
|
+
where `ActiveRecord::RecordNotFound` is `:not_found` and `ActiveRecord::RecordInvalid` is
|
|
142
|
+
`:unprocessable_content` — so including the module installs a Connect code for each of its
|
|
143
|
+
entries, read off the nearest classified ancestor. A `RecordNotFound` out of an RPC is a
|
|
144
|
+
Connect `not_found` without the app restating it.
|
|
145
|
+
|
|
146
|
+
Mapping the *rest* — your own domain exceptions — is `map_connect_errors`, which applies to
|
|
147
|
+
every RPC on the controller and overrides the code an entry above would have got:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
map_connect_errors MyDomain::Invalid => :invalid_argument,
|
|
151
|
+
MyDomain::QuotaReached => :resource_exhausted
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
That is `rescue_from` with the conversion filled in: each class gets its own handler, so
|
|
155
|
+
nothing is blanket-rescued and anything unmapped still propagates. It exists as a macro
|
|
156
|
+
because a hand-written `rescue_from` can't simply `raise` a `ConnectRpcRails::Error` —
|
|
157
|
+
Rails calls one handler per exception, so the raise would escape instead of reaching the
|
|
158
|
+
handler that renders the wire error.
|
|
159
|
+
|
|
160
|
+
**Everything that escapes is the exceptions app's job.** An exception raised before
|
|
161
|
+
dispatch — a routing error, an unreadable body, a middleware failing — never reaches a
|
|
162
|
+
controller, and the host's `config.exceptions_app` would answer it in a shape a Connect
|
|
163
|
+
client reads as a malformed response. `ConnectRpcRails::ExceptionsApp` wraps that app and
|
|
164
|
+
answers the Connect protocol's error shape for a request carrying
|
|
165
|
+
`connect-protocol-version`, passing everything else through untouched:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The code comes from the status `rescue_responses` assigned the exception, so the app
|
|
172
|
+
configures its classification in one place; the message is the status's own text, never
|
|
173
|
+
the exception's. Override `#connect_error_for` in a subclass to stamp every error with a
|
|
174
|
+
detail of your own (a `google.rpc.RequestInfo` holding the request id, say).
|
|
175
|
+
|
|
176
|
+
Because escaping is now answered correctly, an app needs no blanket
|
|
177
|
+
`rescue_from StandardError` to keep the protocol: let the exception propagate and Rails'
|
|
178
|
+
request-error logging — and the error reporters subscribed to it — see it the way they see
|
|
179
|
+
any other.
|
|
180
|
+
|
|
181
|
+
## Conformance
|
|
182
|
+
|
|
183
|
+
The official [connectrpc/conformance](https://github.com/connectrpc/conformance) suite
|
|
184
|
+
lives in [`conformance/`](conformance/) and passes **84/84** (Connect + unary) against
|
|
185
|
+
the `ActionController::API` transport, with the server-under-test mounted through an
|
|
186
|
+
`ActionDispatch` `RouteSet` — including error details, response headers/trailers (on
|
|
187
|
+
success *and* error), `connect-timeout-ms` enforcement, and the HTTP-status mapping for
|
|
188
|
+
malformed requests (404 unknown method, 405 wrong verb, 415 unsupported media type,
|
|
189
|
+
`unimplemented` for an unimplemented method and for unsupported compression). Streaming, gRPC/gRPC-Web, compression, and
|
|
190
|
+
TLS remain out of scope. This is the real interop check that hand-written specs can't give.
|
|
191
|
+
|
|
192
|
+
## Design highlights
|
|
193
|
+
|
|
194
|
+
- **Reflection-based dispatch, no codegen.** A `protoc`/`buf`-generated service lands in the descriptor pool as a `ServiceDescriptor` whose `MethodDescriptor`s expose input/output message classes. `connect_service` takes the service's full name, looks it up in the pool, and derives the action names and message types purely off that — no per-service generated stubs. (`examples/greet/lib/greet_pb.rb` builds the descriptor in pure Ruby so the example runs with no protoc toolchain.)
|
|
195
|
+
- **Rails instrumentation for free.** `process_action.action_controller` fires for every RPC (including errors), carrying `controller`/`action`/`status` plus a `connect_method` payload key (`pkg.Service/Method`) for clean trace/log resource naming.
|
|
196
|
+
- **No object outlives the request.** The RPC is a controller action, so there is no handler singleton on the class to accumulate state between callers — the failure mode of putting gRPC-style handlers behind Rails.
|
|
197
|
+
- **Nothing to learn beyond Rails.** An RPC is an action, cross-cutting logic is a callback, exception mapping is `rescue_from`, metadata is headers. The only Connect-specific thing in a controller is `connect_request`.
|
|
198
|
+
- **authN vs authZ split.** `BearerAuthentication` (a concern standing in for a real bearer-token verifier) authenticates the `Bearer` token in a `before_action` and exposes `principal`; the RPC method authorizes against it.
|
|
199
|
+
- **Connect wire compliance for unary:** `POST /pkg.Service/Method`, `application/json` + `application/proto`, error body `{code,message,details}` with the spec's code→HTTP-status table.
|
|
200
|
+
|
|
201
|
+
## Layout
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
lib/connect_rpc_rails/
|
|
205
|
+
controller.rb # the ActionController::API transport (mix-in)
|
|
206
|
+
routing.rb # routes DSL: a route per declared RPC + the unknown-method catch-all
|
|
207
|
+
railtie.rb # installs the routes DSL / Connect's content-type at Rails boot
|
|
208
|
+
service_registration.rb # descriptor -> RPC table (reflection)
|
|
209
|
+
codec.rb # JSON / proto, via google-protobuf
|
|
210
|
+
errors.rb # Connect codes -> HTTP status, wire error body
|
|
211
|
+
exceptions_app.rb # config.exceptions_app wrapper: Connect error shape for what escapes
|
|
212
|
+
examples/greet/ # the example as a real, bootable Rails app (own Gemfile + config.ru)
|
|
213
|
+
app/controllers/greet_controller.rb # connect_service + the RPC action
|
|
214
|
+
app/controllers/concerns/bearer_authentication.rb # authN as a before_action + stub verifier
|
|
215
|
+
config/routes.rb # connect_service "greet.v1.GreetService" => :greet
|
|
216
|
+
config/application.rb # api_only Rails app boot (Action Controller + Active Record)
|
|
217
|
+
proto/greet/v1/greet.proto # the service contract
|
|
218
|
+
lib/greet_pb.rb # hand-built stand-in for `buf generate` output
|
|
219
|
+
spec/ # RSpec: controller, routing, auth, error mapping, instance lifecycle
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Run
|
|
223
|
+
|
|
224
|
+
Ruby is pinned in `.mise.toml`, so [mise](https://mise.jdx.dev) users get the right
|
|
225
|
+
interpreter automatically; otherwise use Ruby 3.4.
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
rspec # specs (controller, routing, auth, error mapping, deadline)
|
|
229
|
+
hk check --all # rubocop, steep, actionlint, zizmor (tools pinned in .mise.toml)
|
|
230
|
+
rake rbs # regenerate + validate sig/generated from inline annotations
|
|
231
|
+
rake steep # regenerate, then type check lib with Steep
|
|
232
|
+
rake conformance # the Connect conformance suite (needs Go and buf on PATH)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`hk install` wires the same checks into a pre-commit hook. CI runs exactly these.
|
|
236
|
+
|
|
237
|
+
### The example service
|
|
238
|
+
|
|
239
|
+
`examples/greet` is a bootable Rails app with its own bundle (the gem itself depends only
|
|
240
|
+
on actionpack, so full Rails lives in the example's `Gemfile`, not the gem's):
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
cd examples/greet
|
|
244
|
+
bundle install
|
|
245
|
+
bundle exec puma -b tcp://127.0.0.1:9711 config.ru
|
|
246
|
+
|
|
247
|
+
curl -X POST -H 'Content-Type: application/json' \
|
|
248
|
+
-H 'Authorization: Bearer valid-token' \
|
|
249
|
+
-d '{"name":"Ada","preferredLanguage":"ja"}' \
|
|
250
|
+
http://127.0.0.1:9711/greet.v1.GreetService/SayHello
|
|
251
|
+
# => {"greeting":"こんにちは, Ada!"}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Drop the token for `401 unauthenticated`, send `{}` for `400 invalid_argument`, use `GET`
|
|
255
|
+
for `405`, and ask for a method the service doesn't declare for a `404`.
|
|
256
|
+
|
|
257
|
+
## Types
|
|
258
|
+
|
|
259
|
+
The library carries [rbs-inline](https://github.com/soutaro/rbs-inline) annotations
|
|
260
|
+
(`# rbs_inline: enabled`, `#:` method signatures). `rake rbs` transpiles them into
|
|
261
|
+
`sig/generated/**/*.rbs` and runs `rbs validate`. Protobuf messages are typed
|
|
262
|
+
`untyped` — in a typical project their `.rbs` comes from buf's `rbs` plugin.
|
|
263
|
+
|
|
264
|
+
`rake steep` goes further and checks `lib` against those signatures. Dependency
|
|
265
|
+
signatures come from [gem_rbs_collection](https://github.com/ruby/gem_rbs_collection);
|
|
266
|
+
run `rbs collection install` once to populate `.gem_rbs_collection` from
|
|
267
|
+
`rbs_collection.lock.yaml`. Note the collection's `actionpack` and `google-protobuf`
|
|
268
|
+
signatures lag the versions this gem builds against, and much of that surface is
|
|
269
|
+
`untyped` there, so Steep checks this library's own logic rather than its use of Rails.
|
|
270
|
+
|
|
271
|
+
`ConnectRpcRails::Controller` is a mix-in, so `sig/manual/controller_self.rbs` declares
|
|
272
|
+
what it is mixed into (`ActionController::API`) plus the class-level accessors
|
|
273
|
+
`extend ClassMethods` installs — a shape RBS cannot infer from the module body.
|
|
274
|
+
|
|
275
|
+
## Releasing
|
|
276
|
+
|
|
277
|
+
Tags drive the release. `.github/workflows/release.yml` fires on `v*`, reruns the full
|
|
278
|
+
test workflow as a gate, then creates a draft GitHub release and publishes the gem to
|
|
279
|
+
RubyGems through OIDC trusted publishing — there is no API key stored anywhere.
|
|
280
|
+
|
|
281
|
+
1. Bump `ConnectRpcRails::VERSION` and retitle the `## Unreleased` heading in
|
|
282
|
+
`CHANGELOG.md` to `## <version> (<YYYY-MM-DD>)`. Merge that as its own PR.
|
|
283
|
+
2. `git tag v<version> && git push origin v<version>`.
|
|
284
|
+
3. Once the workflow finishes, review the draft release and publish it.
|
|
285
|
+
|
|
286
|
+
## Deliberately out of scope
|
|
287
|
+
|
|
288
|
+
Streaming (enveloped framing), gRPC / gRPC-Web compatibility, request compression,
|
|
289
|
+
and the idempotent-GET variant. Unary over the Connect protocol is the whole surface
|
|
290
|
+
here; add the rest only when a real consumer needs it.
|
|
@@ -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
|
+
module ConnectRpcRails
|
|
8
|
+
# Encodes/decodes bare unary message bodies. Both codecs delegate to
|
|
9
|
+
# google-protobuf, so serialization is not something this library implements.
|
|
10
|
+
module Codec
|
|
11
|
+
# @rbs!
|
|
12
|
+
# interface _Codec
|
|
13
|
+
# def decode: (untyped message_class, String bytes) -> untyped
|
|
14
|
+
# def encode: (untyped message) -> String
|
|
15
|
+
# def content_type: () -> String
|
|
16
|
+
# end
|
|
17
|
+
|
|
18
|
+
#: (String?) -> _Codec?
|
|
19
|
+
def self.for_content_type(content_type)
|
|
20
|
+
case content_type&.split(';')&.first&.strip
|
|
21
|
+
when Json::CONTENT_TYPE then Json
|
|
22
|
+
when Proto::CONTENT_TYPE, 'application/protobuf' then Proto
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module Json
|
|
27
|
+
CONTENT_TYPE = 'application/json' #: String
|
|
28
|
+
|
|
29
|
+
#: (untyped, String) -> untyped
|
|
30
|
+
def self.decode(message_class, bytes)
|
|
31
|
+
message_class.decode_json(bytes, {ignore_unknown_fields: true})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (untyped) -> String
|
|
35
|
+
def self.encode(message)
|
|
36
|
+
message.class.encode_json(message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: () -> String
|
|
40
|
+
def self.content_type = CONTENT_TYPE
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module Proto
|
|
44
|
+
CONTENT_TYPE = 'application/proto' #: String
|
|
45
|
+
|
|
46
|
+
#: (untyped, String) -> untyped
|
|
47
|
+
def self.decode(message_class, bytes)
|
|
48
|
+
message_class.decode(bytes)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: (untyped) -> String
|
|
52
|
+
def self.encode(message)
|
|
53
|
+
message.class.encode(message)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: () -> String
|
|
57
|
+
def self.content_type = CONTENT_TYPE
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|