stomp_out 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.
- data/.travis.yml +10 -0
- data/CHANGELOG.rdoc +3 -0
- data/LICENSE +20 -0
- data/README.rdoc +91 -0
- data/Rakefile +36 -0
- data/VERSION +1 -0
- data/examples/config.ru +13 -0
- data/examples/websocket_client.rb +153 -0
- data/examples/websocket_server.rb +120 -0
- data/lib/stomp_out/client.rb +580 -0
- data/lib/stomp_out/errors.rb +67 -0
- data/lib/stomp_out/frame.rb +71 -0
- data/lib/stomp_out/heartbeat.rb +151 -0
- data/lib/stomp_out/parser.rb +134 -0
- data/lib/stomp_out/server.rb +667 -0
- data/lib/stomp_out.rb +29 -0
- data/stomp_out.gemspec +95 -0
- metadata +293 -0
@@ -0,0 +1,580 @@
|
|
1
|
+
# Copyright (c) 2015 RightScale Inc
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
# a copy of this software and associated documentation files (the
|
5
|
+
# "Software"), to deal in the Software without restriction, including
|
6
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
# the following conditions:
|
10
|
+
#
|
11
|
+
# The above copyright notice and this permission notice shall be
|
12
|
+
# included in all copies or substantial portions of the Software.
|
13
|
+
#
|
14
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
22
|
+
module StompOut
|
23
|
+
|
24
|
+
# Abstract base class for STOMP client for use with an existing server connection, such
|
25
|
+
# as a WebSocket. Derived classes are responsible for supplying the following functions:
|
26
|
+
# send_data(data) - send data over connection
|
27
|
+
# on_connected(frame, session_id, server_name) - handle notification that now connected
|
28
|
+
# on_message(frame, destination, message, content_type, message_id, ack_id) - handle message
|
29
|
+
# received from server
|
30
|
+
# on_receipt(frame, receipt_id) - handle notification that a request was successfully
|
31
|
+
# handled by server
|
32
|
+
# on_error(frame, message, details, receipt_id) - handle notification from server
|
33
|
+
# that a request failed and that the connection should be closed
|
34
|
+
#
|
35
|
+
class Client
|
36
|
+
|
37
|
+
SUPPORTED_VERSIONS = ["1.0", "1.1", "1.2"]
|
38
|
+
|
39
|
+
ACK_SETTINGS = {
|
40
|
+
"1.0" => ["auto", "client"],
|
41
|
+
"1.1" => ["auto", "client", "client-individual"],
|
42
|
+
"1.2" => ["auto", "client", "client-individual"]
|
43
|
+
}
|
44
|
+
|
45
|
+
SERVER_COMMANDS = [:connected, :message, :receipt, :error]
|
46
|
+
|
47
|
+
MIN_SEND_HEARTBEAT = 5000
|
48
|
+
|
49
|
+
attr_reader :version, :session_id, :server_name, :host, :heartbeat
|
50
|
+
|
51
|
+
# Create STOMP client
|
52
|
+
#
|
53
|
+
# @option options [String] :host to which client wishes to connect; if not using virtual hosts,
|
54
|
+
# recommended setting is the host name that the socket in use was connected against,
|
55
|
+
# or any name of client's choosing; defaults to "stomp"
|
56
|
+
# @option options [Boolean] :receipt enabled for all requests except connect; disabled
|
57
|
+
# by default but can still enable on individual requests
|
58
|
+
# @option options [Boolean] :auto_json encode/decode "application/json" content-type
|
59
|
+
# @option options [Integer] :min_send_interval in msec that this client can guarantee;
|
60
|
+
# defaults to MIN_SEND_HEARTBEAT
|
61
|
+
def initialize(options = {})
|
62
|
+
@options = options
|
63
|
+
@host = @options[:host] || "stomp"
|
64
|
+
@parser = StompOut::Parser.new
|
65
|
+
@ack_id = 0
|
66
|
+
@message_ids = {} # ack ID is key
|
67
|
+
@subscribe_id = 0
|
68
|
+
@subscribes = {} # destination is key
|
69
|
+
@transaction_id = 0
|
70
|
+
@transaction_ids = []
|
71
|
+
@receipt = options[:receipt]
|
72
|
+
@receipt_id = 0
|
73
|
+
@receipted_frames = {} # receipt-id is key
|
74
|
+
@connected = false
|
75
|
+
end
|
76
|
+
|
77
|
+
# List active subscriptions
|
78
|
+
#
|
79
|
+
# @return [Array<String>] subscription destinations
|
80
|
+
def subscriptions
|
81
|
+
@subscribes.keys
|
82
|
+
end
|
83
|
+
|
84
|
+
# List active transactions
|
85
|
+
#
|
86
|
+
# @return [Array<String>] transaction IDs
|
87
|
+
def transactions
|
88
|
+
@transaction_ids
|
89
|
+
end
|
90
|
+
|
91
|
+
# Determine whether connected to STOMP server
|
92
|
+
#
|
93
|
+
# @return [Boolean] true if connected, otherwise false
|
94
|
+
def connected?
|
95
|
+
!!@connected
|
96
|
+
end
|
97
|
+
|
98
|
+
# Report to client that an error was encountered locally
|
99
|
+
# Not intended for use by end user of this class
|
100
|
+
#
|
101
|
+
# @param [Exception, String] error being reported
|
102
|
+
#
|
103
|
+
# @return [TrueClass] always true
|
104
|
+
def report_error(error)
|
105
|
+
details = ""
|
106
|
+
if error.is_a?(ProtocolError) || error.is_a?(ApplicationError)
|
107
|
+
message = error.message
|
108
|
+
elsif error.is_a?(Exception)
|
109
|
+
message = "#{error.class}: #{error.message}"
|
110
|
+
details = error.backtrace.join("\n") if error.respond_to?(:backtrace)
|
111
|
+
else
|
112
|
+
message = error.to_s
|
113
|
+
end
|
114
|
+
frame = Frame.new("ERROR", {"message" => message}, details)
|
115
|
+
on_error(frame, message, details, receipt_id = nil)
|
116
|
+
true
|
117
|
+
end
|
118
|
+
|
119
|
+
# Process data received over connection from server
|
120
|
+
#
|
121
|
+
# @param [String] data to be processed
|
122
|
+
#
|
123
|
+
# @return [TrueClass] always true
|
124
|
+
def receive_data(data)
|
125
|
+
@parser << data
|
126
|
+
process_frames
|
127
|
+
@heartbeat.received_data if @heartbeat
|
128
|
+
true
|
129
|
+
rescue StandardError => e
|
130
|
+
report_error(e)
|
131
|
+
end
|
132
|
+
|
133
|
+
##################################
|
134
|
+
## STOMP client subclass functions
|
135
|
+
##################################
|
136
|
+
|
137
|
+
# Send data over connection to server
|
138
|
+
#
|
139
|
+
# @param [String] data that is STOMP encoded
|
140
|
+
#
|
141
|
+
# @return [TrueClass] always true
|
142
|
+
def send_data(data)
|
143
|
+
raise "Not implemented"
|
144
|
+
end
|
145
|
+
|
146
|
+
# Handle notification that now connected to server
|
147
|
+
#
|
148
|
+
# @param [Frame] frame received from server
|
149
|
+
# @param [String] session_id uniquely identifying the given STOMP session
|
150
|
+
# @param [String, NilClass] server_name in form "<name>/<version>" with
|
151
|
+
# "/<version>" being optional; nil if not provided by server
|
152
|
+
#
|
153
|
+
# @return [TrueClass] always true
|
154
|
+
def on_connected(frame, session_id, server_name)
|
155
|
+
raise "Not implemented"
|
156
|
+
end
|
157
|
+
|
158
|
+
# Handle message received from server
|
159
|
+
#
|
160
|
+
# @param [Frame] frame received from server
|
161
|
+
# @param [String] destination to which the message was sent
|
162
|
+
# @param [Object] message body; if content_type is "application/json"
|
163
|
+
# and :auto_json client option specified the message is JSON decoded
|
164
|
+
# @param [String] content_type of message in MIME terms, e.g., "text/plain"
|
165
|
+
# @param [String] message_id uniquely identifying message
|
166
|
+
# @param [String, NilClass] ack_id to be used when acknowledging message
|
167
|
+
# to server if acknowledgement enabled
|
168
|
+
#
|
169
|
+
# @return [TrueClass] always true
|
170
|
+
def on_message(frame, destination, message, content_type, message_id, ack_id)
|
171
|
+
raise "Not implemented"
|
172
|
+
end
|
173
|
+
|
174
|
+
# Handle notification that a request was successfully handled by server
|
175
|
+
#
|
176
|
+
# @param [Frame] frame received from server
|
177
|
+
# @param [String] receipt_id identifying request completed (client request
|
178
|
+
# functions optionally return a receipt_id)
|
179
|
+
#
|
180
|
+
# @return [TrueClass] always true
|
181
|
+
def on_receipt(frame, receipt_id)
|
182
|
+
raise "Not implemented"
|
183
|
+
end
|
184
|
+
|
185
|
+
# Handle notification from server that a request failed and that the connection
|
186
|
+
# should be closed
|
187
|
+
#
|
188
|
+
# @param [Frame] frame received from server
|
189
|
+
# @param [String] error message
|
190
|
+
# @param [String, NilClass] details about the error, e.g., the frame that failed
|
191
|
+
# @param [String, NilClass] receipt_id identifying request that failed (Client
|
192
|
+
# functions optionally return a receipt_id)
|
193
|
+
#
|
194
|
+
# @return [TrueClass] always true
|
195
|
+
def on_error(frame, error, details, receipt_id)
|
196
|
+
raise "Not implemented"
|
197
|
+
end
|
198
|
+
|
199
|
+
########################
|
200
|
+
## STOMP client commands
|
201
|
+
########################
|
202
|
+
|
203
|
+
# Connect to server
|
204
|
+
#
|
205
|
+
# @param [Integer, NilClass] heartbeat rate in milliseconds that is desired;
|
206
|
+
# defaults to no heartbeat; not usable unless eventmachine gem available
|
207
|
+
# @param [String, NilClass] login name for authentication with server; defaults
|
208
|
+
# to no authentication, although this may not be acceptable to server
|
209
|
+
# @param [String, NilClass] passcode for authentication
|
210
|
+
# @param [Hash, NilClass] headers that are application specific
|
211
|
+
#
|
212
|
+
# @return [TrueClass] always true
|
213
|
+
#
|
214
|
+
# @raise [ProtocolError] already connected
|
215
|
+
# @raise [ApplicationError] eventmachine not available
|
216
|
+
def connect(heartbeat = nil, login = nil, passcode = nil, headers = nil)
|
217
|
+
raise ProtocolError, "Already connected" if @connected
|
218
|
+
headers ||= {}
|
219
|
+
headers["accept-version"] = SUPPORTED_VERSIONS.join(",")
|
220
|
+
headers["host"] = @host
|
221
|
+
if heartbeat
|
222
|
+
raise ApplicationError.new("Heartbeat not usable without eventmachine") unless Heartbeat.usable?
|
223
|
+
headers["heart-beat"] = "#{@options[:min_send_interval] || MIN_SEND_HEARTBEAT},#{heartbeat}"
|
224
|
+
end
|
225
|
+
if login
|
226
|
+
headers["login"] = login
|
227
|
+
headers["passcode"] = passcode
|
228
|
+
end
|
229
|
+
send_frame("CONNECT", headers)
|
230
|
+
true
|
231
|
+
end
|
232
|
+
|
233
|
+
# Send message to given destination
|
234
|
+
#
|
235
|
+
# @param [String] destination for message
|
236
|
+
# @param [String] message being sent
|
237
|
+
# @param [String, NilClass] content_type of message body in MIME format;
|
238
|
+
# optionally JSON-encodes body automatically if "application/json";
|
239
|
+
# defaults to "plain/text"
|
240
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
241
|
+
# @param [String, NilClass] transaction_id for transaction into which this command
|
242
|
+
# is to be included; defaults to no transaction
|
243
|
+
# @param [Hash] headers that are application specific, e.g., "message-id"
|
244
|
+
#
|
245
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
246
|
+
#
|
247
|
+
# @raise [ProtocolError] not connected
|
248
|
+
def message(destination, message, content_type = nil, receipt = nil, transaction_id = nil, headers = nil)
|
249
|
+
raise ProtocolError.new("Not connected") unless @connected
|
250
|
+
headers ||= {}
|
251
|
+
headers["destination"] = destination
|
252
|
+
frame = send_frame("SEND", headers, message, content_type, receipt, transaction_id)
|
253
|
+
frame.headers["receipt"]
|
254
|
+
end
|
255
|
+
|
256
|
+
# Register to listen to a given destination
|
257
|
+
#
|
258
|
+
# @param [String] destination of interest
|
259
|
+
# @param [String, NilClass] ack setting: "auto", "client", or "client-individual";
|
260
|
+
# defaults to "auto"
|
261
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
262
|
+
# @param [Hash, NilClass] headers that are application specific
|
263
|
+
#
|
264
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
265
|
+
#
|
266
|
+
# @raise [ProtocolError] not connected, invalid ack setting
|
267
|
+
# @raise [ApplicationError] duplicate subscription
|
268
|
+
def subscribe(destination, ack = nil, receipt = nil, headers = nil)
|
269
|
+
raise ProtocolError.new("Not connected") unless @connected
|
270
|
+
raise ApplicationError.new("Already subscribed to '#{destination}'") if @subscribes[destination]
|
271
|
+
raise ProtocolError.new("Invalid 'ack' setting") if ack && !ACK_SETTINGS[@version].include?(ack)
|
272
|
+
@subscribes[destination] = {:id => (@subscribe_id += 1).to_s, :ack => ack}
|
273
|
+
headers ||= {}
|
274
|
+
headers["destination"] = destination
|
275
|
+
headers["id"] = @subscribe_id.to_s
|
276
|
+
headers["ack"] = ack if ack
|
277
|
+
frame = send_frame("SUBSCRIBE", headers, body = nil, content_type = nil, receipt)
|
278
|
+
frame.headers["receipt"]
|
279
|
+
end
|
280
|
+
|
281
|
+
# Remove an existing subscription
|
282
|
+
#
|
283
|
+
# @param [String] destination no longer of interest
|
284
|
+
#
|
285
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
286
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
287
|
+
# @param [Hash, NilClass] headers that are application specific
|
288
|
+
#
|
289
|
+
# @raise [ProtocolError] not connected
|
290
|
+
# @raise [ApplicationError] subscription not found
|
291
|
+
def unsubscribe(destination, receipt = nil, headers = nil)
|
292
|
+
raise ProtocolError.new("Not connected") unless @connected
|
293
|
+
subscribe = @subscribes.delete(destination)
|
294
|
+
raise ApplicationError.new("Subscription to '#{destination}' not found") if subscribe.nil?
|
295
|
+
headers ||= {}
|
296
|
+
headers["id"] = subscribe[:id]
|
297
|
+
headers["destination"] = destination if @version == "1.0"
|
298
|
+
frame = send_frame("UNSUBSCRIBE", headers, body = nil, content_type = nil, receipt)
|
299
|
+
frame.headers["receipt"]
|
300
|
+
end
|
301
|
+
|
302
|
+
# Acknowledge consumption of a message from a subscription
|
303
|
+
#
|
304
|
+
# @param [String] ack_id identifying message being acknowledged
|
305
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
306
|
+
# @param [String, NilClass] transaction_id for transaction into which this command
|
307
|
+
# is to be included; defaults to no transaction
|
308
|
+
# @param [Hash, NilClass] headers that are application specific
|
309
|
+
#
|
310
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
311
|
+
#
|
312
|
+
# @raise [ProtocolError] not connected
|
313
|
+
# @raise [ApplicationError] message for ack not found
|
314
|
+
def ack(ack_id, receipt = nil, transaction_id = nil, headers = nil)
|
315
|
+
raise ProtocolError.new("Not connected") unless @connected
|
316
|
+
message_id = @message_ids.delete(ack_id)
|
317
|
+
headers ||= {}
|
318
|
+
if @version == "1.0"
|
319
|
+
raise ApplicationError.new("No message was received with ack #{ack_id}") if message_id.nil?
|
320
|
+
headers["message-id"] = message_id
|
321
|
+
frame = send_frame("ACK", headers, body = nil, content_type = nil, receipt, transaction_id)
|
322
|
+
else
|
323
|
+
headers["id"] = ack_id.to_s
|
324
|
+
frame = send_frame("ACK", headers, body = nil, content_type = nil, receipt, transaction_id)
|
325
|
+
end
|
326
|
+
frame.headers["receipt"]
|
327
|
+
end
|
328
|
+
|
329
|
+
# Tell the server that a message was not consumed
|
330
|
+
#
|
331
|
+
# @param [String] ack_id identifying message being negatively acknowledged
|
332
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
333
|
+
# @param [String, NilClass] transaction_id for transaction into which this command
|
334
|
+
# is to be included; defaults to no transaction
|
335
|
+
# @param [Hash, NilClass] headers that are application specific
|
336
|
+
#
|
337
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
338
|
+
#
|
339
|
+
# @raise [ProtocolError] nack not supported, not connected
|
340
|
+
def nack(ack_id, receipt = nil, transaction_id = nil, headers = nil)
|
341
|
+
raise ProtocolError.new("Command 'nack' not supported") if @version == "1.0"
|
342
|
+
raise ProtocolError.new("Not connected") unless @connected
|
343
|
+
@message_ids.delete(ack_id)
|
344
|
+
headers ||= {}
|
345
|
+
headers["id"] = ack_id.to_s
|
346
|
+
frame = send_frame("NACK", headers, body = nil, content_type = nil, receipt, transaction_id)
|
347
|
+
frame.headers["receipt"]
|
348
|
+
end
|
349
|
+
|
350
|
+
# Start a transaction
|
351
|
+
#
|
352
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
353
|
+
#
|
354
|
+
# @return [Array<String>] transaction ID and receipt ID if receipt enabled
|
355
|
+
# @param [Hash, NilClass] headers that are application specific
|
356
|
+
#
|
357
|
+
# @raise [ProtocolError] not connected
|
358
|
+
def begin(receipt = nil, headers = nil)
|
359
|
+
raise ProtocolError.new("Not connected") unless @connected
|
360
|
+
id = (@transaction_id += 1).to_s
|
361
|
+
headers ||= {}
|
362
|
+
headers["transaction"] = id.to_s
|
363
|
+
frame = send_frame("BEGIN", headers, body = nil, content_type = nil, receipt)
|
364
|
+
@transaction_ids << id
|
365
|
+
[id, frame.headers["receipt"]]
|
366
|
+
end
|
367
|
+
|
368
|
+
# Commit a transaction
|
369
|
+
#
|
370
|
+
# @param [String] transaction_id uniquely identifying transaction
|
371
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
372
|
+
# @param [Hash, NilClass] headers that are application specific
|
373
|
+
#
|
374
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
375
|
+
#
|
376
|
+
# @raise [ProtocolError] not connected
|
377
|
+
# @raise [ApplicationError] transaction not found
|
378
|
+
def commit(id, receipt = nil, headers = nil)
|
379
|
+
raise ProtocolError.new("Not connected") unless @connected
|
380
|
+
raise ApplicationError.new("Transaction #{id} not found") unless @transaction_ids.delete(id.to_s)
|
381
|
+
headers ||= {}
|
382
|
+
headers["transaction"] = id.to_s
|
383
|
+
frame = send_frame("COMMIT", headers, body = nil, content_type = nil, receipt)
|
384
|
+
frame.headers["receipt"]
|
385
|
+
end
|
386
|
+
|
387
|
+
# Roll back a transaction
|
388
|
+
#
|
389
|
+
# @param [String] id uniquely identifying transaction
|
390
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
391
|
+
# @param [Hash, NilClass] headers that are application specific
|
392
|
+
#
|
393
|
+
# @return [String, NilClass] receipt ID if enabled, otherwise nil
|
394
|
+
#
|
395
|
+
# @raise [ProtocolError] not connected
|
396
|
+
# @raise [ApplicationError] transaction not found
|
397
|
+
def abort(id, receipt = nil, headers = nil)
|
398
|
+
raise ProtocolError.new("Not connected") unless @connected
|
399
|
+
raise ApplicationError.new("Transaction #{id} not found") unless @transaction_ids.delete(id.to_s)
|
400
|
+
headers ||= {}
|
401
|
+
headers["transaction"] = id.to_s
|
402
|
+
frame = send_frame("ABORT", headers, body = nil, content_type = nil, receipt)
|
403
|
+
frame.headers["receipt"]
|
404
|
+
end
|
405
|
+
|
406
|
+
# Disconnect from the server
|
407
|
+
# Client is expected to close its connection after calling this function
|
408
|
+
# If receipt is requested, it may not be received before frame is reset
|
409
|
+
#
|
410
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
411
|
+
# @param [Hash, NilClass] headers that are application specific
|
412
|
+
#
|
413
|
+
# @return [String, NilClass] receipt ID if enabled and connected, otherwise nil
|
414
|
+
#
|
415
|
+
# @raise [ProtocolError] not connected
|
416
|
+
def disconnect(receipt = nil, headers = nil)
|
417
|
+
raise ProtocolError.new("Not connected") unless @connected
|
418
|
+
frame = send_frame("DISCONNECT", headers, body = nil, content_type = nil, receipt)
|
419
|
+
@heartbeat.stop if @heartbeat
|
420
|
+
@connected = false
|
421
|
+
frame.headers["receipt"]
|
422
|
+
end
|
423
|
+
|
424
|
+
protected
|
425
|
+
|
426
|
+
########################
|
427
|
+
## STOMP Server Commands
|
428
|
+
########################
|
429
|
+
|
430
|
+
# Handle notification from server that now connected at STOMP protocol level
|
431
|
+
#
|
432
|
+
# @param [Frame] frame received from server
|
433
|
+
# @param [String, NilClass] body for frame after optional decoding
|
434
|
+
#
|
435
|
+
# @return [TrueClass] always true
|
436
|
+
def receive_connected(frame, body)
|
437
|
+
@version = frame.headers["version"] || "1.0"
|
438
|
+
@session_id = frame.headers["session"]
|
439
|
+
@server_name = frame.headers["server"]
|
440
|
+
if frame.headers["heart-beat"]
|
441
|
+
@heartbeat = Heartbeat.new(self, frame.headers["heart-beat"])
|
442
|
+
@heartbeat.start
|
443
|
+
end
|
444
|
+
@connected = true
|
445
|
+
on_connected(frame, @session_id, @server_name)
|
446
|
+
true
|
447
|
+
end
|
448
|
+
|
449
|
+
# Handle message from server
|
450
|
+
# Attempt to decode body if not text
|
451
|
+
#
|
452
|
+
# @param [Frame] frame received from server
|
453
|
+
# @param [String, NilClass] body for frame after optional decoding
|
454
|
+
#
|
455
|
+
# @return [TrueClass] always true
|
456
|
+
#
|
457
|
+
# @raise [ApplicationError] subscription not found, subscription does not
|
458
|
+
# match destination, duplicate ack ID
|
459
|
+
def receive_message(frame, body)
|
460
|
+
required = {"destination" => [], "message-id" => [], "subscription" => ["1.0"]}
|
461
|
+
destination, message_id, subscribe_id = frame.require(@version, required)
|
462
|
+
if (subscribe = @subscribes[destination])
|
463
|
+
if subscribe[:id] != subscribe_id && @version != "1.0"
|
464
|
+
raise ApplicationError.new("Subscription does not match destination '#{destination}'", frame)
|
465
|
+
end
|
466
|
+
ack_id = nil
|
467
|
+
if subscribe[:ack] != "auto"
|
468
|
+
# Create ack ID if there is none so that user of this class can always rely
|
469
|
+
# on its use for ack/nack and then correspondingly track message IDs so that
|
470
|
+
# convert back to ack ID when needed
|
471
|
+
ack_id = frame.require(@version, "ack" => ["1.0", "1.1"])
|
472
|
+
ack_id ||= (@ack_id += 1).to_s
|
473
|
+
if (message_id2 = @message_ids[ack_id])
|
474
|
+
raise ApplicationError.new("Duplicate ack #{ack_id} for messages #{message_id2} and #{message_id}", frame)
|
475
|
+
end
|
476
|
+
@message_ids[ack_id] = message_id
|
477
|
+
end
|
478
|
+
else
|
479
|
+
raise ApplicationError.new("Subscription to '#{destination}' not found", frame)
|
480
|
+
end
|
481
|
+
content_type = frame.headers["content-type"] || "text/plain"
|
482
|
+
on_message(frame, destination, body, content_type, message_id, ack_id)
|
483
|
+
true
|
484
|
+
end
|
485
|
+
|
486
|
+
# Handle receipt acknowledgement from server for frame sent earlier
|
487
|
+
#
|
488
|
+
# @param [Frame] frame received from server
|
489
|
+
# @param [String, NilClass] body for frame after optional decoding
|
490
|
+
#
|
491
|
+
# @return [TrueClass] always true
|
492
|
+
#
|
493
|
+
# @raise [ProtocolError] missing header
|
494
|
+
# @raise [ApplicationError] request for receipt not found
|
495
|
+
def receive_receipt(frame, body)
|
496
|
+
id = frame.require(@version, "receipt-id" => [])
|
497
|
+
raise ApplicationError.new("Request not found matching receipt #{id}") if @receipted_frames.delete(id).nil?
|
498
|
+
on_receipt(frame, id)
|
499
|
+
end
|
500
|
+
|
501
|
+
# Handle error reported by server
|
502
|
+
#
|
503
|
+
# @param [Frame] frame received from server
|
504
|
+
# @param [String, NilClass] body for frame after optional decoding
|
505
|
+
#
|
506
|
+
# @return [TrueClass] always true
|
507
|
+
def receive_error(frame, body)
|
508
|
+
on_error(frame, frame.headers["message"], body, frame.headers["receipt-id"])
|
509
|
+
true
|
510
|
+
end
|
511
|
+
|
512
|
+
##########################
|
513
|
+
## STOMP Support Functions
|
514
|
+
##########################
|
515
|
+
|
516
|
+
# Process all complete frames that have been received
|
517
|
+
#
|
518
|
+
# @return [TrueClass] always true
|
519
|
+
def process_frames
|
520
|
+
while (frame = @parser.next) do process_frame(frame) end
|
521
|
+
true
|
522
|
+
end
|
523
|
+
|
524
|
+
# Process frame received from server
|
525
|
+
# Optionally JSON-decode body if "content-type" is "application/json"
|
526
|
+
#
|
527
|
+
# @param [Frame] frame received; body updated on return if is decoded
|
528
|
+
#
|
529
|
+
# @return [TrueClass] always true
|
530
|
+
#
|
531
|
+
# @raise [ProtocolError] unhandled frame
|
532
|
+
def process_frame(frame)
|
533
|
+
command = frame.command.downcase.to_sym
|
534
|
+
raise ProtocolError.new("Unhandled frame: #{frame.command}", frame) unless SERVER_COMMANDS.include?(command)
|
535
|
+
if (body = frame.body) && !body.empty? && frame.headers["content-type"] == "application/json" && @options[:auto_json]
|
536
|
+
body = JSON.load(body)
|
537
|
+
end
|
538
|
+
send(("receive_" + command.to_s).to_sym, frame, body)
|
539
|
+
end
|
540
|
+
|
541
|
+
# Send frame to server
|
542
|
+
# Optionally JSON-encode body if "content-type" is "application/json"
|
543
|
+
#
|
544
|
+
# @param [String] command name
|
545
|
+
# @param [Hash, NilClass] headers for frame; others added if there is a body
|
546
|
+
# @param [String, NilClass] body of message
|
547
|
+
# @param [String, NilClass] content_type per MIME; defaults to "text/plain"
|
548
|
+
# @param [Boolean, NilClass] receipt enabled (or'd with global setting)
|
549
|
+
# @param [String, NilClass] transaction_id uniquely identifying transaction
|
550
|
+
#
|
551
|
+
# @return [Frame] frame sent
|
552
|
+
#
|
553
|
+
# @raise [ApplicationError] transaction not found
|
554
|
+
def send_frame(command, headers = nil, body = nil, content_type = nil, receipt = nil, transaction_id = nil)
|
555
|
+
headers ||= {}
|
556
|
+
if body && !body.empty?
|
557
|
+
headers["content-type"] = content_type || "text/plain"
|
558
|
+
body = JSON.dump(body) if content_type == "application/json" && @options[:auto_json]
|
559
|
+
headers["content-length"] = body.size.to_s
|
560
|
+
else
|
561
|
+
body = ""
|
562
|
+
end
|
563
|
+
if transaction_id
|
564
|
+
transaction_id = transaction_id.to_s
|
565
|
+
raise ApplicationError.new("Transaction not found") unless @transaction_ids.index(transaction_id)
|
566
|
+
headers["transaction"] = transaction_id
|
567
|
+
end
|
568
|
+
frame = StompOut::Frame.new(command, headers, body)
|
569
|
+
if (receipt || @receipt) && command != "CONNECT"
|
570
|
+
receipt_id = frame.headers["receipt"] = (@receipt_id += 1).to_s
|
571
|
+
@receipted_frames[receipt_id] = frame
|
572
|
+
end
|
573
|
+
send_data(frame.to_s)
|
574
|
+
@heartbeat.sent_data if @heartbeat
|
575
|
+
frame
|
576
|
+
end
|
577
|
+
|
578
|
+
end # Client
|
579
|
+
|
580
|
+
end # StompOut
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# Copyright (c) 2015 RightScale Inc
|
2
|
+
#
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
# a copy of this software and associated documentation files (the
|
5
|
+
# "Software"), to deal in the Software without restriction, including
|
6
|
+
# without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
# distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
# permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
# the following conditions:
|
10
|
+
#
|
11
|
+
# The above copyright notice and this permission notice shall be
|
12
|
+
# included in all copies or substantial portions of the Software.
|
13
|
+
#
|
14
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
22
|
+
module StompOut
|
23
|
+
|
24
|
+
# Exception for STOMP protocol violations
|
25
|
+
class ProtocolError < RuntimeError
|
26
|
+
|
27
|
+
# [Hash] Headers to be included in an ERROR response
|
28
|
+
attr_reader :headers
|
29
|
+
|
30
|
+
# [String, NilClass] Contents of "receipt" header in frame causing error
|
31
|
+
attr_reader :receipt
|
32
|
+
|
33
|
+
# [Frame, NilClass] Frame for which error occurred
|
34
|
+
attr_reader :frame
|
35
|
+
|
36
|
+
# Create exception
|
37
|
+
#
|
38
|
+
# @param [String] message describing error
|
39
|
+
# @param [Frame, NilClass] frame that caused error
|
40
|
+
# @param [Hash, NilClass] headers to be included in an ERROR response
|
41
|
+
def initialize(message, frame = nil, headers = nil)
|
42
|
+
@frame = frame
|
43
|
+
@headers = headers || {}
|
44
|
+
super(message)
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
|
49
|
+
# Exception for application level STOMP protocol violations, i.e.,
|
50
|
+
# for any additional rules that the application applying STOMP imposes
|
51
|
+
class ApplicationError < RuntimeError
|
52
|
+
|
53
|
+
# [Frame, NilClass] Frame for which error occurred
|
54
|
+
attr_reader :frame
|
55
|
+
|
56
|
+
# Create exception
|
57
|
+
#
|
58
|
+
# @param [String] message describing error
|
59
|
+
# @param [Frame, NilClass] frame that caused error
|
60
|
+
def initialize(message, frame = nil)
|
61
|
+
@frame = frame
|
62
|
+
super(message)
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
|
67
|
+
end # StompOut
|