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.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +39 -0
- data/gen/loams-descriptor.binpb +0 -0
- data/lib/loams/client.rb +71 -0
- data/lib/loams/codec.rb +78 -0
- data/lib/loams/descriptors.rb +320 -0
- data/lib/loams/envelopes.rb +53 -0
- data/lib/loams/errors.rb +512 -0
- data/lib/loams/idempotency.rb +62 -0
- data/lib/loams/invoker.rb +218 -0
- data/lib/loams/pagination.rb +84 -0
- data/lib/loams/retry.rb +38 -0
- data/lib/loams/streams.rb +134 -0
- data/lib/loams/token_source.rb +27 -0
- data/lib/loams/transport.rb +83 -0
- data/lib/loams/version.rb +5 -0
- data/lib/loams/wire.rb +110 -0
- data/lib/loams.rb +22 -0
- metadata +82 -0
|
@@ -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
|
data/lib/loams/retry.rb
ADDED
|
@@ -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
|
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
|