startback 1.2.4 → 2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +954 -0
- data/README.md +45 -2
- data/UPGRADING.md +460 -0
- data/lib/startback/caching/entity_cache.rb +2 -2
- data/lib/startback/event/bus/bunny/async.rb +131 -9
- data/lib/startback/security/rate_limiter.rb +1 -1
- data/lib/startback/version.rb +3 -3
- data/lib/startback/web/auto_caching.rb +10 -4
- data/lib/startback/web/cors_headers.rb +8 -2
- data/lib/startback/web/health_check.rb +1 -1
- data/spec/spec_helper.rb +7 -0
- data/spec/support/bunny_broker.rb +145 -0
- data/spec/unit/event/bus/bunny/test_async.rb +296 -0
- data/spec/unit/web/test_auto_caching.rb +19 -0
- data/spec/unit/web/test_cors_headers.rb +21 -0
- metadata +142 -58
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'startback/event/bus/bunny'
|
|
3
|
+
|
|
4
|
+
module Startback
|
|
5
|
+
class Event
|
|
6
|
+
describe Bus::Bunny::Async do
|
|
7
|
+
|
|
8
|
+
# Exchange and queue names are per-example, see BunnyBroker#unique:
|
|
9
|
+
# both are durable, so they outlive the example that created them.
|
|
10
|
+
let(:type) { BunnyBroker.unique("Spec::Event") }
|
|
11
|
+
let(:processor) { BunnyBroker.unique("spec-processor") }
|
|
12
|
+
let(:url) { BunnyBroker.url }
|
|
13
|
+
|
|
14
|
+
let(:bus) { Bus::Bunny::Async.new(url) }
|
|
15
|
+
|
|
16
|
+
# Busses an example connected, closed afterwards whatever happens, so
|
|
17
|
+
# that a failing example does not leak a connection into the next one.
|
|
18
|
+
let(:opened) { [] }
|
|
19
|
+
|
|
20
|
+
def connected_bus(options = {})
|
|
21
|
+
Bus::Bunny::Async.new({ url: url }.merge(options)).tap do |b|
|
|
22
|
+
opened << b
|
|
23
|
+
b.connect unless options[:autoconnect]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
before do
|
|
28
|
+
BunnyBroker.skip_unless_available!(self)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
after do
|
|
32
|
+
opened.each { |b| b.disconnect rescue nil }
|
|
33
|
+
BunnyBroker.delete_topology(exchanges: [type], queues: [processor, "main"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "connecting" do
|
|
37
|
+
|
|
38
|
+
it 'is not connected before connect is called' do
|
|
39
|
+
expect(bus.connected?).to be_falsey
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'connects and disconnects' do
|
|
43
|
+
b = connected_bus
|
|
44
|
+
expect(b.connected?).to eql(true)
|
|
45
|
+
b.disconnect
|
|
46
|
+
expect(b.connected?).to be_falsey
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'connects at construction when autoconnect is set' do
|
|
50
|
+
b = connected_bus(autoconnect: true)
|
|
51
|
+
expect(b.connected?).to eql(true)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'takes a String as being the url' do
|
|
55
|
+
b = Bus::Bunny::Async.new(url)
|
|
56
|
+
opened << b
|
|
57
|
+
expect(b.options[:url]).to eql(url)
|
|
58
|
+
b.connect
|
|
59
|
+
expect(b.connected?).to eql(true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'defaults the url to STARTBACK_BUS_BUNNY_ASYNC_URL' do
|
|
63
|
+
# DEFAULT_OPTIONS captures the variable at load time, so the class
|
|
64
|
+
# constant is the observable, not a re-read of ENV here.
|
|
65
|
+
expect(Bus::Bunny::Async::DEFAULT_OPTIONS[:url])
|
|
66
|
+
.to eql(ENV['STARTBACK_BUS_BUNNY_ASYNC_URL'])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'refuses to hand out a channel before connecting' do
|
|
70
|
+
expect {
|
|
71
|
+
bus.channel
|
|
72
|
+
}.to raise_error(Startback::Errors::Error, /connect your bus first/)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe "emitting and listening" do
|
|
78
|
+
|
|
79
|
+
it 'round trips an event through the broker with default options' do
|
|
80
|
+
# Regression test for the defaults themselves: RabbitMQ 4 refuses
|
|
81
|
+
# transient non-exclusive queues, which is what queue_options used
|
|
82
|
+
# to ask for. This example fails with a Timeout::Error there.
|
|
83
|
+
b = connected_bus
|
|
84
|
+
seen = Queue.new
|
|
85
|
+
b.listen(type, processor) { |body| seen << body }
|
|
86
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
87
|
+
|
|
88
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
89
|
+
expect(received).not_to be_nil
|
|
90
|
+
parsed = JSON.parse(received)
|
|
91
|
+
expect(parsed["type"]).to eql(type)
|
|
92
|
+
expect(parsed["data"]).to eql({ "id" => 12 })
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'hands the listener the raw JSON String, not an Event' do
|
|
96
|
+
# Documents an asymmetry with Bus::Memory::Async, which hands over
|
|
97
|
+
# an Event instance. Listeners are not portable between the two.
|
|
98
|
+
b = connected_bus
|
|
99
|
+
seen = Queue.new
|
|
100
|
+
b.listen(type, processor) { |body| seen << body }
|
|
101
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
102
|
+
|
|
103
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
104
|
+
expect(received).to be_a(String)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'declares the exchange and the queue durable' do
|
|
108
|
+
b = connected_bus
|
|
109
|
+
b.listen(type, processor) { |body| }
|
|
110
|
+
|
|
111
|
+
# Redeclaring passively tells us what the broker actually holds:
|
|
112
|
+
# a mismatch on durable would raise Bunny::PreconditionFailed.
|
|
113
|
+
expect {
|
|
114
|
+
ch = b.channel
|
|
115
|
+
ch.fanout(type, durable: true, passive: true)
|
|
116
|
+
ch.queue(processor, durable: true, passive: true)
|
|
117
|
+
}.not_to raise_error
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'allows mixing Symbol vs. String for event type' do
|
|
121
|
+
b = connected_bus
|
|
122
|
+
seen = Queue.new
|
|
123
|
+
b.listen(type.to_sym, processor) { |body| seen << body }
|
|
124
|
+
b.emit(Event.new(type.to_sym, { id: 12 }))
|
|
125
|
+
|
|
126
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
127
|
+
expect(received).not_to be_nil
|
|
128
|
+
expect(JSON.parse(received)["type"]).to eql(type)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'fans an event out to every processor queue' do
|
|
132
|
+
other = BunnyBroker.unique("spec-other")
|
|
133
|
+
b = connected_bus
|
|
134
|
+
one, two = Queue.new, Queue.new
|
|
135
|
+
b.listen(type, processor) { |body| one << body }
|
|
136
|
+
b.listen(type, other) { |body| two << body }
|
|
137
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
138
|
+
|
|
139
|
+
expect(BunnyBroker.wait_for { one.pop unless one.empty? }).not_to be_nil
|
|
140
|
+
expect(BunnyBroker.wait_for { two.pop unless two.empty? }).not_to be_nil
|
|
141
|
+
|
|
142
|
+
BunnyBroker.delete_topology(queues: [other])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'does not deliver an event to a queue bound to another type' do
|
|
146
|
+
other_type = BunnyBroker.unique("Spec::Other")
|
|
147
|
+
b = connected_bus
|
|
148
|
+
seen = Queue.new
|
|
149
|
+
b.listen(other_type, processor) { |body| seen << body }
|
|
150
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
151
|
+
|
|
152
|
+
# Nothing should arrive. Give the broker a real chance to prove us
|
|
153
|
+
# wrong before concluding, hence a short but non-zero wait.
|
|
154
|
+
expect(BunnyBroker.wait_for(1) { seen.pop unless seen.empty? }).to be_nil
|
|
155
|
+
|
|
156
|
+
BunnyBroker.delete_topology(exchanges: [other_type])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'requires a listener' do
|
|
160
|
+
b = connected_bus
|
|
161
|
+
expect {
|
|
162
|
+
b.listen(type, processor)
|
|
163
|
+
}.to raise_error(ArgumentError, /listener must be provided/)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe "adopting a pre-existing topology" do
|
|
169
|
+
|
|
170
|
+
# What an older Startback left on the broker: a transient exchange.
|
|
171
|
+
# This is the upgrade path, and it must not need a broker-side
|
|
172
|
+
# migration. Only the exchange, because a transient queue cannot be
|
|
173
|
+
# declared at all from RabbitMQ 4.3 on -- see the last example here.
|
|
174
|
+
def declare_legacy_exchange!
|
|
175
|
+
conn = ::Bunny.new(url, log_level: :fatal)
|
|
176
|
+
conn.start
|
|
177
|
+
conn.create_channel.fanout(type, {})
|
|
178
|
+
conn.close
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'emits through a transient exchange instead of dropping the event' do
|
|
182
|
+
declare_legacy_exchange!
|
|
183
|
+
b = connected_bus
|
|
184
|
+
|
|
185
|
+
seen = Queue.new
|
|
186
|
+
b.listen(type, processor) { |body| seen << body }
|
|
187
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
188
|
+
|
|
189
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
190
|
+
expect(received).not_to be_nil
|
|
191
|
+
expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'leaves the adopted exchange alone rather than redeclaring it' do
|
|
195
|
+
declare_legacy_exchange!
|
|
196
|
+
b = connected_bus
|
|
197
|
+
b.listen(type, processor) { |body| }
|
|
198
|
+
|
|
199
|
+
# Still transient: adopting means taking what is there. Declaring
|
|
200
|
+
# it transient again would raise had the bus turned it durable.
|
|
201
|
+
conn = ::Bunny.new(url, log_level: :fatal)
|
|
202
|
+
conn.start
|
|
203
|
+
expect {
|
|
204
|
+
conn.create_channel.fanout(type, durable: false)
|
|
205
|
+
}.not_to raise_error
|
|
206
|
+
conn.close
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'keeps the channel usable after the conflict' do
|
|
210
|
+
# The conflict closes the channel. Before the bus learned to renew
|
|
211
|
+
# it, the closed one stayed cached per thread and every later emit
|
|
212
|
+
# failed with "cannot use a closed channel" -- silently, since
|
|
213
|
+
# `emit` runs inside stop_errors -- whatever the event type.
|
|
214
|
+
declare_legacy_exchange!
|
|
215
|
+
b = connected_bus
|
|
216
|
+
b.emit(Event.new(type, { id: 1 }))
|
|
217
|
+
|
|
218
|
+
expect(b.channel.open?).to eql(true)
|
|
219
|
+
|
|
220
|
+
other_type = BunnyBroker.unique("Spec::Unrelated")
|
|
221
|
+
other_proc = BunnyBroker.unique("spec-unrelated")
|
|
222
|
+
seen = Queue.new
|
|
223
|
+
b.listen(other_type, other_proc) { |body| seen << body }
|
|
224
|
+
b.emit(Event.new(other_type, { id: 2 }))
|
|
225
|
+
|
|
226
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
227
|
+
expect(received).not_to be_nil
|
|
228
|
+
|
|
229
|
+
BunnyBroker.delete_topology(exchanges: [other_type], queues: [other_proc])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'keeps delivering when exchange AND queue were both transient' do
|
|
233
|
+
# The true state an older Startback leaves behind, and the one that
|
|
234
|
+
# matters: adopting two objects means two channel renewals, so the
|
|
235
|
+
# consumer ends up on a different channel than the one the exchange
|
|
236
|
+
# was adopted on. Probing on the shared channel would then have
|
|
237
|
+
# `emit` close the consumer's channel -- events silently lost.
|
|
238
|
+
#
|
|
239
|
+
# Only reproducible where transient queues can still be declared,
|
|
240
|
+
# i.e. RabbitMQ <= 4.2, which is why CI runs a broker of that
|
|
241
|
+
# generation alongside the current one.
|
|
242
|
+
BunnyBroker.skip_unless_transient_queues!(self)
|
|
243
|
+
|
|
244
|
+
conn = ::Bunny.new(url, log_level: :fatal)
|
|
245
|
+
conn.start
|
|
246
|
+
ch = conn.create_channel
|
|
247
|
+
ch.queue(processor, {}).bind(ch.fanout(type, {}))
|
|
248
|
+
conn.close
|
|
249
|
+
|
|
250
|
+
b = connected_bus
|
|
251
|
+
seen = Queue.new
|
|
252
|
+
b.listen(type, processor) { |body| seen << body }
|
|
253
|
+
b.emit(Event.new(type, { id: 12 }))
|
|
254
|
+
|
|
255
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
256
|
+
expect(received).not_to be_nil
|
|
257
|
+
expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
describe "the asynchronous contract" do
|
|
263
|
+
|
|
264
|
+
it 'hides emit errors from the emitter' do
|
|
265
|
+
# An async bus MUST NOT let errors reach the emitter. Emitting
|
|
266
|
+
# without connecting raises inside, and must stay inside.
|
|
267
|
+
expect {
|
|
268
|
+
bus.emit(Event.new(type, { id: 12 }))
|
|
269
|
+
}.not_to raise_error
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
it 'keeps consuming after a listener raised' do
|
|
273
|
+
b = connected_bus
|
|
274
|
+
seen = Queue.new
|
|
275
|
+
calls = Queue.new
|
|
276
|
+
b.listen(type, processor) do |body|
|
|
277
|
+
calls << body
|
|
278
|
+
raise "listener blew up" if JSON.parse(body)["data"]["id"] == 1
|
|
279
|
+
|
|
280
|
+
seen << body
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
b.emit(Event.new(type, { id: 1 }))
|
|
284
|
+
expect(BunnyBroker.wait_for { calls.pop unless calls.empty? }).not_to be_nil
|
|
285
|
+
|
|
286
|
+
b.emit(Event.new(type, { id: 2 }))
|
|
287
|
+
received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
|
|
288
|
+
expect(received).not_to be_nil
|
|
289
|
+
expect(JSON.parse(received)["data"]).to eql({ "id" => 2 })
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
@@ -76,6 +76,25 @@ module Startback
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
context 'when a lowercase cache-control header is already set by the app' do
|
|
80
|
+
# This is what Rack 3 applications actually emit, Sinatra 4 included.
|
|
81
|
+
# The raw response triple is inspected here on purpose: Rack::Test
|
|
82
|
+
# normalizes header names on read, and would hide a duplicate.
|
|
83
|
+
subject do
|
|
84
|
+
app = Rack::Builder.new do
|
|
85
|
+
use AutoCaching
|
|
86
|
+
run ->(env){ [200, {"cache-control" => "priority"}, ["Hello error"]] }
|
|
87
|
+
end.to_app
|
|
88
|
+
_, headers, _ = app.call(Rack::MockRequest.env_for("/"))
|
|
89
|
+
headers
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'lets the application win, and does not duplicate the header' do
|
|
93
|
+
expect(subject.keys.grep(/cache-control/i)).to eql(["cache-control"])
|
|
94
|
+
expect(subject["cache-control"]).to eql("priority")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
79
98
|
end # CatchAll
|
|
80
99
|
end # module Web
|
|
81
100
|
end # module Startback
|
|
@@ -134,6 +134,27 @@ module Startback
|
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
+
context 'when the app sets specific headers in lowercase' do
|
|
138
|
+
# This is what Rack 3 applications actually emit, Sinatra 4 included.
|
|
139
|
+
# The raw response triple is inspected here on purpose: Rack::Test
|
|
140
|
+
# normalizes header names on read, and would hide a duplicate.
|
|
141
|
+
subject do
|
|
142
|
+
app = Rack::Builder.new do
|
|
143
|
+
use CorsHeaders
|
|
144
|
+
run ->(env){ [200, {'access-control-allow-methods' => "POST"}, ["Hello world"]] }
|
|
145
|
+
end.to_app
|
|
146
|
+
env = Rack::MockRequest.env_for("/", "HTTP_ORIGIN" => "https://test.com")
|
|
147
|
+
_, headers, _ = app.call(env)
|
|
148
|
+
headers
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'does not override them, and does not duplicate them either' do
|
|
152
|
+
expect(subject.keys.grep(/access-control-allow-methods/i))
|
|
153
|
+
.to eql(["access-control-allow-methods"])
|
|
154
|
+
expect(subject["access-control-allow-methods"]).to eql("POST")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
137
158
|
end # CatchAll
|
|
138
159
|
end # module Web
|
|
139
160
|
end # module Startback
|