connect-ruby 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 86231e5f8dc50ff0230816ebc5e21b1d3dd6edddb646e85117f62621501dda83
4
+ data.tar.gz: 31e30adf092732f7e667bb936a3bcad172be1d107a89cb0261adb4ebe962f94c
5
+ SHA512:
6
+ metadata.gz: f75f16cb3bf278b6b8ccbd4f2ecf2c7464cc77b75b55bae48180bc441d272ed87d8e0e12054fc8460a621fa1c65153700c3fa9deeee449ceab24a54949ad6bc8
7
+ data.tar.gz: f2f201416fb3ecd402bbc516901259e12daa577162909ca07529371da7be48dc6f68614767beac931c901c138220f14cf1e158dbf06519eb2a1bc509c707f299
data/Gemfile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in connect.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.12"
11
+
12
+ gem "vcr", "~> 6.2", require: false
13
+
14
+ gem "webmock", "~> 3.18", require: false
15
+
16
+ gem "rubocop", "~> 1.54", require: false
17
+ gem "rubocop-rake", "~> 0.6.0", require: false
18
+ gem "rubocop-rspec", "~> 2.22", require: false
19
+ gem "rubocop-shopify", "~> 2.14", require: false
20
+
21
+ gem "simplecov", "~> 0.22.0", require: false
data/Gemfile.lock ADDED
@@ -0,0 +1,101 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ connect-ruby (0.1.0)
5
+ google-protobuf (~> 3.23)
6
+ zeitwerk (~> 2.6)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.4)
12
+ public_suffix (>= 2.0.2, < 6.0)
13
+ ast (2.4.2)
14
+ crack (0.4.5)
15
+ rexml
16
+ diff-lcs (1.5.0)
17
+ docile (1.4.0)
18
+ google-protobuf (3.23.4-arm64-darwin)
19
+ hashdiff (1.0.1)
20
+ json (2.6.3)
21
+ language_server-protocol (3.17.0.3)
22
+ parallel (1.23.0)
23
+ parser (3.2.2.3)
24
+ ast (~> 2.4.1)
25
+ racc
26
+ public_suffix (5.0.3)
27
+ racc (1.7.1)
28
+ rainbow (3.1.1)
29
+ rake (13.0.6)
30
+ regexp_parser (2.8.1)
31
+ rexml (3.2.5)
32
+ rspec (3.12.0)
33
+ rspec-core (~> 3.12.0)
34
+ rspec-expectations (~> 3.12.0)
35
+ rspec-mocks (~> 3.12.0)
36
+ rspec-core (3.12.2)
37
+ rspec-support (~> 3.12.0)
38
+ rspec-expectations (3.12.3)
39
+ diff-lcs (>= 1.2.0, < 2.0)
40
+ rspec-support (~> 3.12.0)
41
+ rspec-mocks (3.12.6)
42
+ diff-lcs (>= 1.2.0, < 2.0)
43
+ rspec-support (~> 3.12.0)
44
+ rspec-support (3.12.1)
45
+ rubocop (1.54.2)
46
+ json (~> 2.3)
47
+ language_server-protocol (>= 3.17.0)
48
+ parallel (~> 1.10)
49
+ parser (>= 3.2.2.3)
50
+ rainbow (>= 2.2.2, < 4.0)
51
+ regexp_parser (>= 1.8, < 3.0)
52
+ rexml (>= 3.2.5, < 4.0)
53
+ rubocop-ast (>= 1.28.0, < 2.0)
54
+ ruby-progressbar (~> 1.7)
55
+ unicode-display_width (>= 2.4.0, < 3.0)
56
+ rubocop-ast (1.29.0)
57
+ parser (>= 3.2.1.0)
58
+ rubocop-capybara (2.18.0)
59
+ rubocop (~> 1.41)
60
+ rubocop-factory_bot (2.23.1)
61
+ rubocop (~> 1.33)
62
+ rubocop-rake (0.6.0)
63
+ rubocop (~> 1.0)
64
+ rubocop-rspec (2.22.0)
65
+ rubocop (~> 1.33)
66
+ rubocop-capybara (~> 2.17)
67
+ rubocop-factory_bot (~> 2.22)
68
+ rubocop-shopify (2.14.0)
69
+ rubocop (~> 1.51)
70
+ ruby-progressbar (1.13.0)
71
+ simplecov (0.22.0)
72
+ docile (~> 1.1)
73
+ simplecov-html (~> 0.11)
74
+ simplecov_json_formatter (~> 0.1)
75
+ simplecov-html (0.12.3)
76
+ simplecov_json_formatter (0.1.4)
77
+ unicode-display_width (2.4.2)
78
+ vcr (6.2.0)
79
+ webmock (3.18.1)
80
+ addressable (>= 2.8.0)
81
+ crack (>= 0.3.2)
82
+ hashdiff (>= 0.4.0, < 2.0.0)
83
+ zeitwerk (2.6.8)
84
+
85
+ PLATFORMS
86
+ arm64-darwin-22
87
+
88
+ DEPENDENCIES
89
+ connect-ruby!
90
+ rake (~> 13.0)
91
+ rspec (~> 3.12)
92
+ rubocop (~> 1.54)
93
+ rubocop-rake (~> 0.6.0)
94
+ rubocop-rspec (~> 2.22)
95
+ rubocop-shopify (~> 2.14)
96
+ simplecov (~> 0.22.0)
97
+ vcr (~> 6.2)
98
+ webmock (~> 3.18)
99
+
100
+ BUNDLED WITH
101
+ 2.4.13
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Client
5
+ extend DSL
6
+
7
+ class << self
8
+ def on_rpc_method_added(method)
9
+ define_method(method.ruby_method) do |input, header: {}, trailer: {}|
10
+ call(method: method, input: input, header: header, trailer: trailer)
11
+ end
12
+ end
13
+ end
14
+
15
+ attr_reader :transport
16
+
17
+ def initialize(transport:)
18
+ @transport = transport
19
+ end
20
+
21
+ def call(method:, input:, header: {}, trailer: {})
22
+ if method.unary?
23
+ transport.unary(service: service, method: method, input: input, header: header, trailer: trailer)
24
+ elsif method.stream?
25
+ transport.stream(service: service, method: method, input: input, header: header, trailer: trailer)
26
+ else
27
+ raise UnknownMethodError, "Unknown method type: #{method.class}"
28
+ end
29
+ end
30
+
31
+ def service
32
+ self.class.service
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Code
5
+ class << self
6
+ def from_http_code(code)
7
+ case code.to_i
8
+ when 200 then Ok
9
+ when 400 then InvalidArgument
10
+ when 401 then Unauthenticated
11
+ when 403 then PermissionDenied
12
+ when 404 then NotFound
13
+ when 408 then DeadlineExceeded
14
+ when 409 then Aborted
15
+ when 412 then FailedPrecondition
16
+ when 413 then ResourceExhausted
17
+ when 415 then Internal
18
+ when 429 then Unavailable
19
+ when 431 then ResourceExhausted
20
+ when 502, 503, 504 then Unavailable
21
+ else
22
+ Unknown
23
+ end
24
+ end
25
+
26
+ def from_name(name)
27
+ CODES_BY_NAME.fetch(name, Unknown)
28
+ end
29
+ end
30
+
31
+ attr_reader :name, :value
32
+
33
+ def initialize(name, value)
34
+ @name = name
35
+ @value = value
36
+ end
37
+
38
+ def ==(other)
39
+ other.is_a?(Code) && name == other.name && value == other.value
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class.name} name=#{name.inspect} value=#{value.inspect}>"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ module Compression
5
+ class Gzip
6
+ class << self
7
+ def name
8
+ "gzip"
9
+ end
10
+
11
+ def compress(source)
12
+ Zlib.gzip(source)
13
+ end
14
+
15
+ def decompress(source)
16
+ Zlib.gunzip(source)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ module DSL
5
+ def service
6
+ @service
7
+ end
8
+
9
+ def service=(service)
10
+ @service = service
11
+ end
12
+
13
+ def rpc(name, request, response, ruby_method: nil)
14
+ method = Method.new(
15
+ name: name.to_sym,
16
+ request_type: wrap_message_type(request),
17
+ response_type: wrap_message_type(response),
18
+ ruby_method: ruby_method&.to_sym || underscore(name).to_sym,
19
+ )
20
+ rpcs[name] = method
21
+
22
+ on_rpc_method_added(method)
23
+ end
24
+
25
+ def rpcs
26
+ @rpcs ||= {}
27
+ end
28
+
29
+ def on_rpc_method_added(method)
30
+ end
31
+
32
+ private
33
+
34
+ def wrap_message_type(klass)
35
+ case klass
36
+ when Method::Unary
37
+ klass
38
+ when Method::Stream
39
+ klass
40
+ else
41
+ unary(klass)
42
+ end
43
+ end
44
+
45
+ def unary(klass)
46
+ Method::Unary.new(klass)
47
+ end
48
+
49
+ def stream(klass)
50
+ Method::Stream.new(klass)
51
+ end
52
+
53
+ def underscore(string)
54
+ s = string.to_s.dup
55
+ s.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
56
+ s.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
57
+ s.tr!("-", "_")
58
+ s.downcase!
59
+ s
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Envelope
5
+ COMPRESSED_FLAG = 0b00000001
6
+
7
+ class << self
8
+ def pack(source, compression: nil, compress_min_bytes: nil)
9
+ if compress_min_bytes.nil? || source.bytesize < compress_min_bytes || compression.nil?
10
+ [0, source.bytesize, source].pack("CNa*")
11
+ else
12
+ compressed = compression.compress(source)
13
+ [COMPRESSED_FLAG, compressed.bytesize, compressed].pack("CNa*")
14
+ end
15
+ end
16
+
17
+ def unpack(source, compression: nil)
18
+ flags, size = source.read(5).unpack("CN")
19
+
20
+ if flags & COMPRESSED_FLAG == 0
21
+ [flags, source.read(size)]
22
+ else
23
+ [flags, compression.decompress(source.read(size))]
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Error < StandardError
5
+ attr_reader :code, :message, :details, :metadata
6
+
7
+ def initialize(code:, message: nil, details: nil, metadata: nil)
8
+ super(message)
9
+
10
+ @code = code
11
+ @message = message
12
+ @details = details
13
+ @metadata = metadata
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Method
5
+ class Type
6
+ attr_reader :klass
7
+
8
+ def initialize(klass)
9
+ @klass = klass
10
+ end
11
+
12
+ def encode(input)
13
+ klass.encode(input)
14
+ end
15
+
16
+ def decode(input)
17
+ klass.decode(input)
18
+ end
19
+
20
+ def unary?
21
+ false
22
+ end
23
+
24
+ def stream?
25
+ false
26
+ end
27
+
28
+ def ==(other)
29
+ other.is_a?(Type) && klass == other.klass
30
+ end
31
+
32
+ def inspect
33
+ "#{self.class.name}<#{klass.inspect}>"
34
+ end
35
+ end
36
+
37
+ class Unary < Type
38
+ def unary?
39
+ true
40
+ end
41
+ end
42
+
43
+ class Stream < Type
44
+ def stream?
45
+ true
46
+ end
47
+ end
48
+
49
+ attr_reader :name, :request_type, :response_type, :ruby_method
50
+
51
+ def initialize(name:, request_type:, response_type:, ruby_method:)
52
+ @name = name
53
+ @request_type = request_type
54
+ @response_type = response_type
55
+ @ruby_method = ruby_method
56
+ end
57
+
58
+ def unary?
59
+ request_type.unary? && response_type.unary?
60
+ end
61
+
62
+ def stream?
63
+ request_type.stream? || response_type.stream?
64
+ end
65
+
66
+ def bidi_stream?
67
+ request_type.stream? && response_type.stream?
68
+ end
69
+
70
+ def encode_request(input, max_bytes:)
71
+ output = request_type.encode(input)
72
+ raise MaxBytesExceededError, "Request exceeded maximum size of #{max_bytes} bytes" if output.bytesize > max_bytes
73
+
74
+ output
75
+ end
76
+
77
+ def decode_response(input, max_bytes:)
78
+ raise MaxBytesExceededError, "Response exceeded maximum size of #{max_bytes} bytes" if input.bytesize > max_bytes
79
+
80
+ response_type.decode(input)
81
+ end
82
+
83
+ def ==(other)
84
+ other.is_a?(Method) &&
85
+ name == other.name &&
86
+ request_type == other.request_type &&
87
+ response_type == other.response_type &&
88
+ ruby_method == other.ruby_method
89
+ end
90
+
91
+ def inspect
92
+ "#<#{self.class.name} name=#{name.inspect} request_type=#{request_type.inspect} response_type=#{response_type.inspect} ruby_method=#{ruby_method.inspect}>" # rubocop:disable Layout/LineLength
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class RequestCompression
5
+ attr_reader :compression, :compress_min_bytes
6
+
7
+ def initialize(compression:, compress_min_bytes:)
8
+ @compression = compression
9
+ @compress_min_bytes = compress_min_bytes
10
+ end
11
+
12
+ def compress?(source)
13
+ source.bytesize >= compress_min_bytes
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Service
5
+ extend DSL
6
+
7
+ class << self
8
+ def on_rpc_method_added(method)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class StreamResponse
5
+ include Enumerable
6
+
7
+ END_OF_STREAM_FLAG = 0b00000010
8
+
9
+ attr_reader :header
10
+
11
+ def initialize(header:, enumerator:)
12
+ @header = header
13
+ @enumerator = enumerator
14
+ @trailer = nil
15
+ @error = nil
16
+ @stream_exhausted = false
17
+ end
18
+
19
+ def each(&block)
20
+ enumerator.each do |flags, message|
21
+ if flags & END_OF_STREAM_FLAG == 0
22
+ yield message
23
+ else
24
+ read_end_stream_message(message)
25
+ end
26
+ end
27
+
28
+ raise InvalidStreamResponseError, "Stream did not end with end stream message" unless stream_exhausted?
29
+ end
30
+
31
+ def trailer
32
+ raise StreamReadError, "Cannot read trailer before stream is exhausted" unless stream_exhausted?
33
+
34
+ @trailer
35
+ end
36
+
37
+ def error
38
+ raise StreamReadError, "Cannot read error before stream is exhausted" unless stream_exhausted?
39
+
40
+ @error
41
+ end
42
+
43
+ def stream_exhausted?
44
+ @stream_exhausted
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :enumerator
50
+
51
+ attr_writer :trailer, :error
52
+
53
+ def read_end_stream_message(message)
54
+ @stream_exhausted = true
55
+
56
+ @trailer = message["metadata"]
57
+ @error = message["error"]
58
+
59
+ raise Error.new(
60
+ code: Code.from_name(error["code"]),
61
+ message: error["message"],
62
+ details: error["details"],
63
+ metadata: error["metadata"],
64
+ ) if error
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class Transport
5
+ attr_reader :base_url,
6
+ :accept_compression,
7
+ :send_compression,
8
+ :compress_min_bytes,
9
+ :read_max_bytes,
10
+ :write_max_bytes
11
+
12
+ def initialize(
13
+ base_url:,
14
+ accept_compression: [Compression::Gzip],
15
+ send_compression: Compression::Gzip,
16
+ compress_min_bytes: 1024,
17
+ read_max_bytes: 0xffffffff,
18
+ write_max_bytes: 0xffffffff
19
+ )
20
+ @base_url = base_url
21
+ @accept_compression = accept_compression
22
+ @send_compression = send_compression
23
+ @compress_min_bytes = compress_min_bytes
24
+ @read_max_bytes = read_max_bytes
25
+ @write_max_bytes = write_max_bytes
26
+ end
27
+
28
+ def unary(service:, method:, input:, header:, trailer:)
29
+ uri = build_uri(service: service, method: method)
30
+
31
+ response = do_response(
32
+ uri: uri,
33
+ request: build_unary_request(uri: uri, method: method, input: input, header: header, trailer: trailer),
34
+ )
35
+
36
+ case response.code
37
+ when "200"
38
+ parse_unary_response(response: response, method: method)
39
+ else
40
+ raise parse_unary_error(response: response)
41
+ end
42
+ end
43
+
44
+ def stream(service:, method:, input:, header:, trailer:)
45
+ raise NotImplementedError, "Bidi streaming is not supported with HTTP/1.1" if method.bidi_stream?
46
+
47
+ uri = build_uri(service: service, method: method)
48
+
49
+ response = do_response(
50
+ uri: uri,
51
+ request: build_stream_request(uri: uri, method: method, input: input, header: header, trailer: trailer),
52
+ )
53
+
54
+ case response.code
55
+ when "200"
56
+ parse_stream_response(response: response, method: method)
57
+ else
58
+ raise Connect::Error.new(
59
+ code: Code.for_http_code(response.code),
60
+ message: "Unexpected HTTP status code: #{response.code}",
61
+ )
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def build_uri(service:, method:)
68
+ URI("#{base_url}/#{service}/#{method.name}")
69
+ end
70
+
71
+ def do_response(uri:, request:)
72
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
73
+ http.request(request)
74
+ end
75
+ end
76
+
77
+ def build_unary_request(uri:, method:, input:, header:, trailer:)
78
+ request = Net::HTTP::Post.new(uri)
79
+
80
+ request[CONNECT_HEADER_PROTOCOL_VERSION] = CONNECT_PROTOCOL_VERSION
81
+ request[CONNECT_HEADER_CONTENT_TYPE] = "application/proto"
82
+
83
+ request[CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION] = if accept_compression.any?
84
+ accept_compression.map(&:name).join(",")
85
+ else
86
+ CONNECT_COMPRESSION_IDENTITY
87
+ end
88
+
89
+ if send_compression
90
+ request_compression = RequestCompression.new(
91
+ compression: send_compression,
92
+ compress_min_bytes: compress_min_bytes,
93
+ )
94
+ end
95
+
96
+ message = method.encode_request(input, max_bytes: write_max_bytes)
97
+
98
+ if request_compression&.compress?(message)
99
+ request[CONNECT_UNARY_HEADER_COMPRESSION] = request_compression.compression.name
100
+ request.body = request_compression.compression.compress(message)
101
+ else
102
+ request.body = message
103
+ end
104
+
105
+ inject_header_and_trailer(request: request, header: header, trailer: trailer)
106
+ end
107
+
108
+ def parse_unary_response(response:, method:)
109
+ compression = parse_compression_from_header(response[CONNECT_UNARY_HEADER_COMPRESSION])
110
+
111
+ message = if compression
112
+ method.decode_response(compression.decompress(response.body), max_bytes: read_max_bytes)
113
+ else
114
+ method.decode_response(response.body, max_bytes: read_max_bytes)
115
+ end
116
+
117
+ header, trailer = parse_header_and_trailer(response: response)
118
+
119
+ UnaryResponse.new(header: header, message: message, trailer: trailer)
120
+ end
121
+
122
+ def parse_unary_error(response:)
123
+ message = JSON.parse(response.body)
124
+
125
+ Error.new(
126
+ code: message.fetch("code", Code.from_http_code(response.code)),
127
+ message: message["message"],
128
+ details: message["details"],
129
+ )
130
+ rescue JSON::ParserError
131
+ Error.new(code: Code.from_http_code(response.code))
132
+ end
133
+
134
+ def build_stream_request(uri:, method:, input:, header:, trailer:)
135
+ request = Net::HTTP::Post.new(uri)
136
+
137
+ request[CONNECT_HEADER_PROTOCOL_VERSION] = CONNECT_PROTOCOL_VERSION
138
+ request[CONNECT_HEADER_CONTENT_TYPE] = "application/connect+proto"
139
+
140
+ request[CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION] = CONNECT_COMPRESSION_IDENTITY
141
+
142
+ request[CONNECT_STREAM_HEADER_ACCEPT_COMPRESSION] = if accept_compression.any?
143
+ accept_compression.map(&:name).join(",")
144
+ else
145
+ CONNECT_COMPRESSION_IDENTITY
146
+ end
147
+
148
+ if send_compression
149
+ request_compression = RequestCompression.new(
150
+ compression: send_compression,
151
+ compress_min_bytes: compress_min_bytes,
152
+ )
153
+ request[CONNECT_STREAM_HEADER_COMPRESSION] = request_compression.compression.name
154
+ end
155
+
156
+ buffer = StringIO.new
157
+
158
+ enumerator = build_stream_request_enumerator(
159
+ method: method,
160
+ input: input.is_a?(Enumerable) ? input : [input],
161
+ request_compression: request_compression,
162
+ )
163
+
164
+ enumerator.each do |enveloped|
165
+ buffer << enveloped
166
+ end
167
+
168
+ buffer.rewind
169
+
170
+ request["transfer-encoding"] = "chunked"
171
+ request.body_stream = buffer
172
+
173
+ inject_header_and_trailer(request: request, header: header, trailer: trailer)
174
+ end
175
+
176
+ def parse_stream_response(response:, method:)
177
+ compression = parse_compression_from_header(response[CONNECT_STREAM_HEADER_COMPRESSION])
178
+
179
+ enumerator = build_stream_response_enumerator(
180
+ method: method,
181
+ buffer: StringIO.new(response.body),
182
+ compression: compression,
183
+ )
184
+
185
+ StreamResponse.new(header: response.each_header.to_h, enumerator: enumerator)
186
+ end
187
+
188
+ def build_stream_request_enumerator(method:, input:, request_compression:)
189
+ Enumerator.new do |yielder|
190
+ input.each do |message|
191
+ yielder << Envelope.pack(
192
+ method.encode_request(message, max_bytes: write_max_bytes),
193
+ compression: request_compression&.compression,
194
+ compress_min_bytes: request_compression&.compress_min_bytes,
195
+ )
196
+ end
197
+ end
198
+ end
199
+
200
+ def build_stream_response_enumerator(method:, buffer:, compression:)
201
+ Enumerator.new do |yielder|
202
+ until buffer.eof?
203
+ flags, data = Envelope.unpack(buffer, compression: compression)
204
+
205
+ yielder << if flags & 2 == 0
206
+ [flags, method.decode_response(data, max_bytes: read_max_bytes)]
207
+ else
208
+ [flags, parse_end_stream_message(data)]
209
+ end
210
+ end
211
+ end
212
+ end
213
+
214
+ def parse_end_stream_message(data)
215
+ JSON.parse(data)
216
+ rescue JSON::ParserError
217
+ raise InvalidStreamResponseError, "Invalid end stream message: #{data.inspect}"
218
+ end
219
+
220
+ def inject_header_and_trailer(request:, header:, trailer:)
221
+ header.each do |key, value|
222
+ request[key] = value
223
+ end
224
+
225
+ trailer.each do |key, value|
226
+ request["#{CONNECT_UNARY_TRAILER_PREFIX}#{key}"] = value
227
+ end
228
+
229
+ request
230
+ end
231
+
232
+ def parse_header_and_trailer(response:, header: {}, trailer: {})
233
+ response.each_header do |key, value|
234
+ if key.start_with?(CONNECT_UNARY_TRAILER_PREFIX)
235
+ trailer[key[CONNECT_UNARY_TRAILER_PREFIX.length..-1]] = value
236
+ else
237
+ header[key] = value
238
+ end
239
+ end
240
+
241
+ [header, trailer]
242
+ end
243
+
244
+ def parse_compression_from_header(header)
245
+ if header && header != CONNECT_COMPRESSION_IDENTITY
246
+ compression = accept_compression.find { |c| c.name == header }
247
+
248
+ unless compression
249
+ accepted = accept_compression.map(&:name)
250
+
251
+ raise UnknownCompressionError,
252
+ "Received unknown compression: #{header}. Supported encodings are: #{accepted.inspect}"
253
+ end
254
+
255
+ compression
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ class UnaryResponse
5
+ attr_reader :header, :message, :trailer
6
+
7
+ def initialize(header:, message:, trailer:)
8
+ @header = header
9
+ @message = message
10
+ @trailer = trailer
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Connect
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "connect"
data/lib/connect.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+ require "stringio"
6
+ require "net/http"
7
+ require "zlib"
8
+ require "google/protobuf"
9
+ require "zeitwerk"
10
+
11
+ loader = Zeitwerk::Loader.for_gem
12
+ loader.ignore("#{__dir__}/connect-ruby.rb")
13
+ loader.inflector.inflect(
14
+ "dsl" => "DSL",
15
+ )
16
+ loader.setup
17
+
18
+ module Connect
19
+ UnknownMethodError = Class.new(StandardError)
20
+ MaxBytesExceededError = Class.new(StandardError)
21
+ InvalidStreamResponseError = Class.new(StandardError)
22
+ StreamReadError = Class.new(StandardError)
23
+ UnknownCompressionError = Class.new(StandardError)
24
+
25
+ Ok = Code.new("ok", 0)
26
+ Canceled = Code.new("canceled", 1)
27
+ Unknown = Code.new("unknown", 2)
28
+ InvalidArgument = Code.new("invalid_argument", 3)
29
+ DeadlineExceeded = Code.new("deadline_exceeded", 4)
30
+ NotFound = Code.new("not_found", 5)
31
+ AlreadyExists = Code.new("already_exists", 6)
32
+ PermissionDenied = Code.new("permission_denied", 7)
33
+ ResourceExhausted = Code.new("resource_exhausted", 8)
34
+ FailedPrecondition = Code.new("failed_precondition", 9)
35
+ Aborted = Code.new("aborted", 10)
36
+ OutOfRange = Code.new("out_of_range", 11)
37
+ Unimplemented = Code.new("unimplemented", 12)
38
+ Internal = Code.new("internal", 13)
39
+ Unavailable = Code.new("unavailable", 14)
40
+ DataLoss = Code.new("data_loss", 15)
41
+ Unauthenticated = Code.new("unauthenticated", 16)
42
+
43
+ CODES = [
44
+ Ok,
45
+ Canceled,
46
+ Unknown,
47
+ InvalidArgument,
48
+ DeadlineExceeded,
49
+ NotFound,
50
+ AlreadyExists,
51
+ PermissionDenied,
52
+ ResourceExhausted,
53
+ FailedPrecondition,
54
+ Aborted,
55
+ OutOfRange,
56
+ Unimplemented,
57
+ Internal,
58
+ Unavailable,
59
+ DataLoss,
60
+ Unauthenticated,
61
+ ]
62
+
63
+ CODES_BY_NAME = {
64
+ "ok" => Ok,
65
+ "canceled" => Canceled,
66
+ "unknown" => Unknown,
67
+ "invalid_argument" => InvalidArgument,
68
+ "deadline_exceeded" => DeadlineExceeded,
69
+ "not_found" => NotFound,
70
+ "already_exists" => AlreadyExists,
71
+ "permission_denied" => PermissionDenied,
72
+ "resource_exhausted" => ResourceExhausted,
73
+ "failed_precondition" => FailedPrecondition,
74
+ "aborted" => Aborted,
75
+ "out_of_range" => OutOfRange,
76
+ "unimplemented" => Unimplemented,
77
+ "internal" => Internal,
78
+ "unavailable" => Unavailable,
79
+ "data_loss" => DataLoss,
80
+ "unauthenticated" => Unauthenticated,
81
+ }.freeze
82
+
83
+ CONNECT_HEADER_CONTENT_TYPE = "content-type"
84
+ CONNECT_UNARY_HEADER_COMPRESSION = "content-encoding"
85
+ CONNECT_UNARY_HEADER_ACCEPT_COMPRESSION = "accept-encoding"
86
+ CONNECT_STREAM_HEADER_COMPRESSION = "connect-content-encoding"
87
+ CONNECT_STREAM_HEADER_ACCEPT_COMPRESSION = "connect-accept-encoding"
88
+ CONNECT_UNARY_TRAILER_PREFIX = "trailer-"
89
+ CONNECT_UNARY_HEADER_TIMEOUT = "connect-timeout-ms"
90
+ CONNECT_HEADER_PROTOCOL_VERSION = "connect-protocol-version"
91
+ CONNECT_PROTOCOL_VERSION = "1"
92
+ CONNECT_COMPRESSION_IDENTITY = "identity"
93
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: connect-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Brave Hager
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-07-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: google-protobuf
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.23'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.23'
27
+ - !ruby/object:Gem::Dependency
28
+ name: zeitwerk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ description:
42
+ email:
43
+ - bravehager@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - Gemfile
49
+ - Gemfile.lock
50
+ - lib/connect-ruby.rb
51
+ - lib/connect.rb
52
+ - lib/connect/client.rb
53
+ - lib/connect/code.rb
54
+ - lib/connect/compression/gzip.rb
55
+ - lib/connect/dsl.rb
56
+ - lib/connect/envelope.rb
57
+ - lib/connect/error.rb
58
+ - lib/connect/method.rb
59
+ - lib/connect/request_compression.rb
60
+ - lib/connect/service.rb
61
+ - lib/connect/stream_response.rb
62
+ - lib/connect/transport.rb
63
+ - lib/connect/unary_response.rb
64
+ - lib/connect/version.rb
65
+ homepage: https://github.com/bravehager/connect-ruby
66
+ licenses: []
67
+ metadata:
68
+ allowed_push_host: https://rubygems.org
69
+ rubygems_mfa_required: 'true'
70
+ homepage_uri: https://github.com/bravehager/connect-ruby
71
+ source_code_uri: https://github.com/bravehager/connect-ruby
72
+ changelog_uri: https://github.com/bravehager/connect-ruby/releases
73
+ post_install_message:
74
+ rdoc_options: []
75
+ require_paths:
76
+ - lib
77
+ required_ruby_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 3.0.0
82
+ required_rubygems_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ requirements: []
88
+ rubygems_version: 3.4.13
89
+ signing_key:
90
+ specification_version: 4
91
+ summary: Idiomatic Connect RPCs for Ruby.
92
+ test_files: []