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