message_bus-http_client 1.0.0.pre1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: da6aba476a01a5481d92971aab35612b332e3cb2187d5f5b633b7466202f219b
4
+ data.tar.gz: 5e8fd7f77341e8e156697a193816dd39fee11cd81e650a65c7bff071a911fb88
5
+ SHA512:
6
+ metadata.gz: e988e4d40e3cb571c7b3aa7c803465317f913d25bf8b06c30c76b6befde831a7bd06e9b4e8141d19f61a4a154a538b475a82ce3e550df90cca6464a5b3a69cc2
7
+ data.tar.gz: ba35c7f2e3faf4df33d190d59182bd9c673ba50304b9a8d49a5bb8085c40bb023dc94888da5466f90b2363ee4a6335ff889521d425ff3c904dcbc764bb442a05
@@ -0,0 +1,294 @@
1
+ require 'securerandom'
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'uri'
5
+ require 'message_bus/http_client/configuration'
6
+ require 'message_bus/http_client/channel'
7
+
8
+ module MessageBus
9
+ # MessageBus client that enables subscription via long polling with support
10
+ # for chunked encoding. Falls back to normal polling if long polling is not
11
+ # available.
12
+ #
13
+ # @!attribute [r] channels
14
+ # @return [Hash] a map of the channels that the client is subscribed to
15
+ # @!attribute [r] stats
16
+ # @return [Stats] a Struct containing the statistics of failed and successful
17
+ # polling requests
18
+ class HTTPClient
19
+ include Configuration
20
+
21
+ class InvalidChannel < StandardError; end
22
+
23
+ attr_reader :channels, :stats
24
+
25
+ CHUNK_SEPARATOR = "\r\n|\r\n".freeze
26
+ private_constant :CHUNK_SEPARATOR
27
+ STATUS_CHANNEL = "/__status".freeze
28
+ private_constant :STATUS_CHANNEL
29
+
30
+ STOPPED = 0
31
+ STARTED = 1
32
+
33
+ Stats = Struct.new(:failed, :success)
34
+ private_constant :Stats
35
+
36
+ # @param base_url [String] Base URL of the message_bus server to connect to
37
+ #
38
+ # @return [Object] Instance of MessageBus::HTTPClient
39
+ def initialize(base_url, *args)
40
+ super
41
+ @uri = URI(base_url)
42
+ @client_id = SecureRandom.hex
43
+ @channels = {}
44
+ @status = STOPPED
45
+ @mutex = Mutex.new
46
+ @stats = Stats.new(0, 0)
47
+ end
48
+
49
+ # Starts a background thread that polls the message bus endpoint
50
+ # for the given base_url.
51
+ #
52
+ # Intervals for long polling can be configured via min_poll_interval and
53
+ # max_poll_interval.
54
+ #
55
+ # Intervals for polling can be configured via background_callback_interval.
56
+ #
57
+ # @return [Object] Instance of MessageBus::HTTPClient
58
+ def start
59
+ @mutex.synchronize do
60
+ return if started?
61
+
62
+ @status = STARTED
63
+
64
+ thread = Thread.new do
65
+ begin
66
+ while started?
67
+ poll unless @channels.empty?
68
+ @stats.success += 1
69
+ sleep interval
70
+ end
71
+ rescue StandardError => e
72
+ @stats.failed += 1
73
+ warn("#{e.class} #{e.message}: #{e.backtrace.join("\n")}")
74
+ retry
75
+ ensure
76
+ stop
77
+ end
78
+ end
79
+
80
+ thread.abort_on_exception = true
81
+ end
82
+
83
+ self
84
+ end
85
+
86
+ # Stops the client from polling the message bus endpoint.
87
+ #
88
+ # @return [Integer] the current status of the client
89
+ def stop
90
+ @status = STOPPED
91
+ end
92
+
93
+ # Subscribes to a channel which executes the given callback when a message
94
+ # is published to the channel
95
+ #
96
+ # @example Subscribing to a channel for message
97
+ # client = MessageBus::HTTPClient.new('http://some.test.com')
98
+ #
99
+ # client.subscribe("/test") do |payload, _message_id, _global_id|
100
+ # puts payload
101
+ # end
102
+ #
103
+ # A last_message_id may be provided.
104
+ # * -1 will subscribe to all new messages
105
+ # * -2 will recieve last message + all new messages
106
+ # * -3 will recieve last 2 message + all new messages
107
+ #
108
+ # @example Subscribing to a channel with `last_message_id`
109
+ # client.subscribe("/test", last_message_id: -2) do |payload|
110
+ # puts payload
111
+ # end
112
+ #
113
+ # @param channel [String] channel to listen for messages on
114
+ # @param last_message_id [Integer] last message id to start polling on.
115
+ #
116
+ # @yield [data, message_id, global_id]
117
+ # callback to be executed whenever a message is received
118
+ #
119
+ # @yieldparam data [Hash] data payload of the message received on the channel
120
+ # @yieldparam message_id [Integer] id of the message in the channel
121
+ # @yieldparam global_id [Integer] id of the message in the global backlog
122
+ # @yieldreturn [void]
123
+ #
124
+ # @return [Integer] the current status of the client
125
+ def subscribe(channel, last_message_id: nil, &callback)
126
+ raise InvalidChannel unless channel.to_s.start_with?("/")
127
+
128
+ last_message_id = -1 if last_message_id && !last_message_id.is_a?(Integer)
129
+
130
+ @channels[channel] ||= Channel.new
131
+ channel = @channels[channel]
132
+ channel.last_message_id = last_message_id if last_message_id
133
+ channel.callbacks.push(callback)
134
+ start if stopped?
135
+ end
136
+
137
+ # unsubscribes from a channel
138
+ #
139
+ # @example Unsubscribing from a channel
140
+ # client = MessageBus::HTTPClient.new('http://some.test.com')
141
+ # callback = -> { |payload| puts payload }
142
+ # client.subscribe("/test", &callback)
143
+ # client.unsubscribe("/test")
144
+ #
145
+ # If a callback is given, only the specific callback will be unsubscribed.
146
+ #
147
+ # @example Unsubscribing a callback from a channel
148
+ # client.unsubscribe("/test", &callback)
149
+ #
150
+ # When the client does not have any channels left, it will stop polling and
151
+ # waits until a new subscription is started.
152
+ #
153
+ # @param channel [String] channel to unsubscribe
154
+ # @yield [data, global_id, message_id] specific callback to unsubscribe
155
+ #
156
+ # @return [Integer] the current status of the client
157
+ def unsubscribe(channel, &callback)
158
+ if callback
159
+ @channels[channel].callbacks.delete(callback)
160
+ remove_channel(channel) if @channels[channel].callbacks.empty?
161
+ else
162
+ remove_channel(channel)
163
+ end
164
+
165
+ stop if @channels.empty?
166
+ @status
167
+ end
168
+
169
+ private
170
+
171
+ def stopped?
172
+ @status == STOPPED
173
+ end
174
+
175
+ def started?
176
+ @status == STARTED
177
+ end
178
+
179
+ def remove_channel(channel)
180
+ @channels.delete(channel)
181
+ end
182
+
183
+ def interval
184
+ if @enable_long_polling
185
+ if (failed_count = @stats.failed) > 2
186
+ (@min_poll_interval * failed_count).clamp(
187
+ @min_poll_interval, @max_poll_interval
188
+ )
189
+ else
190
+ @min_poll_interval
191
+ end
192
+ else
193
+ @background_callback_interval
194
+ end
195
+ end
196
+
197
+ def poll
198
+ http = Net::HTTP.new(@uri.host, @uri.port)
199
+ http.use_ssl = true if @uri.scheme == 'https'
200
+ request = Net::HTTP::Post.new(request_path, headers)
201
+ request.body = poll_payload
202
+
203
+ if @enable_long_polling
204
+ buffer = ''
205
+
206
+ http.request(request) do |response|
207
+ response.read_body do |chunk|
208
+ unless chunk.empty?
209
+ buffer << chunk
210
+ process_buffer(buffer)
211
+ end
212
+ end
213
+ end
214
+ else
215
+ response = http.request(request)
216
+ notify_channels(JSON.parse(response.body))
217
+ end
218
+ end
219
+
220
+ def is_chunked?
221
+ !headers["Dont-Chunk"]
222
+ end
223
+
224
+ def process_buffer(buffer)
225
+ index = buffer.index(CHUNK_SEPARATOR)
226
+
227
+ if is_chunked?
228
+ return unless index
229
+
230
+ messages = buffer[0..(index - 1)]
231
+ buffer.slice!("#{messages}#{CHUNK_SEPARATOR}")
232
+ else
233
+ messages = buffer[0..-1]
234
+ buffer.slice!(messages)
235
+ end
236
+
237
+ notify_channels(JSON.parse(messages))
238
+ end
239
+
240
+ def notify_channels(messages)
241
+ messages.each do |message|
242
+ current_channel = message['channel']
243
+
244
+ if current_channel == STATUS_CHANNEL
245
+ message["data"].each do |channel_name, last_message_id|
246
+ if (channel = @channels[channel_name])
247
+ channel.last_message_id = last_message_id
248
+ end
249
+ end
250
+ else
251
+ @channels.each do |channel_name, channel|
252
+ next unless channel_name == current_channel
253
+
254
+ channel.last_message_id = message['message_id']
255
+
256
+ channel.callbacks.each do |callback|
257
+ callback.call(
258
+ message['data'],
259
+ channel.last_message_id,
260
+ message['global_id']
261
+ )
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
267
+
268
+ def poll_payload
269
+ payload = {}
270
+
271
+ @channels.each do |channel_name, channel|
272
+ payload[channel_name] = channel.last_message_id
273
+ end
274
+
275
+ payload.to_json
276
+ end
277
+
278
+ def request_path
279
+ "/message-bus/#{@client_id}/poll"
280
+ end
281
+
282
+ def headers
283
+ headers = {}
284
+ headers['Content-Type'] = 'application/json'
285
+ headers['X-Silence-logger'] = 'true'
286
+
287
+ if !@enable_long_polling || !@enable_chunked_encoding
288
+ headers['Dont-Chunk'] = 'true'
289
+ end
290
+
291
+ headers.merge!(@headers)
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,13 @@
1
+ module MessageBus
2
+ class HTTPClient
3
+ # @private
4
+ class Channel
5
+ attr_accessor :last_message_id, :callbacks
6
+
7
+ def initialize(last_message_id: -1, callbacks: [])
8
+ @last_message_id = last_message_id
9
+ @callbacks = callbacks
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ module MessageBus
2
+ class HTTPClient
3
+ module Configuration
4
+ def self.included(base)
5
+ base.send(:attr_accessor,
6
+ :enable_long_polling,
7
+ :status,
8
+ :enable_chunked_encoding,
9
+ :min_poll_interval,
10
+ :max_poll_interval,
11
+ :background_callback_interval
12
+ )
13
+ end
14
+
15
+ # @param enable_long_polling [Boolean] Enable long polling
16
+ # @param enable_chunked_encoding [Boolean] Enable chunk encoding
17
+ # @param min_poll_interval [Float, Integer] Min poll interval when long polling
18
+ # @param max_poll_interval [Float, Integer] Max poll interval when long polling.
19
+ # When requests fail, the client will backoff and this is the upper limit.
20
+ # @param background_callback_interval [Float, Integer] Interval to poll when
21
+ # when polling.
22
+ # @param headers [Hash] extra HTTP headers to be set on the polling requests.
23
+ def initialize(_base_url,
24
+ enable_long_polling: true,
25
+ enable_chunked_encoding: true,
26
+ min_poll_interval: 0.1,
27
+ max_poll_interval: 180,
28
+ background_callback_interval: 60,
29
+ headers: {})
30
+
31
+ @enable_long_polling = enable_long_polling
32
+ @enable_chunked_encoding = enable_chunked_encoding
33
+ @min_poll_interval = min_poll_interval
34
+ @max_poll_interval = max_poll_interval
35
+ @background_callback_interval = background_callback_interval
36
+ @headers = headers
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MessageBus
4
+ class HTTPClient
5
+ VERSION = '1.0.0.pre1'
6
+ end
7
+ end
@@ -0,0 +1,183 @@
1
+ require_relative '../spec_helper'
2
+ require 'message_bus/http_client'
3
+
4
+ describe MessageBus::HTTPClient do
5
+ let(:base_url) { "http://0.0.0.0:9292" }
6
+ let(:client) { MessageBus::HTTPClient.new(base_url) }
7
+ let(:headers) { client.send(:headers) }
8
+ let(:channel) { "/test" }
9
+ let(:channel2) { "/test2" }
10
+
11
+ def publish_message
12
+ response = Net::HTTP.get_response(URI("#{base_url}/publish"))
13
+ assert_equal("200", response.code)
14
+ end
15
+
16
+ before do
17
+ @threads = Thread.list
18
+ end
19
+
20
+ after do
21
+ new_threads = Thread.list - @threads
22
+ client.stop
23
+ assert(new_threads.size <= 1)
24
+ new_threads.each(&:join)
25
+ end
26
+
27
+ describe '#start and #stop' do
28
+ it 'should be able to start and stop polling correctly' do
29
+ threads = Thread.list
30
+
31
+ assert_equal(MessageBus::HTTPClient::STOPPED, client.status)
32
+
33
+ client.start
34
+ new_threads = Thread.list - threads
35
+
36
+ assert_equal(1, new_threads.size)
37
+ assert_equal(MessageBus::HTTPClient::STARTED, client.status)
38
+
39
+ client.start
40
+
41
+ assert_equal(new_threads, Thread.list - threads)
42
+ end
43
+ end
44
+
45
+ describe '#subscribe' do
46
+ it 'should be able to subscribe to channels for messages' do
47
+ called = 0
48
+ called2 = 0
49
+
50
+ client.subscribe(channel, last_message_id: -1) do |data|
51
+ called += 1
52
+ assert_equal("world", data["hello"])
53
+ end
54
+
55
+ client.subscribe(channel2) do |data|
56
+ called2 += 1
57
+ assert_equal("world", data["hello"])
58
+ end
59
+
60
+ while called < 2 && called2 < 2
61
+ publish_message
62
+ sleep 0.05
63
+ end
64
+ end
65
+
66
+ describe 'supports including extra headers' do
67
+ let(:client) do
68
+ MessageBus::HTTPClient.new(base_url, headers: {
69
+ 'Dont-Chunk' => "true"
70
+ })
71
+ end
72
+
73
+ it 'should include the header in the request' do
74
+ called = 0
75
+
76
+ client.subscribe(channel) do |data|
77
+ called += 1
78
+ assert_equal("world", data["hello"])
79
+ end
80
+
81
+ while called < 2
82
+ publish_message
83
+ sleep 0.05
84
+ end
85
+ end
86
+ end
87
+
88
+ describe 'when chunked encoding is disabled' do
89
+ let(:client) do
90
+ MessageBus::HTTPClient.new(base_url, enable_chunked_encoding: false)
91
+ end
92
+
93
+ it 'should still be able to subscribe to channels for messages' do
94
+ called = 0
95
+
96
+ client.subscribe(channel) do |data|
97
+ called += 1
98
+ assert_equal("world", data["hello"])
99
+ end
100
+
101
+ while called < 2
102
+ publish_message
103
+ sleep 0.05
104
+ end
105
+ end
106
+ end
107
+
108
+ describe 'when enable_long_polling is disabled' do
109
+ let(:client) do
110
+ MessageBus::HTTPClient.new(base_url,
111
+ enable_long_polling: false,
112
+ background_callback_interval: 0.01)
113
+ end
114
+
115
+ it 'should still be able to subscribe to channels for messages' do
116
+ called = 0
117
+
118
+ client.subscribe(channel) do |data|
119
+ called += 1
120
+ assert_equal("world", data["hello"])
121
+ end
122
+
123
+ while called < 2
124
+ publish_message
125
+ sleep 0.05
126
+ end
127
+ end
128
+ end
129
+
130
+ describe 'when channel name is invalid' do
131
+ it 'should raise the right error' do
132
+ ["test", 1, :test].each do |invalid_channel|
133
+ assert_raises MessageBus::HTTPClient::InvalidChannel do
134
+ client.subscribe(invalid_channel)
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ describe 'with last_message_id' do
141
+ describe 'when invalid' do
142
+ it 'should subscribe from the latest message' do
143
+ client.subscribe(channel, last_message_id: 'haha')
144
+ assert_equal(-1, client.channels[channel].last_message_id)
145
+ end
146
+ end
147
+
148
+ describe 'when valid' do
149
+ it 'should subscribe from the right message' do
150
+ client.subscribe(channel, last_message_id: -2)
151
+ assert_equal(-2, client.channels[channel].last_message_id)
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ describe '#unsubscribe' do
158
+ it 'should be able to unsubscribe a channel' do
159
+ client.subscribe(channel) { raise "Not called" }
160
+ assert(client.channels[channel])
161
+
162
+ client.unsubscribe(channel)
163
+ assert_nil(client.channels[channel])
164
+ end
165
+
166
+ describe 'with callback' do
167
+ it 'should be able to unsubscribe a callback for a particular channel' do
168
+ callback = -> { raise "Not called" }
169
+ callback2 = -> { raise "Not called2" }
170
+
171
+ client.subscribe(channel, &callback)
172
+ client.subscribe(channel, &callback2)
173
+ assert_equal([callback, callback2], client.channels[channel].callbacks)
174
+
175
+ client.unsubscribe(channel, &callback)
176
+ assert_equal([callback2], client.channels[channel].callbacks)
177
+
178
+ client.unsubscribe(channel, &callback2)
179
+ assert_nil(client.channels[channel])
180
+ end
181
+ end
182
+ end
183
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: message_bus-http_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.pre1
5
+ platform: ruby
6
+ authors:
7
+ - Tan Guo Xiang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-12-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: puma
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3'
27
+ description: A Ruby HTTP client for message bus
28
+ email:
29
+ - tgx+github@discourse.org
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/message_bus/http_client.rb
35
+ - lib/message_bus/http_client/channel.rb
36
+ - lib/message_bus/http_client/configuration.rb
37
+ - lib/message_bus/http_client/version.rb
38
+ - spec/integration/http_client_spec.rb
39
+ homepage: https://github.com/SamSaffron/message_bus
40
+ licenses:
41
+ - MIT
42
+ metadata: {}
43
+ post_install_message:
44
+ rdoc_options: []
45
+ require_paths:
46
+ - lib
47
+ required_ruby_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '2.4'
52
+ required_rubygems_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">"
55
+ - !ruby/object:Gem::Version
56
+ version: 1.3.1
57
+ requirements: []
58
+ rubyforge_project:
59
+ rubygems_version: 2.7.6
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: ''
63
+ test_files:
64
+ - spec/integration/http_client_spec.rb