protobufable 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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/DESIGN.md +298 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +229 -0
  6. data/docs/columns.md +34 -0
  7. data/docs/connect.md +55 -0
  8. data/docs/errors.md +190 -0
  9. data/docs/request-bodies.md +42 -0
  10. data/docs/responses.md +49 -0
  11. data/docs/validation.md +121 -0
  12. data/lib/protobufable/alba_binding.rb +149 -0
  13. data/lib/protobufable/bad_request.rb +60 -0
  14. data/lib/protobufable/column_validatable.rb +56 -0
  15. data/lib/protobufable/connect/problem_rescuable.rb +92 -0
  16. data/lib/protobufable/connect.rb +20 -0
  17. data/lib/protobufable/field_path.rb +65 -0
  18. data/lib/protobufable/json_type.rb +114 -0
  19. data/lib/protobufable/problem/information.rb +75 -0
  20. data/lib/protobufable/problem/retry_info.rb +62 -0
  21. data/lib/protobufable/problem/typeable.rb +259 -0
  22. data/lib/protobufable/problem.rb +27 -0
  23. data/lib/protobufable/problem_details_pb.rb +17 -0
  24. data/lib/protobufable/problem_pb.rb +15 -0
  25. data/lib/protobufable/protovalidate.rb +15 -0
  26. data/lib/protobufable/railtie.rb +26 -0
  27. data/lib/protobufable/renderers.rb +63 -0
  28. data/lib/protobufable/request_parseable.rb +188 -0
  29. data/lib/protobufable/request_validatable.rb +111 -0
  30. data/lib/protobufable/version.rb +10 -0
  31. data/lib/protobufable.rb +40 -0
  32. data/lib/tapioca/dsl/compilers/protobufable_request_parseable.rb +69 -0
  33. data/proto/protobufable/problem.proto +36 -0
  34. data/proto/protobufable/problem_details.proto +31 -0
  35. data/sig/generated/protobufable/alba_binding.rbs +101 -0
  36. data/sig/generated/protobufable/bad_request.rbs +37 -0
  37. data/sig/generated/protobufable/column_validatable.rbs +23 -0
  38. data/sig/generated/protobufable/connect/problem_rescuable.rbs +49 -0
  39. data/sig/generated/protobufable/connect.rbs +7 -0
  40. data/sig/generated/protobufable/field_path.rbs +33 -0
  41. data/sig/generated/protobufable/json_type.rbs +72 -0
  42. data/sig/generated/protobufable/problem/information.rbs +58 -0
  43. data/sig/generated/protobufable/problem/retry_info.rbs +44 -0
  44. data/sig/generated/protobufable/problem/typeable.rbs +166 -0
  45. data/sig/generated/protobufable/problem.rbs +8 -0
  46. data/sig/generated/protobufable/protovalidate.rbs +2 -0
  47. data/sig/generated/protobufable/railtie.rbs +8 -0
  48. data/sig/generated/protobufable/renderers.rbs +22 -0
  49. data/sig/generated/protobufable/request_parseable.rbs +131 -0
  50. data/sig/generated/protobufable/request_validatable.rbs +71 -0
  51. data/sig/generated/protobufable/version.rbs +6 -0
  52. data/sig/generated/protobufable.rbs +24 -0
  53. data/sig/manual/alba_binding.rbs +29 -0
  54. data/sig/manual/connect/problem_rescuable.rbs +13 -0
  55. data/sig/manual/connect_rpc_rails.rbs +9 -0
  56. data/sig/manual/problem/typeable.rbs +28 -0
  57. data/sig/manual/problem_details_pb.rbs +12 -0
  58. data/sig/manual/problem_pb.rbs +8 -0
  59. data/sig/manual/protovalidate.rbs +38 -0
  60. data/sig/manual/request_parseable.rbs +17 -0
  61. metadata +316 -0
data/docs/errors.md ADDED
@@ -0,0 +1,190 @@
1
+ # Errors
2
+
3
+ Built on [problem](https://github.com/sorah/problem), which owns RFC 9457 itself. This supplies
4
+ the catalogue: an enum whose values carry the identifier and status they publish.
5
+
6
+ The gem supplies the option message. Declare the extension that carries it in your own `.proto`,
7
+ with a field number of your own — see [Caveats](#caveats) for why that part is yours — and
8
+ annotate the enum:
9
+
10
+ ```proto
11
+ import "google/protobuf/descriptor.proto";
12
+ import "protobufable/problem.proto";
13
+
14
+ extend google.protobuf.EnumValueOptions {
15
+ optional protobufable.ProblemTypeOptions problem_type = 50001;
16
+ }
17
+
18
+ enum ProblemType {
19
+ PROBLEM_TYPE_UNSPECIFIED = 0;
20
+ PROBLEM_TYPE_NOT_FOUND = 1 [(problem_type) = {uri: "not-found", http_status: 404}];
21
+ PROBLEM_TYPE_WIDGET_NOT_READY = 2 [(problem_type) = {uri: "widget-not-ready", http_status: 409}];
22
+ }
23
+ ```
24
+
25
+ Point the base class at it, then declare a value per error:
26
+
27
+ ```ruby
28
+ require "protobufable/problem"
29
+
30
+ module Errors
31
+ class ApiError < StandardError
32
+ include Protobufable::Problem::Typeable
33
+
34
+ def self.problem_type_enum = "myapp.api.ProblemType"
35
+ def self.problem_type_annotation = "myapp.api.problem_type"
36
+ end
37
+
38
+ class NotFound < ApiError
39
+ problem_type :PROBLEM_TYPE_NOT_FOUND
40
+ end
41
+ end
42
+
43
+ raise Errors::NotFound.new(detail: "no widget w_123")
44
+ ```
45
+
46
+ A controller renders them as `application/problem+json` once it includes `Problem::Rescuable`,
47
+ from problem. An error escaping before dispatch needs problem's `Problem::ExceptionsApp`
48
+ instead; see its README.
49
+
50
+ ```ruby
51
+ class ApplicationController < ActionController::API
52
+ include Problem::Rescuable
53
+ end
54
+ ```
55
+
56
+ A value the enum does not define, or one carrying no annotation, raises when the class is
57
+ defined, so a typo fails on boot rather than on the first request that reaches the error.
58
+
59
+ The status is a property of the problem type, not of the code path that raised it. Several
60
+ values may publish one identifier where telling them apart would leak something; because
61
+ `Problem::I18nable` keys titles by the identifier, such values share a title too, so the
62
+ distinction cannot come back through the title instead.
63
+
64
+ To answer these errors over Connect RPC, see [Connect RPC](connect.md).
65
+
66
+ ## An enum annotated one option per property
67
+
68
+ A catalogue whose values carry the identifier and the status as two separate options, rather
69
+ than one message, is read by overriding the other pair:
70
+
71
+ ```ruby
72
+ class ApiError < StandardError
73
+ include Protobufable::Problem::Typeable
74
+
75
+ def self.problem_type_enum = "myapp.api.ProblemType"
76
+ def self.problem_uri_annotation = "myapp.api.problem_uri"
77
+ def self.problem_status_annotation = "myapp.api.http_status"
78
+ end
79
+ ```
80
+
81
+ Both halves are required, and they replace `problem_type_annotation` rather than supplementing
82
+ it. Everything else behaves the same. This is for a schema that is already shaped that way; a
83
+ new catalogue should use the single option message, which spends one registered extension number
84
+ instead of two.
85
+
86
+ ## Publishing the enum value
87
+
88
+ The enum is internal by default: RFC 9457 requires `type` to be a URI reference, so what goes
89
+ on the wire is the identifier the value publishes, and the value itself stays a server-side
90
+ label. An API that wants clients to dispatch on the value instead can publish it as an
91
+ extension member, in one mixin:
92
+
93
+ ```ruby
94
+ module PublishesProblemType
95
+ def problem_extensions = super.merge(problem_type: self.class.problem_type.to_s)
96
+ end
97
+
98
+ module Errors
99
+ class ApiError < StandardError
100
+ include Protobufable::Problem::Typeable
101
+ include PublishesProblemType
102
+
103
+ def self.problem_type_enum = "myapp.api.ProblemType"
104
+ def self.problem_type_annotation = "myapp.api.problem_type"
105
+ end
106
+ end
107
+ ```
108
+
109
+ ```json
110
+ {"type": "not-found", "title": "Not Found", "status": 404, "problem_type": "PROBLEM_TYPE_NOT_FOUND"}
111
+ ```
112
+
113
+ Every class under that base publishes its own value, and `super.merge` means it composes with
114
+ `Problem::Information` and anything else contributing members.
115
+
116
+ > [!WARNING]
117
+ > This publishes the finer grain. If two values deliberately share an identifier because telling
118
+ > them apart would leak something, the extension hands the caller exactly that distinction back.
119
+ > Publish the value only when no pair in the catalogue is hiding one — or override
120
+ > `problem_extensions` on the classes that are.
121
+
122
+ The value is also permanent once published: renaming an enum value is then a breaking change for
123
+ clients, on top of the usual protobuf reasons to treat values as append-only.
124
+
125
+ ## Machine-readable detail
126
+
127
+ RFC 9457 reserves `detail` for prose. Anything a client acts on goes in `information`, as packed
128
+ `google.protobuf.Any`:
129
+
130
+ ```ruby
131
+ class InvalidRequest < Errors::ApiError
132
+ include Protobufable::Problem::Information
133
+
134
+ problem_type :PROBLEM_TYPE_BAD_REQUEST
135
+
136
+ def initialize(violations:, message_class:, **kwargs)
137
+ @violations = violations
138
+ @message_class = message_class
139
+ super(**kwargs)
140
+ end
141
+
142
+ def problem_information
143
+ super + [Protobufable::BadRequest.for(@violations, @message_class, json_names: true)]
144
+ end
145
+ end
146
+ ```
147
+
148
+ ```json
149
+ {"type": "bad-request", "status": 400, "information": [
150
+ {"@type": "type.googleapis.com/google.rpc.BadRequest",
151
+ "field_violations": [{"field": "name", "description": "must be at least 1 characters"}]}
152
+ ]}
153
+ ```
154
+
155
+ To answer a broken `buf.validate` rule with it, see
156
+ [Answering a broken rule](validation.md#answering-a-broken-rule).
157
+
158
+ `Protobufable::Problem::RetryInfo` is the ready-made one: it publishes a `google.rpc.RetryInfo` alongside
159
+ the `Retry-After` header `Problem::RetryAfter` already sends, so a generic HTTP client and a
160
+ generated client get the same answer.
161
+
162
+ ```ruby
163
+ class TooManyRequests < Errors::ApiError
164
+ include Protobufable::Problem::RetryInfo
165
+
166
+ problem_type :PROBLEM_TYPE_TOO_MANY_REQUESTS
167
+ end
168
+
169
+ raise TooManyRequests.new(retry_after: 30)
170
+ ```
171
+
172
+ ## Caveats
173
+
174
+ - **The extension is yours to declare, and the enum always will be.** The catalogue is your
175
+ API's own vocabulary, so the gem cannot supply it, nor anything referencing it;
176
+ `Problem::Typeable` reads whichever enum you point it at. It does supply the option message. The
177
+ `extend` that carries it is still yours for now: extension field numbers are a global
178
+ namespace registered in
179
+ [protocolbuffers/protobuf](https://github.com/protocolbuffers/protobuf/blob/main/docs/options.md),
180
+ protobufable claims 1376 but does not hold it, and an extension whose number can still move
181
+ would break every `.proto` that imported it. Pick a number from the internal-use range
182
+ (50000–99999) meanwhile.
183
+ - **Importing `protobufable/problem.proto` or `protobufable/problem_details.proto` means
184
+ vendoring it.** The files ship in the gem, but buf and protoc resolve imports from your own
185
+ module, not from a gem path, so copy them into your proto tree and generate from there. Then
186
+ drop the generated `protobufable/*_pb.rb`, which the gem already loads: a duplicate
187
+ descriptor is a boot failure, not a warning.
188
+ - **`google.rpc` descriptors are not shipped either.** `Problem::RetryInfo` and `BadRequest` look their
189
+ message classes up in the pool, so generate `google/rpc/error_details.proto` alongside your
190
+ own protos. A second copy of those descriptors in one process is a boot failure.
@@ -0,0 +1,42 @@
1
+ # Request bodies
2
+
3
+ Declare the message an action accepts, above the action:
4
+
5
+ ```ruby
6
+ class WidgetsController < ApplicationController
7
+ include Protobufable::RequestParseable
8
+
9
+ protobuf_body Api::CreateWidgetRequest
10
+ def create
11
+ Widget.create!(name: message.name, tags: message.tags.to_a)
12
+ end
13
+ end
14
+ ```
15
+
16
+ `message` is the decoded message. `params` is still filled from it, so anything already reading
17
+ `params[:name]` keeps working and an endpoint can be moved over one at a time.
18
+
19
+ | option | default | meaning |
20
+ |---|---|---|
21
+ | `ignore_unknown_fields` | `false` | When true, a field the schema does not declare is dropped. Left false, it is a 400, so a client's typo is not a value that silently never arrives. |
22
+
23
+ The body is decoded once, by replacing the `:json` parser, so nothing parses the JSON twice.
24
+ The parsed message is held behind a wrapper that renders as `[FILTERED]`, because Rails'
25
+ parameter filtering cannot see inside a protobuf message and would otherwise log every field it
26
+ holds.
27
+
28
+ To check values as well as types, see [Value validation](validation.md).
29
+
30
+ ## Sorbet
31
+
32
+ With [tapioca](https://github.com/Shopify/tapioca) in the bundle, `tapioca dsl` picks up a
33
+ compiler shipped in this gem that types `message` on every controller declaring
34
+ `protobuf_body`: as the declared class when all of the controller's actions declare the same one,
35
+ and as their union otherwise, which an action narrows itself.
36
+
37
+ ```rbi
38
+ class WidgetsController
39
+ sig { returns(::Api::CreateWidgetRequest) }
40
+ def message; end
41
+ end
42
+ ```
data/docs/responses.md ADDED
@@ -0,0 +1,49 @@
1
+ # Responses
2
+
3
+ ```ruby
4
+ render proto_json: Api::GetWidgetResponse.new(widget:) # application/json
5
+ render protobuf: Api::GetWidgetResponse.new(widget:) # application/protobuf
6
+ ```
7
+
8
+ `proto_json` sends ProtoJSON with `preserve_proto_fieldnames: true` and `emit_defaults: false`:
9
+ snake_case, matching the rest of a Rails API, and no key for a field that was never set, so an
10
+ absent `optional` field stays distinguishable from one set to its default. Both are one-way
11
+ doors for clients, which is why they are not left to chance.
12
+
13
+ `protobuf` sends binary, with the descriptor name in the content type
14
+ (`application/protobuf; proto=myapp.api.GetWidgetResponse`).
15
+
16
+ ## Bound Alba resources
17
+
18
+ ```ruby
19
+ class WidgetResource < ApplicationResource
20
+ include Protobufable::AlbaBinding
21
+
22
+ with_protobuf Api::Widget do
23
+ attributes :id, :name
24
+ attributes created_at: :timestamp
25
+ one :owner, resource: OwnerResource
26
+ end
27
+ end
28
+ ```
29
+
30
+ `with_protobuf` checks both directions when the class loads: an attribute the message does not
31
+ declare, and a field no attribute produces, each raise there rather than at response time. Put
32
+ every attribute inside the block — the check runs when `with_protobuf` does, so anything
33
+ declared after it is never compared.
34
+
35
+ `attributes created_at: :timestamp` needs the converter, because a `google.protobuf.Timestamp`
36
+ field refuses the `ActiveSupport::TimeWithZone` an ActiveRecord column hands back:
37
+
38
+ ```ruby
39
+ # config/initializers/protobufable.rb
40
+ Protobufable::AlbaBinding.register_timestamp_type!
41
+ ```
42
+
43
+ `AlbaBinding` needs the [alba](https://github.com/okuramasafumi/alba) gem.
44
+
45
+ ## Caveats
46
+
47
+ - **`AlbaBinding` reads Alba internals.** The parity check uses `@_attributes` and `@_traits`,
48
+ which are private to Alba, so an Alba release can break it. A spec pins the assumption against
49
+ the installed version.
@@ -0,0 +1,121 @@
1
+ # Value validation
2
+
3
+ Decoding rejects a wrong type. `buf.validate` rules add what the type cannot say:
4
+
5
+ ```proto
6
+ message CreateWidgetRequest {
7
+ string name = 1 [(buf.validate.field).string = {min_len: 1, max_len: 100}];
8
+ repeated string tags = 2 [(buf.validate.field).repeated = {max_items: 8, unique: true}];
9
+ }
10
+ ```
11
+
12
+ ```ruby
13
+ require "protobufable/protovalidate"
14
+
15
+ class WidgetsController < ApplicationController
16
+ include Protobufable::RequestValidatable # brings RequestParseable
17
+
18
+ protobuf_body Api::CreateWidgetRequest
19
+ def create = ...
20
+ end
21
+ ```
22
+
23
+ Only a controller including `RequestValidatable` validates. A broken rule raises
24
+ `Protobufable::RequestValidatable::InvalidMessage`, carrying the violations and the message
25
+ class, and is a 400 with no further setup; to tell the client which fields failed, see
26
+ [Answering a broken rule](#answering-a-broken-rule).
27
+
28
+ The validation callback runs *after* the callbacks declared above the action, so an
29
+ unauthenticated request is answered before an invalid one and the endpoint does not become an
30
+ oracle for what a valid body looks like. Opt an action out with
31
+ `skip_before_action :validate_protobuf_body!`.
32
+
33
+ Compile the rules at boot, so a CEL typo fails startup rather than the first request, and no
34
+ request pays the compilation:
35
+
36
+ ```ruby
37
+ # config/initializers/protobufable.rb
38
+ Rails.application.config.after_initialize { Protovalidate.register_all }
39
+ ```
40
+
41
+ ## Answering a broken rule
42
+
43
+ The railtie lists `InvalidMessage` in `rescue_responses` as `:bad_request`, so unrescued it
44
+ reaches the exceptions app and goes out as a 400 with that app's generic body, a bare problem
45
+ document under `Problem::ExceptionsApp`. That body says nothing about which fields failed.
46
+
47
+ Reporting them is left to the application, because the response belongs to the API: its shape,
48
+ the spelling of its field paths, and whether googleapis is generated. Rescue `InvalidMessage`
49
+ with an error type of your own, or with one from the problem catalogue; either takes precedence
50
+ over `rescue_responses`.
51
+
52
+ An error type of your own renders whatever the API already sends for a client error:
53
+
54
+ ```ruby
55
+ class ApplicationController < ActionController::API
56
+ rescue_from Protobufable::RequestValidatable::InvalidMessage do |error|
57
+ render status: 400,
58
+ proto_json: Protobufable::BadRequest.for(error.violations, error.message_class, json_names: true)
59
+ end
60
+ end
61
+ ```
62
+
63
+ ```json
64
+ {"field_violations": [{"field": "name", "description": "must be at least 1 characters"}]}
65
+ ```
66
+
67
+ A problem-based error is a catalogue entry carrying the violations, such as `InvalidRequest` in
68
+ [Machine-readable detail](errors.md#machine-readable-detail), handed to `Problem::Rescuable`:
69
+
70
+ ```ruby
71
+ class ApplicationController < ActionController::API
72
+ include Problem::Rescuable
73
+
74
+ rescue_from Protobufable::RequestValidatable::InvalidMessage do |error|
75
+ render_problem_detailable(
76
+ InvalidRequest.new(violations: error.violations, message_class: error.message_class),
77
+ )
78
+ end
79
+ end
80
+ ```
81
+
82
+ ```json
83
+ {"type": "bad-request", "status": 400, "information": [
84
+ {"@type": "type.googleapis.com/google.rpc.BadRequest",
85
+ "field_violations": [{"field": "name", "description": "must be at least 1 characters"}]}
86
+ ]}
87
+ ```
88
+
89
+ Call `render_problem_detailable` rather than raising the problem: Rails does not rescue an
90
+ exception raised inside a `rescue_from` handler.
91
+
92
+ ## Stored columns
93
+
94
+ The same rules run over [`JsonType` columns](columns.md) before save, so they hold wherever a
95
+ record is written — application code, a seed, the console. This is opted into on the model,
96
+ independently of `RequestValidatable`:
97
+
98
+ ```ruby
99
+ class ApplicationRecord < ActiveRecord::Base
100
+ include Protobufable::ColumnValidatable
101
+ end
102
+ ```
103
+
104
+ ## Field paths
105
+
106
+ `Protobufable::FieldPath` renders a violation's path in the spelling the caller sent. A protobuf
107
+ field has two published names, and a client that reads lowerCamelCase ProtoJSON cannot find a
108
+ field named the other way, so each segment is resolved against the descriptor rather than taken
109
+ from the name the violation carried:
110
+
111
+ ```ruby
112
+ Protobufable::FieldPath.render(violation.field, Api::CreateWidgetRequest)
113
+ #=> "parts[0].part_name"
114
+ Protobufable::FieldPath.render(violation.field, Api::CreateWidgetRequest, json_names: true)
115
+ #=> "parts[0].partName"
116
+ ```
117
+
118
+ ## Caveats
119
+
120
+ - **`google.rpc` descriptors are not shipped.** `BadRequest` looks `google.rpc.BadRequest` up in
121
+ the pool; see [Errors](errors.md#caveats).
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ require "alba"
8
+
9
+ require "protobufable"
10
+
11
+ module Protobufable
12
+ # Binds an Alba resource to the protobuf message it produces, and checks at load time that
13
+ # the two agree on every field.
14
+ #
15
+ # Serialization stays idiomatic — one resource per model, with Alba's association DSL — while
16
+ # the wire format belongs to the schema. The parity check is the point: without it a field
17
+ # added to the .proto has no producer and simply comes back empty, which nothing reports.
18
+ #
19
+ # @example
20
+ # class WidgetResource < ApplicationResource
21
+ # with_protobuf Api::Widget do
22
+ # attributes :id, :name
23
+ # attribute(:created_at) { |w| w.created_at.to_time }
24
+ # end
25
+ # end
26
+ #
27
+ # render proto_json: Api::GetWidgetResponse.new(widget: WidgetResource.new(widget).as_protobuf)
28
+ #
29
+ # The check reads Alba's `@_attributes` and `@_traits`, which are internal to Alba, so an
30
+ # Alba release can break it. A spec pins the assumption against the installed version.
31
+ #
32
+ # @rbs module-self _AlbaBindingSelf
33
+ module AlbaBinding
34
+ # @param base [Class] the Alba resource class including this
35
+ # @return [void]
36
+ #: (untyped base) -> void
37
+ def self.included(base)
38
+ base.helper(HelperMethods)
39
+ end
40
+
41
+ # Each of these reaches the binding through `self.class`: Alba installs HelperMethods on
42
+ # instances too, so a bare `message_class` here would resolve to the class-level reader with
43
+ # `self` being the instance, whose ivar is never set.
44
+ #
45
+ # @return [Object] the bound message, built from the serialized attributes
46
+ #: () -> untyped
47
+ def as_protobuf = self.class.message_class.new(as_json)
48
+
49
+ # @return [String] the bound message encoded as binary protobuf
50
+ #: () -> String
51
+ def to_protobuf = self.class.message_class.encode(as_protobuf)
52
+
53
+ # Renders through the message's own encoder rather than Alba's, so the wire format is the
54
+ # schema's. Field names and default omission match the proto_json renderer.
55
+ #
56
+ # @return [String] the bound message as ProtoJSON
57
+ #: (*untyped) -> String
58
+ def to_json(*)
59
+ self.class.message_class
60
+ .encode_json(as_protobuf, preserve_proto_fieldnames: true, emit_defaults: false)
61
+ end
62
+
63
+ # Class-level DSL, installed through Alba's own helper mechanism.
64
+ #
65
+ # @rbs module-self _AlbaBindingClass
66
+ module HelperMethods
67
+ # @return [Class] the message class this resource is bound to
68
+ # @raise [Protobufable::Error] when the resource declared no binding
69
+ #: () -> Google::Protobuf::_MessageClass
70
+ def message_class
71
+ @message_class || raise(
72
+ Protobufable::Error,
73
+ "#{name || self} is not bound to a message: declare one with `with_protobuf`",
74
+ )
75
+ end
76
+
77
+ # Binds this resource to a message and declares its attributes.
78
+ #
79
+ # Both directions are checked: an attribute the message does not declare, and a field no
80
+ # attribute produces, each fail here rather than at response time. The check runs when this
81
+ # does, so an attribute declared after it is never compared; everything belongs inside the
82
+ # block.
83
+ #
84
+ # @param klass [Class] the generated message class
85
+ # @yield the resource's attribute declarations
86
+ # @return [void]
87
+ # @raise [Protobufable::Error] when the two have drifted apart
88
+ #: (Google::Protobuf::_MessageClass klass) { () -> void } -> void
89
+ def with_protobuf(klass, &block)
90
+ module_eval(&block)
91
+ @message_class = klass
92
+
93
+ declared = klass.descriptor.map { |field| field.name.to_sym }
94
+ produced = AlbaBinding.produced_attributes(self)
95
+
96
+ unknown = declared - produced
97
+ undeclared = produced - declared
98
+ unless undeclared.empty?
99
+ raise Protobufable::Error,
100
+ "#{name} serializes #{undeclared.inspect}, which #{klass.name} does not declare"
101
+ end
102
+ unless unknown.empty?
103
+ raise Protobufable::Error,
104
+ "#{name} is missing #{unknown.inspect}, which #{klass.name} declares"
105
+ end
106
+
107
+ nil
108
+ end
109
+ end
110
+
111
+ # Registers a `:timestamp` Alba type that converts to Time.
112
+ #
113
+ # A `google.protobuf.Timestamp` field accepts a Time but refuses an
114
+ # ActiveSupport::TimeWithZone, which is what an ActiveRecord timestamp column hands back,
115
+ # so a resource serializing one needs the conversion.
116
+ #
117
+ # Protobufable::AlbaBinding.register_timestamp_type!
118
+ #
119
+ # attribute :created_at, :timestamp
120
+ #
121
+ # Opt-in rather than done on require: Alba's type registry is global, and a gem should not
122
+ # add to it behind a host's back.
123
+ #
124
+ # @return [void]
125
+ #: () -> void
126
+ def self.register_timestamp_type!
127
+ Alba.register_type(:timestamp, converter: :to_time.to_proc, auto_convert: true)
128
+ nil
129
+ end
130
+
131
+ # Every attribute a resource can produce, including the ones a trait adds.
132
+ #
133
+ # A trait is a block rather than a list, so the only way to see what it declares is to
134
+ # evaluate it, which is what the throwaway subclass is for.
135
+ #
136
+ # @param resource [Class] the Alba resource
137
+ # @return [Array<Symbol>]
138
+ #: (untyped resource) -> Array[Symbol]
139
+ def self.produced_attributes(resource)
140
+ attributes = resource.instance_variable_get(:@_attributes).keys
141
+ traits = resource.instance_variable_get(:@_traits) || {}
142
+
143
+ attributes + traits.each_value.flat_map do |trait|
144
+ Class.new(resource).tap { it.class_eval(&trait) }
145
+ .instance_variable_get(:@_attributes).keys
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ require "protobufable"
8
+ require "protobufable/field_path"
9
+
10
+ module Protobufable
11
+ # Turns protovalidate violations into a `google.rpc.BadRequest`, the form AIP-193 defines for
12
+ # telling a client which fields it got wrong.
13
+ #
14
+ # Hand the result to {Problem::Information}, which publishes it in the document.
15
+ #
16
+ # @example
17
+ # class InvalidMessage < Errors::BadRequest
18
+ # include Protobufable::Problem::Information
19
+ #
20
+ # def initialize(violations:, **kwargs)
21
+ # @violations = violations
22
+ # super(**kwargs)
23
+ # end
24
+ #
25
+ # def problem_information
26
+ # super + [Protobufable::BadRequest.for(@violations, message_class)]
27
+ # end
28
+ # end
29
+ #
30
+ # `google.rpc` descriptors are not shipped with this gem; the app that generates googleapis
31
+ # alongside its own protos supplies them.
32
+ module BadRequest
33
+ # The message this builds.
34
+ MESSAGE = "google.rpc.BadRequest" #: String
35
+
36
+ # @param violations [Array<Protovalidate::Violation>]
37
+ # @param message_class [Class] the message the violations were produced from
38
+ # @param json_names [Boolean] whether field paths name fields as ProtoJSON spells them
39
+ # @return [Object] a `google.rpc.BadRequest`, one FieldViolation per violation
40
+ # @raise [Protobufable::Error] when googleapis has not been generated into the pool
41
+ #: (Array[untyped] violations, Google::Protobuf::_MessageClass message_class, ?json_names: bool) -> untyped
42
+ def self.for(violations, message_class, json_names: false)
43
+ pool = Google::Protobuf::DescriptorPool.generated_pool
44
+ bad_request = pool.lookup(MESSAGE) or
45
+ raise Protobufable::Error,
46
+ "#{MESSAGE} is not in the descriptor pool; generate google/rpc/error_details.proto"
47
+ field_violation = pool.lookup("#{MESSAGE}.FieldViolation") #: untyped
48
+
49
+ bad_request.msgclass.new(
50
+ field_violations: violations.map do |violation|
51
+ field_violation.msgclass.new(
52
+ field: FieldPath.render(violation.field, message_class, json_names:),
53
+ description: violation.message,
54
+ reason: violation.rule_id,
55
+ )
56
+ end,
57
+ )
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ require "active_support/concern"
8
+ require "protovalidate"
9
+
10
+ require "protobufable"
11
+ require "protobufable/field_path"
12
+ require "protobufable/json_type"
13
+
14
+ module Protobufable
15
+ # Runs the `buf.validate` rules of every {JsonType} column before the record is saved, so a
16
+ # column holding a message enforces what its .proto declares wherever the record is written:
17
+ # application code, a seed, the console.
18
+ #
19
+ # @example
20
+ # class ApplicationRecord < ActiveRecord::Base
21
+ # include Protobufable::ColumnValidatable
22
+ # end
23
+ #
24
+ # @rbs module-self _ColumnValidatableSelf
25
+ module ColumnValidatable
26
+ extend ActiveSupport::Concern
27
+
28
+ # `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
29
+ # way to name.
30
+ # steep:ignore:start
31
+ included do
32
+ validate(:validate_protobuf_message_columns)
33
+ end
34
+ # steep:ignore:end
35
+
36
+ # Adds one error per violation, under the attribute that holds the message.
37
+ #
38
+ # @return [void]
39
+ #: () -> void
40
+ private def validate_protobuf_message_columns
41
+ self.class.attribute_types.each do |name, type|
42
+ next unless type.is_a?(JsonType)
43
+
44
+ # read_attribute rather than the reader, which a model may have overridden to wrap the
45
+ # message in something of its own.
46
+ message = self[name]
47
+ next if message.nil?
48
+
49
+ Protovalidate.collect_violations(message).each do |violation|
50
+ path = FieldPath.render(violation.field, type.message_class)
51
+ errors.add(name, path.empty? ? violation.message : "#{path}: #{violation.message}")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end