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 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-25)
4
+
5
+ - Initial release: Connect unary RPCs served as ordinary `ActionController::API` actions,
6
+ with the routes DSL, the Connect error shape (including for exceptions that escape to
7
+ `config.exceptions_app`), and RBS signatures.
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