connect_rpc_rails 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 582638c47c9c929929643e53251f54ec5734f3e0cec8aa2f4fe5041db2d09e83
4
- data.tar.gz: a32657f0c08c0e8f3bfd92658d3a7fbb8121a2485e3854260b0f91ea84ae7df0
3
+ metadata.gz: a2f63f561d5da0e371e9998ed36e960564676011496e5d4363e126b28fe9314b
4
+ data.tar.gz: f2346887acb7c9e0ff3aff3093845287ef071cb065a2af26692ff72d6d1ad3a6
5
5
  SHA512:
6
- metadata.gz: 5cbd7087dc4f05689ce9957fa7aad85d4291a6f0c9e69c6af410fab38f80d364062b34e9b835b433fbf1a65fcab72fe0db4a49bd2ab2f89bd240f20f0d0151d5
7
- data.tar.gz: 9840150ff947a838ca21db9bb2935899bd27a69f445fc6a8e64bc7e0ceca155d2b7c7b5fa93deb776f5e8c2f2c1b7bdb4594db6b433c0929983af0e98ef25b28
6
+ metadata.gz: 004fd6cf082f101831c7e87d6208ce05f3b2e76c48d1160c6829d863d7e584b16e8e0246292ab51139759161f54cca419016b99858c94bc23037377ca752a644
7
+ data.tar.gz: ad4dc123f3564a44fd1f3085f1d58af546b374be1b81ff2ab9e0b6ec04be899d5b14f0c1373169c98dc3a3470fbf035b9207e2b6c34fa3cb595eca5540443e0c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-14)
4
+
5
+ - Connect GET: a unary RPC whose `.proto` sets `idempotency_level = NO_SIDE_EFFECTS` is also
6
+ served over GET, with the request in the query string, as connect-es sends with `useHttpGet`
7
+ and connect-go with `WithHTTPGet`. A GET to any other RPC is a 405, and every 405 names the
8
+ allowed verbs in `Allow`. `ConnectRpcRails::ExceptionsApp` answers a GET carrying
9
+ `connect=v1` in the Connect error shape. `ServiceRegistration::Rpc` gains `idempotency_level`
10
+ and `streaming`.
11
+ - The `unimplemented` answer to request compression names the supported encodings in its
12
+ message: `unsupported content-encoding "gzip": supported encodings are identity`.
13
+ - `ConnectRpcRails::MessageValidatable` (`require "connect_rpc_rails/protovalidate"`) checks a
14
+ request against its message's `buf.validate` rules before the RPC runs, answering a
15
+ violation as `invalid_argument` with a `google.rpc.BadRequest` whose field paths follow the
16
+ request's encoding. A violation raises `MessageValidatable::ViolationError`, and
17
+ `connect_error_for_violation` builds the Connect error it is answered with.
18
+
3
19
  ## 0.1.0 (2026-09-25)
4
20
 
5
21
  - Initial release: Connect unary RPCs served as ordinary `ActionController::API` actions,
data/README.md CHANGED
@@ -1,290 +1,133 @@
1
- # connect_rpc_rails
1
+ # connect_rpc_rails: Connect unary RPCs as ordinary Rails controller actions
2
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.
3
+ connect_rpc_rails serves [Connect](https://connectrpc.com/docs/protocol/) unary RPCs from a Rails app. A service is an `ActionController::API` controller and each RPC is one of its actions, so callbacks, `rescue_from`, and the `process_action.action_controller` instrumentation that Datadog, Sentry, lograge, and the Rails request log hook into all apply with no extra wiring.
8
4
 
9
- ## How it works
5
+ Services are read off the `google-protobuf` descriptor pool, so the only generated code is the message classes `buf`/`protoc` already emit. The library is small enough to own outright: it is a Connect transport for Rails, not a parallel framework.
10
6
 
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.
7
+ ## Features
17
8
 
18
- ```
19
- caller ──HTTP──▶ Rails router ──▶ GreetController#say_hello
20
- (ConnectRpcRails::Controller: decode ▸ callbacks ▸ encode)
21
- ```
9
+ - One RPC is one controller action, routed per service from the `.proto` descriptor
10
+ - Cross-cutting logic is Rails callbacks (`before_action`, `around_action`, `rescue_from`), with no interceptor layer to learn
11
+ - Exceptions Rails already classifies become Connect codes without mapping: `ActiveRecord::RecordNotFound` is `not_found`
12
+ - Connect-shaped errors even for exceptions that escape before dispatch, through `ConnectRpcRails::ExceptionsApp`
13
+ - `connect-timeout-ms` deadlines, request metadata, and response trailers
14
+ - Connect GET for RPCs the `.proto` declares free of side effects, as connect-es sends with `useHttpGet`
15
+ - Optional request validation against [`buf.validate`](https://buf.build/docs/protovalidate/) rules through [protovalidate](https://github.com/sorah/protovalidate-rb)
16
+ - Passes every in-scope case of the official [Connect conformance suite](conformance/)
17
+ - RBS signatures included
22
18
 
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
19
+ ## Requirements
28
20
 
29
- connect_service "greet.v1.GreetService" # the name the .proto gives it
21
+ - Ruby 3.4 or later
22
+ - Rails (Action Pack) 7.0 or later
23
+ - google-protobuf 4.26 or later, below 5
30
24
 
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
- ```
25
+ ## Installation
40
26
 
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
27
+ ```
28
+ bundle add connect_rpc_rails
46
29
  ```
47
30
 
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.
31
+ ## Usage
56
32
 
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:`:
33
+ Given a service contract:
61
34
 
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
- ```
35
+ ```protobuf
36
+ // proto/greet/v1/greet.proto
37
+ syntax = "proto3";
38
+ package greet.v1;
69
39
 
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:
40
+ service GreetService {
41
+ rpc SayHello(SayHelloRequest) returns (SayHelloResponse);
42
+ }
148
43
 
149
- ```ruby
150
- map_connect_errors MyDomain::Invalid => :invalid_argument,
151
- MyDomain::QuotaReached => :resource_exhausted
44
+ message SayHelloRequest {
45
+ string name = 1;
46
+ }
47
+
48
+ message SayHelloResponse {
49
+ string greeting = 1;
50
+ }
152
51
  ```
153
52
 
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.
53
+ ### 1. Load the generated messages
159
54
 
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:
55
+ Generate Ruby code with `buf generate` or `protoc --ruby_out`, and require it before the routes are drawn. `connect_service` looks the service up in the descriptor pool by name.
166
56
 
167
57
  ```ruby
168
- config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
58
+ # config/application.rb
59
+ require_relative "../lib/greet/v1/greet_pb"
169
60
  ```
170
61
 
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.
62
+ ### 2. Implement the service as a controller
180
63
 
181
- ## Conformance
64
+ ```ruby
65
+ # app/controllers/greet_controller.rb
66
+ class GreetController < ActionController::API
67
+ include ConnectRpcRails::Controller
182
68
 
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.
69
+ connect_service "greet.v1.GreetService"
191
70
 
192
- ## Design highlights
71
+ def say_hello
72
+ Greet::V1::SayHelloResponse.new(greeting: "Hello, #{connect_request.name}!")
73
+ end
74
+ end
75
+ ```
193
76
 
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.
77
+ An RPC takes no arguments, like any other action. `connect_request` is the decoded request message, and the returned message is encoded in the caller's format (`application/json` or `application/proto`).
200
78
 
201
- ## Layout
79
+ ### 3. Route it
202
80
 
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
81
+ ```ruby
82
+ # config/routes.rb
83
+ Rails.application.routes.draw do
84
+ connect_service "greet.v1.GreetService" => :greet
85
+ end
220
86
  ```
221
87
 
222
- ## Run
88
+ This draws `POST /greet.v1.GreetService/SayHello` to `GreetController#say_hello`, one route per RPC the descriptor declares.
223
89
 
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.
90
+ ### 4. Call it
226
91
 
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)
92
+ ```console
93
+ $ curl -X POST -H 'Content-Type: application/json' \
94
+ -d '{"name":"Ada"}' \
95
+ http://localhost:3000/greet.v1.GreetService/SayHello
96
+ {"greeting":"Hello, Ada!"}
233
97
  ```
234
98
 
235
- `hk install` wires the same checks into a pre-commit hook. CI runs exactly these.
99
+ Raise `ConnectRpcRails::Error.new(:invalid_argument, "name is required")` to answer an error, and use `before_action` for authentication as in any controller. [`examples/greet`](examples/greet/) is a bootable app with a bearer-token concern and error handling; see [docs/development.md](docs/development.md#the-example-service) to run it.
236
100
 
237
- ### The example service
101
+ ## Documentation
238
102
 
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):
103
+ - [Writing services](docs/controllers.md): reading the request, metadata, trailers, deadlines, callbacks, serving GET
104
+ - [Routing](docs/routing.md): one controller per service or per RPC, and what unrouted calls are answered
105
+ - [Error handling](docs/error-handling.md): Connect codes, Rails' exception classification, `map_connect_errors`, `ExceptionsApp`
106
+ - [Validating requests with protovalidate](docs/protovalidate.md)
107
+ - [Development](docs/development.md): running the specs, type checks, the conformance suite, and releasing
108
+ - [Design](DESIGN.md): how the transport hooks into `ActionController::API`, and why
241
109
 
242
- ```sh
243
- cd examples/greet
244
- bundle install
245
- bundle exec puma -b tcp://127.0.0.1:9711 config.ru
110
+ ## Caveats
246
111
 
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
- ```
112
+ - Only the Connect protocol's unary RPCs are served, by POST, and by GET for RPCs declared free of side effects. Streaming, gRPC and gRPC-Web, and request compression are out of scope.
113
+ - `connect-timeout-ms` is enforced with `Timeout.timeout`, which interrupts the action wherever it is running when the deadline passes.
114
+ - The message of an exception mapped to a Connect code, including Rails-classified ones such as `ActiveRecord::RecordNotFound`, is sent to the caller as the error message.
253
115
 
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`.
116
+ ## Development
256
117
 
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.
118
+ ```
119
+ bundle install
120
+ bundle exec rspec
121
+ ```
270
122
 
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.
123
+ See [docs/development.md](docs/development.md) for the full set of checks.
274
124
 
275
- ## Releasing
125
+ ## Contributing
276
126
 
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.
127
+ Bug reports and pull requests are welcome on GitHub at https://github.com/ivry-inc/connect_rpc_rails.
280
128
 
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.
129
+ ## License
285
130
 
286
- ## Deliberately out of scope
131
+ This project is licensed under the Apache-2.0 License.
287
132
 
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.
133
+ Copyright 2026 IVRy Inc.
@@ -23,6 +23,15 @@ module ConnectRpcRails
23
23
  end
24
24
  end
25
25
 
26
+ # The codec a Connect GET names in its `encoding` query parameter.
27
+ #: (String?) -> _Codec?
28
+ def self.for_name(name)
29
+ case name
30
+ when 'json' then Json
31
+ when 'proto' then Proto
32
+ end
33
+ end
34
+
26
35
  module Json
27
36
  CONTENT_TYPE = 'application/json' #: String
28
37