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.
@@ -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'