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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +501 -0
- data/docs/design.md +504 -0
- data/docs/development.md +566 -0
- data/docs/grpc-web.md +165 -0
- data/docs/grpc.md +75 -0
- data/docs/rbs.md +469 -0
- data/docs/streaming.md +251 -0
- data/exe/protoc-gen-connectrpc-ruby +23 -0
- data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
- data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
- data/lib/connectrpc/cancellation.rb +220 -0
- data/lib/connectrpc/client.rb +260 -0
- data/lib/connectrpc/compression.rb +120 -0
- data/lib/connectrpc/deadline.rb +81 -0
- data/lib/connectrpc/error.rb +72 -0
- data/lib/connectrpc/generator/names.rb +105 -0
- data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
- data/lib/connectrpc/generator/renderer.rb +115 -0
- data/lib/connectrpc/generator/runtime_names.rb +84 -0
- data/lib/connectrpc/generator/schema.rb +218 -0
- data/lib/connectrpc/generator/streaming_methods.rb +29 -0
- data/lib/connectrpc/generator.rb +42 -0
- data/lib/connectrpc/get_query.rb +159 -0
- data/lib/connectrpc/grpc.rb +200 -0
- data/lib/connectrpc/grpc_client.rb +23 -0
- data/lib/connectrpc/grpc_server.rb +158 -0
- data/lib/connectrpc/grpc_web.rb +131 -0
- data/lib/connectrpc/grpc_web_cors.rb +135 -0
- data/lib/connectrpc/grpc_web_io.rb +156 -0
- data/lib/connectrpc/grpc_web_server.rb +207 -0
- data/lib/connectrpc/instrumentation.rb +216 -0
- data/lib/connectrpc/message_io.rb +47 -0
- data/lib/connectrpc/metadata.rb +105 -0
- data/lib/connectrpc/method.rb +68 -0
- data/lib/connectrpc/protocol.rb +189 -0
- data/lib/connectrpc/server.rb +398 -0
- data/lib/connectrpc/streaming/client.rb +104 -0
- data/lib/connectrpc/streaming/client_execution.rb +87 -0
- data/lib/connectrpc/streaming/client_scope.rb +105 -0
- data/lib/connectrpc/streaming/framing.rb +123 -0
- data/lib/connectrpc/streaming/framing_io.rb +61 -0
- data/lib/connectrpc/streaming/method.rb +33 -0
- data/lib/connectrpc/streaming/server.rb +122 -0
- data/lib/connectrpc/streaming/server_execution.rb +88 -0
- data/lib/connectrpc/streaming/server_terminal.rb +70 -0
- data/lib/connectrpc/streaming/session.rb +143 -0
- data/lib/connectrpc/streaming/session_completion.rb +134 -0
- data/lib/connectrpc/streaming/session_io.rb +107 -0
- data/lib/connectrpc/streaming/session_messages.rb +119 -0
- data/lib/connectrpc/streaming/strategy.rb +95 -0
- data/lib/connectrpc/streaming/streams.rb +116 -0
- data/lib/connectrpc/streaming/terminal.rb +52 -0
- data/lib/connectrpc/transports/net_http.rb +246 -0
- data/lib/connectrpc/version.rb +5 -0
- data/lib/connectrpc.rb +38 -0
- data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
- data/sig/connectrpc.rbs +772 -0
- data/sig/manifest.yaml +2 -0
- data/third_party/README.md +16 -0
- data/third_party/google/LICENSE +201 -0
- data/third_party/google/rpc/status.proto +48 -0
- data/third_party/protobuf-LICENSE +32 -0
- 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
|