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 +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: []
|