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,667 @@
|
|
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
|
+
require 'simple_uuid'
|
23
|
+
|
24
|
+
module StompOut
|
25
|
+
|
26
|
+
# Abstract base class for STOMP server for use with an existing client connection, such
|
27
|
+
# as a WebSocket. Derived classes are responsible for supplying the following functions:
|
28
|
+
# on_connect(frame, login, passcode, host, session_id) - handle connect request from
|
29
|
+
# client including any authentication
|
30
|
+
# on_message(frame, destination, message, content_type) - handle delivery of message
|
31
|
+
# from client to given destination
|
32
|
+
# on_subscribe(frame, id, destination, ack_setting) - subscribe client to messages
|
33
|
+
# from given destination
|
34
|
+
# on_unsubscribe(frame, id, destination) - remove existing subscription
|
35
|
+
# on_ack(frame, ack_id) - handle acknowledgement from client that message has
|
36
|
+
# been successfully processed
|
37
|
+
# on_nack(frame, ack_id) - handle negative acknowledgement from client for message
|
38
|
+
# on_error(frame, error) - handle notification from server that client or server
|
39
|
+
# request failed and that connection should be closed
|
40
|
+
# on_disconnect(frame, reason) - handle request from client to close connection
|
41
|
+
# The above functions should raise ApplicationError for requests that violate their
|
42
|
+
# server constraints.
|
43
|
+
#
|
44
|
+
class Server
|
45
|
+
|
46
|
+
SUPPORTED_VERSIONS = ["1.0", "1.1", "1.2"]
|
47
|
+
|
48
|
+
ACK_SETTINGS = {
|
49
|
+
"1.0" => ["auto", "client"],
|
50
|
+
"1.1" => ["auto", "client", "client-individual"],
|
51
|
+
"1.2" => ["auto", "client", "client-individual"]
|
52
|
+
}
|
53
|
+
|
54
|
+
CLIENT_COMMANDS = [:stomp, :connect, :send, :subscribe, :unsubscribe, :ack, :nack, :begin, :commit, :abort, :disconnect]
|
55
|
+
TRANSACTIONAL_COMMANDS = [:send, :ack, :nack, :begin, :commit, :abort]
|
56
|
+
|
57
|
+
MIN_SEND_HEARTBEAT = 5000
|
58
|
+
DESIRED_RECEIVE_HEARTBEAT = 60000
|
59
|
+
|
60
|
+
attr_reader :version, :session_id, :server_name, :heartbeat
|
61
|
+
|
62
|
+
# Create STOMP server
|
63
|
+
#
|
64
|
+
# @option options [String] :name of server using STOMP that is to be sent to client
|
65
|
+
# @option options [String] :version of server using STOMP
|
66
|
+
# @option options [Integer] :min_send_interval in msec that server is willing to guarantee;
|
67
|
+
# defaults to MIN_SEND_HEARTBEAT
|
68
|
+
# @option options [Integer] :desired_receive_interval in msec for client to send heartbeats;
|
69
|
+
# defaults to DESIRED_RECEIVE_HEARTBEAT
|
70
|
+
def initialize(options = {})
|
71
|
+
@options = options
|
72
|
+
@ack_id = 0
|
73
|
+
@ack_ids = {} # message-id is key
|
74
|
+
@subscribe_id = 0
|
75
|
+
@subscribes = {} # destination is key
|
76
|
+
@server_name = options[:name] + (options[:version] ? "/#{options[:version]}" : "") if options[:name]
|
77
|
+
@parser = StompOut::Parser.new
|
78
|
+
@transactions = {}
|
79
|
+
@connected = false
|
80
|
+
end
|
81
|
+
|
82
|
+
# Report to server that an error was encountered locally
|
83
|
+
# Not intended for use by end user of this class
|
84
|
+
#
|
85
|
+
# @param [String] error being reported
|
86
|
+
#
|
87
|
+
# @return [TrueClass] always true
|
88
|
+
def report_error(error)
|
89
|
+
frame = Frame.new("ERROR", {"message" => error})
|
90
|
+
on_error(frame, error)
|
91
|
+
true
|
92
|
+
end
|
93
|
+
|
94
|
+
# Determine whether connected to STOMP server
|
95
|
+
#
|
96
|
+
# @return [Boolean] true if connected, otherwise false
|
97
|
+
def connected?
|
98
|
+
!!@connected
|
99
|
+
end
|
100
|
+
|
101
|
+
# Stop service
|
102
|
+
#
|
103
|
+
# @return [TrueClass] always true
|
104
|
+
def disconnect
|
105
|
+
if @connected
|
106
|
+
@heartbeat.stop if @heartbeat
|
107
|
+
@connected = false
|
108
|
+
end
|
109
|
+
true
|
110
|
+
end
|
111
|
+
|
112
|
+
# Process data received over connection from client
|
113
|
+
#
|
114
|
+
# @param [String] data to be processed
|
115
|
+
#
|
116
|
+
# @return [TrueClass] always true
|
117
|
+
def receive_data(data)
|
118
|
+
@parser << data
|
119
|
+
process_frames
|
120
|
+
@heartbeat.received_data if @heartbeat
|
121
|
+
true
|
122
|
+
rescue StandardError => e
|
123
|
+
error(e)
|
124
|
+
end
|
125
|
+
|
126
|
+
##################################
|
127
|
+
## STOMP server subclass functions
|
128
|
+
##################################
|
129
|
+
|
130
|
+
# Send data over connection to client
|
131
|
+
#
|
132
|
+
# @param [String] data that is STOMP encoded
|
133
|
+
#
|
134
|
+
# @return [TrueClass] always true
|
135
|
+
def send_data(data)
|
136
|
+
raise "Not implemented"
|
137
|
+
end
|
138
|
+
|
139
|
+
# Handle connect request from client including any authentication
|
140
|
+
#
|
141
|
+
# @param [Frame] frame received from client
|
142
|
+
# @param [String, NilClass] login name for authentication
|
143
|
+
# @param [String, NilClass] passcode for authentication
|
144
|
+
# @param [String] host to which client wishes to connect; this could be
|
145
|
+
# a virtual host or anything the application requires, or it may
|
146
|
+
# be arbitrary
|
147
|
+
# @param [String] session_id uniquely identifying the given STOMP session
|
148
|
+
#
|
149
|
+
# @return [Boolean] true if connection accepted, otherwise false
|
150
|
+
def on_connect(frame, login, passcode, host, session_id)
|
151
|
+
raise "Not implemented"
|
152
|
+
end
|
153
|
+
|
154
|
+
# Handle delivery of message from client to given destination
|
155
|
+
#
|
156
|
+
# @param [Frame] frame received from client
|
157
|
+
# @param [String] destination for message with format being application specific
|
158
|
+
# @param [Object] message body
|
159
|
+
# @param [String] content_type of message in MIME terms, e.g., "text/plain"
|
160
|
+
#
|
161
|
+
# @raise [ApplicationError] invalid destination
|
162
|
+
#
|
163
|
+
# @return [TrueClass] always true
|
164
|
+
def on_message(frame, destination, message, content_type)
|
165
|
+
raise "Not implemented"
|
166
|
+
end
|
167
|
+
|
168
|
+
# Subscribe client to messages from given destination
|
169
|
+
#
|
170
|
+
# @param [Frame] frame received from client
|
171
|
+
# @param [String] id uniquely identifying subscription within given session
|
172
|
+
# @param [String] destination from which client wishes to receive messages
|
173
|
+
# @param [String] ack_setting for how client wishes to handle acknowledgements:
|
174
|
+
# "auto", "client", or "client-individual"
|
175
|
+
#
|
176
|
+
# @raise [ApplicationError] invalid destination
|
177
|
+
#
|
178
|
+
# @return [TrueClass] always true
|
179
|
+
def on_subscribe(frame, id, destination, ack_setting)
|
180
|
+
raise "Not implemented"
|
181
|
+
end
|
182
|
+
|
183
|
+
# Remove existing subscription
|
184
|
+
#
|
185
|
+
# @param [Frame] frame received from client
|
186
|
+
# @param [String] id of an existing subscription
|
187
|
+
# @param [String] destination for subscription
|
188
|
+
#
|
189
|
+
# @return [TrueClass] always true
|
190
|
+
def on_unsubscribe(frame, id, destination)
|
191
|
+
raise "Not implemented"
|
192
|
+
end
|
193
|
+
|
194
|
+
# Handle acknowledgement from client that message has been successfully processed
|
195
|
+
#
|
196
|
+
# @param [Frame] frame received from client
|
197
|
+
# @param [String] id for acknowledgement assigned to previously sent message
|
198
|
+
#
|
199
|
+
# @return [TrueClass] always true
|
200
|
+
def on_ack(frame, id)
|
201
|
+
raise "Not implemented"
|
202
|
+
end
|
203
|
+
|
204
|
+
# Handle negative acknowledgement from client for message
|
205
|
+
#
|
206
|
+
# @param [Frame] frame received from client
|
207
|
+
# @param [String] id for acknowledgement assigned to previously sent message
|
208
|
+
#
|
209
|
+
# @return [TrueClass] always true
|
210
|
+
def on_nack(frame, id)
|
211
|
+
raise "Not implemented"
|
212
|
+
end
|
213
|
+
|
214
|
+
# Handle notification that a client or server request failed and that the connection
|
215
|
+
# should be closed
|
216
|
+
#
|
217
|
+
# @param [Frame, NilClass] frame for error that was sent to client; nil if failed to send
|
218
|
+
# @param [ProtocolError, ApplicationError, Exception, String] error raised
|
219
|
+
#
|
220
|
+
# @return [TrueClass] always true
|
221
|
+
def on_error(frame, error)
|
222
|
+
raise "Not implemented"
|
223
|
+
end
|
224
|
+
|
225
|
+
# Handle request from client to close connection
|
226
|
+
#
|
227
|
+
# @param [Frame] frame received from client
|
228
|
+
# @param [String] reason for disconnect
|
229
|
+
#
|
230
|
+
# @return [TrueClass] always true
|
231
|
+
def on_disconnect(frame, reason)
|
232
|
+
raise "Not implemented"
|
233
|
+
end
|
234
|
+
|
235
|
+
########################
|
236
|
+
## STOMP Server Commands
|
237
|
+
########################
|
238
|
+
|
239
|
+
# Send message from a subscribed destination to client using MESSAGE frame
|
240
|
+
# - must set "destination" header with the destination to which the message was sent;
|
241
|
+
# should be identical to "destination" of SEND frame if sent using STOMP
|
242
|
+
# - must set "message-id" header uniquely identifying message
|
243
|
+
# - must set "subscription" header matching identifier of subscription receiving the message (only 1.1, 1.2)
|
244
|
+
# - must set "ack" header identifying ack/nack uniquely for this connection if subscription
|
245
|
+
# specified "ack" header with mode "client" or "client-individual" (only 1.2)
|
246
|
+
# - must set the frame body to the body of the message
|
247
|
+
# - should set "content-length" and "content-type" headers if there is a body
|
248
|
+
# - may set other application-specific headers
|
249
|
+
#
|
250
|
+
# @param [Hash] headers for message per requirements above but with "message-id"
|
251
|
+
# defaulting to generated UUID and "ack" defaulting to generated ID if not specified
|
252
|
+
# @param [String] body of message
|
253
|
+
#
|
254
|
+
# @return [Array] message ID and ack ID; latter is nil if ack is in "auto" mode
|
255
|
+
#
|
256
|
+
# @raise [ProtocolError] not connected
|
257
|
+
# @raise [ApplicationError] subscription not found, subscription does not match destination
|
258
|
+
def message(headers, body)
|
259
|
+
raise ProtocolError.new("Not connected") unless @connected
|
260
|
+
frame = Frame.new(nil, (headers && headers.dup) || {})
|
261
|
+
destination, subscribe_id = frame.require(@version, "destination" => [], "subscription" => ["1.0"])
|
262
|
+
message_id = frame.headers["message-id"] ||= SimpleUUID::UUID.new.to_guid
|
263
|
+
|
264
|
+
ack_id = nil
|
265
|
+
if (subscribe = @subscribes[destination])
|
266
|
+
if subscribe[:id] != subscribe_id && @version != "1.0"
|
267
|
+
raise ApplicationError.new("Subscription does not match destination")
|
268
|
+
end
|
269
|
+
if subscribe[:ack] != "auto"
|
270
|
+
# Create ack ID if there is none so that user of this server can rely
|
271
|
+
# on always receiving an ack ID (as opposed to a message ID) on ack/nack
|
272
|
+
# independent of STOMP version in use
|
273
|
+
ack_id = if @version < "1.2"
|
274
|
+
@ack_ids[message_id] = frame.headers.delete("ack") || (@ack_id += 1).to_s
|
275
|
+
else
|
276
|
+
frame.headers["ack"] ||= (@ack_id += 1).to_s
|
277
|
+
end
|
278
|
+
end
|
279
|
+
else
|
280
|
+
raise ApplicationError.new("Subscription not found")
|
281
|
+
end
|
282
|
+
|
283
|
+
send_frame("MESSAGE", frame.headers, body)
|
284
|
+
[message_id, ack_id]
|
285
|
+
end
|
286
|
+
|
287
|
+
protected
|
288
|
+
|
289
|
+
# Report to client using a RECEIPT frame that server has successfully processed a client frame
|
290
|
+
# - must set "receipt-id" header with value from "receipt" header of frame for which
|
291
|
+
# receipt was requested
|
292
|
+
# - the receipt is a cumulative acknowledgement that all previous frames have been
|
293
|
+
# received by server, although not necessarily yet processed; previously received
|
294
|
+
# frames should continue to get processed by the server if the client disconnects
|
295
|
+
#
|
296
|
+
# @param [String] id of receipt
|
297
|
+
#
|
298
|
+
# @return [TrueClass] always true
|
299
|
+
def receipt(id)
|
300
|
+
send_frame("RECEIPT", {"receipt-id" => id})
|
301
|
+
true
|
302
|
+
end
|
303
|
+
|
304
|
+
# Report to client using an ERROR frame that an error was encountered when processing a frame
|
305
|
+
# - must close connection after sending frame
|
306
|
+
# - should set "message" header with short description of the error
|
307
|
+
# - should set additional headers to help identify the original frame, e.g., set
|
308
|
+
# "receipt-id" header if frame in error contained a "receipt" header
|
309
|
+
# - may set the frame body to contain more detailed information
|
310
|
+
# - should set "content-length" and "content-type" headers if there is a body
|
311
|
+
#
|
312
|
+
# @param [Exception] error being reported
|
313
|
+
#
|
314
|
+
# @return [TrueClass] always true
|
315
|
+
def error(exception)
|
316
|
+
details = nil
|
317
|
+
if exception.is_a?(ProtocolError) || exception.is_a?(ApplicationError)
|
318
|
+
headers = exception.respond_to?(:headers) ? exception.headers : {}
|
319
|
+
message = headers["message"] = exception.message
|
320
|
+
if (frame = exception.frame)
|
321
|
+
headers["receipt-id"] = frame.headers["receipt"] if frame.headers.has_key?("receipt") && frame.command != "CONNECT"
|
322
|
+
frame = frame.to_s
|
323
|
+
non_null_length = frame.rindex(NULL) - 1
|
324
|
+
details = "Failed frame:\n-----\n#{frame[0..non_null_length]}\n-----"
|
325
|
+
end
|
326
|
+
frame = send_frame("ERROR", headers, details)
|
327
|
+
else
|
328
|
+
# Rescue this send given that this is an unexpected exception and the send too may
|
329
|
+
# fail; do not want such an exception to keep the user of this class from being notified
|
330
|
+
frame = send_frame("ERROR", {"message" => "Internal STOMP server error"}) rescue nil
|
331
|
+
end
|
332
|
+
on_error(frame, exception)
|
333
|
+
true
|
334
|
+
end
|
335
|
+
|
336
|
+
########################
|
337
|
+
## STOMP client commands
|
338
|
+
########################
|
339
|
+
|
340
|
+
# Create STOMP level connection between client and server
|
341
|
+
# - must contain "accept-version" header with comma-separated list of STOMP versions supported
|
342
|
+
# (only 1.1, 1.2); defaults to "1.0" if missing
|
343
|
+
# - must send ERROR frame and close connection if client and server do not share any common
|
344
|
+
# protocol versions
|
345
|
+
# - must contain "host" header with the name of virtual host to which client wants to connect
|
346
|
+
# (only 1.1, 1.2); if does not match a known virtual host, server supporting virtual hosting
|
347
|
+
# may select default or reject connection
|
348
|
+
# - may contain "login" header identifying client for authentication
|
349
|
+
# - may contain "passcode" header with password for authentication
|
350
|
+
# - may contain "heart-beat" header with two positive integers separated by comma (only 1.1, 1.2);
|
351
|
+
# first integer indicates client support for sending heartbeats with 0 meaning cannot send and any
|
352
|
+
# other value indicating number of milliseconds between heartbeats it can guarantee; second integer
|
353
|
+
# indicates the heartbeats the client would like to receive with 0 meaning none and any other
|
354
|
+
# value indicating the desired number of milliseconds between heartbeats; defaults to no heartbeat
|
355
|
+
# - if accepting connection, must send CONNECTED frame
|
356
|
+
# - must set "version" header with the version this session will use, which is the highest version
|
357
|
+
# that the client and server have in common
|
358
|
+
# - may set "heart-beat" header with the server's settings
|
359
|
+
# - may set "session" header uniquely identifying this session
|
360
|
+
# - may set "server" header with information about the server that must include server name,
|
361
|
+
# optionally followed by "/" and the server version number
|
362
|
+
#
|
363
|
+
# @param [Frame] frame received from client
|
364
|
+
#
|
365
|
+
# @return [TrueClass] always true
|
366
|
+
#
|
367
|
+
# @raise [ProtocolError] missing header, receipt not permitted, invalid login
|
368
|
+
def receive_connect(frame)
|
369
|
+
raise ProtocolError.new("Already connected", frame) if @connected
|
370
|
+
@version = negotiate_version(frame)
|
371
|
+
# No need to pass frame to ProtocolError because connect does not permit "receipt" header
|
372
|
+
raise ProtocolError.new("Receipt not permitted", frame) if frame.headers["receipt"]
|
373
|
+
host = frame.require(@version, "host" => ["1.0"])
|
374
|
+
@session_id = SimpleUUID::UUID.new.to_guid
|
375
|
+
headers = {"version" => @version, "session" => @session_id}
|
376
|
+
if (rate = frame.headers["heart-beat"])
|
377
|
+
@heartbeat = Heartbeat.new(self, rate, @options[:min_send_interval] || MIN_SEND_HEARTBEAT,
|
378
|
+
@options[:desired_receive_interval] || DESIRED_RECEIVE_HEARTBEAT)
|
379
|
+
headers["heart-beat"] = [@heartbeat.outgoing_rate, @heartbeat.incoming_rate].join(",")
|
380
|
+
end
|
381
|
+
headers["server"] = @server_name if @server_name
|
382
|
+
if on_connect(frame, frame.headers["login"], frame.headers["passcode"], host, @session_id)
|
383
|
+
@connected = true
|
384
|
+
send_frame("CONNECTED", headers)
|
385
|
+
@heartbeat.start if @heartbeat
|
386
|
+
else
|
387
|
+
raise ProtocolError.new("Invalid login", frame)
|
388
|
+
end
|
389
|
+
true
|
390
|
+
end
|
391
|
+
|
392
|
+
alias :receive_stomp :receive_connect
|
393
|
+
|
394
|
+
# Receive message from client to be delivered to given destination in messaging system
|
395
|
+
# - must send ERROR frame and close connection if server cannot process message
|
396
|
+
# - must contain "destination" header
|
397
|
+
# - should include "content-length" and "content-type" headers if there is a body
|
398
|
+
# - may contain "transaction" header
|
399
|
+
# - may contain other application-specific headers, e.g., for filtering
|
400
|
+
#
|
401
|
+
# @param [Frame] frame received from client
|
402
|
+
#
|
403
|
+
# @return [TrueClass] always true
|
404
|
+
#
|
405
|
+
# @raise [ProtocolError] missing header
|
406
|
+
def receive_message(frame)
|
407
|
+
destination = frame.require(@version, "destination" => [])
|
408
|
+
content_type = frame.headers["content-type"] || "text/plain"
|
409
|
+
on_message(frame, destination, frame.body, content_type)
|
410
|
+
true
|
411
|
+
end
|
412
|
+
|
413
|
+
# Handle request from client to register to listen to a given destination
|
414
|
+
# - must send ERROR frame and close connection if server cannot create the subscription
|
415
|
+
# - must contain "destination" header
|
416
|
+
# - any messages received on this destination will be delivered to client as MESSAGE frames
|
417
|
+
# - may contain other server-specific headers to customize delivery
|
418
|
+
# - must contain "id" header uniquely identifying subscription within given connection (optional for 1.0)
|
419
|
+
# - may contain "ack" header with values "auto", "client", or "client-individual"; defaults to "auto"
|
420
|
+
# - "auto" mode means the client does not need to send ACK frames for messages it receives;
|
421
|
+
# the server will assume client has received messages as soon as it sends it to the client
|
422
|
+
# - "client" mode means the client must send ACK/NACK frames and if connection is lost without
|
423
|
+
# receiving ACK, server may redeliver the message to another client; ACK/NACK frames are treated
|
424
|
+
# as cumulative meaning an ACK/NACK acknowledges identified message and all previous
|
425
|
+
# - "client-individual" mode acts like "client" mode except ACK/NACK frames are not cumulative
|
426
|
+
#
|
427
|
+
# @param [Frame] frame received from client
|
428
|
+
#
|
429
|
+
# @return [TrueClass] always true
|
430
|
+
#
|
431
|
+
# @raise [ProtocolError] missing header, invalid ack setting
|
432
|
+
def receive_subscribe(frame)
|
433
|
+
destination, id = frame.require(@version, "destination" => [], "id" => ["1.0"])
|
434
|
+
ack = frame.headers["ack"] || "auto"
|
435
|
+
raise ProtocolError.new("Invalid 'ack' header", frame) unless ACK_SETTINGS[@version].include?(ack)
|
436
|
+
# Assign ID for 1.0 if there is none, but at uniqueness risk if client sometimes specifies
|
437
|
+
id ||= (@subscribe_id += 1).to_s
|
438
|
+
@subscribes[destination] = {:id => id, :ack => ack}
|
439
|
+
on_subscribe(frame, id, destination, ack)
|
440
|
+
true
|
441
|
+
end
|
442
|
+
|
443
|
+
# Handle request from client to remove an existing subscription
|
444
|
+
# - must contain "id" header identifying the subscription (optional for 1.0)
|
445
|
+
# - must contain "destination" header identifying subscription if no "id" header (1.0 only)
|
446
|
+
#
|
447
|
+
# @param [Frame] frame received from client
|
448
|
+
#
|
449
|
+
# @return [TrueClass] always true
|
450
|
+
#
|
451
|
+
# @raise [ProtocolError] missing header
|
452
|
+
def receive_unsubscribe(frame)
|
453
|
+
id = destination = nil
|
454
|
+
begin
|
455
|
+
id = frame.require(@version, "id" => [])
|
456
|
+
rescue ProtocolError
|
457
|
+
raise if @version != "1.0"
|
458
|
+
destination = frame.require(@version, "destination" => ["1.1", "1.2"])
|
459
|
+
end
|
460
|
+
unless destination
|
461
|
+
@subscribes.each { |key, value| (destination = key; break) if value[:id] == id }
|
462
|
+
end
|
463
|
+
if (subscribe = @subscribes.delete(destination))
|
464
|
+
on_unsubscribe(frame, id || subscribe[:id], destination)
|
465
|
+
else
|
466
|
+
raise ProtocolError.new("Subscription not found", frame)
|
467
|
+
end
|
468
|
+
true
|
469
|
+
end
|
470
|
+
|
471
|
+
# Handle acknowledgement from client that it has consumed a message for a subscription
|
472
|
+
# with "ack" header set to "client" or "client-individual"
|
473
|
+
# - must contain "id" header matching the "ack" header of the MESSAGE being acknowledged (1.2 only)
|
474
|
+
# - must contain "message-id" header matching the header of the MESSAGE being acknowledged (1.0, 1.1 only)
|
475
|
+
# - may contain "transaction" header indicating acknowledging as part of the named transaction
|
476
|
+
#
|
477
|
+
# @param [Frame] frame received from client
|
478
|
+
#
|
479
|
+
# @return [TrueClass] always true
|
480
|
+
#
|
481
|
+
# @raise [ProtocolError] missing header
|
482
|
+
def receive_ack(frame)
|
483
|
+
id, message_id = frame.require(@version, "id" => ["1.0", "1.1"], "message-id" => ["1.2"])
|
484
|
+
on_ack(frame, id || @ack_ids.delete(message_id))
|
485
|
+
true
|
486
|
+
end
|
487
|
+
|
488
|
+
# Handle negative acknowledgement from client indicating that a message was not consumed (only 1.2)
|
489
|
+
# - must contain "id" header matching the "ack" header of the MESSAGE not consumed (1.2 only)
|
490
|
+
# - may contain "transaction" header indicating not acknowledging as part of the named transaction
|
491
|
+
#
|
492
|
+
# @param [Frame] frame received from client
|
493
|
+
#
|
494
|
+
# @return [TrueClass] always true
|
495
|
+
#
|
496
|
+
# @raise [ProtocolError] missing header, invalid command
|
497
|
+
def receive_nack(frame)
|
498
|
+
raise ProtocolError.new("Invalid command", frame) if @version == "1.0"
|
499
|
+
id, message_id = frame.require(@version, "id" => ["1.0", "1.1"], "message-id" => ["1.2"])
|
500
|
+
on_nack(frame, id || @ack_ids.delete(message_id))
|
501
|
+
true
|
502
|
+
end
|
503
|
+
|
504
|
+
# Handle request from client to start a transaction such that any messages sent or
|
505
|
+
# acknowledged during the transaction are processed atomically based on the transaction
|
506
|
+
# - must contain "transaction" header uniquely identifying the transaction within given connection;
|
507
|
+
# value is used in associated SEND, ACK, NACK, COMMIT, and ABORT frames
|
508
|
+
# - any started transactions which have not been committed are implicitly aborted if the
|
509
|
+
# client sends a DISCONNECT or the connection fails
|
510
|
+
#
|
511
|
+
# @param [Frame] frame received from client
|
512
|
+
#
|
513
|
+
# @return [TrueClass] always true
|
514
|
+
#
|
515
|
+
# @raise [ProtocolError] missing header, transaction already exists
|
516
|
+
def receive_begin(frame)
|
517
|
+
transaction = frame.require(@version, "transaction" => [])
|
518
|
+
raise ProtocolError.new("Transaction already exists", frame) if @transactions.has_key?(transaction)
|
519
|
+
@transactions[transaction] = []
|
520
|
+
true
|
521
|
+
end
|
522
|
+
|
523
|
+
# Handle request from client to commit a transaction in progress
|
524
|
+
# - must contain "transaction" header of an existing transaction
|
525
|
+
#
|
526
|
+
# @param [Frame] frame received from client
|
527
|
+
#
|
528
|
+
# @return [TrueClass] always true
|
529
|
+
#
|
530
|
+
# @raise [ProtocolError] missing header, transaction not found
|
531
|
+
def receive_commit(frame)
|
532
|
+
transaction = frame.require(@version, "transaction" => [])
|
533
|
+
raise ProtocolError.new("Transaction not found", frame) unless @transactions.has_key?(transaction)
|
534
|
+
(@transactions[transaction]).each do |f|
|
535
|
+
f.headers.delete("transaction")
|
536
|
+
process_frame(f)
|
537
|
+
end
|
538
|
+
@transactions.delete(transaction)
|
539
|
+
true
|
540
|
+
end
|
541
|
+
|
542
|
+
# Handle request from client to roll back a transaction in progress
|
543
|
+
# - must contain "transaction" header of an existing transaction
|
544
|
+
#
|
545
|
+
# @param [Frame] frame received from client
|
546
|
+
#
|
547
|
+
# @return [TrueClass] always true
|
548
|
+
#
|
549
|
+
# @raise [ProtocolError] missing header, transaction not found
|
550
|
+
def receive_abort(frame)
|
551
|
+
transaction = frame.require(@version, "transaction" => [])
|
552
|
+
raise ProtocolError.new("Transaction not found", frame) unless @transactions.has_key?(transaction)
|
553
|
+
@transactions.delete(transaction)
|
554
|
+
end
|
555
|
+
|
556
|
+
# Handle request from client to close the connection
|
557
|
+
# - may contain "receipt" header
|
558
|
+
# - no other frames should be received from client after this
|
559
|
+
#
|
560
|
+
# @param [Frame] frame received from client
|
561
|
+
#
|
562
|
+
# @return [TrueClass] always true
|
563
|
+
def receive_disconnect(frame)
|
564
|
+
on_disconnect(frame, "client request")
|
565
|
+
end
|
566
|
+
|
567
|
+
##########################
|
568
|
+
## STOMP Support Functions
|
569
|
+
##########################
|
570
|
+
|
571
|
+
# Process all complete frames that have been received
|
572
|
+
#
|
573
|
+
# @return [TrueClass] always true
|
574
|
+
def process_frames
|
575
|
+
while (frame = @parser.next) do process_frame(frame) end
|
576
|
+
true
|
577
|
+
end
|
578
|
+
|
579
|
+
# Process frame received from client, if necessary within a transaction
|
580
|
+
#
|
581
|
+
# @param [Frame] frame received from client
|
582
|
+
#
|
583
|
+
# @return [TrueClass] always true
|
584
|
+
#
|
585
|
+
# @raise [ProtocolError] unhandled frame, not connected, transaction not permitted
|
586
|
+
def process_frame(frame)
|
587
|
+
command = frame.command.downcase.to_sym
|
588
|
+
raise ProtocolError.new("Unhandled frame: #{frame.command}", frame) unless CLIENT_COMMANDS.include?(command)
|
589
|
+
raise ProtocolError.new("Not connected", frame) if !@connected && ![:stomp, :connect].include?(command)
|
590
|
+
|
591
|
+
if (transaction = frame.headers["transaction"])
|
592
|
+
raise ProtocolError.new("Transaction not permitted", frame) unless TRANSACTIONAL_COMMANDS.include?(command)
|
593
|
+
handle_transaction(frame, transaction, command)
|
594
|
+
else
|
595
|
+
send((command == :send) ? :receive_message : ("receive_" + command.to_s).to_sym, frame)
|
596
|
+
end
|
597
|
+
|
598
|
+
receipt(frame.headers["receipt"]) if frame.headers["receipt"] && ![:stomp, :connect].include?(command)
|
599
|
+
true
|
600
|
+
end
|
601
|
+
|
602
|
+
# Send frame to client
|
603
|
+
#
|
604
|
+
# @param [String] command name
|
605
|
+
# @param [Hash, NilClass] headers for frame; others added if there is a body
|
606
|
+
# @param [String, NilClass] body of message
|
607
|
+
#
|
608
|
+
# @return [Frame] frame sent
|
609
|
+
#
|
610
|
+
# @raise [ProtocolError] not connected
|
611
|
+
def send_frame(command, headers = nil, body = nil)
|
612
|
+
raise ProtocolError.new("Not connected") if !@connected && command != "ERROR"
|
613
|
+
headers ||= {}
|
614
|
+
if body && !body.empty?
|
615
|
+
headers["content-type"] ||= "text/plain"
|
616
|
+
headers["content-length"] = body.size.to_s
|
617
|
+
else
|
618
|
+
body = ""
|
619
|
+
end
|
620
|
+
frame = StompOut::Frame.new(command, headers, body)
|
621
|
+
send_data(frame.to_s)
|
622
|
+
@heartbeat.sent_data if @heartbeat
|
623
|
+
frame
|
624
|
+
end
|
625
|
+
|
626
|
+
# Handle command being requested in the context of a transaction
|
627
|
+
#
|
628
|
+
# @param [Frame] frame received from client
|
629
|
+
# @param [String] transaction identifier
|
630
|
+
# @param [Symbol] command name
|
631
|
+
#
|
632
|
+
# @return [TrueClass] always true
|
633
|
+
#
|
634
|
+
# @raise [ProtocolError] transaction not found
|
635
|
+
def handle_transaction(frame, transaction, command)
|
636
|
+
if [:begin, :commit, :abort].include?(command)
|
637
|
+
send(("receive_" + command.to_s).to_sym, frame)
|
638
|
+
else
|
639
|
+
raise ProtocolError.new("Transaction not found", frame) unless @transactions.has_key?(transaction)
|
640
|
+
@transactions[transaction] << frame
|
641
|
+
end
|
642
|
+
true
|
643
|
+
end
|
644
|
+
|
645
|
+
# Determine STOMP version to be applied based on what client can support and
|
646
|
+
# what this server can support; generate error if there is no common version
|
647
|
+
#
|
648
|
+
# @param [Frame] frame received from client
|
649
|
+
#
|
650
|
+
# @return [String] version chosen
|
651
|
+
#
|
652
|
+
# @raise [ProtocolError] incompatible version
|
653
|
+
def negotiate_version(frame)
|
654
|
+
if (accept = frame.headers["accept-version"])
|
655
|
+
version = nil
|
656
|
+
accepts = accept.split(",")
|
657
|
+
SUPPORTED_VERSIONS.reverse.each { |v| (version = v; break) if accepts.include?(v) }
|
658
|
+
raise ProtocolError.new("Incompatible version", frame, {"version" => SUPPORTED_VERSIONS.join(",")}) if version.nil?
|
659
|
+
else
|
660
|
+
version = SUPPORTED_VERSIONS.first
|
661
|
+
end
|
662
|
+
version
|
663
|
+
end
|
664
|
+
|
665
|
+
end # Server
|
666
|
+
|
667
|
+
end # StompOut
|
data/lib/stomp_out.rb
ADDED
@@ -0,0 +1,29 @@
|
|
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
|
+
require 'json'
|
23
|
+
|
24
|
+
require 'stomp_out/errors.rb'
|
25
|
+
require 'stomp_out/frame.rb'
|
26
|
+
require 'stomp_out/parser.rb'
|
27
|
+
require 'stomp_out/heartbeat.rb'
|
28
|
+
require 'stomp_out/client.rb'
|
29
|
+
require 'stomp_out/server.rb'
|