connect-ruby 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []