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.
@@ -15,6 +15,10 @@ module ConnectRpcRails
15
15
  # : (String?) -> _Codec?
16
16
  def self.for_content_type: (String?) -> _Codec?
17
17
 
18
+ # The codec a Connect GET names in its `encoding` query parameter.
19
+ # : (String?) -> _Codec?
20
+ def self.for_name: (String?) -> _Codec?
21
+
18
22
  module Json
19
23
  CONTENT_TYPE: String
20
24
 
@@ -56,6 +56,9 @@ module ConnectRpcRails
56
56
 
57
57
  UNKNOWN_METHOD_PARAM: ::String
58
58
 
59
+ # The request compression the transport accepts, advertised when a call uses another.
60
+ SUPPORTED_COMPRESSION: Array[String]
61
+
59
62
  # The one `rescue_responses` entry that doesn't become a Connect error: #action_missing
60
63
  # raises ActionNotFound for a name that isn't an RPC, which means the app routed
61
64
  # something to an action this controller doesn't have. That is a misconfiguration for
@@ -112,14 +115,15 @@ module ConnectRpcRails
112
115
  # Connect actions decode the body themselves and never read `params`, so skip
113
116
  # Rails' lazy body param parsing: it would deserialize a JSON body a second time
114
117
  # (instrumentation reads filtered_parameters) and leak request payloads into
115
- # logs. Query params still parse; instrumentation is otherwise unaffected.
116
- # Runs before instrumentation reads the params, so it decodes the Connect body
117
- # here (exactly once) and reuses it two ways: the typed message drives the action,
118
- # and its hash form populates `request_parameters` — so the standard Rails request
119
- # log, `config.filter_parameters`, and APM see the request without Rails parsing
120
- # the body a second time. Also reports the wire format for logs/instrumentation
121
- # (via the formats header rather than `request.format=`, which would inject a
122
- # :format key into params).
118
+ # logs. Query params other than a GET's Connect parameters still parse; instrumentation
119
+ # is otherwise unaffected.
120
+ # Runs before instrumentation reads the params, so it decodes the Connect message (a
121
+ # POST's body, a GET's `message`) here, exactly once, and reuses it two ways: the
122
+ # typed message drives the action, and its hash form populates `request_parameters`
123
+ # — so the standard Rails request log, `config.filter_parameters`, and APM see the
124
+ # request without Rails parsing it a second time. Also reports the wire format for
125
+ # logs/instrumentation (via the formats header rather than `request.format=`, which
126
+ # would inject a :format key into params).
123
127
  def process_action: (*untyped) -> untyped
124
128
 
125
129
  # The RPC method is the controller's own method, so the transport wraps dispatch
@@ -153,6 +157,27 @@ module ConnectRpcRails
153
157
 
154
158
  private def connect_request_params: () -> untyped
155
159
 
160
+ # The serialized request message: the body of a POST, the `message` parameter of a GET.
161
+ # : () -> String
162
+ private def connect_request_bytes: () -> String
163
+
164
+ # Connect serves an RPC over GET only when it is unary and its `.proto` declares it free of
165
+ # side effects.
166
+ # : (ServiceRegistration::Rpc) -> bool
167
+ private def connect_get_allowed?: (ServiceRegistration::Rpc) -> bool
168
+
169
+ # Whether this call is a Connect GET, which carries its request in the query string.
170
+ # : () -> bool
171
+ private def connect_get?: () -> bool
172
+
173
+ # : () -> GetQuery
174
+ private def connect_get_query: () -> GetQuery
175
+
176
+ # A GET's query parameters are its request, which `request_parameters` already holds
177
+ # decoded, so `params` shows the message once. A query Rails refuses, such as a binary
178
+ # message sent without base64, keeps no parameters.
179
+ private def exclude_connect_query_parameters: () -> untyped
180
+
156
181
  # The transport preconditions have all passed by the time this runs (see
157
182
  # #validate_connect_request), so this is the wire round-trip and nothing else. The RPC
158
183
  # runs on this controller instance — the one Rails built for this request — and takes
@@ -160,15 +185,25 @@ module ConnectRpcRails
160
185
  # DSL checks that at boot), so there is no missing-method case.
161
186
  private def dispatch_connect_rpc: (untyped rpc) -> untyped
162
187
 
163
- # The codec for this call's content-type. Never missing once the action runs:
164
- # #validate_connect_request answers a content-type no codec handles with a 415.
188
+ # The codec for this call. Never missing once the action runs: #validate_connect_request
189
+ # answers a codec it has none for with a 415.
165
190
  # : () -> Codec::_Codec
166
191
  private def connect_codec: () -> Codec::_Codec
167
192
 
168
- # Connect rejects unsupported request compression with `unimplemented`, advertising
169
- # what it can accept.
193
+ # The codec the caller named: a GET's `encoding` parameter, a POST's content-type. Nil
194
+ # when no codec handles it.
195
+ # : () -> Codec::_Codec?
196
+ private def connect_requested_codec: () -> Codec::_Codec?
197
+
198
+ # Connect rejects unsupported request compression with `unimplemented`, naming what it
199
+ # can accept in both `accept-encoding` and the message. A GET names its compression in
200
+ # the query.
170
201
  private def reject_unsupported_encoding: () -> untyped
171
202
 
203
+ # `connect=v1` is optional, as the protocol lets a server accept a GET without it. Any
204
+ # other version is a revision of the protocol this server does not speak.
205
+ private def reject_unsupported_protocol_version: () -> untyped
206
+
172
207
  # The decoded request message, for the action to read the way an HTTP action reads
173
208
  # `params`. Assigned during decode, so callbacks see it too.
174
209
  # : () -> untyped
@@ -206,17 +241,22 @@ module ConnectRpcRails
206
241
  # : () { (?) -> untyped } -> untyped
207
242
  private def enforce_connect_deadline: () { (?) -> untyped } -> untyped
208
243
 
209
- # The handler `map_connect_errors` installs: the mapping lives on the class, so the
210
- # code for the exception at hand is looked up rather than baked into a closure.
211
- private def render_mapped_connect_error: (untyped exception) -> untyped
212
-
213
- # The handler installed for every `rescue_responses` entry. The status is read off the
214
- # nearest ancestor the registry names, since `rescue_from` matches subclasses too and a
215
- # subclass isn't a key (an `ActiveRecord::RecordNotUnique` is classified as the
216
- # `StatementInvalid` it descends from). Read with `fetch`, because the registry answers
217
- # anything at all with a default of :internal_server_error — which would stop the walk
218
- # on the first ancestor and call every subclass a 500.
219
- private def render_rescue_response_error: (untyped exception) -> untyped
244
+ # The handler every `rescue_from` the library installs points at.
245
+ private def render_connect_exception: (untyped exception) -> untyped
246
+
247
+ # The Connect error to send for a rescued exception. Override to add details every
248
+ # error should carry, the way ExceptionsApp#connect_error_for is overridden for errors
249
+ # that escape the controller.
250
+ #
251
+ # A mapped exception takes its code from `map_connect_errors`, which overrides the
252
+ # `rescue_responses` classification. That status is read off the nearest ancestor the
253
+ # registry names, since `rescue_from` matches subclasses too and a subclass isn't a key
254
+ # (an `ActiveRecord::RecordNotUnique` is classified as the `StatementInvalid` it
255
+ # descends from). Read with `fetch`, because the registry answers anything at all with
256
+ # a default of :internal_server_error — which would stop the walk on the first ancestor
257
+ # and call every subclass a 500.
258
+ # : (Exception) -> Error
259
+ private def connect_error_for: (Exception) -> Error
220
260
 
221
261
  private def render_connect_error: (untyped error) -> untyped
222
262
 
@@ -7,8 +7,9 @@ module ConnectRpcRails
7
7
  #
8
8
  # config.exceptions_app = ConnectRpcRails::ExceptionsApp.new(MyExceptions.new(Rails.public_path))
9
9
  #
10
- # A Connect request is recognized by `connect-protocol-version`, which the protocol
11
- # requires on every unary call; anything else reaches the wrapped app untouched.
10
+ # A Connect request is recognized by the `connect-protocol-version` header Connect clients
11
+ # send on a unary POST, or by the `connect=v1` query parameter they send on a GET; anything
12
+ # else reaches the wrapped app untouched.
12
13
  class ExceptionsApp
13
14
  # `connect-protocol-version`, as Rack names it in the env.
14
15
  PROTOCOL_VERSION_HEADER: ::String
@@ -19,6 +20,9 @@ module ConnectRpcRails
19
20
  # : (Hash[String, untyped]) -> [Integer, Hash[String, String], Array[String]]
20
21
  def call: (Hash[String, untyped]) -> [ Integer, Hash[String, String], Array[String] ]
21
22
 
23
+ # : (Hash[String, untyped]) -> bool
24
+ private def connect_request?: (Hash[String, untyped]) -> bool
25
+
22
26
  # The error to send for an escaped exception. The message is the status's own text,
23
27
  # never the exception's, which can quote internals. Override in a subclass to add
24
28
  # details every error should carry.
@@ -0,0 +1,47 @@
1
+ # Generated from lib/connect_rpc_rails/get_query.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # The query string of a Connect unary GET, which carries the codec, compression and message
5
+ # a POST sends as headers and body:
6
+ #
7
+ # GET /greet.v1.GreetService/SayHello?connect=v1&encoding=json&message=%7B%22name%22%3A%22Ada%22%7D
8
+ class GetQuery
9
+ # The parameters the protocol defines. Any other parameter is allowed and ignored.
10
+ PARAMS: Array[String]
11
+
12
+ # The `connect` value Connect clients send, the GET counterpart of `connect-protocol-version: 1`.
13
+ PROTOCOL_VERSION: String
14
+
15
+ # The RFC 4648 §5 alphabet. Base64.urlsafe_decode64 checks the padding and the length, but
16
+ # also takes "+" and "/" from the standard alphabet.
17
+ URL_SAFE_BASE64: Regexp
18
+
19
+ # Raised when the `message` parameter is missing or is not the base64 it claims to be.
20
+ class InvalidMessage < StandardError
21
+ end
22
+
23
+ # Each parameter takes its first value. An invalid percent-escape is kept as written, as
24
+ # URI.decode_www_form does, so it fails as an unknown value rather than being dropped. The
25
+ # query is decoded as binary, because decoding as UTF-8 scrubs the bytes of an unencoded
26
+ # binary message; values are then tagged UTF-8 without being checked.
27
+ # : (String) -> GetQuery
28
+ def self.parse: (String) -> GetQuery
29
+
30
+ # : (Hash[String, String]) -> void
31
+ def initialize: (Hash[String, String]) -> void
32
+
33
+ # : () -> String?
34
+ def connect: () -> String?
35
+
36
+ # : () -> String?
37
+ def encoding: () -> String?
38
+
39
+ # : () -> String?
40
+ def compression: () -> String?
41
+
42
+ # The `message` parameter's bytes. With `base64=1` it is URL-safe base64, padded or not;
43
+ # any other `base64` value is ignored, per the protocol.
44
+ # : () -> String
45
+ def message: () -> String
46
+ end
47
+ end
@@ -0,0 +1,96 @@
1
+ # Generated from lib/connect_rpc_rails/message_validatable.rb with RBS::Inline
2
+
3
+ module ConnectRpcRails
4
+ # Checks a Connect request against the `buf.validate` rules of its message before the RPC
5
+ # runs, and answers a violation the way AIP-193 describes: `invalid_argument`, with a
6
+ # `google.rpc.BadRequest` naming each field.
7
+ #
8
+ # class GreetController < ActionController::API
9
+ # include ConnectRpcRails::Controller
10
+ # include ConnectRpcRails::MessageValidatable
11
+ # include BearerAuthentication
12
+ # connect_service "greet.v1.GreetService"
13
+ #
14
+ # def say_hello = ...
15
+ # end
16
+ #
17
+ # Fields are named in the caller's encoding: `preferredLanguage` for a JSON request,
18
+ # `preferred_language` for a binary one. `google.rpc.BadRequest` is looked up in the
19
+ # descriptor pool rather than required, because googleapis belongs to the application: it
20
+ # generates `google/rpc/error_details.proto` alongside its own protos, or bundles
21
+ # googleapis-common-protos-types.
22
+ #
23
+ # A violation raises ViolationError, which the concern rescues into the Connect error
24
+ # #connect_error_for_violation builds. Override that method to answer differently. Opt a
25
+ # controller out with `skip_before_action :validate_connect_message!` at its top.
26
+ # @rbs module-self ActionController::API
27
+ # @rbs module-self Controller
28
+ module MessageValidatable : ActionController::API, Controller
29
+ extend ActiveSupport::Concern
30
+
31
+ # Raised when `google.rpc.BadRequest` is not in the descriptor pool.
32
+ class MissingDescriptorError < StandardError
33
+ end
34
+
35
+ # Raised when a request breaks a rule its message declares.
36
+ class ViolationError < StandardError
37
+ # @return [Array<Protovalidate::Violation>]
38
+ attr_reader violations: Array[untyped]
39
+
40
+ # @return [Object] the request message the violations were found in
41
+ attr_reader request_message: untyped
42
+
43
+ # : (Array[untyped] violations, untyped request_message) -> void
44
+ def initialize: (Array[untyped] violations, untyped request_message) -> void
45
+ end
46
+
47
+ BAD_REQUEST: String
48
+
49
+ # AIP-193: a FieldViolation's reason is UPPER_SNAKE_CASE and at most 63 characters.
50
+ REASON_PATTERN: Regexp
51
+
52
+ # Published for a rule id that cannot be a reason, such as a CEL rule named in lowercase.
53
+ FALLBACK_REASON: String
54
+
55
+ # Rails appends a re-registered callback to the end of the chain, so defining an RPC
56
+ # moves the validation behind the authentication a controller declares above it. An
57
+ # invalid request answered before an unauthenticated one would tell a caller what a valid
58
+ # request looks like. No entry means the controller skipped it, and it stays skipped.
59
+ def method_added: (untyped method_name) -> untyped
60
+
61
+ # The violated field as `parts[0].part_name`, each segment resolved against the descriptor
62
+ # so it is spelled the way the caller's encoding spells it. Empty for a message-level rule.
63
+ # : (untyped path, untyped message_class, json_names: bool) -> String
64
+ def self.field_path: (untyped path, untyped message_class, json_names: bool) -> String
65
+
66
+ # The message a path continues into after this field: for a map, the value's type rather
67
+ # than the synthetic entry, because the key is the subscript and not a segment.
68
+ # : (untyped field) -> untyped
69
+ def self.field_message: (untyped field) -> untyped
70
+
71
+ # : (untyped element) -> String
72
+ def self.subscript: (untyped element) -> String
73
+
74
+ # The rule id as a reason constant: `string.min_len` becomes `STRING_MIN_LEN`.
75
+ # : (String rule_id) -> String
76
+ def self.reason: (String rule_id) -> String
77
+
78
+ # A googleapis message class, looked up in the descriptor pool.
79
+ # : (String name) -> untyped
80
+ def self.googleapis_class: (String name) -> untyped
81
+
82
+ # : () -> void
83
+ private def validate_connect_message!: () -> void
84
+
85
+ # The Connect error a violation is answered with: `invalid_argument` with a
86
+ # `google.rpc.BadRequest`, naming each field the way this request's encoding spells it.
87
+ # Override to answer differently.
88
+ # : (ViolationError error) -> Error
89
+ private def connect_error_for_violation: (ViolationError error) -> Error
90
+
91
+ # Raised rather than only built, so the Connect error carries the violation as its cause for
92
+ # whatever reports it.
93
+ # : (ViolationError error) -> void
94
+ private def render_connect_violation: (ViolationError error) -> void
95
+ end
96
+ end
@@ -0,0 +1,2 @@
1
+ # Generated from lib/connect_rpc_rails/protovalidate.rb with RBS::Inline
2
+
@@ -24,7 +24,7 @@ module ConnectRpcRails
24
24
  # service but not that method, so it is answered `unimplemented` the way a declared RPC
25
25
  # nobody implements always is.
26
26
  #
27
- # Every RPC the descriptor declares becomes one `POST /<pkg.Service>/<Method>` route to
27
+ # Every RPC the descriptor declares becomes one `/<pkg.Service>/<Method>` route to
28
28
  # the action implementing it (1 RPC = 1 action), so the protobuf definition is the only
29
29
  # place the method list lives. A declared RPC the controller doesn't implement is
30
30
  # answered Connect `unimplemented` by the controller, not by a route. After the RPC
@@ -16,6 +16,8 @@ module ConnectRpcRails
16
16
  # so leftover instance state could leak between callers, whereas Rails builds a
17
17
  # controller per request.
18
18
  class ServiceRegistration
19
+ # `idempotency_level` is the method option's enum symbol: `:NO_SIDE_EFFECTS`, `:IDEMPOTENT`
20
+ # or `:IDEMPOTENCY_UNKNOWN`. `streaming` is true when either side of the RPC streams.
19
21
  class Rpc < Struct[untyped]
20
22
  attr_accessor name(): untyped
21
23
 
@@ -25,8 +27,12 @@ module ConnectRpcRails
25
27
 
26
28
  attr_accessor output_class(): untyped
27
29
 
28
- def self.new: (?name: untyped, ?action: untyped, ?input_class: untyped, ?output_class: untyped) -> instance
29
- | ({ ?name: untyped, ?action: untyped, ?input_class: untyped, ?output_class: untyped }) -> instance
30
+ attr_accessor idempotency_level(): untyped
31
+
32
+ attr_accessor streaming(): untyped
33
+
34
+ def self.new: (?name: untyped, ?action: untyped, ?input_class: untyped, ?output_class: untyped, ?idempotency_level: untyped, ?streaming: untyped) -> instance
35
+ | ({ ?name: untyped, ?action: untyped, ?input_class: untyped, ?output_class: untyped, ?idempotency_level: untyped, ?streaming: untyped }) -> instance
30
36
  end
31
37
 
32
38
  attr_reader service_name: String
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: connect_rpc_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - IVRy Inc.
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: google-protobuf
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +51,34 @@ dependencies:
37
51
  - - "~>"
38
52
  - !ruby/object:Gem::Version
39
53
  version: '4.26'
54
+ - !ruby/object:Gem::Dependency
55
+ name: googleapis-common-protos-types
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.21'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.21'
68
+ - !ruby/object:Gem::Dependency
69
+ name: protovalidate
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 0.1.0.beta3
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 0.1.0.beta3
40
82
  - !ruby/object:Gem::Dependency
41
83
  name: puma
42
84
  requirement: !ruby/object:Gem::Requirement
@@ -166,6 +208,9 @@ files:
166
208
  - lib/connect_rpc_rails/controller.rb
167
209
  - lib/connect_rpc_rails/errors.rb
168
210
  - lib/connect_rpc_rails/exceptions_app.rb
211
+ - lib/connect_rpc_rails/get_query.rb
212
+ - lib/connect_rpc_rails/message_validatable.rb
213
+ - lib/connect_rpc_rails/protovalidate.rb
169
214
  - lib/connect_rpc_rails/railtie.rb
170
215
  - lib/connect_rpc_rails/routing.rb
171
216
  - lib/connect_rpc_rails/service_registration.rb
@@ -175,6 +220,9 @@ files:
175
220
  - sig/generated/connect_rpc_rails/controller.rbs
176
221
  - sig/generated/connect_rpc_rails/errors.rbs
177
222
  - sig/generated/connect_rpc_rails/exceptions_app.rbs
223
+ - sig/generated/connect_rpc_rails/get_query.rbs
224
+ - sig/generated/connect_rpc_rails/message_validatable.rbs
225
+ - sig/generated/connect_rpc_rails/protovalidate.rbs
178
226
  - sig/generated/connect_rpc_rails/railtie.rbs
179
227
  - sig/generated/connect_rpc_rails/routing.rbs
180
228
  - sig/generated/connect_rpc_rails/service_registration.rbs