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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Streaming
|
|
5
|
+
class ClientScope
|
|
6
|
+
attr_accessor :completed, :abandoned, :independent_error, :failure, :result, :delivered_error
|
|
7
|
+
|
|
8
|
+
def initialize(lifecycle)
|
|
9
|
+
@lifecycle = lifecycle
|
|
10
|
+
@completed = false
|
|
11
|
+
@abandoned = false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(timeout, cancellation, &)
|
|
15
|
+
# @type var error: Exception?
|
|
16
|
+
error = nil
|
|
17
|
+
execute(timeout, cancellation, &)
|
|
18
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- Finalization needs the actual escape, including fatal exceptions.
|
|
19
|
+
error = e
|
|
20
|
+
raise
|
|
21
|
+
ensure
|
|
22
|
+
finish_preserving_user(error)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def execute(timeout, cancellation)
|
|
26
|
+
active, deadline = start(timeout, cancellation)
|
|
27
|
+
yield active, deadline
|
|
28
|
+
active.commit! if @completed
|
|
29
|
+
@result
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
@lifecycle&.record_failure(e)
|
|
32
|
+
@state&.commit! unless @independent_error || @abandoned
|
|
33
|
+
raise
|
|
34
|
+
end
|
|
35
|
+
private :execute
|
|
36
|
+
|
|
37
|
+
def cleanup(session)
|
|
38
|
+
return if @completed || @delivered_error
|
|
39
|
+
|
|
40
|
+
@abandoned = true if implicit_abandonment?
|
|
41
|
+
session.abandon unless session.abandoned?
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@lifecycle&.record_failure(e)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record_user_error(error)
|
|
47
|
+
@independent_error = error
|
|
48
|
+
@lifecycle&.record_failure(error)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def finish_preserving_user(error)
|
|
54
|
+
finish(@independent_error || error)
|
|
55
|
+
ensure
|
|
56
|
+
original = @independent_error
|
|
57
|
+
raise original, cause: original.cause if original
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def finish(error)
|
|
61
|
+
@state&.close
|
|
62
|
+
lifecycle = @lifecycle
|
|
63
|
+
return unless lifecycle
|
|
64
|
+
|
|
65
|
+
lifecycle.record_failure(error) if error
|
|
66
|
+
report_processing(lifecycle, error)
|
|
67
|
+
lifecycle.finish(delivery: delivery_completed?(error) ? :completed : :aborted)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def start(timeout, cancellation)
|
|
71
|
+
deadline = Deadline.new(timeout) unless timeout.nil?
|
|
72
|
+
active = @state = CallCancellation.new(deadline: deadline, cancellation: cancellation)
|
|
73
|
+
active.check!
|
|
74
|
+
[active, deadline]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def implicit_abandonment?
|
|
78
|
+
!@independent_error && (@failure.nil? || @failure.is_a?(StreamNotConsumedError))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def success?(error) = @completed && error.nil?
|
|
82
|
+
|
|
83
|
+
def delivery_completed?(error)
|
|
84
|
+
return true if success?(error)
|
|
85
|
+
|
|
86
|
+
delivered = @delivered_error
|
|
87
|
+
return false unless delivered
|
|
88
|
+
|
|
89
|
+
delivered.equal?(error)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def report_processing(lifecycle, error)
|
|
93
|
+
normalized = normalized_error(error)
|
|
94
|
+
lifecycle.processing_finished(code: normalized&.code || ("OK" if success?(error)),
|
|
95
|
+
error: error, normalized_error: normalized)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalized_error(error)
|
|
99
|
+
return Error.new(:canceled, "Stream abandoned") if @abandoned
|
|
100
|
+
|
|
101
|
+
error if error.is_a?(Error)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Streaming
|
|
5
|
+
# @type var types: Hash[codec, String]
|
|
6
|
+
types = { proto: "application/connect+proto", json: "application/connect+json" }
|
|
7
|
+
CONTENT_TYPES = types.freeze
|
|
8
|
+
TERMINAL_LIMIT = 65_536
|
|
9
|
+
FRAGMENT_SIZE = 16_384
|
|
10
|
+
RECOVERY_LIMIT = 262_144
|
|
11
|
+
RECOVERY_SECONDS = 0.1
|
|
12
|
+
TAIL_SECONDS = 1.0
|
|
13
|
+
|
|
14
|
+
def self.codec(content_type)
|
|
15
|
+
return :proto if content_type&.match?(%r{\Aapplication/connect\+proto\z}i)
|
|
16
|
+
return :json if content_type&.match?(%r{\Aapplication/connect\+json\z}i)
|
|
17
|
+
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Streaming content coding uses distinct protocol headers; reuse only the
|
|
22
|
+
# validated unary negotiation rules, never whole-stream compression.
|
|
23
|
+
def self.coding_headers(headers)
|
|
24
|
+
result = headers.except("content-encoding", "accept-encoding")
|
|
25
|
+
result["content-encoding"] = headers["connect-content-encoding"] if headers.key?("connect-content-encoding")
|
|
26
|
+
result["accept-encoding"] = headers["connect-accept-encoding"] if headers.key?("connect-accept-encoding")
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Framing
|
|
31
|
+
attr_reader :messages, :terminal
|
|
32
|
+
attr_writer :encoding
|
|
33
|
+
|
|
34
|
+
def initialize(input:, request:, wire_limit:, limit:, output: nil, encoding: :identity,
|
|
35
|
+
terminal_wire_limit: TERMINAL_LIMIT, terminal_limit: TERMINAL_LIMIT, state: nil, on_error: nil)
|
|
36
|
+
@input = input
|
|
37
|
+
@output = output
|
|
38
|
+
@request = request
|
|
39
|
+
@wire_limit = wire_limit
|
|
40
|
+
@limit = limit
|
|
41
|
+
@encoding = encoding
|
|
42
|
+
@terminal_wire_limit = terminal_wire_limit
|
|
43
|
+
@terminal_limit = terminal_limit
|
|
44
|
+
@on_error = on_error
|
|
45
|
+
initialize_state(state)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def read_frame(timeout: nil, skip_data: false)
|
|
49
|
+
prefix = exact(5, eof: true, timeout: timeout)
|
|
50
|
+
return if prefix.nil?
|
|
51
|
+
|
|
52
|
+
flags, length = prefix.unpack("CN") #: [Integer, Integer]
|
|
53
|
+
validate_envelope(flags, length)
|
|
54
|
+
bytes = payload(flags, length, timeout: timeout, skip_data: skip_data)
|
|
55
|
+
@terminal = true if flags.anybits?(2)
|
|
56
|
+
@messages += 1 unless @terminal
|
|
57
|
+
[flags, bytes]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def emit(bytes, terminal: false)
|
|
61
|
+
flags, bytes = prepare_payload(bytes, terminal)
|
|
62
|
+
@state&.check!
|
|
63
|
+
output = @output #: _StreamingWriter
|
|
64
|
+
output.write([flags, bytes.bytesize].pack("CN"))
|
|
65
|
+
emit_fragments(bytes, output)
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def initialize_state(state)
|
|
72
|
+
@state = state
|
|
73
|
+
@messages = 0
|
|
74
|
+
@terminal = false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_envelope(flags, length)
|
|
78
|
+
validate_flags(flags)
|
|
79
|
+
bound = flags.anybits?(2) ? @terminal_wire_limit : @wire_limit
|
|
80
|
+
raise Error.new(:resource_exhausted, "Envelope exceeds wire byte limit"), cause: nil if length > bound
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_flags(flags)
|
|
84
|
+
protocol_error("Invalid envelope flags") unless [0, 1, 2, 3].include?(flags)
|
|
85
|
+
protocol_error("Request terminal is forbidden") if flags.anybits?(2) && @request
|
|
86
|
+
protocol_error("Envelope after terminal") if @terminal
|
|
87
|
+
raise Error.new(:internal, "Unnegotiated compression"), cause: nil if flags.anybits?(1) && @encoding != :gzip
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def payload(flags, length, timeout:, skip_data:)
|
|
91
|
+
return skip_payload(length, timeout) if skip_data && !flags.anybits?(2)
|
|
92
|
+
|
|
93
|
+
bytes = exact(length, timeout: timeout) #: String
|
|
94
|
+
bound = flags.anybits?(2) ? @terminal_limit : @limit
|
|
95
|
+
bytes = inflate(bytes, bound) if flags.anybits?(1)
|
|
96
|
+
Protocol.check_size!(bytes, bound)
|
|
97
|
+
bytes
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def inflate(bytes, bound)
|
|
101
|
+
protocol_error("Empty compressed envelope") if bytes.empty?
|
|
102
|
+
Compression.decode(bytes, limit: bound, deadline: @state, on_error: @on_error)
|
|
103
|
+
rescue Compression::Malformed, Zlib::Error => e
|
|
104
|
+
@on_error&.call(e)
|
|
105
|
+
protocol_error("Malformed compressed envelope")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def skip_payload(length, timeout)
|
|
109
|
+
remaining = length
|
|
110
|
+
while remaining.positive?
|
|
111
|
+
chunk = exact([remaining, FRAGMENT_SIZE].min, timeout: timeout) #: String
|
|
112
|
+
remaining -= chunk.bytesize
|
|
113
|
+
end
|
|
114
|
+
""
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def protocol_error(message)
|
|
118
|
+
raise Error.new(@request ? :invalid_argument : :internal, message), cause: nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_constant :Streaming
|
|
123
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Streaming
|
|
5
|
+
class Framing
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def prepare_payload(bytes, terminal)
|
|
9
|
+
bound = terminal ? @terminal_wire_limit : @wire_limit
|
|
10
|
+
Protocol.check_size!(bytes, terminal ? @terminal_limit : @limit)
|
|
11
|
+
flags = terminal ? 2 : 0
|
|
12
|
+
if !terminal && @encoding == :gzip
|
|
13
|
+
bytes = Compression.encode(bytes, limit: bound, deadline: @state, on_error: @on_error)
|
|
14
|
+
flags = 1
|
|
15
|
+
end
|
|
16
|
+
check_output_size(bytes, bound)
|
|
17
|
+
[flags, bytes]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def check_output_size(bytes, bound)
|
|
21
|
+
Protocol.check_size!(bytes, bound)
|
|
22
|
+
return if bytes.bytesize <= 0xffff_ffff
|
|
23
|
+
|
|
24
|
+
raise Error.new(:resource_exhausted, "Envelope exceeds uint32 length"), cause: nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def emit_fragments(bytes, output)
|
|
28
|
+
offset = 0
|
|
29
|
+
while offset < bytes.bytesize
|
|
30
|
+
@state&.check!
|
|
31
|
+
fragment = bytes.byteslice(offset, FRAGMENT_SIZE) #: String
|
|
32
|
+
output.write(fragment)
|
|
33
|
+
offset += fragment.bytesize
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def exact(length, eof: false, timeout: nil)
|
|
38
|
+
bytes = +"".b
|
|
39
|
+
while bytes.bytesize < length
|
|
40
|
+
chunk = read_chunk(length - bytes.bytesize, timeout)
|
|
41
|
+
if chunk.nil? || chunk.empty?
|
|
42
|
+
return if eof && bytes.empty?
|
|
43
|
+
|
|
44
|
+
protocol_error("Truncated envelope")
|
|
45
|
+
end
|
|
46
|
+
bytes << chunk
|
|
47
|
+
end
|
|
48
|
+
bytes
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_chunk(length, timeout)
|
|
52
|
+
@state&.check!
|
|
53
|
+
input = @input #: _StreamingInput
|
|
54
|
+
chunk = input.read([length, FRAGMENT_SIZE].min, timeout: timeout)
|
|
55
|
+
@state&.check!
|
|
56
|
+
protocol_error("Input exceeded requested fragment") if chunk && chunk.bytesize > length
|
|
57
|
+
chunk
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
# Streaming declarations share validation, but are never unary Methods.
|
|
5
|
+
class StreamDeclaration
|
|
6
|
+
attr_reader :service, :name, :request_type, :response_type, :path, :idempotency
|
|
7
|
+
|
|
8
|
+
def initialize(service:, name:, request_type:, response_type:, idempotency: :unknown)
|
|
9
|
+
declaration = Method.new(service: service, name: name, request_type: request_type,
|
|
10
|
+
response_type: response_type, idempotency: idempotency)
|
|
11
|
+
@service = declaration.service
|
|
12
|
+
@name = declaration.name
|
|
13
|
+
@request_type = declaration.request_type
|
|
14
|
+
@response_type = declaration.response_type
|
|
15
|
+
@path = declaration.path
|
|
16
|
+
@idempotency = declaration.idempotency
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
private_constant :StreamDeclaration
|
|
21
|
+
|
|
22
|
+
class ServerStreamMethod < StreamDeclaration
|
|
23
|
+
def cardinality = :server_stream
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class ClientStreamMethod < StreamDeclaration
|
|
27
|
+
def cardinality = :client_stream
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class BidiStreamMethod < StreamDeclaration
|
|
31
|
+
def cardinality = :bidi_stream
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Server
|
|
5
|
+
# Explicit native-host SPI. Portable adapters retain the unary entry point.
|
|
6
|
+
def call_stream(path:, http_method:, headers:, input:, output:, publish:, lifecycle: nil, cancellation: nil, # rubocop:disable Metrics/MethodLength
|
|
7
|
+
http2: false, capabilities: nil)
|
|
8
|
+
capabilities = stream_capabilities(http2, capabilities)
|
|
9
|
+
unless capabilities
|
|
10
|
+
return call(path: path, http_method: http_method, headers: headers, input: input,
|
|
11
|
+
lifecycle: lifecycle, cancellation: cancellation)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
if @methods[path].is_a?(BidiStreamMethod) && !capabilities.include?(:bidi_stream)
|
|
15
|
+
return bare(505, {}, cancellation: cancellation)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return bare(415, {}, cancellation: cancellation) unless @protocol.include?(:connect)
|
|
19
|
+
|
|
20
|
+
values = Protocol.headers(headers)
|
|
21
|
+
codec = Streaming.codec(Protocol.single(values, "content-type"))
|
|
22
|
+
rejection = stream_preflight(http_method, codec, cancellation)
|
|
23
|
+
return rejection if rejection || !codec
|
|
24
|
+
|
|
25
|
+
execute_native_stream(path, values, codec, http_method: http_method,
|
|
26
|
+
input: input, output: output, publish: publish,
|
|
27
|
+
lifecycle: lifecycle, cancellation: cancellation,
|
|
28
|
+
capabilities: capabilities)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def stream_capabilities(http2, capabilities)
|
|
34
|
+
# @type var shapes: Array[streaming_cardinality]
|
|
35
|
+
shapes = %i[server_stream client_stream bidi_stream]
|
|
36
|
+
return (http2 ? shapes : nil) if capabilities.nil?
|
|
37
|
+
|
|
38
|
+
raise ArgumentError, "Invalid streaming capabilities" if http2 || !stream_capability_list?(capabilities)
|
|
39
|
+
|
|
40
|
+
capabilities
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stream_capability_list?(capabilities)
|
|
44
|
+
# @type var shapes: Array[streaming_cardinality]
|
|
45
|
+
shapes = %i[server_stream client_stream bidi_stream]
|
|
46
|
+
capabilities.is_a?(Array) && !capabilities.empty? && capabilities.uniq == capabilities &&
|
|
47
|
+
(capabilities - shapes).empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stream_preflight(http_method, codec, cancellation)
|
|
51
|
+
return bare(405, { "allow" => ["POST"] }, cancellation: cancellation) unless http_method == "POST"
|
|
52
|
+
return if codec
|
|
53
|
+
|
|
54
|
+
bare(415, { "accept-post" => [Streaming::CONTENT_TYPES.values.join(", ")] }, cancellation: cancellation)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute_native_stream(path, headers, codec, http_method:, input:, output:, publish:, lifecycle:,
|
|
58
|
+
cancellation:, capabilities:) # rubocop:disable Metrics/MethodLength
|
|
59
|
+
# @type var owned: bool?
|
|
60
|
+
owned = nil
|
|
61
|
+
# @type var error: Exception?
|
|
62
|
+
error = nil
|
|
63
|
+
lifecycle, owned = begin_native_processing(path, headers, lifecycle, http_method)
|
|
64
|
+
work = lambda {
|
|
65
|
+
process_stream(path, headers, codec, input: input, output: output, publish: publish,
|
|
66
|
+
lifecycle: lifecycle, cancellation: cancellation,
|
|
67
|
+
capabilities: capabilities)
|
|
68
|
+
}
|
|
69
|
+
lifecycle ? lifecycle.activate(&work) : work.call
|
|
70
|
+
nil
|
|
71
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- observe the actual escape and preserve it
|
|
72
|
+
error = e
|
|
73
|
+
raise
|
|
74
|
+
ensure
|
|
75
|
+
finish_native_processing(lifecycle, owned, error) unless owned.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def begin_native_processing(path, headers, lifecycle, http_method)
|
|
79
|
+
owned = lifecycle.nil?
|
|
80
|
+
lifecycle ||= start_call(path: path, headers: headers, http_method: http_method)
|
|
81
|
+
lifecycle&.begin_processing(self, path)
|
|
82
|
+
[lifecycle, owned]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def finish_native_processing(lifecycle, owned, error)
|
|
86
|
+
return unless lifecycle
|
|
87
|
+
|
|
88
|
+
lifecycle.processing_finished(code: nil, error: error) unless lifecycle.outcome
|
|
89
|
+
lifecycle.finish(delivery: :unobserved) if owned
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def process_stream(path, headers, codec, input:, output:, publish:, lifecycle:, cancellation:, capabilities:)
|
|
93
|
+
# @type var state: Streaming::ServerState?
|
|
94
|
+
active = state = Streaming::ServerState.new(headers: headers, codec: codec, publish: publish,
|
|
95
|
+
lifecycle: lifecycle, cancellation: cancellation)
|
|
96
|
+
capture_stream_error(active) do
|
|
97
|
+
require_stream_capability(path, capabilities)
|
|
98
|
+
execute_stream_handler(active, path, input, output)
|
|
99
|
+
end
|
|
100
|
+
render_stream_terminal(active, output)
|
|
101
|
+
ensure
|
|
102
|
+
state&.session&.close
|
|
103
|
+
state&.context&.close
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def require_stream_capability(path, capabilities)
|
|
107
|
+
declaration = @methods[path]
|
|
108
|
+
return if !declaration || declaration.is_a?(Method) || capabilities.include?(declaration.cardinality)
|
|
109
|
+
|
|
110
|
+
raise Error.new(:unimplemented, "Unsupported streaming method"), cause: nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def capture_stream_error(state)
|
|
114
|
+
yield
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
state.context&.record_failure(e)
|
|
117
|
+
state.lifecycle&.record_failure(e)
|
|
118
|
+
safe = e.is_a?(Error) ? e : Error.new(:internal, "Internal error")
|
|
119
|
+
state.error = terminal_error(safe, state.context, state.cancellation)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
module Streaming
|
|
5
|
+
# Mutable state is local to one original processing task, never the Server.
|
|
6
|
+
class ServerState
|
|
7
|
+
attr_reader :headers, :codec, :publish, :lifecycle, :cancellation
|
|
8
|
+
attr_accessor :context, :session, :encoding, :error
|
|
9
|
+
|
|
10
|
+
def initialize(headers:, codec:, publish:, lifecycle:, cancellation:)
|
|
11
|
+
@headers = headers
|
|
12
|
+
@codec = codec
|
|
13
|
+
@publish = publish
|
|
14
|
+
@lifecycle = lifecycle
|
|
15
|
+
@cancellation = cancellation
|
|
16
|
+
@encoding = :identity
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Server
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def execute_stream_handler(state, path, input, output)
|
|
25
|
+
state.encoding = stream_encoding(state.headers)
|
|
26
|
+
context = state.context = streaming_context(state)
|
|
27
|
+
declaration = streaming_declaration(path)
|
|
28
|
+
session = state.session = new_stream_session(state, declaration, input, output)
|
|
29
|
+
invoke_stream_handler(@handlers.fetch(path), declaration, session, context)
|
|
30
|
+
context.check!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stream_encoding(headers)
|
|
34
|
+
normalized = Streaming.coding_headers(headers)
|
|
35
|
+
validate_headers!(normalized)
|
|
36
|
+
request_encoding = Protocol.content_encoding(normalized) #: encoding
|
|
37
|
+
Protocol.response_encoding(normalized, request_encoding, @encoding)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def streaming_declaration(path)
|
|
41
|
+
declaration = @methods[path]
|
|
42
|
+
return declaration if declaration && !declaration.is_a?(Method) && @handlers[path]
|
|
43
|
+
|
|
44
|
+
raise Error.new(:unimplemented, "Unknown streaming method"), cause: nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def new_stream_session(state, declaration, input, output)
|
|
48
|
+
context = state.context #: StreamingContext
|
|
49
|
+
coding = Protocol.content_encoding(Streaming.coding_headers(state.headers)) #: encoding
|
|
50
|
+
Streaming::Session.new(binding: @binding, method: declaration, codec: state.codec, state: context,
|
|
51
|
+
input: input, output: output, request_limit: @request_limit,
|
|
52
|
+
response_limit: @response_limit, request_wire_limit: @request_wire_limit,
|
|
53
|
+
response_wire_limit: @response_wire_limit,
|
|
54
|
+
request_encoding: coding,
|
|
55
|
+
response_encoding: state.encoding, terminal_wire_limit: @terminal_wire_limit,
|
|
56
|
+
terminal_limit: @terminal_limit, server: true, context: state.context,
|
|
57
|
+
lifecycle: state.lifecycle)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def invoke_stream_handler(handler, declaration, session, context) # rubocop:disable Metrics/MethodLength
|
|
61
|
+
context.check!
|
|
62
|
+
case declaration
|
|
63
|
+
when ServerStreamMethod
|
|
64
|
+
ss = handler #: _ServerStreamHandler[untyped, untyped]
|
|
65
|
+
request = single_stream_request(session)
|
|
66
|
+
context.check!
|
|
67
|
+
ss.call(request, ResponseWriter.new(session, context), context)
|
|
68
|
+
when ClientStreamMethod
|
|
69
|
+
cs = handler #: _ClientStreamHandler[untyped, untyped]
|
|
70
|
+
session.send_message(cs.call(RequestReader.new(session), context))
|
|
71
|
+
when BidiStreamMethod
|
|
72
|
+
invoke_bidi_stream(handler, session, context)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def invoke_bidi_stream(handler, session, context)
|
|
77
|
+
bidi = handler #: _BidiStreamHandler[untyped, untyped]
|
|
78
|
+
bidi.call(ServerDuplex.new(session, context), context)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def single_stream_request(session)
|
|
82
|
+
request = session.receive
|
|
83
|
+
return request if !request.nil? && session.receive.nil?
|
|
84
|
+
|
|
85
|
+
raise Error.new(:unimplemented, "Expected exactly one request"), cause: nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Server
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def render_stream_terminal(state, output)
|
|
8
|
+
context = state.context ||= streaming_context(state, headers: {})
|
|
9
|
+
merge_stream_error_metadata(state.error, context)
|
|
10
|
+
bytes, effective = stream_terminal_bytes(state, context)
|
|
11
|
+
handler_error = state.error
|
|
12
|
+
# A peer reset may reject terminal delivery. Retain the handler failure
|
|
13
|
+
# independently of that later delivery error.
|
|
14
|
+
observe_stream_result(state, effective) if handler_error
|
|
15
|
+
context.send(:publish_headers, check: false)
|
|
16
|
+
emit_stream_terminal(bytes, output)
|
|
17
|
+
context.commit! unless handler_error
|
|
18
|
+
observe_stream_result(state, effective) unless handler_error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stream_terminal_bytes(state, context)
|
|
22
|
+
trailers = stream_trailers(state.error, context)
|
|
23
|
+
Streaming.terminal_bytes(state.error, trailers, wire_limit: @terminal_wire_limit, limit: @terminal_limit)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def merge_stream_error_metadata(error, context)
|
|
27
|
+
return unless error && !context.response_metadata.frozen?
|
|
28
|
+
|
|
29
|
+
error.metadata.each do |key, values|
|
|
30
|
+
context.response_metadata[key] = (context.response_metadata[key] || []) + values
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stream_trailers(error, context)
|
|
35
|
+
trailers = Metadata.new(context.response_trailers)
|
|
36
|
+
error&.trailers&.each { |key, values| trailers[key] = (trailers[key] || []) + values }
|
|
37
|
+
context.response_trailers.freeze
|
|
38
|
+
trailers
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def emit_stream_terminal(bytes, output)
|
|
42
|
+
framing = Streaming::Framing.new(input: nil, output: output, request: false,
|
|
43
|
+
limit: @response_limit, wire_limit: @response_wire_limit,
|
|
44
|
+
terminal_wire_limit: @terminal_wire_limit, terminal_limit: @terminal_limit)
|
|
45
|
+
framing.emit(bytes, terminal: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def observe_stream_result(state, effective)
|
|
49
|
+
context = state.context #: StreamingContext
|
|
50
|
+
state.lifecycle&.processing_finished(code: effective ? effective.code : "OK", error: state.error,
|
|
51
|
+
normalized_error: effective, http_status: 200,
|
|
52
|
+
response_metadata: Instrumentation.carrier(context.response_metadata))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def streaming_context(state, headers: state.headers)
|
|
56
|
+
metadata = Protocol.metadata(headers)
|
|
57
|
+
state.lifecycle.request_metadata = Instrumentation.carrier(metadata) if state.lifecycle
|
|
58
|
+
leading = ->(snapshot) { state.publish.call(stream_head(snapshot, state.codec, state.encoding)) }
|
|
59
|
+
StreamingContext.new(publish: leading, request_metadata: metadata, deadline: request_deadline(headers),
|
|
60
|
+
cancellation: state.cancellation, lifecycle: state.lifecycle)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stream_head(metadata, codec, encoding)
|
|
64
|
+
values = metadata.to_h.merge("content-type" => [Streaming::CONTENT_TYPES.fetch(codec)])
|
|
65
|
+
values["connect-content-encoding"] = ["gzip"] if encoding == :gzip
|
|
66
|
+
values["connect-accept-encoding"] = ["gzip, identity"] if @encoding == :gzip
|
|
67
|
+
StreamHead.new(status: 200, headers: values)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|