connect-ruby 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +21 -0
- data/Gemfile.lock +101 -0
- data/lib/connect/client.rb +35 -0
- data/lib/connect/code.rb +46 -0
- data/lib/connect/compression/gzip.rb +21 -0
- data/lib/connect/dsl.rb +62 -0
- data/lib/connect/envelope.rb +28 -0
- data/lib/connect/error.rb +16 -0
- data/lib/connect/method.rb +95 -0
- data/lib/connect/request_compression.rb +16 -0
- data/lib/connect/service.rb +12 -0
- data/lib/connect/stream_response.rb +67 -0
- data/lib/connect/transport.rb +259 -0
- data/lib/connect/unary_response.rb +13 -0
- data/lib/connect/version.rb +5 -0
- data/lib/connect-ruby.rb +3 -0
- data/lib/connect.rb +93 -0
- metadata +92 -0
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
|
data/lib/connect/code.rb
ADDED
@@ -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
|
data/lib/connect/dsl.rb
ADDED
@@ -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,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
|
data/lib/connect-ruby.rb
ADDED
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: []
|