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,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