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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +88 -245
- data/lib/connect_rpc_rails/codec.rb +9 -0
- data/lib/connect_rpc_rails/controller.rb +142 -62
- data/lib/connect_rpc_rails/exceptions_app.rb +13 -3
- data/lib/connect_rpc_rails/get_query.rb +70 -0
- data/lib/connect_rpc_rails/message_validatable.rb +208 -0
- data/lib/connect_rpc_rails/protovalidate.rb +12 -0
- data/lib/connect_rpc_rails/routing.rb +1 -1
- data/lib/connect_rpc_rails/service_registration.rb +7 -1
- data/lib/connect_rpc_rails/version.rb +1 -1
- data/lib/connect_rpc_rails.rb +1 -0
- data/sig/generated/connect_rpc_rails/codec.rbs +4 -0
- data/sig/generated/connect_rpc_rails/controller.rbs +63 -23
- data/sig/generated/connect_rpc_rails/exceptions_app.rbs +6 -2
- data/sig/generated/connect_rpc_rails/get_query.rbs +47 -0
- data/sig/generated/connect_rpc_rails/message_validatable.rbs +96 -0
- data/sig/generated/connect_rpc_rails/protovalidate.rbs +2 -0
- data/sig/generated/connect_rpc_rails/routing.rbs +1 -1
- data/sig/generated/connect_rpc_rails/service_registration.rbs +8 -2
- metadata +49 -1
|
@@ -63,6 +63,9 @@ module ConnectRpcRails
|
|
|
63
63
|
UNKNOWN_METHOD_ACTION = "connect_unknown_method"
|
|
64
64
|
UNKNOWN_METHOD_PARAM = "connect_method"
|
|
65
65
|
|
|
66
|
+
# The request compression the transport accepts, advertised when a call uses another.
|
|
67
|
+
SUPPORTED_COMPRESSION = ["identity"].freeze #: Array[String]
|
|
68
|
+
|
|
66
69
|
# The one `rescue_responses` entry that doesn't become a Connect error: #action_missing
|
|
67
70
|
# raises ActionNotFound for a name that isn't an RPC, which means the app routed
|
|
68
71
|
# something to an action this controller doesn't have. That is a misconfiguration for
|
|
@@ -85,7 +88,7 @@ module ConnectRpcRails
|
|
|
85
88
|
# Registered first, so both the Error handler and anything `map_connect_errors` adds
|
|
86
89
|
# later take precedence: Rails picks the most recently registered matching handler.
|
|
87
90
|
install_rescue_response_defaults(base)
|
|
88
|
-
base.rescue_from(Error, with: :
|
|
91
|
+
base.rescue_from(Error, with: :render_connect_exception)
|
|
89
92
|
base.around_action(:enforce_connect_deadline)
|
|
90
93
|
base.prepend_before_action(:validate_connect_request)
|
|
91
94
|
end
|
|
@@ -102,7 +105,7 @@ module ConnectRpcRails
|
|
|
102
105
|
ActionDispatch::ExceptionWrapper.rescue_responses.each_key do |class_name|
|
|
103
106
|
next if RESCUE_RESPONSE_EXCLUSIONS.include?(class_name)
|
|
104
107
|
|
|
105
|
-
base.rescue_from(class_name, with: :
|
|
108
|
+
base.rescue_from(class_name, with: :render_connect_exception)
|
|
106
109
|
end
|
|
107
110
|
end
|
|
108
111
|
|
|
@@ -140,24 +143,26 @@ module ConnectRpcRails
|
|
|
140
143
|
#: (Hash[Class, Symbol]) -> void
|
|
141
144
|
def map_connect_errors(mapping)
|
|
142
145
|
self.connect_error_mapping = connect_error_mapping.merge(mapping)
|
|
143
|
-
mapping.each_key { |klass| rescue_from(klass, with: :
|
|
146
|
+
mapping.each_key { |klass| rescue_from(klass, with: :render_connect_exception) }
|
|
144
147
|
end
|
|
145
148
|
end
|
|
146
149
|
|
|
147
150
|
# Connect actions decode the body themselves and never read `params`, so skip
|
|
148
151
|
# Rails' lazy body param parsing: it would deserialize a JSON body a second time
|
|
149
152
|
# (instrumentation reads filtered_parameters) and leak request payloads into
|
|
150
|
-
# logs. Query params still parse; instrumentation
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
153
|
+
# logs. Query params other than a GET's Connect parameters still parse; instrumentation
|
|
154
|
+
# is otherwise unaffected.
|
|
155
|
+
# Runs before instrumentation reads the params, so it decodes the Connect message (a
|
|
156
|
+
# POST's body, a GET's `message`) here, exactly once, and reuses it two ways: the
|
|
157
|
+
# typed message drives the action, and its hash form populates `request_parameters`
|
|
158
|
+
# — so the standard Rails request log, `config.filter_parameters`, and APM see the
|
|
159
|
+
# request without Rails parsing it a second time. Also reports the wire format for
|
|
160
|
+
# logs/instrumentation (via the formats header rather than `request.format=`, which
|
|
161
|
+
# would inject a :format key into params).
|
|
158
162
|
def process_action(*)
|
|
159
163
|
request.request_parameters = connect_request_params
|
|
160
|
-
|
|
164
|
+
exclude_connect_query_parameters if connect_get?
|
|
165
|
+
format = Codec::Proto == connect_requested_codec ? :proto : :json
|
|
161
166
|
request.set_header("action_dispatch.request.formats", [Mime[format]])
|
|
162
167
|
super
|
|
163
168
|
end
|
|
@@ -212,16 +217,18 @@ module ConnectRpcRails
|
|
|
212
217
|
|
|
213
218
|
@connect_full_method = "#{self.class.connect_registration.service_name}/#{rpc.name}"
|
|
214
219
|
|
|
215
|
-
# A Connect RPC
|
|
216
|
-
# match all verbs so the wrong-verb case reaches here
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
# A Connect RPC takes POST, and GET too when it is unary with no side effects; other
|
|
221
|
+
# verbs are a 405, per the protocol. (Routes match all verbs so the wrong-verb case reaches here
|
|
222
|
+
# rather than 404-ing.) HTTP requires a 405 to name the verbs the resource takes.
|
|
223
|
+
unless request.post? || connect_get?
|
|
224
|
+
response.headers["allow"] = connect_get_allowed?(rpc) ? "GET, POST" : "POST"
|
|
225
|
+
return render(plain: "method not allowed", status: 405)
|
|
226
|
+
end
|
|
227
|
+
return render(plain: "unsupported media type", status: 415) unless connect_requested_codec
|
|
219
228
|
|
|
220
229
|
reject_unsupported_encoding
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
# (Don't echo the decoder message — it can quote payload fragments back.)
|
|
224
|
-
raise Error.new(:invalid_argument, "invalid request body") if @connect_decode_error
|
|
230
|
+
reject_unsupported_protocol_version if connect_get?
|
|
231
|
+
raise @connect_decode_error if @connect_decode_error
|
|
225
232
|
end
|
|
226
233
|
|
|
227
234
|
#: () -> String
|
|
@@ -231,20 +238,65 @@ module ConnectRpcRails
|
|
|
231
238
|
|
|
232
239
|
private def connect_request_params
|
|
233
240
|
rpc = self.class.connect_rpcs[action_name]
|
|
234
|
-
codec =
|
|
235
|
-
return {} unless request.post? && rpc && codec
|
|
241
|
+
codec = connect_requested_codec
|
|
242
|
+
return {} unless (request.post? || connect_get?) && rpc && codec
|
|
236
243
|
|
|
237
|
-
|
|
238
|
-
# empty message encodes to an empty proto body.
|
|
239
|
-
@connect_request = codec.decode(rpc.input_class, request.body&.read || "")
|
|
244
|
+
@connect_request = codec.decode(rpc.input_class, connect_request_bytes)
|
|
240
245
|
@connect_request.to_h
|
|
241
|
-
rescue Google::Protobuf::ParseError
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
@connect_decode_error =
|
|
246
|
+
rescue Google::Protobuf::ParseError
|
|
247
|
+
# Deferred to the action, so a malformed message flows through instrumentation and the
|
|
248
|
+
# normal error path. The decoder's message is not sent: it can quote the payload back.
|
|
249
|
+
@connect_decode_error = Error.new(
|
|
250
|
+
:invalid_argument,
|
|
251
|
+
connect_get? ? "invalid message query parameter" : "invalid request body",
|
|
252
|
+
)
|
|
253
|
+
{}
|
|
254
|
+
rescue GetQuery::InvalidMessage => e
|
|
255
|
+
@connect_decode_error = Error.new(:invalid_argument, e.message)
|
|
245
256
|
{}
|
|
246
257
|
end
|
|
247
258
|
|
|
259
|
+
# The serialized request message: the body of a POST, the `message` parameter of a GET.
|
|
260
|
+
#: () -> String
|
|
261
|
+
private def connect_request_bytes
|
|
262
|
+
return connect_get_query.message if connect_get?
|
|
263
|
+
|
|
264
|
+
# Rack 3.1 lets a server omit rack.input for an empty body (Falcon does), and an
|
|
265
|
+
# empty message encodes to an empty proto body.
|
|
266
|
+
request.body&.read || ""
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Connect serves an RPC over GET only when it is unary and its `.proto` declares it free of
|
|
270
|
+
# side effects.
|
|
271
|
+
#: (ServiceRegistration::Rpc) -> bool
|
|
272
|
+
private def connect_get_allowed?(rpc)
|
|
273
|
+
!rpc.streaming && rpc.idempotency_level == :NO_SIDE_EFFECTS
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Whether this call is a Connect GET, which carries its request in the query string.
|
|
277
|
+
#: () -> bool
|
|
278
|
+
private def connect_get?
|
|
279
|
+
rpc = self.class.connect_rpcs[action_name]
|
|
280
|
+
!rpc.nil? && request.get? && connect_get_allowed?(rpc)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
#: () -> GetQuery
|
|
284
|
+
private def connect_get_query
|
|
285
|
+
@connect_get_query ||= GetQuery.parse(request.query_string)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A GET's query parameters are its request, which `request_parameters` already holds
|
|
289
|
+
# decoded, so `params` shows the message once. A query Rails refuses, such as a binary
|
|
290
|
+
# message sent without base64, keeps no parameters.
|
|
291
|
+
private def exclude_connect_query_parameters
|
|
292
|
+
query = begin
|
|
293
|
+
request.query_parameters.except(*GetQuery::PARAMS)
|
|
294
|
+
rescue ActionController::BadRequest
|
|
295
|
+
{} #: Hash[String, untyped]
|
|
296
|
+
end
|
|
297
|
+
request.set_header("action_dispatch.request.query_parameters", query)
|
|
298
|
+
end
|
|
299
|
+
|
|
248
300
|
# The transport preconditions have all passed by the time this runs (see
|
|
249
301
|
# #validate_connect_request), so this is the wire round-trip and nothing else. The RPC
|
|
250
302
|
# runs on this controller instance — the one Rails built for this request — and takes
|
|
@@ -260,22 +312,45 @@ module ConnectRpcRails
|
|
|
260
312
|
response.headers["content-type"] = connect_codec.content_type
|
|
261
313
|
end
|
|
262
314
|
|
|
263
|
-
# The codec for this call
|
|
264
|
-
#
|
|
315
|
+
# The codec for this call. Never missing once the action runs: #validate_connect_request
|
|
316
|
+
# answers a codec it has none for with a 415.
|
|
265
317
|
#: () -> Codec::_Codec
|
|
266
318
|
private def connect_codec
|
|
267
|
-
|
|
268
|
-
raise(Error.new(:internal, "no codec for #{request.content_type}"))
|
|
319
|
+
connect_requested_codec || raise(Error.new(:internal, "no codec for this request"))
|
|
269
320
|
end
|
|
270
321
|
|
|
271
|
-
#
|
|
272
|
-
#
|
|
322
|
+
# The codec the caller named: a GET's `encoding` parameter, a POST's content-type. Nil
|
|
323
|
+
# when no codec handles it.
|
|
324
|
+
#: () -> Codec::_Codec?
|
|
325
|
+
private def connect_requested_codec
|
|
326
|
+
return Codec.for_name(connect_get_query.encoding) if connect_get?
|
|
327
|
+
|
|
328
|
+
Codec.for_content_type(request.content_type)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Connect rejects unsupported request compression with `unimplemented`, naming what it
|
|
332
|
+
# can accept in both `accept-encoding` and the message. A GET names its compression in
|
|
333
|
+
# the query.
|
|
273
334
|
private def reject_unsupported_encoding
|
|
274
|
-
encoding =
|
|
275
|
-
|
|
335
|
+
source, encoding = if connect_get?
|
|
336
|
+
["compression", connect_get_query.compression]
|
|
337
|
+
else
|
|
338
|
+
["content-encoding", request.headers["Content-Encoding"]]
|
|
339
|
+
end
|
|
340
|
+
return if encoding.nil? || encoding.empty? || SUPPORTED_COMPRESSION.include?(encoding)
|
|
276
341
|
|
|
277
|
-
|
|
278
|
-
|
|
342
|
+
supported = SUPPORTED_COMPRESSION.join(", ")
|
|
343
|
+
response.headers["accept-encoding"] = supported
|
|
344
|
+
raise Error.new(:unimplemented, "unsupported #{source} #{encoding.inspect}: supported encodings are #{supported}")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# `connect=v1` is optional, as the protocol lets a server accept a GET without it. Any
|
|
348
|
+
# other version is a revision of the protocol this server does not speak.
|
|
349
|
+
private def reject_unsupported_protocol_version
|
|
350
|
+
version = connect_get_query.connect
|
|
351
|
+
return if version.nil? || ["", GetQuery::PROTOCOL_VERSION].include?(version)
|
|
352
|
+
|
|
353
|
+
raise Error.new(:invalid_argument, "connect query parameter must be #{GetQuery::PROTOCOL_VERSION}")
|
|
279
354
|
end
|
|
280
355
|
|
|
281
356
|
# The decoded request message, for the action to read the way an HTTP action reads
|
|
@@ -355,32 +430,37 @@ module ConnectRpcRails
|
|
|
355
430
|
end
|
|
356
431
|
end
|
|
357
432
|
|
|
358
|
-
# The handler `
|
|
359
|
-
|
|
360
|
-
|
|
433
|
+
# The handler every `rescue_from` the library installs points at.
|
|
434
|
+
private def render_connect_exception(exception)
|
|
435
|
+
render_connect_error(connect_error_for(exception))
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The Connect error to send for a rescued exception. Override to add details every
|
|
439
|
+
# error should carry, the way ExceptionsApp#connect_error_for is overridden for errors
|
|
440
|
+
# that escape the controller.
|
|
441
|
+
#
|
|
442
|
+
# A mapped exception takes its code from `map_connect_errors`, which overrides the
|
|
443
|
+
# `rescue_responses` classification. That status is read off the nearest ancestor the
|
|
444
|
+
# registry names, since `rescue_from` matches subclasses too and a subclass isn't a key
|
|
445
|
+
# (an `ActiveRecord::RecordNotUnique` is classified as the `StatementInvalid` it
|
|
446
|
+
# descends from). Read with `fetch`, because the registry answers anything at all with
|
|
447
|
+
# a default of :internal_server_error — which would stop the walk on the first ancestor
|
|
448
|
+
# and call every subclass a 500.
|
|
449
|
+
#: (Exception) -> Error
|
|
450
|
+
private def connect_error_for(exception)
|
|
451
|
+
return exception if exception.is_a?(Error)
|
|
452
|
+
|
|
361
453
|
_, code = self.class.connect_error_mapping.find { |klass, _| exception.is_a?(klass) }
|
|
362
|
-
|
|
363
|
-
|
|
454
|
+
unless code
|
|
455
|
+
responses = ActionDispatch::ExceptionWrapper.rescue_responses
|
|
456
|
+
status = exception.class.ancestors.lazy.filter_map { |klass| responses.fetch(klass.name, nil) }.first
|
|
457
|
+
code = Error.code_for_http_status(Rack::Utils.status_code(status)) if status
|
|
458
|
+
end
|
|
459
|
+
# Only mapped or classified exceptions are rescued, so this can't miss; re-raise
|
|
460
|
+
# rather than invent a code if it somehow does.
|
|
364
461
|
raise exception unless code
|
|
365
462
|
|
|
366
|
-
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# The handler installed for every `rescue_responses` entry. The status is read off the
|
|
370
|
-
# nearest ancestor the registry names, since `rescue_from` matches subclasses too and a
|
|
371
|
-
# subclass isn't a key (an `ActiveRecord::RecordNotUnique` is classified as the
|
|
372
|
-
# `StatementInvalid` it descends from). Read with `fetch`, because the registry answers
|
|
373
|
-
# anything at all with a default of :internal_server_error — which would stop the walk
|
|
374
|
-
# on the first ancestor and call every subclass a 500.
|
|
375
|
-
private def render_rescue_response_error(exception)
|
|
376
|
-
responses = ActionDispatch::ExceptionWrapper.rescue_responses
|
|
377
|
-
status = exception.class.ancestors.lazy.filter_map { |klass| responses.fetch(klass.name, nil) }.first
|
|
378
|
-
# Only registered names are rescued here, so this can't miss; re-raise rather than
|
|
379
|
-
# invent a code if it somehow does.
|
|
380
|
-
raise exception unless status
|
|
381
|
-
|
|
382
|
-
code = Error.code_for_http_status(Rack::Utils.status_code(status))
|
|
383
|
-
render_connect_error(Error.new(code, exception.message))
|
|
463
|
+
Error.new(code, exception.message)
|
|
384
464
|
end
|
|
385
465
|
|
|
386
466
|
private def render_connect_error(error)
|
|
@@ -14,8 +14,9 @@ module ConnectRpcRails
|
|
|
14
14
|
#
|
|
15
15
|
# config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
|
|
16
16
|
#
|
|
17
|
-
# A Connect request is recognized by `connect-protocol-version
|
|
18
|
-
#
|
|
17
|
+
# A Connect request is recognized by the `connect-protocol-version` header Connect clients
|
|
18
|
+
# send on a unary POST, or by the `connect=v1` query parameter they send on a GET; anything
|
|
19
|
+
# else reaches the wrapped app untouched.
|
|
19
20
|
class ExceptionsApp
|
|
20
21
|
# `connect-protocol-version`, as Rack names it in the env.
|
|
21
22
|
PROTOCOL_VERSION_HEADER = "HTTP_CONNECT_PROTOCOL_VERSION"
|
|
@@ -28,11 +29,20 @@ module ConnectRpcRails
|
|
|
28
29
|
#: (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
|
|
29
30
|
def call(env)
|
|
30
31
|
exception = env["action_dispatch.exception"]
|
|
31
|
-
return @app.call(env) unless exception && env
|
|
32
|
+
return @app.call(env) unless exception && connect_request?(env)
|
|
32
33
|
|
|
33
34
|
render_connect_error(connect_error_for(exception, env))
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
#: (Hash[String, untyped]) -> bool
|
|
38
|
+
private def connect_request?(env)
|
|
39
|
+
return true if env[PROTOCOL_VERSION_HEADER]
|
|
40
|
+
|
|
41
|
+
# ActionDispatch::ShowExceptions turns every request into a GET before calling this app.
|
|
42
|
+
method = env.fetch("action_dispatch.original_request_method") { env["REQUEST_METHOD"] }
|
|
43
|
+
method == "GET" && GetQuery.parse(env["QUERY_STRING"].to_s).connect == GetQuery::PROTOCOL_VERSION
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
# The error to send for an escaped exception. The message is the status's own text,
|
|
37
47
|
# never the exception's, which can quote internals. Override in a subclass to add
|
|
38
48
|
# details every error should carry.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 IVRy Inc.
|
|
5
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
6
|
+
|
|
7
|
+
require "base64"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module ConnectRpcRails
|
|
11
|
+
# The query string of a Connect unary GET, which carries the codec, compression and message
|
|
12
|
+
# a POST sends as headers and body:
|
|
13
|
+
#
|
|
14
|
+
# GET /greet.v1.GreetService/SayHello?connect=v1&encoding=json&message=%7B%22name%22%3A%22Ada%22%7D
|
|
15
|
+
class GetQuery
|
|
16
|
+
# The parameters the protocol defines. Any other parameter is allowed and ignored.
|
|
17
|
+
PARAMS = ["connect", "base64", "compression", "encoding", "message"].freeze #: Array[String]
|
|
18
|
+
|
|
19
|
+
# The `connect` value Connect clients send, the GET counterpart of `connect-protocol-version: 1`.
|
|
20
|
+
PROTOCOL_VERSION = "v1" #: String
|
|
21
|
+
|
|
22
|
+
# The RFC 4648 §5 alphabet. Base64.urlsafe_decode64 checks the padding and the length, but
|
|
23
|
+
# also takes "+" and "/" from the standard alphabet.
|
|
24
|
+
URL_SAFE_BASE64 = /\A[A-Za-z0-9_-]*={0,2}\z/ #: Regexp
|
|
25
|
+
|
|
26
|
+
# Raised when the `message` parameter is missing or is not the base64 it claims to be.
|
|
27
|
+
class InvalidMessage < StandardError; end
|
|
28
|
+
|
|
29
|
+
# Each parameter takes its first value. An invalid percent-escape is kept as written, as
|
|
30
|
+
# URI.decode_www_form does, so it fails as an unknown value rather than being dropped. The
|
|
31
|
+
# query is decoded as binary, because decoding as UTF-8 scrubs the bytes of an unencoded
|
|
32
|
+
# binary message; values are then tagged UTF-8 without being checked.
|
|
33
|
+
#: (String) -> GetQuery
|
|
34
|
+
def self.parse(query_string)
|
|
35
|
+
params = {} #: Hash[String, String]
|
|
36
|
+
URI.decode_www_form(query_string, Encoding::BINARY).each do |key, value|
|
|
37
|
+
key = key.force_encoding(Encoding::UTF_8)
|
|
38
|
+
params[key] = value.force_encoding(Encoding::UTF_8) if PARAMS.include?(key) && !params.key?(key)
|
|
39
|
+
end
|
|
40
|
+
new(params)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (Hash[String, String]) -> void
|
|
44
|
+
def initialize(params)
|
|
45
|
+
@params = params
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: () -> String?
|
|
49
|
+
def connect = @params["connect"]
|
|
50
|
+
|
|
51
|
+
#: () -> String?
|
|
52
|
+
def encoding = @params["encoding"]
|
|
53
|
+
|
|
54
|
+
#: () -> String?
|
|
55
|
+
def compression = @params["compression"]
|
|
56
|
+
|
|
57
|
+
# The `message` parameter's bytes. With `base64=1` it is URL-safe base64, padded or not;
|
|
58
|
+
# any other `base64` value is ignored, per the protocol.
|
|
59
|
+
#: () -> String
|
|
60
|
+
def message
|
|
61
|
+
message = @params["message"] || raise(InvalidMessage, "missing message query parameter")
|
|
62
|
+
return message unless @params["base64"] == "1"
|
|
63
|
+
raise InvalidMessage, "invalid message query parameter" unless URL_SAFE_BASE64.match?(message)
|
|
64
|
+
|
|
65
|
+
Base64.urlsafe_decode64(message)
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
raise InvalidMessage, "invalid message query parameter"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 IVRy Inc.
|
|
5
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
6
|
+
|
|
7
|
+
require "active_support/concern"
|
|
8
|
+
require "google/protobuf/well_known_types"
|
|
9
|
+
require "protovalidate"
|
|
10
|
+
|
|
11
|
+
require "connect_rpc_rails"
|
|
12
|
+
|
|
13
|
+
module ConnectRpcRails
|
|
14
|
+
# Checks a Connect request against the `buf.validate` rules of its message before the RPC
|
|
15
|
+
# runs, and answers a violation the way AIP-193 describes: `invalid_argument`, with a
|
|
16
|
+
# `google.rpc.BadRequest` naming each field.
|
|
17
|
+
#
|
|
18
|
+
# class GreetController < ActionController::API
|
|
19
|
+
# include ConnectRpcRails::Controller
|
|
20
|
+
# include ConnectRpcRails::MessageValidatable
|
|
21
|
+
# include BearerAuthentication
|
|
22
|
+
# connect_service "greet.v1.GreetService"
|
|
23
|
+
#
|
|
24
|
+
# def say_hello = ...
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Fields are named in the caller's encoding: `preferredLanguage` for a JSON request,
|
|
28
|
+
# `preferred_language` for a binary one. `google.rpc.BadRequest` is looked up in the
|
|
29
|
+
# descriptor pool rather than required, because googleapis belongs to the application: it
|
|
30
|
+
# generates `google/rpc/error_details.proto` alongside its own protos, or bundles
|
|
31
|
+
# googleapis-common-protos-types.
|
|
32
|
+
#
|
|
33
|
+
# A violation raises ViolationError, which the concern rescues into the Connect error
|
|
34
|
+
# #connect_error_for_violation builds. Override that method to answer differently. Opt a
|
|
35
|
+
# controller out with `skip_before_action :validate_connect_message!` at its top.
|
|
36
|
+
# @rbs module-self ActionController::API
|
|
37
|
+
# @rbs module-self Controller
|
|
38
|
+
module MessageValidatable
|
|
39
|
+
extend ActiveSupport::Concern
|
|
40
|
+
|
|
41
|
+
# Raised when `google.rpc.BadRequest` is not in the descriptor pool.
|
|
42
|
+
class MissingDescriptorError < StandardError; end
|
|
43
|
+
|
|
44
|
+
# Raised when a request breaks a rule its message declares.
|
|
45
|
+
class ViolationError < StandardError
|
|
46
|
+
# @return [Array<Protovalidate::Violation>]
|
|
47
|
+
attr_reader :violations #: Array[untyped]
|
|
48
|
+
|
|
49
|
+
# @return [Object] the request message the violations were found in
|
|
50
|
+
attr_reader :request_message #: untyped
|
|
51
|
+
|
|
52
|
+
#: (Array[untyped] violations, untyped request_message) -> void
|
|
53
|
+
def initialize(violations, request_message)
|
|
54
|
+
@violations = violations
|
|
55
|
+
@request_message = request_message
|
|
56
|
+
super(violations.map(&:to_s).join("; "))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
BAD_REQUEST = "google.rpc.BadRequest" #: String
|
|
61
|
+
|
|
62
|
+
# AIP-193: a FieldViolation's reason is UPPER_SNAKE_CASE and at most 63 characters.
|
|
63
|
+
REASON_PATTERN = /\A[A-Z][A-Z0-9_]{1,61}[A-Z0-9]\z/ #: Regexp
|
|
64
|
+
|
|
65
|
+
# Published for a rule id that cannot be a reason, such as a CEL rule named in lowercase.
|
|
66
|
+
FALLBACK_REASON = "INVALID_VALUE" #: String
|
|
67
|
+
|
|
68
|
+
# `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
|
|
69
|
+
# way to name.
|
|
70
|
+
# steep:ignore:start
|
|
71
|
+
included do
|
|
72
|
+
# Registered at include time so `skip_before_action` at the top of a controller has an
|
|
73
|
+
# entry to delete. .method_added moves it behind the callbacks declared after this.
|
|
74
|
+
before_action(:validate_connect_message!)
|
|
75
|
+
rescue_from(ViolationError, with: :render_connect_violation)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class_methods do
|
|
79
|
+
# Rails appends a re-registered callback to the end of the chain, so defining an RPC
|
|
80
|
+
# moves the validation behind the authentication a controller declares above it. An
|
|
81
|
+
# invalid request answered before an unauthenticated one would tell a caller what a valid
|
|
82
|
+
# request looks like. No entry means the controller skipped it, and it stays skipped.
|
|
83
|
+
def method_added(method_name)
|
|
84
|
+
super
|
|
85
|
+
return unless connect_rpcs&.key?(method_name.to_s)
|
|
86
|
+
return unless _process_action_callbacks.any? { |callback| callback.filter == :validate_connect_message! }
|
|
87
|
+
|
|
88
|
+
before_action(:validate_connect_message!)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
# steep:ignore:end
|
|
92
|
+
|
|
93
|
+
# The violated field as `parts[0].part_name`, each segment resolved against the descriptor
|
|
94
|
+
# so it is spelled the way the caller's encoding spells it. Empty for a message-level rule.
|
|
95
|
+
#: (untyped path, untyped message_class, json_names: bool) -> String
|
|
96
|
+
def self.field_path(path, message_class, json_names:)
|
|
97
|
+
return "" if path.nil?
|
|
98
|
+
|
|
99
|
+
descriptor = message_class.descriptor
|
|
100
|
+
path.elements.map do |element|
|
|
101
|
+
field = descriptor&.lookup(element.field_name)
|
|
102
|
+
descriptor = field_message(field)
|
|
103
|
+
# A segment the descriptor cannot resolve keeps the violation's spelling, rather than
|
|
104
|
+
# being dropped and shifting the rest of the path.
|
|
105
|
+
name = if field.nil?
|
|
106
|
+
element.field_name
|
|
107
|
+
else
|
|
108
|
+
json_names ? field.json_name : field.name
|
|
109
|
+
end
|
|
110
|
+
"#{name}#{subscript(element)}"
|
|
111
|
+
end.join(".")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The message a path continues into after this field: for a map, the value's type rather
|
|
115
|
+
# than the synthetic entry, because the key is the subscript and not a segment.
|
|
116
|
+
#: (untyped field) -> untyped
|
|
117
|
+
def self.field_message(field)
|
|
118
|
+
return unless field&.type == :message
|
|
119
|
+
|
|
120
|
+
entry = field.subtype
|
|
121
|
+
return entry unless entry.options.map_entry
|
|
122
|
+
|
|
123
|
+
value = entry.lookup("value")
|
|
124
|
+
value.type == :message ? value.subtype : nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (untyped element) -> String
|
|
128
|
+
def self.subscript(element)
|
|
129
|
+
case element.subscript
|
|
130
|
+
when :index then "[#{element.index}]"
|
|
131
|
+
when :bool_key then "[#{element.bool_key}]"
|
|
132
|
+
when :int_key then "[#{element.int_key}]"
|
|
133
|
+
when :uint_key then "[#{element.uint_key}]"
|
|
134
|
+
when :string_key then "[#{element.string_key.inspect}]"
|
|
135
|
+
else ""
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The rule id as a reason constant: `string.min_len` becomes `STRING_MIN_LEN`.
|
|
140
|
+
#: (String rule_id) -> String
|
|
141
|
+
def self.reason(rule_id)
|
|
142
|
+
reason = rule_id.upcase.tr(".", "_")
|
|
143
|
+
REASON_PATTERN.match?(reason) ? reason : FALLBACK_REASON
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A googleapis message class, looked up in the descriptor pool.
|
|
147
|
+
#: (String name) -> untyped
|
|
148
|
+
def self.googleapis_class(name)
|
|
149
|
+
descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(name)
|
|
150
|
+
if descriptor.nil?
|
|
151
|
+
raise MissingDescriptorError,
|
|
152
|
+
"#{name} is not in the descriptor pool; generate google/rpc/error_details.proto " \
|
|
153
|
+
"or bundle googleapis-common-protos-types"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
descriptor.msgclass
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private_class_method :field_message, :subscript
|
|
160
|
+
|
|
161
|
+
#: () -> void
|
|
162
|
+
private def validate_connect_message!
|
|
163
|
+
return unless self.class.connect_rpcs[action_name]
|
|
164
|
+
|
|
165
|
+
message = connect_request
|
|
166
|
+
return if message.nil?
|
|
167
|
+
|
|
168
|
+
# protovalidate's signatures are not loaded: see rbs_collection.yaml.
|
|
169
|
+
violations = Protovalidate.collect_violations(message) # steep:ignore
|
|
170
|
+
return if violations.empty?
|
|
171
|
+
|
|
172
|
+
raise ViolationError.new(violations, message)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The Connect error a violation is answered with: `invalid_argument` with a
|
|
176
|
+
# `google.rpc.BadRequest`, naming each field the way this request's encoding spells it.
|
|
177
|
+
# Override to answer differently.
|
|
178
|
+
#: (ViolationError error) -> Error
|
|
179
|
+
private def connect_error_for_violation(error)
|
|
180
|
+
bad_request = MessageValidatable.googleapis_class(BAD_REQUEST)
|
|
181
|
+
field_violation = MessageValidatable.googleapis_class("#{BAD_REQUEST}.FieldViolation")
|
|
182
|
+
json_names = Codec::Json == connect_codec
|
|
183
|
+
field_violations = error.violations.map do |violation|
|
|
184
|
+
field_violation.new(
|
|
185
|
+
field: MessageValidatable.field_path(violation.field, error.request_message.class, json_names:),
|
|
186
|
+
reason: MessageValidatable.reason(violation.rule_id),
|
|
187
|
+
description: violation.message,
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
detail = field_violations.map { |v| v.field.empty? ? v.description : "#{v.field}: #{v.description}" }
|
|
191
|
+
|
|
192
|
+
Error.new(
|
|
193
|
+
:invalid_argument,
|
|
194
|
+
detail.join("; "),
|
|
195
|
+
details: [Google::Protobuf::Any.pack(bad_request.new(field_violations:))],
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Raised rather than only built, so the Connect error carries the violation as its cause for
|
|
200
|
+
# whatever reports it.
|
|
201
|
+
#: (ViolationError error) -> void
|
|
202
|
+
private def render_connect_violation(error)
|
|
203
|
+
raise connect_error_for_violation(error), cause: error
|
|
204
|
+
rescue Error => connect_error
|
|
205
|
+
render_connect_exception(connect_error)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 IVRy Inc.
|
|
5
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
6
|
+
|
|
7
|
+
# The protovalidate integration, required on its own because protovalidate is not a dependency
|
|
8
|
+
# of connect_rpc_rails: a service that declares no buf.validate rules never loads it.
|
|
9
|
+
#
|
|
10
|
+
# require "connect_rpc_rails/protovalidate"
|
|
11
|
+
|
|
12
|
+
require "connect_rpc_rails/message_validatable"
|
|
@@ -30,7 +30,7 @@ module ConnectRpcRails
|
|
|
30
30
|
# service but not that method, so it is answered `unimplemented` the way a declared RPC
|
|
31
31
|
# nobody implements always is.
|
|
32
32
|
#
|
|
33
|
-
# Every RPC the descriptor declares becomes one
|
|
33
|
+
# Every RPC the descriptor declares becomes one `/<pkg.Service>/<Method>` route to
|
|
34
34
|
# the action implementing it (1 RPC = 1 action), so the protobuf definition is the only
|
|
35
35
|
# place the method list lives. A declared RPC the controller doesn't implement is
|
|
36
36
|
# answered Connect `unimplemented` by the controller, not by a route. After the RPC
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
# SPDX-License-Identifier: Apache-2.0
|
|
6
6
|
|
|
7
7
|
require "google/protobuf"
|
|
8
|
+
# MethodDescriptor#options decodes into MethodOptions, which generated code does not load.
|
|
9
|
+
require "google/protobuf/descriptor_pb"
|
|
8
10
|
|
|
9
11
|
module ConnectRpcRails
|
|
10
12
|
# Reads a protobuf ServiceDescriptor (generated by protoc/buf and present in the
|
|
@@ -22,7 +24,9 @@ module ConnectRpcRails
|
|
|
22
24
|
# so leftover instance state could leak between callers, whereas Rails builds a
|
|
23
25
|
# controller per request.
|
|
24
26
|
class ServiceRegistration
|
|
25
|
-
|
|
27
|
+
# `idempotency_level` is the method option's enum symbol: `:NO_SIDE_EFFECTS`, `:IDEMPOTENT`
|
|
28
|
+
# or `:IDEMPOTENCY_UNKNOWN`. `streaming` is true when either side of the RPC streams.
|
|
29
|
+
Rpc = Struct.new(:name, :action, :input_class, :output_class, :idempotency_level, :streaming, keyword_init: true)
|
|
26
30
|
|
|
27
31
|
attr_reader :service_name #: String
|
|
28
32
|
|
|
@@ -39,6 +43,8 @@ module ConnectRpcRails
|
|
|
39
43
|
action: action,
|
|
40
44
|
input_class: method.input_type.msgclass,
|
|
41
45
|
output_class: method.output_type.msgclass,
|
|
46
|
+
idempotency_level: method.options.idempotency_level,
|
|
47
|
+
streaming: method.client_streaming || method.server_streaming,
|
|
42
48
|
)
|
|
43
49
|
end
|
|
44
50
|
end
|
data/lib/connect_rpc_rails.rb
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
require 'connect_rpc_rails/version'
|
|
8
8
|
require 'connect_rpc_rails/errors'
|
|
9
9
|
require 'connect_rpc_rails/codec'
|
|
10
|
+
require 'connect_rpc_rails/get_query'
|
|
10
11
|
require 'connect_rpc_rails/service_registration'
|
|
11
12
|
require 'connect_rpc_rails/controller'
|
|
12
13
|
require 'connect_rpc_rails/routing'
|