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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ # The problem gem integration, required on its own because problem is not a dependency of
8
+ # protobufable: a host that renders no problem details never loads it.
9
+ #
10
+ # require "protobufable/problem"
11
+ #
12
+ # Brings Protobufable::ProblemTypeOptions with it, the message an application annotates its
13
+ # problem type enum with. Requiring it registers that descriptor, so an application must not
14
+ # also generate protobufable/problem.proto itself.
15
+
16
+ require "protobufable/problem_pb"
17
+
18
+ module Protobufable
19
+ # The problem gem integration: a catalogue backed by a protobuf enum, and protobuf messages in
20
+ # a problem document. Code in here names the problem gem's own constants as `::Problem`.
21
+ module Problem
22
+ end
23
+ end
24
+
25
+ require "protobufable/problem/typeable"
26
+ require "protobufable/problem/information"
27
+ require "protobufable/problem/retry_info"
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: protobufable/problem_details.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+ require 'google/protobuf/any_pb'
8
+
9
+
10
+ descriptor_data = "\n\"protobufable/problem_details.proto\x12\x0cprotobufable\x1a\x19google/protobuf/any.proto\"\xbe\x01\n\x0eProblemDetails\x12\x12\n\x04type\x18\x01 \x01(\tR\x04type\x12\x14\n\x05title\x18\x02 \x01(\tR\x05title\x12\x16\n\x06status\x18\x03 \x01(\x05R\x06status\x12\x16\n\x06\x64\x65tail\x18\x04 \x01(\tR\x06\x64\x65tail\x12\x1a\n\x08instance\x18\x05 \x01(\tR\x08instance\x12\x36\n\x0binformation\x18\x06 \x03(\x0b\x32\x14.google.protobuf.AnyR\x0binformationb\x06proto3"
11
+
12
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
13
+ pool.add_serialized_file(descriptor_data)
14
+
15
+ module Protobufable
16
+ ProblemDetails = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("protobufable.ProblemDetails").msgclass
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: protobufable/problem.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x1aprotobufable/problem.proto\x12\x0cprotobufable\"G\n\x12ProblemTypeOptions\x12\x10\n\x03uri\x18\x01 \x01(\tR\x03uri\x12\x1f\n\x0bhttp_status\x18\x02 \x01(\rR\nhttpStatusb\x06proto3"
9
+
10
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module Protobufable
14
+ ProblemTypeOptions = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("protobufable.ProblemTypeOptions").msgclass
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ # The protovalidate integration, required on its own because protovalidate is not a dependency
8
+ # of protobufable: a host that declares no buf.validate rules never loads it.
9
+ #
10
+ # require "protobufable/protovalidate"
11
+
12
+ require "protobufable/bad_request"
13
+ require "protobufable/column_validatable"
14
+ require "protobufable/field_path"
15
+ require "protobufable/request_validatable"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ require "rails/railtie"
8
+ require "action_dispatch/railtie"
9
+
10
+ require "protobufable"
11
+
12
+ module Protobufable
13
+ # Installs the renderers and the request body parser on boot, and classifies a broken
14
+ # `buf.validate` rule as a 400.
15
+ class Railtie < ::Rails::Railtie
16
+ # A string, so protovalidate stays unloaded for an application that does not use it.
17
+ # `config.action_dispatch` is answered by method_missing, which RBS cannot describe.
18
+ # steep:ignore:start
19
+ config.action_dispatch.rescue_responses["Protobufable::RequestValidatable::InvalidMessage"] = :bad_request
20
+ # steep:ignore:end
21
+
22
+ initializer "protobufable.install" do
23
+ Protobufable.install!
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ require "action_controller"
8
+
9
+ module Protobufable
10
+ # The media types and renderers for answering with a protobuf message.
11
+ #
12
+ # @example Binary, for a client that asked for it
13
+ # render protobuf: Api::GetWidgetResponse.new(widget:)
14
+ #
15
+ # @example ProtoJSON, which is ordinary JSON a client needs to know nothing about
16
+ # render proto_json: Api::GetWidgetResponse.new(widget:)
17
+ module Renderers
18
+ # The media type the binary renderer answers with.
19
+ CONTENT_TYPE = "application/protobuf" #: String
20
+
21
+ # Registers the media type and both renderers. Idempotent, so a second host installing on
22
+ # the same process is harmless.
23
+ #
24
+ # @return [void]
25
+ #: () -> void
26
+ def self.install!
27
+ Mime::Type.register(CONTENT_TYPE, :protobuf) unless Mime[:protobuf]
28
+
29
+ # `self` inside a renderer block is the controller, which RBS has no way to name.
30
+ # steep:ignore:start
31
+ ActionController::Renderers.add(:protobuf) do |message, _options|
32
+ self.content_type = if message.is_a?(String)
33
+ Protobufable::Renderers::CONTENT_TYPE
34
+ else
35
+ # The descriptor name tells a caller which message it received without having to
36
+ # infer it from the endpoint.
37
+ "#{Protobufable::Renderers::CONTENT_TYPE}; " \
38
+ "proto=#{message.class.descriptor.name}"
39
+ end
40
+ # After content_type=, which re-applies the default charset.
41
+ response.charset = false
42
+
43
+ message.is_a?(String) ? message : message.class.encode(message)
44
+ end
45
+
46
+ ActionController::Renderers.add(:proto_json) do |message, _options|
47
+ self.content_type = "application/json"
48
+ message.class.encode_json(
49
+ message,
50
+ # ProtoJSON defaults to lowerCamelCase. snake_case matches what the rest of a Rails
51
+ # API sends and what the generated OpenAPI describes, and it is a one-way door for
52
+ # clients, so it is not left to chance.
53
+ preserve_proto_fieldnames: true,
54
+ # Responses stay small and an absent `optional` field stays distinguishable from one
55
+ # set to its default. Stored columns take the opposite setting, deliberately; see
56
+ # Protobufable::JsonType.
57
+ emit_defaults: false,
58
+ )
59
+ end
60
+ # steep:ignore:end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,188 @@
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 "action_dispatch"
9
+
10
+ require "protobufable"
11
+
12
+ module Protobufable
13
+ # Parses a JSON request body straight into a generated protobuf message, so a wrong type or
14
+ # an unknown field is rejected before the action runs and the action reads a typed object
15
+ # instead of `params`.
16
+ #
17
+ # The declaration sits above the action it describes, like a signature:
18
+ #
19
+ # @example
20
+ # class WidgetsController < ApplicationController
21
+ # include Protobufable::RequestParseable
22
+ #
23
+ # protobuf_body Api::CreateWidgetRequest
24
+ # def create
25
+ # Widget.create!(name: message.name)
26
+ # end
27
+ # end
28
+ #
29
+ # {RequestPatch} has to be prepended to `ActionDispatch::Request` for any of this to happen;
30
+ # {Protobufable.install!} does that, and the railtie calls it.
31
+ #
32
+ # @rbs module-self _RequestParseableSelf
33
+ module RequestParseable
34
+ extend ActiveSupport::Concern
35
+
36
+ # What `protobuf_body` records about one action.
37
+ Declaration = Data.define(:message_class, :ignore_unknown_fields)
38
+
39
+ # Holds the parsed message in `params` without letting it be logged.
40
+ #
41
+ # Rails' parameter filtering cannot see inside a protobuf message, so a message reaching
42
+ # the log formatter would print every field it holds, secrets included.
43
+ class MessageWrapper
44
+ # Stands in for the message wherever `params` is rendered.
45
+ FILTERED = "[FILTERED]" #: String
46
+
47
+ # @param message [Object] the parsed protobuf message
48
+ # @return [void]
49
+ #: (untyped message) -> void
50
+ def initialize(message)
51
+ @message = message
52
+ end
53
+
54
+ # @return [Array] always empty, so `inspect` does not reach the message
55
+ #: () -> Array[Symbol]
56
+ def instance_variables_to_inspect = []
57
+
58
+ # The JSON log formatter renders payloads with ActiveSupport's `#to_json`, and
59
+ # `Object#as_json` falls back to dumping instance variables.
60
+ #
61
+ # @return [String] {FILTERED}
62
+ #: (*untyped) -> String
63
+ def as_json(*) = FILTERED
64
+
65
+ # @return [Object] the wrapped message
66
+ #: () -> untyped
67
+ def to_protobuf = to_original_message
68
+
69
+ # @return [Object] the wrapped message
70
+ #: () -> untyped
71
+ def to_original_message
72
+ @message
73
+ end
74
+ end
75
+
76
+ # Replaces the `:json` body parser so the body is decoded once, into the declared class,
77
+ # rather than parsed as JSON and then converted.
78
+ module RequestPatch
79
+ #: () -> Hash[Symbol, untyped]
80
+ private def params_parsers
81
+ original_parsers = super
82
+ original_json_parser = original_parsers[Mime[:json].symbol]
83
+
84
+ original_parsers.merge(
85
+ Mime[:json].symbol => ->(raw_post) {
86
+ RequestParseable.param_parser(self, raw_post, original_json_parser)
87
+ },
88
+ )
89
+ end
90
+ end
91
+
92
+ # Decodes the body for a request whose action declared a message class, and falls through
93
+ # to the original parser for every other request.
94
+ #
95
+ # The declaration is read off the controller class the request already resolves, rather
96
+ # than a process-wide table keyed by controller path: a table outlives reloads and renames,
97
+ # and cannot describe an anonymous controller at all.
98
+ #
99
+ # @param request [ActionDispatch::Request]
100
+ # @param raw_post [String] the request body
101
+ # @param original_parser [Proc] the parser this one stands in front of
102
+ # @return [Hash] the request parameters, carrying the message under `:_protobuf`
103
+ #: (untyped request, String raw_post, ^(String) -> untyped original_parser) -> untyped
104
+ def self.param_parser(request, raw_post, original_parser)
105
+ declaration = declaration_for(request)
106
+ return original_parser.call(raw_post) unless declaration
107
+
108
+ message = declaration.message_class.decode_json(
109
+ raw_post,
110
+ ignore_unknown_fields: declaration.ignore_unknown_fields,
111
+ )
112
+ message.to_h.merge(_protobuf: MessageWrapper.new(message))
113
+ end
114
+
115
+ # @param request [ActionDispatch::Request]
116
+ # @return [Declaration, nil] what the routed action declared, if anything
117
+ #: (untyped request) -> Declaration?
118
+ def self.declaration_for(request)
119
+ action = request.path_parameters[:action]
120
+ return if action.nil?
121
+
122
+ controller = request.controller_class
123
+ return unless controller.respond_to?(:protobuf_body_declarations)
124
+
125
+ controller.protobuf_body_declarations[action.to_s]
126
+ rescue NameError
127
+ # An unresolvable controller is not this parser's problem; routing reports it.
128
+ nil
129
+ end
130
+
131
+ # `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
132
+ # way to name; the accessor this installs is declared in sig/manual instead.
133
+ # steep:ignore:start
134
+ included do
135
+ class_attribute(:protobuf_body_declarations, default: {}.freeze, instance_accessor: false)
136
+ end
137
+ # steep:ignore:end
138
+
139
+ # Class-level DSL installed by including the concern.
140
+ #
141
+ # @rbs module-self _RequestParseableClass
142
+ module ClassMethods
143
+ # Declares the message class the next action accepts.
144
+ #
145
+ # @param message_class [Class] the generated request message class
146
+ # @param ignore_unknown_fields [Boolean] whether a field the schema does not declare is
147
+ # dropped rather than rejected. False by default, so a client's typo is a 400 rather
148
+ # than a value that silently never arrives.
149
+ # @return [void]
150
+ #: (Google::Protobuf::_MessageClass message_class, ?ignore_unknown_fields: bool) -> void
151
+ def protobuf_body(message_class, ignore_unknown_fields: false)
152
+ @next_protobuf_body = Declaration.new(message_class:, ignore_unknown_fields:)
153
+ nil
154
+ end
155
+
156
+ # Binds a pending `protobuf_body` to the action defined under it.
157
+ #
158
+ # @param method_name [Symbol]
159
+ # @return [void]
160
+ #: (Symbol method_name) -> void
161
+ def method_added(method_name)
162
+ super
163
+ declaration = @next_protobuf_body
164
+ return unless declaration
165
+
166
+ @next_protobuf_body = nil
167
+ self.protobuf_body_declarations =
168
+ protobuf_body_declarations.merge(method_name.to_s => declaration).freeze
169
+ end
170
+ end
171
+
172
+ # Reads the message from the parsed body only: `params` also holds the query string, where a
173
+ # `_protobuf` parameter would otherwise stand in for the message.
174
+ #
175
+ # @return [Object] the parsed message for this action
176
+ # @raise [KeyError] when the action declared no message class
177
+ # @raise [ActionDispatch::Http::Parameters::ParseError] when the request carried no JSON body
178
+ #: () -> untyped
179
+ def message
180
+ wrapper = request.request_parameters[:_protobuf]
181
+ return wrapper.to_original_message if wrapper.is_a?(MessageWrapper)
182
+
183
+ raise KeyError, "this action declared no protobuf_body" unless RequestParseable.declaration_for(request)
184
+
185
+ raise ActionDispatch::Http::Parameters::ParseError, "expected a JSON request body"
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,111 @@
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/request_parseable"
12
+
13
+ module Protobufable
14
+ # Runs the `buf.validate` rules of a `protobuf_body` message before the action does.
15
+ #
16
+ # Decoding already rejects a wrong type; this adds what the rules declare — lengths, formats,
17
+ # ranges, cross-field conditions — so value validation lives in the schema next to the type
18
+ # rather than in the controller.
19
+ #
20
+ # @example
21
+ # class WidgetsController < ApplicationController
22
+ # include Protobufable::RequestValidatable
23
+ #
24
+ # protobuf_body Api::CreateWidgetRequest
25
+ # def create = ...
26
+ # end
27
+ #
28
+ # Raises {InvalidMessage}, which carries the violations. Rescue it wherever the application
29
+ # turns errors into responses.
30
+ #
31
+ # @rbs module-self _RequestValidatableSelf
32
+ module RequestValidatable
33
+ extend ActiveSupport::Concern
34
+
35
+ include RequestParseable
36
+
37
+ # Raised when the request message breaks a rule its schema declares.
38
+ class InvalidMessage < Protobufable::Error
39
+ # @return [Array<Protovalidate::Violation>]
40
+ attr_reader :violations #: Array[untyped]
41
+
42
+ # @return [Class] the message the violations were produced from
43
+ attr_reader :message_class #: Google::Protobuf::_MessageClass
44
+
45
+ # @param violations [Array<Protovalidate::Violation>]
46
+ # @param message_class [Class]
47
+ # @return [void]
48
+ #: (Array[untyped] violations, Google::Protobuf::_MessageClass message_class) -> void
49
+ def initialize(violations, message_class)
50
+ @violations = violations
51
+ @message_class = message_class
52
+ super(violations.map(&:to_s).join("; "))
53
+ end
54
+ end
55
+
56
+ # `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
57
+ # way to name.
58
+ # steep:ignore:start
59
+ included do
60
+ # Registered at include time so a controller can opt out at the top of the class with
61
+ # `skip_before_action :validate_protobuf_body!`.
62
+ before_action(:validate_protobuf_body!)
63
+ end
64
+ # steep:ignore:end
65
+
66
+ # @rbs module-self _RequestValidatableClass
67
+ module ClassMethods
68
+ # Re-registers the validation callback behind the action's own declaration.
69
+ #
70
+ # Rails appends a re-registered callback to the end of the chain. Without this, the
71
+ # callback stays where `included` put it — ahead of the authentication and authorization
72
+ # callbacks a controller declares afterwards — and an invalid body would be answered
73
+ # before an unauthenticated one, turning the endpoint into an oracle for what a valid
74
+ # body looks like.
75
+ #
76
+ # A controller that skipped the callback has no entry to move, and is left alone.
77
+ #
78
+ # @param method_name [Symbol]
79
+ # @return [void]
80
+ #: (Symbol method_name) -> void
81
+ def method_added(method_name)
82
+ pending = protobuf_body_pending?
83
+ super
84
+ return unless pending
85
+ return unless _process_action_callbacks.any? { |cb| cb.filter == :validate_protobuf_body! }
86
+
87
+ before_action(:validate_protobuf_body!)
88
+ end
89
+
90
+ # @return [Boolean] whether a `protobuf_body` is waiting to bind to the next action
91
+ #: () -> bool
92
+ def protobuf_body_pending? = !instance_variable_get(:@next_protobuf_body).nil?
93
+ end
94
+
95
+ # Validates the parsed message, if this action declared one.
96
+ #
97
+ # @return [void]
98
+ # @raise [InvalidMessage] when the message breaks a rule
99
+ #: () -> void
100
+ private def validate_protobuf_body!
101
+ declaration = self.class.protobuf_body_declarations[action_name]
102
+ return if declaration.nil?
103
+ return unless params.key?(:_protobuf)
104
+
105
+ violations = Protovalidate.collect_violations(message)
106
+ return if violations.empty?
107
+
108
+ raise InvalidMessage.new(violations, declaration.message_class)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ module Protobufable
8
+ # The gem's version.
9
+ VERSION = "0.1.0" #: String
10
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Copyright 2026 Sorah Fukumori
5
+ # SPDX-License-Identifier: MIT
6
+
7
+ require "google/protobuf"
8
+ require "google/protobuf/well_known_types"
9
+
10
+ require "protobufable/version"
11
+ require "protobufable/json_type"
12
+ require "protobufable/request_parseable"
13
+ require "protobufable/renderers"
14
+
15
+ # Protocol Buffers as the schema of record for a Rails API: request bodies, stored columns,
16
+ # error catalogues and responses all described by generated message classes.
17
+ #
18
+ # Each integration is required on its own, so a host pays only for what it uses:
19
+ #
20
+ # require "protobufable/problem" # needs the problem gem
21
+ # require "protobufable/protovalidate" # needs the protovalidate gem
22
+ # require "protobufable/alba_binding" # needs the alba gem
23
+ module Protobufable
24
+ # Raised when a declaration cannot be honoured, such as an enum value the catalogue does not
25
+ # define or a serializer that has drifted from its message.
26
+ class Error < StandardError; end
27
+
28
+ # Registers the renderers and installs the request body parser. Called by the railtie on
29
+ # boot; a Rack host, or a spec that never boots Rails, calls it itself. Installing twice is
30
+ # harmless.
31
+ #
32
+ # @return [void]
33
+ #: () -> void
34
+ def self.install!
35
+ Renderers.install!
36
+ ActionDispatch::Request.prepend(RequestParseable::RequestPatch)
37
+ end
38
+ end
39
+
40
+ require "protobufable/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2026 Sorah Fukumori
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ return unless defined?(Protobufable::RequestParseable)
7
+
8
+ module Tapioca
9
+ module Dsl
10
+ module Compilers
11
+ # Types `message` on each controller that declares `protobuf_body`, so an action reads the
12
+ # decoded request body as its message class without a cast.
13
+ #
14
+ # A controller whose actions declare one class gets that class:
15
+ #
16
+ # ~~~rb
17
+ # class WidgetsController < ApplicationController
18
+ # include Protobufable::RequestParseable
19
+ #
20
+ # protobuf_body Api::CreateWidgetRequest
21
+ # def create; end
22
+ # end
23
+ # ~~~
24
+ #
25
+ # ~~~rbi
26
+ # # widgets_controller.rbi
27
+ # # typed: true
28
+ # class WidgetsController
29
+ # sig { returns(::Api::CreateWidgetRequest) }
30
+ # def message; end
31
+ # end
32
+ # ~~~
33
+ #
34
+ # A controller whose actions declare different classes gets their union, which an action
35
+ # narrows itself, for example with `#: self as WidgetsController & ...` or a cast.
36
+ #: [ConstantType = Class[top]]
37
+ class ProtobufableRequestParseable < Compiler
38
+ # @override
39
+ #: -> void
40
+ def decorate
41
+ root.create_path(constant) do |controller|
42
+ controller.create_method("message", return_type: return_type)
43
+ end
44
+ end
45
+
46
+ class << self
47
+ # @override
48
+ #: -> Enumerable[Module[top]]
49
+ def gather_constants
50
+ all_classes.select do |klass|
51
+ klass < ::Protobufable::RequestParseable && !declared_classes(klass).empty?
52
+ end
53
+ end
54
+
55
+ #: (Class[top] klass) -> Array[Module[top]]
56
+ def declared_classes(klass)
57
+ klass.protobuf_body_declarations.values.map(&:message_class).uniq
58
+ end
59
+ end
60
+
61
+ #: -> String
62
+ private def return_type
63
+ names = self.class.declared_classes(constant).map { |klass| "::#{klass.name}" }.sort
64
+ names.one? ? names.first : "T.any(#{names.join(", ")})"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,36 @@
1
+ syntax = "proto3";
2
+
3
+ package protobufable;
4
+
5
+ // What an enum value publishes when it is rendered as an RFC 9457 problem document.
6
+ //
7
+ // Carried by a custom option on the value, which an application declares itself:
8
+ //
9
+ // import "google/protobuf/descriptor.proto";
10
+ // import "protobufable/problem.proto";
11
+ //
12
+ // extend google.protobuf.EnumValueOptions {
13
+ // optional protobufable.ProblemTypeOptions problem_type = 50001;
14
+ // }
15
+ //
16
+ // enum ProblemType {
17
+ // PROBLEM_TYPE_UNSPECIFIED = 0;
18
+ // PROBLEM_TYPE_NOT_FOUND = 1 [(problem_type) = {uri: "not-found", http_status: 404}];
19
+ // }
20
+ //
21
+ // The extension is declared by the application rather than here because its field number is a
22
+ // global namespace: numbers are registered in protocolbuffers/protobuf docs/options.md, and
23
+ // protobufable does not hold one yet. This message needs none, so it ships now, and an
24
+ // application picks its own number from the internal-use range meanwhile.
25
+ //
26
+ // One message rather than one option per property: the extension then spends one number where
27
+ // separate scalars would spend one each, and a property added later is a field here.
28
+ message ProblemTypeOptions {
29
+ // Identifier this problem type publishes, resolved against the deployment's type prefix to
30
+ // form the document's `type`. Several values may deliberately publish the same one.
31
+ string uri = 1;
32
+
33
+ // HTTP status this problem type is returned with. A property of the problem type rather than
34
+ // of the code path that raised it: two errors needing different statuses are different types.
35
+ uint32 http_status = 2;
36
+ }
@@ -0,0 +1,31 @@
1
+ syntax = "proto3";
2
+
3
+ package protobufable;
4
+
5
+ import "google/protobuf/any.proto";
6
+
7
+ // An RFC 9457 problem document as a message, for a transport whose errors carry protobuf
8
+ // messages rather than a JSON body, such as a Connect error's details.
9
+ //
10
+ // Kept apart from problem.proto so an application can vendor this file to reference the
11
+ // message from its own schema without vendoring the option message too.
12
+ message ProblemDetails {
13
+ // URI reference identifying the problem type.
14
+ string type = 1;
15
+
16
+ // Short, human-readable summary of the problem type.
17
+ string title = 2;
18
+
19
+ // HTTP status of this occurrence.
20
+ int32 status = 3;
21
+
22
+ // Human-readable explanation specific to this occurrence.
23
+ string detail = 4;
24
+
25
+ // URI reference identifying this occurrence.
26
+ string instance = 5;
27
+
28
+ // Machine-readable data about this occurrence, such as a google.rpc.BadRequest. Named apart
29
+ // from `detail`, which RFC 9457 reserves for prose.
30
+ repeated google.protobuf.Any information = 6;
31
+ }