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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/DESIGN.md +298 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/docs/columns.md +34 -0
- data/docs/connect.md +55 -0
- data/docs/errors.md +190 -0
- data/docs/request-bodies.md +42 -0
- data/docs/responses.md +49 -0
- data/docs/validation.md +121 -0
- data/lib/protobufable/alba_binding.rb +149 -0
- data/lib/protobufable/bad_request.rb +60 -0
- data/lib/protobufable/column_validatable.rb +56 -0
- data/lib/protobufable/connect/problem_rescuable.rb +92 -0
- data/lib/protobufable/connect.rb +20 -0
- data/lib/protobufable/field_path.rb +65 -0
- data/lib/protobufable/json_type.rb +114 -0
- data/lib/protobufable/problem/information.rb +75 -0
- data/lib/protobufable/problem/retry_info.rb +62 -0
- data/lib/protobufable/problem/typeable.rb +259 -0
- data/lib/protobufable/problem.rb +27 -0
- data/lib/protobufable/problem_details_pb.rb +17 -0
- data/lib/protobufable/problem_pb.rb +15 -0
- data/lib/protobufable/protovalidate.rb +15 -0
- data/lib/protobufable/railtie.rb +26 -0
- data/lib/protobufable/renderers.rb +63 -0
- data/lib/protobufable/request_parseable.rb +188 -0
- data/lib/protobufable/request_validatable.rb +111 -0
- data/lib/protobufable/version.rb +10 -0
- data/lib/protobufable.rb +40 -0
- data/lib/tapioca/dsl/compilers/protobufable_request_parseable.rb +69 -0
- data/proto/protobufable/problem.proto +36 -0
- data/proto/protobufable/problem_details.proto +31 -0
- data/sig/generated/protobufable/alba_binding.rbs +101 -0
- data/sig/generated/protobufable/bad_request.rbs +37 -0
- data/sig/generated/protobufable/column_validatable.rbs +23 -0
- data/sig/generated/protobufable/connect/problem_rescuable.rbs +49 -0
- data/sig/generated/protobufable/connect.rbs +7 -0
- data/sig/generated/protobufable/field_path.rbs +33 -0
- data/sig/generated/protobufable/json_type.rbs +72 -0
- data/sig/generated/protobufable/problem/information.rbs +58 -0
- data/sig/generated/protobufable/problem/retry_info.rbs +44 -0
- data/sig/generated/protobufable/problem/typeable.rbs +166 -0
- data/sig/generated/protobufable/problem.rbs +8 -0
- data/sig/generated/protobufable/protovalidate.rbs +2 -0
- data/sig/generated/protobufable/railtie.rbs +8 -0
- data/sig/generated/protobufable/renderers.rbs +22 -0
- data/sig/generated/protobufable/request_parseable.rbs +131 -0
- data/sig/generated/protobufable/request_validatable.rbs +71 -0
- data/sig/generated/protobufable/version.rbs +6 -0
- data/sig/generated/protobufable.rbs +24 -0
- data/sig/manual/alba_binding.rbs +29 -0
- data/sig/manual/connect/problem_rescuable.rbs +13 -0
- data/sig/manual/connect_rpc_rails.rbs +9 -0
- data/sig/manual/problem/typeable.rbs +28 -0
- data/sig/manual/problem_details_pb.rbs +12 -0
- data/sig/manual/problem_pb.rbs +8 -0
- data/sig/manual/protovalidate.rbs +38 -0
- data/sig/manual/request_parseable.rbs +17 -0
- 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.
|
data/docs/validation.md
ADDED
|
@@ -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
|