stomp_out 0.1.0

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