connectrpc 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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +501 -0
  4. data/docs/design.md +504 -0
  5. data/docs/development.md +566 -0
  6. data/docs/grpc-web.md +165 -0
  7. data/docs/grpc.md +75 -0
  8. data/docs/rbs.md +469 -0
  9. data/docs/streaming.md +251 -0
  10. data/exe/protoc-gen-connectrpc-ruby +23 -0
  11. data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
  12. data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
  13. data/lib/connectrpc/cancellation.rb +220 -0
  14. data/lib/connectrpc/client.rb +260 -0
  15. data/lib/connectrpc/compression.rb +120 -0
  16. data/lib/connectrpc/deadline.rb +81 -0
  17. data/lib/connectrpc/error.rb +72 -0
  18. data/lib/connectrpc/generator/names.rb +105 -0
  19. data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
  20. data/lib/connectrpc/generator/renderer.rb +115 -0
  21. data/lib/connectrpc/generator/runtime_names.rb +84 -0
  22. data/lib/connectrpc/generator/schema.rb +218 -0
  23. data/lib/connectrpc/generator/streaming_methods.rb +29 -0
  24. data/lib/connectrpc/generator.rb +42 -0
  25. data/lib/connectrpc/get_query.rb +159 -0
  26. data/lib/connectrpc/grpc.rb +200 -0
  27. data/lib/connectrpc/grpc_client.rb +23 -0
  28. data/lib/connectrpc/grpc_server.rb +158 -0
  29. data/lib/connectrpc/grpc_web.rb +131 -0
  30. data/lib/connectrpc/grpc_web_cors.rb +135 -0
  31. data/lib/connectrpc/grpc_web_io.rb +156 -0
  32. data/lib/connectrpc/grpc_web_server.rb +207 -0
  33. data/lib/connectrpc/instrumentation.rb +216 -0
  34. data/lib/connectrpc/message_io.rb +47 -0
  35. data/lib/connectrpc/metadata.rb +105 -0
  36. data/lib/connectrpc/method.rb +68 -0
  37. data/lib/connectrpc/protocol.rb +189 -0
  38. data/lib/connectrpc/server.rb +398 -0
  39. data/lib/connectrpc/streaming/client.rb +104 -0
  40. data/lib/connectrpc/streaming/client_execution.rb +87 -0
  41. data/lib/connectrpc/streaming/client_scope.rb +105 -0
  42. data/lib/connectrpc/streaming/framing.rb +123 -0
  43. data/lib/connectrpc/streaming/framing_io.rb +61 -0
  44. data/lib/connectrpc/streaming/method.rb +33 -0
  45. data/lib/connectrpc/streaming/server.rb +122 -0
  46. data/lib/connectrpc/streaming/server_execution.rb +88 -0
  47. data/lib/connectrpc/streaming/server_terminal.rb +70 -0
  48. data/lib/connectrpc/streaming/session.rb +143 -0
  49. data/lib/connectrpc/streaming/session_completion.rb +134 -0
  50. data/lib/connectrpc/streaming/session_io.rb +107 -0
  51. data/lib/connectrpc/streaming/session_messages.rb +119 -0
  52. data/lib/connectrpc/streaming/strategy.rb +95 -0
  53. data/lib/connectrpc/streaming/streams.rb +116 -0
  54. data/lib/connectrpc/streaming/terminal.rb +52 -0
  55. data/lib/connectrpc/transports/net_http.rb +246 -0
  56. data/lib/connectrpc/version.rb +5 -0
  57. data/lib/connectrpc.rb +38 -0
  58. data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
  59. data/sig/connectrpc.rbs +772 -0
  60. data/sig/manifest.yaml +2 -0
  61. data/third_party/README.md +16 -0
  62. data/third_party/google/LICENSE +201 -0
  63. data/third_party/google/rpc/status.proto +48 -0
  64. data/third_party/protobuf-LICENSE +32 -0
  65. metadata +106 -0
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ # Synchronous, fixed per-RPC callbacks. Subscribers never own the application yield.
5
+ class Instrumentation
6
+ # The fixed call contract names its declared RPC method explicitly.
7
+ Call = Data.define(:role, :protocol, :method, :server_address, :server_port, :carrier) # rubocop:disable Lint/DataDefineOverride
8
+ Outcome = Data.define(:code, :http_status, :error, :normalized_error, :duration,
9
+ :request_bytes, :response_bytes, :request_metadata, :response_metadata)
10
+ Completion = Data.define(:outcome, :processing_state, :decision, :delivery, :error, :http_status, :duration)
11
+
12
+ Callbacks = Data.define(:attach, :detach, :processing_finished, :finished)
13
+ private_constant :Callbacks
14
+
15
+ class Callbacks
16
+ def self.discover(observer, bus)
17
+ new(attach: callback(observer, :attach, bus), detach: callback(observer, :detach, bus),
18
+ processing_finished: callback(observer, :processing_finished, bus),
19
+ finished: callback(observer, :finished, bus))
20
+ end
21
+
22
+ # Reflection ends here; only typed callback records reach lifecycle work.
23
+ def self.callback(observer, name, bus)
24
+ bus.safely { observer.public_method(name).to_proc if observer.respond_to?(name) }
25
+ end
26
+ private_class_method :callback
27
+ end
28
+
29
+ def initialize(subscribers:, on_error: nil)
30
+ @subscribers = subscribers.dup.freeze
31
+ @on_error = on_error
32
+ end
33
+
34
+ # Internal constructor shared by client processing and the server hosting SPI.
35
+ def start(owner:, role:, method:, carrier:, server_address: nil, server_port: nil, path: nil, protocol: :connect)
36
+ call = Call.new(role: role, protocol: protocol, method: method,
37
+ server_address: server_address, server_port: server_port, carrier: carrier)
38
+ observers = @subscribers.filter_map { |subscriber| safely { subscriber.start(call) } }
39
+ Handle.new(self, owner, path, observers)
40
+ end
41
+
42
+ def safely
43
+ yield
44
+ rescue StandardError => e
45
+ begin
46
+ @on_error&.call(e)
47
+ rescue StandardError
48
+ nil
49
+ end
50
+ nil
51
+ end
52
+
53
+ # Defensive read-only snapshot. Extraction must not validate/reorder protocol work.
54
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
55
+ def self.carrier(headers) # rubocop:disable Metrics/CyclomaticComplexity
56
+ # @type var result: headers
57
+ # @type var empty: headers
58
+ empty = {}
59
+ values = headers.each_with_object(empty) do |(key, entries), result|
60
+ next unless key.is_a?(String)
61
+
62
+ # Preserve Array coercion; the split lets Steep resolve Kernel.Array on each arm.
63
+ entries = entries.is_a?(Array) ? entries : Array(entries) # rubocop:disable Style/RedundantSelfAssignmentBranch
64
+ next unless entries.all?(String)
65
+
66
+ (result[key.downcase] ||= []).concat(entries.map { |entry| entry.dup.freeze })
67
+ end
68
+ values.transform_values!(&:freeze).freeze
69
+ rescue StandardError
70
+ empty = {} #: headers
71
+ empty.freeze
72
+ end
73
+
74
+ class Handle # rubocop:disable Metrics/ClassLength
75
+ attr_reader :outcome
76
+ attr_accessor :request_bytes, :request_metadata, :code, :normalized_error
77
+
78
+ def initialize(bus, owner, path, observers)
79
+ @bus = bus
80
+ @owner = owner
81
+ @path = path
82
+ # Resolve the optional callback surface once per observer.
83
+ @observers = observers.map { |observer| Callbacks.discover(observer, @bus) }
84
+ @started_at = now
85
+ @processing_state = :not_started
86
+ request_metadata = {} #: headers
87
+ @request_metadata = request_metadata.freeze
88
+ end
89
+
90
+ # Keep the application yield and reverse unwind in one exception boundary.
91
+ def activate # rubocop:disable Metrics/MethodLength
92
+ return yield if @terminal
93
+
94
+ # @type var tokens: Array[[(^(Object?) -> void)?, Object?]]
95
+ tokens = []
96
+ begin
97
+ @observers.each do |callbacks|
98
+ next unless callbacks.attach
99
+
100
+ @bus.safely do
101
+ token = callbacks.attach.call
102
+ tokens << [callbacks.detach, token]
103
+ end
104
+ end
105
+ yield
106
+ ensure
107
+ detach_tokens(tokens)
108
+ end
109
+ end
110
+
111
+ # Hosts may finish before processing unwinds. The first delivery fact wins.
112
+ def finish(delivery:, error: nil, decision: :processed, http_status: nil)
113
+ return if @terminal || @pending
114
+
115
+ @pending = { delivery: delivery, error: error, decision: decision, http_status: http_status }
116
+ complete unless @processing_held || @processing_state == :running || @notifying_processing
117
+ nil
118
+ end
119
+
120
+ def host_rejected!
121
+ @host_rejected = true
122
+ end
123
+
124
+ def host_rejected? = !!@host_rejected
125
+
126
+ def record_failure(error)
127
+ @error ||= error
128
+ nil
129
+ end
130
+
131
+ # Internal, bound single-use processing entry. Hosts only activate and finish.
132
+ def begin_processing(owner, path = nil)
133
+ unless @owner.equal?(owner) && @path == path && @processing_state == :not_started && !@pending && !@terminal
134
+ raise ArgumentError, "Lifecycle belongs to another call or has already been used"
135
+ end
136
+
137
+ @processing_state = :running
138
+ @processing_at = now
139
+ end
140
+
141
+ # Prepare immutable facts; the original processor owns their final notification.
142
+ def processing_finished(code:, http_status: nil, normalized_error: nil, error: nil,
143
+ response_bytes: nil, response_metadata: {})
144
+ record_failure(error) if error
145
+ processing_at = @processing_at #: Float
146
+ @outcome = Outcome.new(code: code, http_status: http_status, error: @error,
147
+ normalized_error: normalized_error, duration: now - processing_at,
148
+ request_bytes: @request_bytes, response_bytes: response_bytes,
149
+ request_metadata: Instrumentation.carrier(@request_metadata),
150
+ response_metadata: Instrumentation.carrier(response_metadata))
151
+ complete_processing unless @processing_held
152
+ end
153
+
154
+ private
155
+
156
+ def detach_tokens(tokens)
157
+ while (entry = tokens.pop)
158
+ detach, token = entry
159
+ @bus.safely { detach&.call(token) }
160
+ end
161
+ nil
162
+ ensure
163
+ # Only an escaping detach leaves tokens for this continuation.
164
+ detach_tokens(tokens) unless tokens.empty?
165
+ end
166
+
167
+ # Native hosts hold preparation through the original application's cleanup.
168
+ # Delivery can arrive meanwhile, but neither observer callback can run yet.
169
+ def with_processing_completion
170
+ @processing_held = true
171
+ yield
172
+ rescue Exception => e # rubocop:disable Lint/RescueException -- Retain fatal cleanup failures without swallowing them.
173
+ record_failure(e)
174
+ raise
175
+ ensure
176
+ @processing_held = false
177
+ complete_processing if @outcome
178
+ complete if @pending && !@outcome && !@terminal
179
+ end
180
+
181
+ def complete_processing
182
+ return if @processing_state == :finished
183
+
184
+ prepared = @outcome #: Outcome
185
+ processing_at = @processing_at #: Float
186
+ outcome = @outcome = prepared.with(duration: now - processing_at, error: @error)
187
+ @processing_state = :finished
188
+ notify_processing(outcome)
189
+ nil
190
+ end
191
+
192
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
193
+
194
+ def notify_processing(outcome)
195
+ @notifying_processing = true
196
+ @observers.each { |callbacks| @bus.safely { callbacks.processing_finished&.call(outcome) } }
197
+ ensure
198
+ @notifying_processing = false
199
+ complete if @pending
200
+ end
201
+
202
+ def notify_finished(completion)
203
+ @observers.each { |callbacks| @bus.safely { callbacks.finished&.call(completion) } }
204
+ end
205
+
206
+ def complete
207
+ @terminal = true
208
+ pending = @pending #: pending
209
+ completion = Completion.new(outcome: @outcome, processing_state: @processing_state,
210
+ duration: now - @started_at, delivery: pending[:delivery], error: pending[:error],
211
+ decision: pending[:decision], http_status: pending[:http_status])
212
+ notify_finished(completion)
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ # Bounded host-neutral input reader. A synchronous host still owns blocking I/O.
5
+ class MessageIO
6
+ def initialize(input, limit, context)
7
+ @input = input
8
+ @limit = limit
9
+ @context = context
10
+ end
11
+
12
+ def read(length)
13
+ raise Error.new(:resource_exhausted, "Message exceeds byte limit") if length && length > @limit
14
+
15
+ body = +"".b
16
+ read_chunks(body) if @input
17
+ @context.check!
18
+ raise Error.new(:invalid_argument, "Content-Length does not match body") if length && body.bytesize != length
19
+
20
+ body
21
+ end
22
+
23
+ private
24
+
25
+ def read_chunk(input, length)
26
+ input.read(length)
27
+ rescue StandardError => e
28
+ @context.record_failure(e)
29
+ @context.check!
30
+ raise
31
+ end
32
+
33
+ def read_chunks(body)
34
+ input = @input #: _Input
35
+ loop do
36
+ @context.check!
37
+ chunk = read_chunk(input, [16_384, @limit + 1 - body.bytesize].min)
38
+ @context.check!
39
+ break if chunk.nil? || chunk.empty?
40
+
41
+ raise Error.new(:resource_exhausted, "Message exceeds byte limit") if body.bytesize + chunk.bytesize > @limit
42
+
43
+ body << chunk
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ class Metadata
5
+ include Enumerable
6
+
7
+ RESERVED = %w[accept accept-encoding accept-post allow connection content-encoding content-length content-type
8
+ expect host keep-alive proxy-authenticate proxy-authorization proxy-connection te trailer
9
+ transfer-encoding upgrade].freeze
10
+
11
+ def self.reserved?(key)
12
+ RESERVED.include?(key) || key.start_with?("connect-", "trailer-", "grpc-")
13
+ end
14
+
15
+ def self.encode_binary(bytes)
16
+ [bytes].pack("m0").delete_suffix("==").delete_suffix("=")
17
+ end
18
+
19
+ def self.decode_binary(value)
20
+ raw = base64_payload(value)
21
+ # @type var bytes: String
22
+ bytes = (raw + ("=" * ((4 - (raw.size % 4)) % 4))).unpack1("m0") #: String
23
+ raise ArgumentError, "Noncanonical base64" unless encode_binary(bytes) == raw
24
+
25
+ bytes
26
+ end
27
+
28
+ def self.base64_payload(value)
29
+ raise ArgumentError, "Malformed base64" unless value.is_a?(String) && %r{\A[A-Za-z0-9+/]*={0,2}\z}.match?(value)
30
+
31
+ raw = value.delete_suffix("==").delete_suffix("=")
32
+ raise ArgumentError, "Malformed base64" if raw.size % 4 == 1
33
+ raise ArgumentError, "Malformed base64 padding" if value.include?("=") && value.size % 4 != 0
34
+
35
+ raw
36
+ end
37
+ private_class_method :base64_payload
38
+
39
+ def initialize(values = {})
40
+ @values = {}
41
+ values.each { |(key, entries)| self[key] = entries }
42
+ end
43
+
44
+ def [](key)
45
+ @values[key.downcase]&.dup
46
+ end
47
+
48
+ def []=(key, entries)
49
+ key = validate_key(key)
50
+ values = entries.is_a?(Array) ? entries : Array(entries)
51
+ @values[key] = values.map { |value| validate_value(value) }.freeze
52
+ end
53
+
54
+ def add(key, value)
55
+ self[key] = (self[key] || []) + [value]
56
+ self
57
+ end
58
+
59
+ def each(&block)
60
+ return @values.each unless block
61
+
62
+ @values.each(&block)
63
+ end
64
+
65
+ def freeze
66
+ @values.freeze
67
+ super
68
+ end
69
+
70
+ def to_h
71
+ @values.dup
72
+ end
73
+
74
+ def add_binary(key, bytes)
75
+ raise ArgumentError, "Binary metadata keys must end in -bin" unless key.end_with?("-bin")
76
+
77
+ add(key, self.class.encode_binary(bytes))
78
+ end
79
+
80
+ def binary(key)
81
+ (self[key] || []).flat_map do |value|
82
+ parts = value.empty? ? [""] : value.split(",", -1)
83
+ parts.map { |part| self.class.decode_binary(part.strip) }
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def validate_key(key)
90
+ unless key.is_a?(String) && /\A[0-9A-Za-z_.-]+\z/.match?(key) && !self.class.reserved?(key.downcase)
91
+ raise ArgumentError, "Invalid or reserved metadata key"
92
+ end
93
+
94
+ key.downcase.freeze
95
+ end
96
+
97
+ def validate_value(value)
98
+ unless value.is_a?(String) && value.ascii_only? && /\A[\x20-\x7e]*\z/.match?(value)
99
+ raise ArgumentError, "Metadata values must be printable ASCII header strings"
100
+ end
101
+
102
+ value.dup.freeze
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConnectRPC
4
+ class Method
5
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z_0-9]*\z/
6
+ QUALIFIED_NAME = /\A[A-Za-z_][A-Za-z_0-9]*(?:\.[A-Za-z_][A-Za-z_0-9]*)*\z/
7
+
8
+ attr_reader :service, :name, :request_type, :response_type, :path, :idempotency
9
+
10
+ def cardinality = :unary
11
+
12
+ def self.validate_name!(value, qualified: false)
13
+ pattern = qualified ? QUALIFIED_NAME : IDENTIFIER
14
+ raise ArgumentError, "Invalid protobuf name" unless value.is_a?(String) && pattern.match?(value)
15
+ end
16
+
17
+ # Validate and assign the immutable declaration together.
18
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
19
+ def initialize(service:, name:, request_type:, response_type:, cardinality: :unary, idempotency: :unknown)
20
+ raise ArgumentError, "Only unary methods are supported" unless cardinality == :unary
21
+
22
+ unless %i[unknown no_side_effects idempotent].include?(idempotency)
23
+ raise ArgumentError, "Invalid method idempotency"
24
+ end
25
+
26
+ self.class.validate_name!(service, qualified: true)
27
+ self.class.validate_name!(name)
28
+ raise ArgumentError, "Message types are required" if request_type.nil? || response_type.nil?
29
+
30
+ @idempotency = idempotency
31
+ @service = service.dup.freeze
32
+ @name = name.dup.freeze
33
+ @request_type = request_type
34
+ @response_type = response_type
35
+ @path = "/#{service}/#{name}".freeze
36
+ freeze
37
+ end
38
+ end
39
+
40
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
41
+
42
+ class Service
43
+ attr_reader :name, :methods
44
+
45
+ def initialize(name:, methods:)
46
+ Method.validate_name!(name, qualified: true)
47
+ validate_methods!(name, methods)
48
+
49
+ @name = name.dup.freeze
50
+ @methods = methods.dup.freeze
51
+ freeze
52
+ end
53
+
54
+ private
55
+
56
+ def validate_methods!(name, methods)
57
+ unless methods.is_a?(Array) && methods.all? { |method| service_method?(method, name) }
58
+ raise ArgumentError, "Methods must belong to this service"
59
+ end
60
+ raise ArgumentError, "Duplicate methods" unless methods.map(&:name).uniq.size == methods.size
61
+ end
62
+
63
+ def service_method?(method, name)
64
+ [Method, ServerStreamMethod, ClientStreamMethod, BidiStreamMethod].any? { |type| method.is_a?(type) } &&
65
+ method.service == name
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConnectRPC
6
+ TransportRequest = Struct.new(:url, :http_method, :headers, :body, :response_limit, :deadline, :lifecycle,
7
+ :cancellation,
8
+ keyword_init: true)
9
+ TransportResponse = Struct.new(:status, :headers, :body, keyword_init: true)
10
+ Response = Struct.new(:message, :metadata, :trailers, keyword_init: true)
11
+
12
+ module Protocol # rubocop:disable Metrics/ModuleLength
13
+ DEFAULT_LIMIT = 4 * 1024 * 1024
14
+ CONTENT_TYPES = { proto: "application/proto", json: "application/json" }.freeze
15
+
16
+ def self.limit!(value)
17
+ raise ArgumentError, "Message limits must be positive integers" unless value.is_a?(Integer) && value.positive?
18
+
19
+ value
20
+ end
21
+
22
+ def self.codec!(codec)
23
+ raise ArgumentError, "Supported codecs: proto, json" unless CONTENT_TYPES.key?(codec)
24
+
25
+ codec
26
+ end
27
+
28
+ def self.profile!(protocol, mode, encoding, server: false)
29
+ valid = server ? valid_server_protocol?(protocol) : %i[connect grpc].include?(protocol)
30
+ return if valid &&
31
+ mode == :unary && %i[identity gzip].include?(encoding)
32
+
33
+ raise ArgumentError, "Unsupported protocol, compatibility mode or encoding"
34
+ end
35
+
36
+ def self.valid_server_protocol?(protocol)
37
+ return true if %i[connect grpc grpc_web auto].include?(protocol)
38
+
39
+ protocol.is_a?(Array) && !protocol.empty? && protocol.uniq.size == protocol.size &&
40
+ protocol.all? { |value| %i[connect grpc grpc_web].include?(value) }
41
+ end
42
+
43
+ def self.server_protocols(protocol)
44
+ defaults = %i[connect grpc] #: Array[server_protocol]
45
+ return defaults.freeze if protocol == :auto
46
+
47
+ selected = protocol #: server_protocol | Array[server_protocol]
48
+ (selected.is_a?(Array) ? selected.dup : [selected]).freeze
49
+ end
50
+
51
+ def self.content_codec(value)
52
+ return :proto if value&.match?(%r{\Aapplication/proto\z}i)
53
+ return :json if value&.match?(%r{\Aapplication/json(?:\s*;\s*charset=(?:utf-8|"utf-8"))?\z}i)
54
+
55
+ nil
56
+ end
57
+
58
+ def self.headers(values)
59
+ result = {} #: headers
60
+ values.each_with_object(result) { |(key, entries), result| (result[key.downcase] ||= []).concat(Array(entries)) }
61
+ end
62
+
63
+ def self.single(headers, key)
64
+ entries = headers[key]
65
+ entries&.size == 1 ? entries.first : nil
66
+ end
67
+
68
+ def self.identity_encoding?(headers)
69
+ !headers.key?("content-encoding") || single(headers, "content-encoding")&.casecmp?("identity")
70
+ end
71
+
72
+ def self.content_encoding(headers)
73
+ return :identity unless headers.key?("content-encoding")
74
+
75
+ value = single(headers, "content-encoding")
76
+ return :identity if value&.casecmp?("identity")
77
+ return :gzip if value&.casecmp?("gzip")
78
+
79
+ nil
80
+ end
81
+
82
+ def self.accept_encoding!(value, encoding)
83
+ # @type var defaults: Array[encoding]
84
+ defaults = [encoding, :identity]
85
+ return defaults.uniq if value.nil?
86
+ unless value.is_a?(Array) && !value.empty? && value.uniq.size == value.size &&
87
+ value.all? { |entry| %i[identity gzip].include?(entry) }
88
+ raise ArgumentError, "Response encodings must be distinct identity/gzip symbols"
89
+ end
90
+
91
+ identity = [:identity] #: Array[encoding]
92
+ (value - identity) + identity
93
+ end
94
+
95
+ def self.response_encoding(headers, request_encoding, enabled)
96
+ return :identity unless enabled == :gzip
97
+ return request_encoding unless headers.key?("accept-encoding")
98
+
99
+ tokens = headers.fetch("accept-encoding").flat_map { |entry| entry.split(",") }
100
+ tokens.any? { |token| token.strip.casecmp?("gzip") } ? :gzip : :identity
101
+ end
102
+
103
+ def self.metadata(headers, trailers: false)
104
+ headers.each_with_object(Metadata.new) do |(key, entries), result|
105
+ if trailers
106
+ next unless key.start_with?("trailer-")
107
+
108
+ key = key.delete_prefix("trailer-")
109
+ end
110
+ next if Metadata.reserved?(key)
111
+
112
+ result[key] = entries
113
+ rescue ArgumentError
114
+ # HTTP permits names and values outside Connect's metadata contract.
115
+ # Omit the whole field while retaining adjacent valid metadata.
116
+ next
117
+ end
118
+ end
119
+
120
+ def self.check_size!(bytes, limit)
121
+ raise Error.new(:resource_exhausted, "Message exceeds byte limit") if bytes.bytesize > limit
122
+ end
123
+
124
+ def self.content_length!(entries, invalid_code:)
125
+ return unless entries
126
+ unless entries.size == 1 && /\A[0-9]{1,20}\z/.match?(entries.first)
127
+ raise Error.new(invalid_code, "Invalid Content-Length")
128
+ end
129
+
130
+ entries.first.to_i
131
+ end
132
+
133
+ def self.error_body(error, limit) # rubocop:disable Metrics/AbcSize
134
+ body = JSON.generate(error.to_h)
135
+ if body.bytesize <= limit
136
+ yield error, error.code if block_given?
137
+ return [body, error.http_status]
138
+ end
139
+
140
+ error = Error.new(:resource_exhausted, "Error exceeds byte limit")
141
+ body = JSON.generate(error.to_h)
142
+ emitted = body.bytesize <= limit
143
+ yield error, (error.code if emitted) if block_given?
144
+ [emitted ? body : "", error.http_status]
145
+ end
146
+
147
+ def self.parse_error(status, headers, body)
148
+ values = { metadata: metadata(headers), trailers: metadata(headers, trailers: true) }
149
+ if content_codec(single(headers, "content-type")) == :json
150
+ wire = wire_error(body, fallback: Error::HTTP_FALLBACK.fetch(status, "unknown"))
151
+ return Error.new(wire.fetch(:code), wire.fetch(:message), details: wire.fetch(:details), **values) if wire
152
+ end
153
+ Error.new(Error::HTTP_FALLBACK.fetch(status, "unknown"), "HTTP #{status}", **values)
154
+ end
155
+
156
+ def self.wire_error(body, fallback:)
157
+ # JSON.parse is the external data boundary; validate before assembling wire_error.
158
+ object = JSON.parse(body)
159
+ return unless valid_wire_error?(object)
160
+
161
+ code = Error::HTTP_STATUS.key?(object["code"]) ? object["code"] : fallback
162
+ # @type var details: Array[ErrorDetail]
163
+ details = []
164
+ { code: code, message: object.fetch("message", ""), details: wire_details(object.fetch("details", details)) }
165
+ rescue JSON::ParserError, ArgumentError, TypeError, EncodingError
166
+ # Invalid Connect error bodies use the protocol's HTTP fallback table.
167
+ nil
168
+ end
169
+
170
+ def self.valid_wire_error?(object)
171
+ return false unless object.is_a?(Hash)
172
+
173
+ code = object["code"]
174
+ message = object.fetch("message", "")
175
+ (code.nil? || (code.is_a?(String) && code.valid_encoding?)) &&
176
+ message.is_a?(String) && message.valid_encoding?
177
+ end
178
+
179
+ def self.wire_details(details)
180
+ raise ArgumentError unless details.is_a?(Array)
181
+
182
+ details.map do |detail|
183
+ raise ArgumentError unless detail.is_a?(Hash)
184
+
185
+ ErrorDetail.new(type: detail["type"], value: Metadata.decode_binary(detail["value"]))
186
+ end
187
+ end
188
+ end
189
+ end