message_bus-http_client 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
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