loams 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.
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "loams/codec"
4
+ require "loams/envelopes"
5
+ require "loams/errors"
6
+ require "loams/idempotency"
7
+ require "loams/retry"
8
+ require "loams/token_source"
9
+ require "loams/streams"
10
+
11
+ module Loams
12
+ class CallInvoker
13
+ attr_reader :transport, :protocol, :codec, :token_source, :retry_policy
14
+
15
+ def initialize(
16
+ transport:,
17
+ protocol: :connect,
18
+ codec: :proto,
19
+ token_source: nil,
20
+ retry_policy: nil
21
+ )
22
+ @transport = transport
23
+ @protocol = protocol
24
+ @codec = codec
25
+ @token_source = token_source.is_a?(TokenSource) ? token_source : (token_source ? TokenSource.new(token_source) : nil)
26
+ @retry_policy = retry_policy || RetryPolicy.new
27
+ end
28
+
29
+ def unary(binding, request, options = {})
30
+ # Decide idempotency key
31
+ mint = options.fetch(:mint_idempotency_key, true)
32
+ key = Idempotency.apply(binding, request, options[:idempotency_key], mint: mint)
33
+
34
+ attempt = 0
35
+ refreshed_token = false
36
+
37
+ loop do
38
+ attempt += 1
39
+
40
+ # Build headers
41
+ ct = ContentTypes.content_type_for(@protocol, @codec, streaming: false)
42
+ headers = { "content-type" => ct }
43
+
44
+ if (tok = current_token)
45
+ headers["authorization"] = "Bearer #{tok}"
46
+ end
47
+
48
+ if key && !key.empty?
49
+ headers["idempotency-key"] = key
50
+ end
51
+
52
+ if options[:headers]
53
+ options[:headers].each { |k, v| headers[k.to_s.downcase] = v.to_s }
54
+ end
55
+
56
+ # Serialize body
57
+ body = MessageCodec.serialize(binding.request, request, @codec)
58
+ if @protocol == :grpc_web
59
+ body = Envelopes.wrap(body, Envelopes::FLAG_DATA)
60
+ end
61
+
62
+ req = Request.new(
63
+ method: "POST",
64
+ path: "/#{binding.rpc}",
65
+ headers: headers,
66
+ body: body,
67
+ rpc: binding.rpc,
68
+ )
69
+
70
+ res = begin
71
+ @transport.call_unary(req)
72
+ rescue StandardError => e
73
+ raise ErrorParser.build_error(:unknown, e.message, nil, nil, binding.rpc)
74
+ end
75
+
76
+ # Handle gRPC-Web trailers in headers
77
+ if @protocol == :grpc_web && (res.headers["grpc-status"] || res.headers["Grpc-Status"])
78
+ status_num = (res.headers["grpc-status"] || res.headers["Grpc-Status"]).to_i
79
+ if status_num != 0
80
+ err = ErrorParser.parse_grpc_trailers(res.headers, res.status, binding.rpc)
81
+ if can_refresh_and_retry?(err, refreshed_token)
82
+ refreshed_token = true
83
+ next
84
+ end
85
+ if can_retry?(err, binding, request, options, attempt)
86
+ sleep(@retry_policy.backoff_duration(attempt))
87
+ next
88
+ end
89
+ raise err
90
+ end
91
+ end
92
+
93
+ # Non-200 HTTP response
94
+ if res.status != 200
95
+ err = ErrorParser.parse_connect_error(res.body, res.status, binding.rpc)
96
+
97
+ if can_refresh_and_retry?(err, refreshed_token)
98
+ refreshed_token = true
99
+ next
100
+ end
101
+
102
+ if can_retry?(err, binding, request, options, attempt)
103
+ sleep(@retry_policy.backoff_duration(attempt))
104
+ next
105
+ end
106
+
107
+ raise err
108
+ end
109
+
110
+ # Check gRPC-Web response payload
111
+ res_body = res.body
112
+ if @protocol == :grpc_web
113
+ frames = Envelopes.split(res_body)
114
+ trailer_frame = frames.find(&:trailers?)
115
+ if trailer_frame
116
+ headers = {}
117
+ trailer_frame.payload.each_line do |line|
118
+ if line.include?(":")
119
+ k, v = line.split(":", 2)
120
+ headers[k.strip.downcase] = v.strip
121
+ end
122
+ end
123
+ status_num = (headers["grpc-status"] || headers["Grpc-Status"]).to_i
124
+ if status_num != 0
125
+ err = ErrorParser.parse_grpc_trailers(headers, res.status, binding.rpc)
126
+ if can_refresh_and_retry?(err, refreshed_token)
127
+ refreshed_token = true
128
+ next
129
+ end
130
+ if can_retry?(err, binding, request, options, attempt)
131
+ sleep(@retry_policy.backoff_duration(attempt))
132
+ next
133
+ end
134
+ raise err
135
+ end
136
+ end
137
+
138
+ data_frame = frames.find(&:data?)
139
+ res_body = data_frame ? data_frame.payload : ""
140
+ end
141
+
142
+ return MessageCodec.deserialize(binding.response, res_body, @codec)
143
+ end
144
+ end
145
+
146
+ def server_stream(binding, request, options = {}, resume_policy = nil)
147
+ ct = ContentTypes.content_type_for(@protocol, @codec, streaming: true)
148
+
149
+ stream_proc = proc do |cursor|
150
+ if cursor && !cursor.empty?
151
+ if request.respond_to?(:resume_cursor=)
152
+ request.resume_cursor = cursor
153
+ elsif request.respond_to?(:cursor=)
154
+ request.cursor = cursor
155
+ elsif request.is_a?(Hash)
156
+ request[:resume_cursor] = cursor
157
+ end
158
+ end
159
+
160
+ headers = { "content-type" => ct }
161
+ if (tok = current_token)
162
+ headers["authorization"] = "Bearer #{tok}"
163
+ end
164
+ if options[:headers]
165
+ options[:headers].each { |k, v| headers[k.to_s.downcase] = v.to_s }
166
+ end
167
+
168
+ body = MessageCodec.serialize(binding.request, request, @codec)
169
+ body = Envelopes.wrap(body, Envelopes::FLAG_DATA)
170
+
171
+ req = Request.new(
172
+ method: "POST",
173
+ path: "/#{binding.rpc}",
174
+ headers: headers,
175
+ body: body,
176
+ rpc: binding.rpc,
177
+ )
178
+
179
+ res = @transport.call_unary(req)
180
+ if res.status != 200
181
+ raise ErrorParser.parse_connect_error(res.body, res.status, binding.rpc)
182
+ end
183
+
184
+ Envelopes.split(res.body)
185
+ end
186
+
187
+ initial_frames = stream_proc.call(nil)
188
+ handle = ServerStreamHandle.new(initial_frames, &stream_proc)
189
+ handle.instance_variable_set(:@decode_proc, proc { |payload| MessageCodec.deserialize(binding.response, payload, @codec) })
190
+ handle
191
+ end
192
+
193
+ private
194
+
195
+ def current_token(force_refresh: false)
196
+ @token_source&.token(force_refresh: force_refresh)
197
+ end
198
+
199
+ def can_refresh_and_retry?(err, already_refreshed)
200
+ return false if already_refreshed || @token_source.nil?
201
+
202
+ if err.code == :unauthenticated || err.reason == :token_expired || err.http_status == 401
203
+ @token_source.refresh!
204
+ true
205
+ else
206
+ false
207
+ end
208
+ end
209
+
210
+ def can_retry?(err, binding, request, options, attempt)
211
+ max = options[:max_retries] || @retry_policy.max_retries
212
+ return false if attempt >= max + 1
213
+ return false unless err.retryable?
214
+
215
+ @retry_policy.retryable_call?(binding, request, options)
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Loams
4
+ class PageIterator
5
+ include Enumerable
6
+
7
+ attr_reader :items_field, :next_page_token_field, :requested_tokens, :offered_tokens, :stopped_on_repeated_token
8
+
9
+ def initialize(fetch:, items_field:, next_page_token_field:)
10
+ @fetch = fetch
11
+ @items_field = items_field
12
+ @next_page_token_field = next_page_token_field
13
+ @requested_tokens = []
14
+ @offered_tokens = []
15
+ @stopped_on_repeated_token = false
16
+
17
+ validate_fields!
18
+ end
19
+
20
+ def each
21
+ return to_enum(:each) unless block_given?
22
+
23
+ started = false
24
+ next_token = ""
25
+ seen_tokens = []
26
+
27
+ loop do
28
+ token = started ? next_token : ""
29
+ if started && (token.nil? || token.empty?)
30
+ break
31
+ end
32
+
33
+ if started && seen_tokens.include?(token)
34
+ @stopped_on_repeated_token = true
35
+ break
36
+ end
37
+
38
+ @requested_tokens << token
39
+ seen_tokens << token if started
40
+ @offered_tokens << token if started
41
+
42
+ page = @fetch.call(token)
43
+ started = true
44
+
45
+ # Extract items
46
+ items = extract_field(page, @items_field) || []
47
+ items.each { |item| yield item }
48
+
49
+ # Extract next_page_token
50
+ next_token = extract_field(page, @next_page_token_field).to_s
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def validate_fields!
57
+ # Validate items_field and next_page_token_field
58
+ if @items_field.respond_to?(:label) && @items_field.label != :repeated
59
+ raise ArgumentError, "#{@items_field.name} is not a repeated field"
60
+ end
61
+
62
+ if @items_field.respond_to?(:containing_type) && @next_page_token_field.respond_to?(:containing_type)
63
+ if @items_field.containing_type != @next_page_token_field.containing_type
64
+ raise ArgumentError, "fields declared on different messages"
65
+ end
66
+ end
67
+ end
68
+
69
+ def extract_field(message, field)
70
+ return nil if message.nil?
71
+
72
+ name = field.respond_to?(:name) ? field.name : field.to_s
73
+ json_name = field.respond_to?(:json_name) ? field.json_name : nil
74
+
75
+ if message.respond_to?(name)
76
+ message.public_send(name)
77
+ elsif json_name && message.respond_to?(json_name)
78
+ message.public_send(json_name)
79
+ elsif message.is_a?(Hash)
80
+ message[name] || (json_name ? message[json_name] : nil) || message[name.to_sym]
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Loams
4
+ class RetryPolicy
5
+ attr_reader :max_retries, :initial_backoff_ms, :max_backoff_ms
6
+
7
+ def initialize(max_retries: 3, initial_backoff_ms: 10, max_backoff_ms: 500)
8
+ @max_retries = max_retries
9
+ @initial_backoff_ms = initial_backoff_ms
10
+ @max_backoff_ms = max_backoff_ms
11
+ end
12
+
13
+ def retryable_call?(binding, request, options)
14
+ # If caller explicitly set retry policy
15
+ return false if options[:max_retries] == 0 || @max_retries == 0
16
+
17
+ # Check if this call is safe to retry
18
+ if binding.retry == :safe || binding.idempotency != :none
19
+ true
20
+ elsif binding.takes_idempotency_key?
21
+ # A mutation is ONLY retried if it carries an idempotency key!
22
+ key = if request.respond_to?(:idempotency_key)
23
+ request.idempotency_key
24
+ elsif request.is_a?(Hash)
25
+ request[:idempotency_key] || request["idempotency_key"]
26
+ end
27
+ !key.nil? && !key.empty?
28
+ else
29
+ false
30
+ end
31
+ end
32
+
33
+ def backoff_duration(attempt)
34
+ delay_ms = [@initial_backoff_ms * (2**(attempt - 1)), @max_backoff_ms].min
35
+ delay_ms / 1000.0
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "loams/envelopes"
4
+ require "loams/codec"
5
+
6
+ module Loams
7
+ class ServerStreamHandle
8
+ attr_reader :frame_kinds, :heartbeats, :reconnects
9
+
10
+ def initialize(initial_iterator, &reconnect_proc)
11
+ @initial_iterator = initial_iterator
12
+ @reconnect_proc = reconnect_proc
13
+ @frame_kinds = []
14
+ @heartbeats = 0
15
+ @reconnects = 0
16
+ end
17
+
18
+ def messages
19
+ return to_enum(:messages) unless block_given?
20
+
21
+ last_cursor = nil
22
+ current_enum = @initial_iterator
23
+
24
+ loop do
25
+ begin
26
+ current_enum.each do |frame|
27
+ if frame.end_stream?
28
+ # Connect end frame: contains error if status != 0
29
+ handle_end_frame(frame.payload)
30
+ return
31
+ elsif frame.trailers?
32
+ # gRPC-Web trailers
33
+ handle_trailers_frame(frame.payload)
34
+ return
35
+ end
36
+
37
+ # Data frame
38
+ msg = decode_message(frame.payload)
39
+ kind = detect_kind(msg)
40
+ @frame_kinds << kind
41
+
42
+ cursor = detect_cursor(msg)
43
+ last_cursor = cursor if cursor && !cursor.empty?
44
+
45
+ if kind == "heartbeat"
46
+ @heartbeats += 1
47
+ next
48
+ end
49
+
50
+ yield msg
51
+ end
52
+ return
53
+ rescue LoamsError => e
54
+ if @reconnect_proc && e.retryable?
55
+ @reconnects += 1
56
+ current_enum = @reconnect_proc.call(last_cursor)
57
+ else
58
+ raise e
59
+ end
60
+ rescue StandardError => e
61
+ if @reconnect_proc
62
+ @reconnects += 1
63
+ current_enum = @reconnect_proc.call(last_cursor)
64
+ else
65
+ raise e
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ def count_frame(kind)
72
+ @frame_kinds << kind
73
+ end
74
+
75
+ def count_heartbeat
76
+ @heartbeats += 1
77
+ end
78
+
79
+ def record_reconnect
80
+ @reconnects += 1
81
+ end
82
+
83
+ private
84
+
85
+ def decode_message(payload)
86
+ @decode_proc ? @decode_proc.call(payload) : payload
87
+ end
88
+
89
+ def detect_kind(msg)
90
+ return "data" unless msg
91
+
92
+ h = msg.respond_to?(:to_h) ? msg.to_h : (msg.is_a?(Hash) ? msg : {})
93
+ [:snapshot, :heartbeat, :upsert, :remove, :update, :delete].each do |k|
94
+ val = h[k] || h[k.to_s]
95
+ return k.to_s if !val.nil?
96
+ end
97
+ "data"
98
+ end
99
+
100
+ def detect_cursor(msg)
101
+ return nil unless msg
102
+
103
+ h = msg.respond_to?(:to_h) ? msg.to_h : (msg.is_a?(Hash) ? msg : {})
104
+ c = h[:cursor] || h["cursor"] || h[:resume_token] || h["resume_token"]
105
+ c.to_s.empty? ? nil : c.to_s
106
+ end
107
+
108
+ def handle_end_frame(payload)
109
+ return if payload.nil? || payload.empty?
110
+
111
+ parsed = JSON.parse(payload) rescue nil
112
+ if parsed && parsed["error"]
113
+ raise ErrorParser.parse_connect_error(payload, 200)
114
+ end
115
+ end
116
+
117
+ def handle_trailers_frame(payload)
118
+ return if payload.nil? || payload.empty?
119
+
120
+ headers = {}
121
+ payload.each_line do |line|
122
+ if line.include?(":")
123
+ k, v = line.split(":", 2)
124
+ headers[k.strip.downcase] = v.strip
125
+ end
126
+ end
127
+
128
+ status = headers["grpc-status"]
129
+ if status && status.to_i != 0
130
+ raise ErrorParser.parse_grpc_trailers(headers)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Loams
4
+ class TokenSource
5
+ def initialize(token_or_callable = nil, &block)
6
+ @callable = block || (token_or_callable.respond_to?(:call) ? token_or_callable : nil)
7
+ @static_token = @callable ? nil : token_or_callable.to_s
8
+ @cached_token = nil
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def token(force_refresh: false)
13
+ return @static_token if @static_token
14
+
15
+ @mutex.synchronize do
16
+ if @cached_token.nil? || force_refresh
17
+ @cached_token = @callable.call.to_s
18
+ end
19
+ @cached_token
20
+ end
21
+ end
22
+
23
+ def refresh!
24
+ token(force_refresh: true)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Loams
7
+ Request = Struct.new(:method, :path, :headers, :body, :rpc, keyword_init: true)
8
+ Response = Struct.new(:status, :headers, :body, :stream, keyword_init: true)
9
+
10
+ class HttpTransport
11
+ def initialize(endpoint)
12
+ @endpoint = endpoint.to_s
13
+ # Trailing slashes trimmed by index: `sub(%r{/+$}, "")` backtracks
14
+ # quadratically on a long run of slashes (CodeQL rb/polynomial-redos).
15
+ cut = @endpoint.length
16
+ cut -= 1 while cut.positive? && @endpoint[cut - 1] == "/"
17
+ @endpoint = @endpoint[0, cut]
18
+ @uri = URI.parse(@endpoint)
19
+ end
20
+
21
+ def call_unary(request)
22
+ url = URI.join(@endpoint + "/", request.path.sub(%r{^/+}, ""))
23
+ http = Net::HTTP.new(url.host, url.port)
24
+ http.use_ssl = (url.scheme == "https")
25
+ http.read_timeout = 30
26
+ http.open_timeout = 5
27
+
28
+ req = Net::HTTPGenericRequest.new(
29
+ request.method || "POST",
30
+ true,
31
+ true,
32
+ url.request_uri,
33
+ request.headers || {}
34
+ )
35
+ req.body = request.body if request.body
36
+
37
+ res = http.request(req)
38
+
39
+ headers = {}
40
+ res.each_header { |k, v| headers[k.downcase] = v }
41
+
42
+ Response.new(
43
+ status: res.code.to_i,
44
+ headers: headers,
45
+ body: res.body,
46
+ )
47
+ end
48
+
49
+ def call_stream(request, &block)
50
+ url = URI.join(@endpoint + "/", request.path.sub(%r{^/+}, ""))
51
+ http = Net::HTTP.new(url.host, url.port)
52
+ http.use_ssl = (url.scheme == "https")
53
+ http.read_timeout = 60
54
+ http.open_timeout = 5
55
+
56
+ req = Net::HTTPGenericRequest.new(
57
+ request.method || "POST",
58
+ true,
59
+ true,
60
+ url.request_uri,
61
+ request.headers || {}
62
+ )
63
+ req.body = request.body if request.body
64
+
65
+ if block_given?
66
+ http.request(req) do |res|
67
+ headers = {}
68
+ res.each_header { |k, v| headers[k.downcase] = v }
69
+ yield res.code.to_i, headers, res
70
+ end
71
+ else
72
+ res = http.request(req)
73
+ headers = {}
74
+ res.each_header { |k, v| headers[k.downcase] = v }
75
+ Response.new(
76
+ status: res.code.to_i,
77
+ headers: headers,
78
+ body: res.body,
79
+ )
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Loams
4
+ VERSION = "0.1.0"
5
+ end
data/lib/loams/wire.rb ADDED
@@ -0,0 +1,110 @@
1
+ # The protobuf wire format, read and written by hand.
2
+ #
3
+ # Two things need it and neither is the message codec: the **custom
4
+ # options** the protos carry (the `loams.options.v1.module` and
5
+ # `loams.options.v1.facade` annotations, which `buf build` writes as
6
+ # extension fields on `ServiceOptions` and `MethodOptions` and which the
7
+ # descriptor set therefore carries as unknown fields), and the **Connect
8
+ # and gRPC-Web frames** a stream is made of. Both are length-delimited
9
+ # fields with a varint tag, so one reader serves them.
10
+ #
11
+ # The reader is deliberately strict: a tag that names a wire type it
12
+ # cannot skip is a refusal, not a guess, because a mis-read byte here is
13
+ # a module name invented out of nothing.
14
+ module Loams
15
+ module Wire
16
+ # A varint read from +bytes+ at +pos+. Returns [value, next_pos].
17
+ def self.read_varint(bytes, pos)
18
+ value = 0
19
+ shift = 0
20
+ loop do
21
+ b = bytes.getbyte(pos)
22
+ raise ArgumentError, "truncated varint at #{pos}" if b.nil?
23
+ pos += 1
24
+ value |= (b & 0x7f) << shift
25
+ break if (b & 0x80).zero?
26
+ shift += 7
27
+ raise ArgumentError, "varint too long at #{pos}" if shift > 63
28
+ end
29
+ [value, pos]
30
+ end
31
+
32
+ # A varint written to +io+.
33
+ def self.write_varint(io, value)
34
+ until value < 0x80
35
+ io.write(((value & 0x7f) | 0x80).chr)
36
+ value >>= 7
37
+ end
38
+ io.write(value.chr)
39
+ end
40
+
41
+ # The tag for a field: the field number in the high bits, the wire
42
+ # type in the low three.
43
+ def self.tag(field_number, wire_type)
44
+ (field_number << 3) | wire_type
45
+ end
46
+
47
+ # Every field in +bytes+, as [field_number, wire_type, value] where
48
+ # value is an Integer for a varint and a String for a length-delimited
49
+ # field. Unknown wire types (1, 5 — fixed64, fixed32) are skipped by
50
+ # length only when the caller can say how long they are, which it
51
+ # cannot from a tag alone, so they are refused.
52
+ def self.each_field(bytes)
53
+ return to_enum(:each_field, bytes) unless block_given?
54
+ pos = 0
55
+ while pos < bytes.bytesize
56
+ tag, pos = read_varint(bytes, pos)
57
+ field = tag >> 3
58
+ wire_type = tag & 7
59
+ case wire_type
60
+ when 0
61
+ value, pos = read_varint(bytes, pos)
62
+ yield field, wire_type, value
63
+ when 2
64
+ len, pos = read_varint(bytes, pos)
65
+ raise ArgumentError, "length #{len} overruns #{bytes.bytesize} at #{pos}" if pos + len > bytes.bytesize
66
+ value = bytes.byteslice(pos, len)
67
+ pos += len
68
+ yield field, wire_type, value
69
+ when 1
70
+ raise ArgumentError, "fixed64 field #{field} cannot be skipped" if pos + 8 > bytes.bytesize
71
+ pos += 8
72
+ when 5
73
+ raise ArgumentError, "fixed32 field #{field} cannot be skipped" if pos + 4 > bytes.bytesize
74
+ pos += 4
75
+ else
76
+ raise ArgumentError, "wire type #{wire_type} on field #{field} is a group, which this corpus does not carry"
77
+ end
78
+ end
79
+ end
80
+
81
+ # The first length-delimited field +field_number+ in +bytes+, or nil.
82
+ def self.find_bytes(bytes, field_number)
83
+ each_field(bytes) do |field, wire_type, value|
84
+ return value if field == field_number && wire_type == 2
85
+ end
86
+ nil
87
+ end
88
+
89
+ # The first varint field +field_number+ in +bytes+, or nil.
90
+ def self.find_varint(bytes, field_number)
91
+ each_field(bytes) do |field, wire_type, value|
92
+ return value if field == field_number && wire_type == 0
93
+ end
94
+ nil
95
+ end
96
+
97
+ # A length-delimited field written to +io+.
98
+ def self.write_bytes(io, field_number, payload)
99
+ write_varint(io, tag(field_number, 2))
100
+ write_varint(io, payload.bytesize)
101
+ io.write(payload)
102
+ end
103
+
104
+ # A varint field written to +io+.
105
+ def self.write_varint_field(io, field_number, value)
106
+ write_varint(io, tag(field_number, 0))
107
+ write_varint(io, value)
108
+ end
109
+ end
110
+ end