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,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'
|