stomper 0.3.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,180 @@
1
+ module Stomper
2
+ # An exception raised whenever a Transaction object has been aborted
3
+ # due to an unhandled exception generated by its supplied block, or when
4
+ # the block explicitly aborts the transaction.
5
+ #
6
+ # See also: Stomper::Transaction#perform
7
+ class TransactionAborted < RuntimeError; end
8
+
9
+ # An encapsulation of a stomp transaction. Manually managing transactions
10
+ # is possible through the use of Stomper::Client#begin, Stomper::Client#commit,
11
+ # and Stomper::Client#abort.
12
+ #
13
+ # === Example Usage
14
+ #
15
+ # When the transaction is passed to the block:
16
+ #
17
+ # client.transaction do |t|
18
+ # t.send("/queue/target", "doing some work")
19
+ #
20
+ # # do something that might raise an exception, indicating that any
21
+ # # messages and acknowledgements we have sent should be "undone"
22
+ #
23
+ # t.send("/queue/target", "completed work")
24
+ # end
25
+ #
26
+ # When the block is evaluated within the transaction:
27
+ #
28
+ # client.transaction do
29
+ # send("/queue/target", "doing some work")
30
+ #
31
+ # # ...
32
+ #
33
+ # send("/queue/target", "completed work")
34
+ # end
35
+ #
36
+ # Nesting transactions:
37
+ #
38
+ # client.transaction do |t|
39
+ # t.transaction do |nt|
40
+ # nt.send("/queue/target", ...)
41
+ #
42
+ # nt.transaction do |nnt|
43
+ # nnt.send("/queue/target", ...)
44
+ #
45
+ # # do something with potentially exceptional results
46
+ # end
47
+ #
48
+ # nt.send("/queue/target", ...)
49
+ # end
50
+ #
51
+ # t.send("/queue/target", ...)
52
+ # end
53
+ #
54
+ # See also: Stomper::Client#transaction
55
+ #
56
+ class Transaction
57
+ # The id of this transaction, used to reference the transaction with the stomp broker.
58
+ attr_reader :id
59
+
60
+ # Creates a new Transaction instance. The +client+ parameter
61
+ # is an instance of Stomper::Client and is required so that the Transaction
62
+ # instance has somewhere to forward +begin+, +ack+ and +abort+ methods
63
+ # to. If the +trans_id+ parameter is not specified, an id is automatically
64
+ # generated of the form "tx-{Time.now.to_f}". This name can be accessed
65
+ # through the +id+ attribute and is used in naming the transaction to
66
+ # the stomp broker. If +block+ is given, the Transaction instance immediately
67
+ # calls its perform method with the supplied +block+.
68
+ def initialize(client, trans_id=nil, &block)
69
+ @client = client
70
+ @id = trans_id || "tx-#{Time.now.to_f}"
71
+ @committed = false
72
+ @aborted = false
73
+ perform(&block) if block_given?
74
+ end
75
+
76
+ # Invokes the given +block+. If the +block+ executes normally, the
77
+ # transaction is committed, otherwise it is aborted.
78
+ # If +block+ accepts a parameter, this method yields itself to the block,
79
+ # otherwise, +block+ is evaluated within the context of this instance through
80
+ # +instance_eval+.
81
+ #
82
+ # If a call to +abort+ is issued within the block, the transaction is aborted
83
+ # as demanded, and no attempt is made to commit it; however, no code after the
84
+ # call to +abort+ will be evaluated, as +abort+ raises a TransactionAborted
85
+ # exception.
86
+ #
87
+ # If a call to +commit+ is issued within the block, the transaction is committed
88
+ # as demanded, and no attempt is made to commit it after +block+ has finished
89
+ # executing. As +commit+ does not raise an excpetion, all code after the call
90
+ # to commit will be evaluated.
91
+ #
92
+ # If you are using Transaction objects directly, and not relying on their
93
+ # generation through Stomper::Client#transaction, be warned that this method
94
+ # will raise a TransactionAborted exception if the +block+ evaluation fails.
95
+ # This behavior allows for nesting transactions and ensuring that if a nested
96
+ # transaction fails, so do all of its ancestors.
97
+ def perform(&block) #:yields: transaction
98
+ begin
99
+ @client.begin(@id)
100
+ if block.arity == 1
101
+ yield self
102
+ else
103
+ instance_eval(&block)
104
+ end
105
+ commit
106
+ rescue => err
107
+ _abort
108
+ raise TransactionAborted, "aborted transaction '#{@id}' originator: #{err.to_s}"
109
+ end
110
+ end
111
+
112
+ # Returns true if the Transaction object has already been committed, false
113
+ # otherwise.
114
+ def committed?
115
+ @committed
116
+ end
117
+
118
+ # Returns true if the Transaction object has already been aborted, false
119
+ # otherwise.
120
+ def aborted?
121
+ @aborted
122
+ end
123
+
124
+ # Similar to Stomper::Client#transaction, this method creates a new
125
+ # Transaction object, nested inside of this one. To prevent name
126
+ # collisions, this method automatically generates a transaction id,
127
+ # if one is not specified, of the form "#{parent_transaction_id}-#{Time.now.to_f}.
128
+ def transaction(transaction_id=nil,&block)
129
+ # To get a transaction name guaranteed to not collide with this one
130
+ # we will supply an explicit id to the constructor unless an id was
131
+ # provided
132
+ transaction_id ||= "#{@id}-#{Time.now.to_f}"
133
+ self.class.new(@client, transaction_id, &block)
134
+ end
135
+
136
+ # Wraps the Stomper::Client#send method, injecting a "transaction" header
137
+ # into the +headers+ hash, thus informing the stomp broker that the message
138
+ # generated here is part of this transaction.
139
+ def send(destination, body, headers={})
140
+ headers['transaction'] = @id
141
+ @client.send(destination, body, headers)
142
+ end
143
+
144
+ # Wraps the Stomper::Client#ack method, injecting a "transaction" header
145
+ # into the +headers+ hash, thus informing the stomp broker that the message
146
+ # acknowledgement is part of this transaction.
147
+ def ack(message_or_id, headers={})
148
+ headers['transaction'] = @id
149
+ @client.ack(message_or_id, headers)
150
+ end
151
+
152
+ # Aborts this transaction if it has not already been committed or aborted.
153
+ # Note that it does so by raising a TransactionAborted exception, allowing
154
+ # the +abort+ call to force any ancestral transactions to also fail.
155
+ #
156
+ # See also: commit, committed?, aborted?
157
+ def abort
158
+ raise TransactionAborted, "transaction '#{@id}' aborted explicitly" if _abort
159
+ end
160
+
161
+ # Commits this transaction unless it has already been committed or aborted.
162
+ #
163
+ # See also: abort, committed?, aborted?
164
+ def commit
165
+ # Guard against sending multiple commit messages to the server for a
166
+ # single transaction.
167
+ @client.commit(@id) unless committed? || aborted?
168
+ @committed = true
169
+ end
170
+
171
+ private
172
+ def _abort
173
+ # Guard against sending multiple abort messages to the server for a
174
+ # single transaction.
175
+ return false if committed? || aborted?
176
+ @client.abort(@id)
177
+ @aborted = true
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,167 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'spec_helper'))
2
+
3
+ module Stomper
4
+ describe Client do
5
+ before(:each) do
6
+ # For the client, we want to mock the underlying connection
7
+ @mock_connection = mock("connection")
8
+ @mock_connection.should_receive(:disconnect).with(no_args()).at_most(:once).and_return(nil)
9
+ Stomper::Connection.stub!(:new).and_return(@mock_connection)
10
+ @client = Client.new("stomp:///")
11
+ end
12
+
13
+ describe "expected interface" do
14
+ it "should provide a send method" do
15
+ @client.should respond_to(:send)
16
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Send)).twice.and_return(nil)
17
+ @client.send("/queue/to", "message body", {:additional => 'header'})
18
+ @client.send("/queue/to", "message body")
19
+ end
20
+ it "should provide a subscribe method" do
21
+ @client.should respond_to(:subscribe)
22
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Subscribe)).twice.and_return(nil)
23
+ @client.subscribe("/queue/to", {:additional => 'header'})
24
+ @client.subscribe("/queue/to")
25
+ end
26
+ it "should provide an unsubscribe method" do
27
+ @client.should respond_to(:unsubscribe)
28
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Subscribe)).twice.and_return(nil)
29
+ @client.subscribe("/queue/to", {:id => 'subscription-id'})
30
+ @client.subscribe("/queue/to")
31
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Unsubscribe)).twice.and_return(nil)
32
+ @client.unsubscribe("/queue/to", 'subscription-id')
33
+ @client.unsubscribe("/queue/to")
34
+ end
35
+ it "should provide an ack method" do
36
+ @client.should respond_to(:ack)
37
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Ack)).exactly(3).times.and_return(nil)
38
+ @client.ack("message-id", {:additional => "header"})
39
+ @client.ack("message-id")
40
+ @client.ack(Stomper::Frames::Message.new({:'message-id' => 'msg-001'}, "body"))
41
+ end
42
+ it "should provide a begin method" do
43
+ @client.should respond_to(:begin)
44
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Begin)).once.and_return(nil)
45
+ @client.begin("tx-001")
46
+ end
47
+ it "should proivde an abort method" do
48
+ @client.should respond_to(:abort)
49
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Abort)).once.and_return(nil)
50
+ @client.abort("tx-001")
51
+ end
52
+ it "should provide a commit method" do
53
+ @client.should respond_to(:commit)
54
+ @mock_connection.should_receive(:transmit).with(an_instance_of(Stomper::Frames::Commit)).once.and_return(nil)
55
+ @client.commit("tx-001")
56
+ end
57
+ it "should provide a recieve method" do
58
+ @client.should respond_to(:receive)
59
+ end
60
+ it "should provide a disconnect method" do
61
+ @client.should respond_to(:disconnect)
62
+ end
63
+ it "should provide a close method" do
64
+ @client.should respond_to(:close)
65
+ end
66
+ it "should provide a connectivity test" do
67
+ @client.should respond_to(:connected?)
68
+ end
69
+ end
70
+
71
+ describe "threaded receiver" do
72
+ it "should respond to start and stop" do
73
+ @client.should respond_to(:start)
74
+ @client.should respond_to(:stop)
75
+ @client.should respond_to(:receiving?)
76
+ end
77
+ it "should only be receiving when it is started" do
78
+ @mock_connection.stub!(:receive).and_return(nil)
79
+ @mock_connection.should_receive(:connected?).any_number_of_times.and_return(true)
80
+ @client.receiving?.should be_false
81
+ @client.start
82
+ @client.receiving?.should be_true
83
+ @client.stop
84
+ @client.receiving?.should be_false
85
+ end
86
+ end
87
+
88
+ describe "subscribing to queue" do
89
+ before(:each) do
90
+ @message_sent = Stomper::Frames::Message.new({'destination' => "/queue/test"}, "test message")
91
+ @mock_connection.should_receive(:connected?).any_number_of_times.and_return(true)
92
+ @mock_connection.should_receive(:transmit).with(duck_type(:to_stomp)).at_least(:once).and_return(nil)
93
+ @mock_connection.should_receive(:receive).any_number_of_times.and_return(@message_sent)
94
+ end
95
+
96
+ it "should subscribe to a destination with a block" do
97
+ wait_for_message = true
98
+ @message_received = nil
99
+ @client.start
100
+ @client.subscribe("/queue/test") do |msg|
101
+ @message_received = msg
102
+ wait_for_message = false
103
+ end
104
+ true while wait_for_message
105
+ @client.stop
106
+ @message_received.should == @message_sent
107
+ end
108
+
109
+ it "should not unsubscribe from all destinations when a subscription id is provided" do
110
+ @client.subscribe("/queue/test", { 'id' => 'subscription-1' }) do |msg|
111
+ @message_received = msg
112
+ end
113
+ @client.subscribe("/queue/test") do |msg|
114
+ @message_received = msg
115
+ end
116
+ @client.subscribe("/queue/test", :id => 'subscription-2') do |msg|
117
+ @message_received = msg
118
+ end
119
+ @client.unsubscribe("/queue/test", 'subscription-1')
120
+ @client.subscriptions.size.should == 2
121
+ end
122
+
123
+ it "should not unsubscribe from non-naive subscriptions when only a destination is supplied" do
124
+ @client.subscribe("/queue/test", { 'id' => 'subscription-1' }) do |msg|
125
+ @message_received = msg
126
+ end
127
+ @client.subscribe("/queue/test") do |msg|
128
+ @message_received = msg
129
+ end
130
+ @client.subscribe("/queue/test") do |msg|
131
+ @message_received = msg
132
+ end
133
+ @client.unsubscribe("/queue/test")
134
+ @client.subscriptions.size.should == 1
135
+ end
136
+
137
+ # Due to the receiver running in a separate thread, this may not be correct?
138
+ it "should unsubscribe from a destination and receive no more messages" do
139
+ @mutex = Mutex.new
140
+ @last_message_received = nil
141
+ @client.start
142
+ @client.subscribe("/queue/test") do |msg|
143
+ @last_message_received = Time.now
144
+ end
145
+ true until @last_message_received
146
+ @client.unsubscribe("/queue/test")
147
+ @unsubscribed_at = Time.now
148
+ @client.stop
149
+ (@last_message_received < @unsubscribed_at).should be_true
150
+ end
151
+ end
152
+
153
+ describe "transactions" do
154
+ before(:each) do
155
+ @mock_connection.should_receive(:transmit).with(duck_type(:to_stomp)).at_least(:once).and_return(nil)
156
+ end
157
+
158
+ it "should provide a transaction method that generates a new Transaction" do
159
+ @evaluated = false
160
+ @client.transaction do |t|
161
+ @evaluated = true
162
+ end
163
+ @evaluated.should be_true
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,12 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'spec_helper'))
2
+ require File.expand_path(File.join(File.dirname(__FILE__), 'shared_connection_examples'))
3
+
4
+ module Stomper
5
+ describe Connection do
6
+ before(:each) do
7
+ @connection = Connection.new("stomp:///")
8
+ end
9
+
10
+ it_should_behave_like "All Client Connections"
11
+ end
12
+ end
@@ -0,0 +1,142 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper'))
2
+
3
+ module Stomper::Frames
4
+ describe ClientFrame do
5
+ before(:each) do
6
+ ClientFrame.generate_content_length = true
7
+ @client_frame = ClientFrame.new('COMMAND')
8
+ end
9
+
10
+ def str_size(str)
11
+ str.respond_to?(:bytesize) ? str.bytesize : str.size
12
+ end
13
+
14
+ it "should be provide a headers as an instance of Headers" do
15
+ @client_frame.headers.should be_an_instance_of(Stomper::Frames::Headers)
16
+ end
17
+
18
+ it "should be convertable into a stomp frame" do
19
+ @client_frame.to_stomp.should == "COMMAND\n\n\0"
20
+ @client_frame.headers.destination = "/queue/test/1"
21
+ @client_frame.headers['transaction-id'] = '2'
22
+ @client_frame.headers[:ack] = 'client'
23
+ @client_frame.to_stomp.should == "COMMAND\nack:client\ndestination:/queue/test/1\ntransaction-id:2\n\n\0"
24
+ end
25
+
26
+ describe "generating content-length header" do
27
+ it "should provide the header by default, overriding any existing header" do
28
+ @frame_body = 'testing'
29
+ @client_frame = ClientFrame.new('COMMAND', {'content-length' => 1}, @frame_body)
30
+ @client_frame.to_stomp.should == "COMMAND\ncontent-length:#{str_size(@frame_body)}\n\n#{@frame_body}\0"
31
+ end
32
+
33
+ it "should not provide the header if the class option is set to false, unless explicitly set on the frame in particular" do
34
+ ClientFrame.generate_content_length = false
35
+ @frame_body = 'testing'
36
+ @client_frame = ClientFrame.new('COMMAND', {}, @frame_body)
37
+ @client_frame.to_stomp.should == "COMMAND\n\n#{@frame_body}\0"
38
+ @client_frame = ClientFrame.new('COMMAND', {}, @frame_body)
39
+ @client_frame.generate_content_length = true
40
+ @client_frame.to_stomp.should == "COMMAND\ncontent-length:#{str_size(@frame_body)}\n\n#{@frame_body}\0"
41
+ end
42
+
43
+ it "should not provide the header if instance option is set false, when the class option is true" do
44
+ @frame_body = 'testing'
45
+ @client_frame = ClientFrame.new('COMMAND', {}, @frame_body)
46
+ @client_frame.generate_content_length = false
47
+ @client_frame.to_stomp.should == "COMMAND\n\n#{@frame_body}\0"
48
+ @client_frame = ClientFrame.new('COMMAND', {:generate_content_length => false}, @frame_body)
49
+ @client_frame.to_stomp.should == "COMMAND\n\n#{@frame_body}\0"
50
+ end
51
+
52
+ it "should not overwrite an explicit content-length header when option is off at class or instance level" do
53
+ @frame_body = 'testing'
54
+ @client_frame = ClientFrame.new('COMMAND', { 'content-length' => 4}, @frame_body)
55
+ @client_frame.generate_content_length = false
56
+ @client_frame.to_stomp.should == "COMMAND\ncontent-length:4\n\n#{@frame_body}\0"
57
+ ClientFrame.generate_content_length = false
58
+ @client_frame = ClientFrame.new('COMMAND', {'content-length' => 2}, @frame_body)
59
+ @client_frame.to_stomp.should == "COMMAND\ncontent-length:2\n\n#{@frame_body}\0"
60
+ end
61
+
62
+ it "should the class option should be scoped to the class it is set on" do
63
+ @frame_body = 'testing'
64
+ Send.generate_content_length = false
65
+ @send_frame = Send.new('/queue/test/1', @frame_body)
66
+ @client_frame = ClientFrame.new('COMMAND', {}, @frame_body)
67
+ @client_frame.to_stomp.should == "COMMAND\ncontent-length:#{str_size(@frame_body)}\n\n#{@frame_body}\0"
68
+ @send_frame.to_stomp.should == "SEND\ndestination:#{@send_frame.headers.destination}\n\n#{@frame_body}\0"
69
+ Send.generate_content_length = true
70
+ ClientFrame.generate_content_length = false
71
+ @send_frame = Send.new('/queue/test/1', @frame_body)
72
+ @client_frame = ClientFrame.new('COMMAND', {}, @frame_body)
73
+ @client_frame.to_stomp.should == "COMMAND\n\n#{@frame_body}\0"
74
+ @send_frame.to_stomp.should == "SEND\ncontent-length:#{str_size(@frame_body)}\ndestination:#{@send_frame.headers.destination}\n\n#{@frame_body}\0"
75
+ end
76
+ end
77
+ describe "client frames" do
78
+ describe Abort do
79
+ it "should produce a proper stomp message" do
80
+ @abort = Abort.new("transaction-test", { :a_header => 'test'})
81
+ @abort.to_stomp.should == "ABORT\na_header:test\ntransaction:transaction-test\n\n\0"
82
+ end
83
+ end
84
+ describe Ack do
85
+ it "should produce a proper stomp message" do
86
+ @ack = Ack.new("message-test", { :a_header => 'test'})
87
+ @ack.to_stomp.should == "ACK\na_header:test\nmessage-id:message-test\n\n\0"
88
+ end
89
+
90
+ it "should provide an Ack for a given message frame" do
91
+ @ack = Ack.ack_for(Message.new({'message-id' => 'test'}, "a body"))
92
+ @ack.to_stomp.should == "ACK\nmessage-id:test\n\n\0"
93
+ @ack = Ack.ack_for(Message.new({'message-id' => 'test', 'transaction' => 'tx-test'}, "a body"))
94
+ @ack.to_stomp.should == "ACK\nmessage-id:test\ntransaction:tx-test\n\n\0"
95
+ end
96
+
97
+ end
98
+ describe Begin do
99
+ it "should produce a proper stomp message" do
100
+ @begin = Begin.new("transaction-test", { :a_header => 'test'})
101
+ @begin.to_stomp.should == "BEGIN\na_header:test\ntransaction:transaction-test\n\n\0"
102
+ end
103
+ end
104
+ describe Commit do
105
+ it "should produce a proper stomp message" do
106
+ @commit = Commit.new("transaction-test", { :a_header => 'test'})
107
+ @commit.to_stomp.should == "COMMIT\na_header:test\ntransaction:transaction-test\n\n\0"
108
+ end
109
+ end
110
+ describe Connect do
111
+ it "should produce a proper stomp message" do
112
+ @connect = Connect.new('uzer','s3cr3t', { :a_header => 'test' })
113
+ @connect.to_stomp.should == "CONNECT\na_header:test\nlogin:uzer\npasscode:s3cr3t\n\n\0"
114
+ end
115
+ end
116
+ describe Disconnect do
117
+ it "should produce a proper stomp message" do
118
+ @disconnect = Disconnect.new({ :a_header => 'test'})
119
+ @disconnect.to_stomp.should == "DISCONNECT\na_header:test\n\n\0"
120
+ end
121
+ end
122
+ describe Send do
123
+ it "should produce a proper stomp message" do
124
+ @send = Send.new("/queue/a/target", "a body", { :a_header => 'test'})
125
+ @send.to_stomp.should == "SEND\na_header:test\ncontent-length:6\ndestination:/queue/a/target\n\na body\0"
126
+ end
127
+ end
128
+ describe Subscribe do
129
+ it "should produce a proper stomp message" do
130
+ @subscribe = Subscribe.new("/topic/some/target", { :a_header => 'test'})
131
+ @subscribe.to_stomp.should == "SUBSCRIBE\na_header:test\nack:auto\ndestination:/topic/some/target\n\n\0"
132
+ end
133
+ end
134
+ describe Unsubscribe do
135
+ it "should produce a proper stomp message" do
136
+ @unsubscribe = Unsubscribe.new("/topic/target.name.path", { :a_header => 'test'})
137
+ @unsubscribe.to_stomp.should == "UNSUBSCRIBE\na_header:test\ndestination:/topic/target.name.path\n\n\0"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end