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,92 @@
|
|
|
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 "connect_rpc_rails"
|
|
9
|
+
require "problem"
|
|
10
|
+
|
|
11
|
+
require "protobufable"
|
|
12
|
+
require "protobufable/problem_details_pb"
|
|
13
|
+
require "protobufable/problem/information"
|
|
14
|
+
|
|
15
|
+
module Protobufable
|
|
16
|
+
module Connect
|
|
17
|
+
# Answers a ::Problem::Detailable raised in a Connect RPC as a Connect error rather than as
|
|
18
|
+
# application/problem+json.
|
|
19
|
+
#
|
|
20
|
+
# The code is read off the problem's status and the message is its detail, or its title when
|
|
21
|
+
# there is none. The document itself travels as a detail, so a caller dispatches on the same
|
|
22
|
+
# `type` over Connect as over REST.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# class WidgetsController < ActionController::API
|
|
26
|
+
# include ConnectRpcRails::Controller
|
|
27
|
+
# include Protobufable::Connect::ProblemRescuable
|
|
28
|
+
#
|
|
29
|
+
# connect_service "myapp.api.WidgetsService"
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Include it after ConnectRpcRails::Controller. Only a problem raised inside the controller is
|
|
33
|
+
# answered this way; one escaping before dispatch reaches the exceptions app.
|
|
34
|
+
#
|
|
35
|
+
# @rbs module-self _ProblemRescuableSelf
|
|
36
|
+
module ProblemRescuable
|
|
37
|
+
extend ActiveSupport::Concern
|
|
38
|
+
|
|
39
|
+
include ::Problem::Rescuable
|
|
40
|
+
|
|
41
|
+
# `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
|
|
42
|
+
# way to name.
|
|
43
|
+
# steep:ignore:start
|
|
44
|
+
included do
|
|
45
|
+
# Registered again for a controller whose parent already included ::Problem::Rescuable, so
|
|
46
|
+
# it follows the rescue_responses handlers ConnectRpcRails::Controller installed since:
|
|
47
|
+
# Rails picks the most recently registered match.
|
|
48
|
+
rescue_from(::Problem::Detailable, with: :render_problem_detailable)
|
|
49
|
+
end
|
|
50
|
+
# steep:ignore:end
|
|
51
|
+
|
|
52
|
+
# Builds the message the document travels as. Override to build the application's own copy
|
|
53
|
+
# of the message instead.
|
|
54
|
+
#
|
|
55
|
+
# `information` carries the error's {Problem::Information} messages, packed as they are rather
|
|
56
|
+
# than re-read from the rendered document. Other extension members are dropped, because the
|
|
57
|
+
# message has no slot for them.
|
|
58
|
+
#
|
|
59
|
+
# @param problem [::Problem::Details]
|
|
60
|
+
# @param error [Exception] the error the document was built from
|
|
61
|
+
# @return [Object] a protobuf message
|
|
62
|
+
#: (::Problem::Details problem, untyped error) -> untyped
|
|
63
|
+
private def problem_message_for(problem, error)
|
|
64
|
+
information = [] #: Array[untyped]
|
|
65
|
+
information = error.problem_information if error.is_a?(Protobufable::Problem::Information)
|
|
66
|
+
|
|
67
|
+
ProblemDetails.new(
|
|
68
|
+
type: problem.type || "",
|
|
69
|
+
title: problem.title || "",
|
|
70
|
+
status: problem.status,
|
|
71
|
+
detail: problem.detail || "",
|
|
72
|
+
instance: problem.instance || "",
|
|
73
|
+
information: information.map { |info| Google::Protobuf::Any.pack(info) },
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param problem [::Problem::Details]
|
|
78
|
+
# @param error [Exception]
|
|
79
|
+
# @return [void]
|
|
80
|
+
#: (::Problem::Details problem, untyped error) -> void
|
|
81
|
+
private def render_problem(problem, error)
|
|
82
|
+
render_connect_error(
|
|
83
|
+
ConnectRpcRails::Error.new(
|
|
84
|
+
ConnectRpcRails::Error.code_for_http_status(problem.status),
|
|
85
|
+
problem.detail.presence || problem.title,
|
|
86
|
+
details: [Google::Protobuf::Any.pack(problem_message_for(problem, error))],
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
# The connect_rpc_rails integration, required on its own because neither connect_rpc_rails nor
|
|
8
|
+
# problem is a dependency of protobufable: a host that serves no Connect RPC never loads it.
|
|
9
|
+
#
|
|
10
|
+
# require "protobufable/connect"
|
|
11
|
+
|
|
12
|
+
require "protobufable/problem"
|
|
13
|
+
|
|
14
|
+
module Protobufable
|
|
15
|
+
# The connect_rpc_rails integration: problems answered as Connect errors.
|
|
16
|
+
module Connect
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require "protobufable/connect/problem_rescuable"
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
|
|
9
|
+
module Protobufable
|
|
10
|
+
# Renders a `buf.validate.FieldPath` in the spelling the caller sent.
|
|
11
|
+
#
|
|
12
|
+
# A field path is only useful if the client can find the field it names, and a protobuf field
|
|
13
|
+
# has two published names: `preferred_language` in the binary encoding and in ProtoJSON with
|
|
14
|
+
# `preserve_proto_fieldnames`, `preferredLanguage` in ProtoJSON without it. Which one to use
|
|
15
|
+
# is a property of the encoding in play, so each segment is resolved against the descriptor
|
|
16
|
+
# rather than interpolated from the name the violation happened to carry.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# Protobufable::FieldPath.render(violation.field, CreateWidgetRequest, json_names: true)
|
|
20
|
+
# #=> "widget.tags[0]"
|
|
21
|
+
module FieldPath
|
|
22
|
+
# Renders a path against the message it was produced from.
|
|
23
|
+
#
|
|
24
|
+
# @param path [Object, nil] a `buf.validate.FieldPath`
|
|
25
|
+
# @param message_class [Class] the message the path is rooted in
|
|
26
|
+
# @param json_names [Boolean] whether to publish `json_name` rather than the proto name
|
|
27
|
+
# @return [String] the path, empty for a message-level rule
|
|
28
|
+
#: (untyped path, Google::Protobuf::_MessageClass message_class, ?json_names: bool) -> String
|
|
29
|
+
def self.render(path, message_class, json_names: false)
|
|
30
|
+
return "" if path.nil?
|
|
31
|
+
|
|
32
|
+
descriptor = message_class.descriptor #: untyped
|
|
33
|
+
path.elements.map do |element|
|
|
34
|
+
field = descriptor&.lookup(element.field_name)
|
|
35
|
+
descriptor = field&.subtype
|
|
36
|
+
"#{publish(field, element, json_names:)}#{subscript(element)}"
|
|
37
|
+
end.join(".")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] the name this segment is published under
|
|
41
|
+
#: (untyped field, untyped element, json_names: bool) -> String
|
|
42
|
+
def self.publish(field, element, json_names:)
|
|
43
|
+
# A path segment the descriptor cannot resolve is published as the violation spelled it,
|
|
44
|
+
# which is better than dropping the segment and renumbering the rest of the path.
|
|
45
|
+
return element.field_name if field.nil?
|
|
46
|
+
|
|
47
|
+
json_names ? field.json_name : field.name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [String] the `[...]` suffix for a repeated or map element, empty otherwise
|
|
51
|
+
#: (untyped element) -> String
|
|
52
|
+
def self.subscript(element)
|
|
53
|
+
case element.subscript
|
|
54
|
+
when :index then "[#{element.index}]"
|
|
55
|
+
when :bool_key then "[#{element.bool_key}]"
|
|
56
|
+
when :int_key then "[#{element.int_key}]"
|
|
57
|
+
when :uint_key then "[#{element.uint_key}]"
|
|
58
|
+
when :string_key then "[#{element.string_key.inspect}]"
|
|
59
|
+
else ""
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private_class_method :publish, :subscript
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "active_model"
|
|
8
|
+
require "active_support/json"
|
|
9
|
+
|
|
10
|
+
require "protobufable"
|
|
11
|
+
|
|
12
|
+
module Protobufable
|
|
13
|
+
# An ActiveModel type storing a protobuf message in a native `json` or `jsonb` column.
|
|
14
|
+
#
|
|
15
|
+
# Encoding goes through the message's own `encode_json` and `decode_json`, so the stored
|
|
16
|
+
# document is constrained to the protobuf schema rather than to whatever a Hash happened to
|
|
17
|
+
# hold. Being a type rather than a `serialize` coder is what buys dirty tracking, casting
|
|
18
|
+
# from a Hash, and a column the adapter knows is JSON.
|
|
19
|
+
#
|
|
20
|
+
# @example A column that is never null in Ruby
|
|
21
|
+
# class Widget < ApplicationRecord
|
|
22
|
+
# attribute :settings, Protobufable::JsonType.new(Internal::WidgetSettings),
|
|
23
|
+
# default: -> { Internal::WidgetSettings.new }
|
|
24
|
+
# end
|
|
25
|
+
class JsonType < ActiveModel::Type::Value
|
|
26
|
+
# @return [Class] the generated message class this column holds
|
|
27
|
+
attr_reader :message_class #: Google::Protobuf::_MessageClass
|
|
28
|
+
|
|
29
|
+
# @param message_class [Class] the generated message class this column holds
|
|
30
|
+
# @param nil_as_default [Boolean] whether a null column reads back as an empty message,
|
|
31
|
+
# for a nullable column whose Ruby value is semantically non-null
|
|
32
|
+
# @param preserve_proto_fieldnames [Boolean] whether documents are written with the
|
|
33
|
+
# `.proto` field names (`snake_case`) rather than their lowerCamelCase JSON names
|
|
34
|
+
# @return [void]
|
|
35
|
+
#: (Google::Protobuf::_MessageClass message_class, ?nil_as_default: bool, ?preserve_proto_fieldnames: bool) -> void
|
|
36
|
+
def initialize(message_class, nil_as_default: false, preserve_proto_fieldnames: false)
|
|
37
|
+
super()
|
|
38
|
+
@message_class = message_class
|
|
39
|
+
@nil_as_default = nil_as_default
|
|
40
|
+
@preserve_proto_fieldnames = preserve_proto_fieldnames
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Symbol] always `:json`, so Rails and the adapter treat the column as JSON
|
|
44
|
+
#: () -> Symbol
|
|
45
|
+
def type = :json
|
|
46
|
+
|
|
47
|
+
# Decodes a stored document into a message.
|
|
48
|
+
#
|
|
49
|
+
# Unknown fields are tolerated: during a rolling deploy a row written by a newer schema has
|
|
50
|
+
# to stay readable on a node that has not picked it up yet.
|
|
51
|
+
#
|
|
52
|
+
# @param value [String, nil] the document as the adapter hands it back
|
|
53
|
+
# @return [Object, nil] the decoded message, or nil for a null column
|
|
54
|
+
#: (String? value) -> untyped
|
|
55
|
+
def deserialize(value)
|
|
56
|
+
return @message_class.new if value.nil? && @nil_as_default
|
|
57
|
+
return if value.nil?
|
|
58
|
+
|
|
59
|
+
@message_class.decode_json(value, ignore_unknown_fields: true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Encodes a message for storage.
|
|
63
|
+
#
|
|
64
|
+
# Defaults are emitted so the stored shape does not depend on which fields happened to be
|
|
65
|
+
# set, which is what makes {#changed_in_place?} meaningful and spares anything reading the
|
|
66
|
+
# column in SQL from telling absent from default.
|
|
67
|
+
#
|
|
68
|
+
# @param value [Object, nil] the message to store
|
|
69
|
+
# @return [String, nil] the document to write, or nil
|
|
70
|
+
#: (untyped value) -> String?
|
|
71
|
+
def serialize(value)
|
|
72
|
+
return if value.nil?
|
|
73
|
+
|
|
74
|
+
@message_class.encode_json(
|
|
75
|
+
value,
|
|
76
|
+
emit_defaults: true,
|
|
77
|
+
preserve_proto_fieldnames: @preserve_proto_fieldnames,
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Coerces an assigned value into a message, accepting what a form, a fixture or a seed
|
|
82
|
+
# would supply as well as a message itself.
|
|
83
|
+
#
|
|
84
|
+
# @param value [Object, String, Hash, nil]
|
|
85
|
+
# @return [Object, nil] the message, or the value unchanged when it is none of those
|
|
86
|
+
#: (untyped value) -> untyped
|
|
87
|
+
def cast(value)
|
|
88
|
+
return deserialize(nil) if value.nil?
|
|
89
|
+
|
|
90
|
+
case value
|
|
91
|
+
when @message_class
|
|
92
|
+
value
|
|
93
|
+
when ::String
|
|
94
|
+
deserialize(value)
|
|
95
|
+
when ::Hash
|
|
96
|
+
deserialize(::ActiveSupport::JSON.encode(value))
|
|
97
|
+
else
|
|
98
|
+
value
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Compares decoded messages rather than encoded text: a jsonb column hands back a
|
|
103
|
+
# canonicalized document whose key order and spacing need not match what `encode_json`
|
|
104
|
+
# produced, which would otherwise read as a change on every load.
|
|
105
|
+
#
|
|
106
|
+
# @param raw_old_value [String, nil] the document as it was loaded
|
|
107
|
+
# @param new_value [Object, nil] the message currently held
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
#: (String? raw_old_value, untyped new_value) -> bool
|
|
110
|
+
def changed_in_place?(raw_old_value, new_value)
|
|
111
|
+
deserialize(raw_old_value) != new_value
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "json"
|
|
8
|
+
require "problem/detailable"
|
|
9
|
+
|
|
10
|
+
require "protobufable"
|
|
11
|
+
|
|
12
|
+
module Protobufable
|
|
13
|
+
module Problem
|
|
14
|
+
# Publishes machine-readable data about an occurrence as protobuf messages, in an
|
|
15
|
+
# `information` extension member.
|
|
16
|
+
#
|
|
17
|
+
# RFC 9457 reserves `detail` for prose, so anything a client is meant to act on rather than
|
|
18
|
+
# read goes somewhere else. This packs each message as a `google.protobuf.Any`, which is what
|
|
19
|
+
# lets one member carry `google.rpc.BadRequest`, `ErrorInfo` and whatever else an occurrence
|
|
20
|
+
# has to say without the document declaring a slot for each.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# class InvalidWidget < Errors::ApiError
|
|
24
|
+
# include Protobufable::Problem::Information
|
|
25
|
+
#
|
|
26
|
+
# def problem_information
|
|
27
|
+
# super + [Google::Rpc::BadRequest.new(field_violations: violations)]
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# {"type": "...", "status": 400, "information": [
|
|
32
|
+
# {"@type": "type.googleapis.com/google.rpc.BadRequest", "field_violations": [...]}
|
|
33
|
+
# ]}
|
|
34
|
+
#
|
|
35
|
+
# Every hook calls `super`, so this composes with ::Problem::RetryAfter and with another mixin
|
|
36
|
+
# of your own rather than replacing what they contribute.
|
|
37
|
+
#
|
|
38
|
+
# @rbs module-self ::Problem::Detailable
|
|
39
|
+
module Information
|
|
40
|
+
# The extension member the packed messages are published in.
|
|
41
|
+
MEMBER = :information #: Symbol
|
|
42
|
+
|
|
43
|
+
# Protobuf messages this occurrence publishes. Override and call `super`.
|
|
44
|
+
#
|
|
45
|
+
# @return [Array<Object>]
|
|
46
|
+
#: () -> Array[untyped]
|
|
47
|
+
def problem_information = []
|
|
48
|
+
|
|
49
|
+
# Adds the packed messages to the document, and nothing at all when there are none: an
|
|
50
|
+
# empty member would be noise on every error that carries no detail of this kind.
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash{Symbol => Object}]
|
|
53
|
+
#: () -> Hash[Symbol, untyped]
|
|
54
|
+
def problem_extensions
|
|
55
|
+
messages = problem_information
|
|
56
|
+
return super if messages.empty?
|
|
57
|
+
|
|
58
|
+
super.merge(MEMBER => messages.map { |message| Information.pack(message) })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Packs one message as a `google.protobuf.Any` and renders it as ProtoJSON.
|
|
62
|
+
#
|
|
63
|
+
# Field names are preserved rather than lowerCamelCased, matching what the proto_json
|
|
64
|
+
# renderer sends, so a client reads one spelling across the whole API.
|
|
65
|
+
#
|
|
66
|
+
# @param message [Object] a protobuf message
|
|
67
|
+
# @return [Hash] the Any as ProtoJSON, carrying an `@type` alongside the message's fields
|
|
68
|
+
#: (untyped message) -> untyped
|
|
69
|
+
def self.pack(message)
|
|
70
|
+
any = Google::Protobuf::Any.pack(message)
|
|
71
|
+
JSON.parse(Google::Protobuf::Any.encode_json(any, preserve_proto_fieldnames: true))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Copyright 2026 Sorah Fukumori
|
|
5
|
+
# SPDX-License-Identifier: MIT
|
|
6
|
+
|
|
7
|
+
require "problem/retry_after"
|
|
8
|
+
|
|
9
|
+
require "protobufable"
|
|
10
|
+
require "protobufable/problem/information"
|
|
11
|
+
|
|
12
|
+
module Protobufable
|
|
13
|
+
module Problem
|
|
14
|
+
# Publishes a retry interval as a `google.rpc.RetryInfo` as well as a Retry-After header.
|
|
15
|
+
#
|
|
16
|
+
# ::Problem::RetryAfter already sends the header and a `retry_after` member, which a generic
|
|
17
|
+
# HTTP client obeys. This adds the form a client generated from the same protos already knows
|
|
18
|
+
# how to read, so the two kinds of caller get the same answer.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# class TooManyRequests < Errors::ApiError
|
|
22
|
+
# include Protobufable::Problem::RetryInfo
|
|
23
|
+
#
|
|
24
|
+
# problem_type :PROBLEM_TYPE_TOO_MANY_REQUESTS
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# raise TooManyRequests.new(retry_after: 30)
|
|
28
|
+
#
|
|
29
|
+
# `google.rpc` descriptors are not shipped with this gem: they belong to the app that
|
|
30
|
+
# generates googleapis alongside its own protos. The message class is looked up when it is
|
|
31
|
+
# first needed, and the error names what to generate if it is missing.
|
|
32
|
+
#
|
|
33
|
+
# @rbs module-self ::Problem::Detailable
|
|
34
|
+
module RetryInfo
|
|
35
|
+
include ::Problem::RetryAfter
|
|
36
|
+
include Information
|
|
37
|
+
|
|
38
|
+
# The message this publishes.
|
|
39
|
+
MESSAGE = "google.rpc.RetryInfo" #: String
|
|
40
|
+
|
|
41
|
+
# @return [Array<Object>] the occurrence's messages, with the retry interval appended
|
|
42
|
+
#: () -> Array[untyped]
|
|
43
|
+
def problem_information
|
|
44
|
+
super + [RetryInfo.message_for(retry_after_seconds)]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param seconds [Integer] the wait
|
|
48
|
+
# @return [Object] a `google.rpc.RetryInfo` carrying it
|
|
49
|
+
# @raise [Protobufable::Error] when googleapis has not been generated into the pool
|
|
50
|
+
#: (Integer seconds) -> untyped
|
|
51
|
+
def self.message_for(seconds)
|
|
52
|
+
descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(MESSAGE) or
|
|
53
|
+
raise Protobufable::Error,
|
|
54
|
+
"#{MESSAGE} is not in the descriptor pool; generate google/rpc/error_details.proto"
|
|
55
|
+
|
|
56
|
+
descriptor.msgclass.new(
|
|
57
|
+
retry_delay: Google::Protobuf::Duration.new(seconds: seconds),
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
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 "active_support/core_ext/class/attribute"
|
|
9
|
+
require "problem/i18nable"
|
|
10
|
+
|
|
11
|
+
require "protobufable"
|
|
12
|
+
|
|
13
|
+
module Protobufable
|
|
14
|
+
module Problem
|
|
15
|
+
# Derives an error's RFC 9457 type and status from a protobuf enum, whose values carry them
|
|
16
|
+
# as a custom option.
|
|
17
|
+
#
|
|
18
|
+
# This is the catalogue layer ::Problem::Detailable documents: it prepends a module to the
|
|
19
|
+
# error class's singleton that overrides `type` and `status`, and calls `super` for a class
|
|
20
|
+
# the catalogue does not cover, so a literal declaration still works.
|
|
21
|
+
#
|
|
22
|
+
# Applications whose enum is annotated with one option per property override
|
|
23
|
+
# {ClassMethods#problem_uri_annotation} and {ClassMethods#problem_status_annotation} instead.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# module Errors
|
|
27
|
+
# class ApiError < StandardError
|
|
28
|
+
# include Protobufable::Problem::Typeable
|
|
29
|
+
#
|
|
30
|
+
# def self.problem_type_enum = "myapp.api.ProblemType"
|
|
31
|
+
# def self.problem_type_annotation = "myapp.api.problem_type"
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# class WidgetNotReady < ApiError
|
|
35
|
+
# problem_type :PROBLEM_TYPE_WIDGET_NOT_READY
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# The status is an annotation on the value rather than a property of the code path that
|
|
40
|
+
# raised it: two errors needing different statuses are different types. Several values may
|
|
41
|
+
# publish one identifier on purpose, where telling them apart would leak something; because
|
|
42
|
+
# ::Problem::I18nable keys titles by the published identifier, they share a title too, which is
|
|
43
|
+
# the invariant that keeps the distinction hidden.
|
|
44
|
+
#
|
|
45
|
+
# @rbs module-self ::Exception
|
|
46
|
+
# @rbs module-self ::Problem::_DetailableSelf
|
|
47
|
+
module Typeable
|
|
48
|
+
extend ActiveSupport::Concern
|
|
49
|
+
|
|
50
|
+
# Brings the DSL this overrides, and the title lookup, so one include covers all three.
|
|
51
|
+
include ::Problem::I18nable
|
|
52
|
+
|
|
53
|
+
# What one enum value publishes.
|
|
54
|
+
Entry = Data.define(:uri, :http_status)
|
|
55
|
+
|
|
56
|
+
# Overrides ::Problem::Detailable's literal DSL for a class that declared a value, and falls
|
|
57
|
+
# through to it for one that did not.
|
|
58
|
+
#
|
|
59
|
+
# Prepended to the singleton class rather than defined in ClassMethods so it wins whichever
|
|
60
|
+
# order the concerns were mixed in. A class that spells out a literal keeps it, the rule
|
|
61
|
+
# ::Problem::I18nable already follows, so a codebase can move onto a catalogue gradually.
|
|
62
|
+
#
|
|
63
|
+
# @rbs module-self _TypeableClass
|
|
64
|
+
module Derivation
|
|
65
|
+
# The identifier the declared value publishes, which is its annotation rather than its
|
|
66
|
+
# name: several values deliberately publish the same one.
|
|
67
|
+
#
|
|
68
|
+
# @param value [String, nil] a literal declaration, which takes precedence
|
|
69
|
+
# @return [String, nil]
|
|
70
|
+
#: (?String?) -> String?
|
|
71
|
+
def type(value = nil)
|
|
72
|
+
return super if value || problem_uri || problem_type.nil?
|
|
73
|
+
|
|
74
|
+
@problem_type_uri ||= problem_type_entry.uri
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The status the declared value is returned with.
|
|
78
|
+
#
|
|
79
|
+
# @param value [Integer, nil] a literal declaration, which takes precedence
|
|
80
|
+
# @return [Integer, nil]
|
|
81
|
+
#: (?Integer?) -> Integer?
|
|
82
|
+
def status(value = nil)
|
|
83
|
+
return super if value || problem_status || problem_type.nil?
|
|
84
|
+
|
|
85
|
+
@problem_type_status ||= problem_type_entry.http_status
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `self` inside an ActiveSupport::Concern block is the including class, which RBS has no
|
|
90
|
+
# way to name; the accessor this installs is declared in sig/manual instead.
|
|
91
|
+
# steep:ignore:start
|
|
92
|
+
included do
|
|
93
|
+
class_attribute(:problem_type_value, instance_accessor: false)
|
|
94
|
+
|
|
95
|
+
singleton_class.prepend(Derivation)
|
|
96
|
+
end
|
|
97
|
+
# steep:ignore:end
|
|
98
|
+
|
|
99
|
+
# @rbs module-self _TypeableClass
|
|
100
|
+
module ClassMethods
|
|
101
|
+
# The full name of the enum this class's catalogue lives in.
|
|
102
|
+
#
|
|
103
|
+
# No default: an app's catalogue is the one thing a gem cannot guess. Override it on the
|
|
104
|
+
# base class the rest of the errors inherit from.
|
|
105
|
+
#
|
|
106
|
+
# @return [String]
|
|
107
|
+
# @raise [Protobufable::Error] when it has not been overridden
|
|
108
|
+
#: () -> String
|
|
109
|
+
def problem_type_enum
|
|
110
|
+
raise Protobufable::Error,
|
|
111
|
+
"#{name || self} declares no problem type enum: define " \
|
|
112
|
+
"`def self.problem_type_enum` returning the full name of the enum its values live in"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The full name of the extension carrying a value's uri and http_status as one message.
|
|
116
|
+
#
|
|
117
|
+
# No default either, because nothing generated from the gem's own .proto is shipped while
|
|
118
|
+
# its option number is provisional. See DESIGN.md.
|
|
119
|
+
#
|
|
120
|
+
# @return [String]
|
|
121
|
+
# @raise [Protobufable::Error] when neither this nor the split pair has been overridden
|
|
122
|
+
#: () -> String
|
|
123
|
+
def problem_type_annotation
|
|
124
|
+
raise Protobufable::Error,
|
|
125
|
+
"#{name || self} declares no problem type annotation: define " \
|
|
126
|
+
"`def self.problem_type_annotation` returning the full name of the extension its " \
|
|
127
|
+
"values are annotated with"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The extension carrying a value's identifier, for a catalogue that spends one option
|
|
131
|
+
# number per property rather than carrying both in one message.
|
|
132
|
+
#
|
|
133
|
+
# Override this and {problem_status_annotation} together, instead of
|
|
134
|
+
# {problem_type_annotation}, when the enum is already annotated that way. An established
|
|
135
|
+
# schema does not have to be rewritten to be read from here.
|
|
136
|
+
#
|
|
137
|
+
# @return [String, nil]
|
|
138
|
+
#: () -> String?
|
|
139
|
+
def problem_uri_annotation = nil
|
|
140
|
+
|
|
141
|
+
# The extension carrying a value's status, alongside {problem_uri_annotation}.
|
|
142
|
+
#
|
|
143
|
+
# @return [String, nil]
|
|
144
|
+
#: () -> String?
|
|
145
|
+
def problem_status_annotation = nil
|
|
146
|
+
|
|
147
|
+
# Declares which enum value this class renders as, or reads back what was declared.
|
|
148
|
+
#
|
|
149
|
+
# An unknown or unannotated value raises here, at class definition time, so a typo fails
|
|
150
|
+
# on boot rather than on the first request that reaches the error.
|
|
151
|
+
#
|
|
152
|
+
# @param value [Symbol, nil] the enum value name
|
|
153
|
+
# @return [Symbol, nil]
|
|
154
|
+
# @raise [Protobufable::Error] when the catalogue does not cover the value
|
|
155
|
+
#: (?Symbol?) -> Symbol?
|
|
156
|
+
def problem_type(value = nil)
|
|
157
|
+
return problem_type_value if value.nil?
|
|
158
|
+
|
|
159
|
+
name = value.to_sym
|
|
160
|
+
unless problem_type_catalogue.key?(name)
|
|
161
|
+
raise Protobufable::Error,
|
|
162
|
+
"unknown or unannotated value #{name} in #{problem_type_enum}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
self.problem_type_value = name
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The catalogue this class reads, built from the enum on first use.
|
|
169
|
+
#
|
|
170
|
+
# Memoized on the class that asked rather than in a table keyed by enum name: a reloaded
|
|
171
|
+
# or anonymous class then cannot read a catalogue built for a previous one.
|
|
172
|
+
#
|
|
173
|
+
# @return [Hash{Symbol => Entry}]
|
|
174
|
+
#: () -> Hash[Symbol, Entry]
|
|
175
|
+
def problem_type_catalogue
|
|
176
|
+
@problem_type_catalogue ||= Typeable.build_catalogue(
|
|
177
|
+
enum: problem_type_enum,
|
|
178
|
+
annotation: problem_uri_annotation ? nil : problem_type_annotation,
|
|
179
|
+
uri_annotation: problem_uri_annotation,
|
|
180
|
+
status_annotation: problem_status_annotation,
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @return [Entry] what the declared value publishes
|
|
185
|
+
#: () -> Entry
|
|
186
|
+
def problem_type_entry = problem_type_catalogue.fetch(problem_type)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Reads every annotated value of an enum.
|
|
190
|
+
#
|
|
191
|
+
# @param enum [String] full name of the enum
|
|
192
|
+
# @param annotation [String, nil] full name of the single sub-message extension
|
|
193
|
+
# @param uri_annotation [String, nil] full name of the identifier extension
|
|
194
|
+
# @param status_annotation [String, nil] full name of the status extension
|
|
195
|
+
# @return [Hash{Symbol => Entry}] one entry per value carrying both properties
|
|
196
|
+
# @raise [Protobufable::Error] when a name does not resolve, or the split pair is half given
|
|
197
|
+
#: (enum: String, annotation: String?, uri_annotation: String?, status_annotation: String?) -> Hash[Symbol, Entry]
|
|
198
|
+
def self.build_catalogue(enum:, annotation:, uri_annotation:, status_annotation:)
|
|
199
|
+
pool = Google::Protobuf::DescriptorPool.generated_pool
|
|
200
|
+
descriptor = pool.lookup(enum) or
|
|
201
|
+
raise Protobufable::Error, "enum #{enum} is not in the descriptor pool"
|
|
202
|
+
|
|
203
|
+
readers = entry_readers(pool, annotation, uri_annotation, status_annotation)
|
|
204
|
+
|
|
205
|
+
catalogue = {} #: Hash[Symbol, Entry]
|
|
206
|
+
descriptor.to_proto.value.each do |value|
|
|
207
|
+
options = value.options
|
|
208
|
+
next if options.nil?
|
|
209
|
+
|
|
210
|
+
entry = readers.call(options)
|
|
211
|
+
next if entry.nil?
|
|
212
|
+
|
|
213
|
+
catalogue[value.name.to_sym] = entry
|
|
214
|
+
end
|
|
215
|
+
catalogue.freeze
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [Proc] reads one value's options into an Entry, or nil when it carries none
|
|
219
|
+
#: (untyped pool, String? annotation, String? uri_annotation, String? status_annotation) -> ^(untyped) -> Entry?
|
|
220
|
+
def self.entry_readers(pool, annotation, uri_annotation, status_annotation)
|
|
221
|
+
if uri_annotation || status_annotation
|
|
222
|
+
unless uri_annotation && status_annotation
|
|
223
|
+
raise Protobufable::Error,
|
|
224
|
+
"a split annotation needs both problem_uri_annotation and problem_status_annotation"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
uri = lookup_extension!(pool, uri_annotation)
|
|
228
|
+
status = lookup_extension!(pool, status_annotation)
|
|
229
|
+
return ->(options) {
|
|
230
|
+
found = uri.get(options)
|
|
231
|
+
code = status.get(options)
|
|
232
|
+
next nil if found.nil? || found.empty? || code.nil? || code.zero?
|
|
233
|
+
|
|
234
|
+
Entry.new(uri: found, http_status: code)
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
extension = lookup_extension!(pool, annotation.to_s)
|
|
239
|
+
->(options) {
|
|
240
|
+
found = extension.get(options)
|
|
241
|
+
next nil if found.nil? || found.uri.empty? || found.http_status.zero?
|
|
242
|
+
|
|
243
|
+
Entry.new(uri: found.uri, http_status: found.http_status)
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# @return [Google::Protobuf::FieldDescriptor]
|
|
248
|
+
# @raise [Protobufable::Error] when the extension is not in the pool
|
|
249
|
+
#: (untyped pool, String name) -> untyped
|
|
250
|
+
def self.lookup_extension!(pool, name)
|
|
251
|
+
pool.lookup(name) or
|
|
252
|
+
raise Protobufable::Error,
|
|
253
|
+
"extension #{name} is not in the descriptor pool; is its _pb.rb required?"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private_class_method :entry_readers, :lookup_extension!
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|