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
|
@@ -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
|
data/lib/protobufable.rb
ADDED
|
@@ -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
|
+
}
|