startback 2.1.0 → 2.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 844c222d0b34ee64835caafe69e4a52b0fad2176653b007208fb16feba998556
4
- data.tar.gz: c7a8ff0dd59aa367d4de6fdf4f23c4c916ac52e98e935316ca267af834ef7655
3
+ metadata.gz: 8d29c7de7c7ebfb8c5cc68d73da62108b88cee7e24f0e59712601ed8ca8d776f
4
+ data.tar.gz: 4b3e850a0465313739cb45662983e6af17edeb3dce768f2dcb4a9227085a8362
5
5
  SHA512:
6
- metadata.gz: e928708f893554970de832c1b0c1f1235134614f0fe7e6b82ed8208502f423c6f911d0ad3898f3ddd8185d0bdc85634d03ec40e9b169164100e64c72b1ca824e
7
- data.tar.gz: ab333a49176eda2128b6cc1c502135be60c4cbc9479f05afef983186452f412c9c388f12cd2cece970e3da5a2a39da05e24dd7c2793f5d57e53d2365d90847d0
6
+ metadata.gz: 2f6b32627f15af41212ec13844db638f0d78bb5786d5151e6b0fab86b522ae294ede73bffb6cf2aff839f805eac9c85a9a6dde70a723f35d3b8da35124019c6d
7
+ data.tar.gz: 0070c3a2dc460fe286090ebaf0428a8697b2d0429df770542eda1b5c9f69e6c733caad05e230ab6be5d12ac75967a37be35b7264c28d3fc1f05da0c5f003e328
data/CHANGELOG.md CHANGED
@@ -1,3 +1,95 @@
1
+ ## 2.1.2 - 2026-10-27
2
+
3
+ A crawler could take a page down for everyone. **AutoCaching's fix needs
4
+ nothing from you**; the second half is a middleware to mount where it helps.
5
+
6
+ ### An error response is never made cacheable
7
+
8
+ AutoCaching stamped its production policy -- `public, must-revalidate,
9
+ max-age=3600, s-max-age=3600` by default -- on every response that carried
10
+ none of its own, a 4xx or a 5xx included. Whether a request is answered with
11
+ an error usually depends on that very request: a header, a cookie, a
12
+ credential. A cache in front of the application keys on the url alone, so one
13
+ client's error was handed to everyone who asked for the url next, for an
14
+ hour. Seen in the wild on a marketing site's home page, 400ing for every
15
+ visitor behind an nginx `proxy_cache` because a crawler had asked once with a
16
+ header it had no business sending.
17
+
18
+ A response of status 400 and up now carries `no-store`, in development and in
19
+ production alike. `no-store` rather than no header at all, because 404 and
20
+ 410 are heuristically cacheable (RFC 9111) and a shared cache may keep them
21
+ with nothing else to go by. An application that sets its own `Cache-Control`
22
+ on an error still wins, as it does on a success.
23
+
24
+ Configurable like the other two policies, for an application that does want
25
+ its errors cached:
26
+
27
+ ```ruby
28
+ use Startback::Web::AutoCaching, error: "public, max-age=60"
29
+ ```
30
+
31
+ or through `STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL`.
32
+
33
+ ### `Web::IgnoreContentTypeWithoutBody`
34
+
35
+ What produced that 400 in the first place: a `Content-Type:
36
+ multipart/form-data; boundary=...` on a plain GET makes Rack parse a form out
37
+ of a body that is not there and raise `Rack::Multipart::EmptyContentError`,
38
+ which Sinatra turns into a 400 before the route is ever reached. The url
39
+ answers an error that has nothing to do with what it serves, and crawlers
40
+ send that header on GETs all day. Rack is inconsistent rather than strict
41
+ here: the same request with `Content-Length: 0` parses to no parameters at
42
+ all.
43
+
44
+ The new middleware drops the content type of a GET or a HEAD, so the route
45
+ runs and the page is served:
46
+
47
+ ```ruby
48
+ use Startback::Web::IgnoreContentTypeWithoutBody
49
+ ```
50
+
51
+ Opt-in, like every middleware here. An application with a GET route that
52
+ loads a body -- `Web::Api#loaded_body` dispatches on the request's content
53
+ type -- would see that body become unsupported. A GET that reads a content
54
+ type at all is unusual, content negotiation being `Accept`'s job.
55
+
56
+ ## 2.1.1 - 2026-10-23
57
+
58
+ The Bunny event bus keeps consuming across a broker restart. **Nothing to do
59
+ to get the fix**; `alive?` is there for whoever wants a liveness probe.
60
+
61
+ ### Listeners survive a broker restart
62
+
63
+ 2.1.0 adopts a pre-existing exchange or queue passively when its properties
64
+ differ from the requested ones -- what an older Startback left behind, a
65
+ transient topology. Bunny does not record passive declarations for topology
66
+ recovery. After a broker restart the transient objects are gone, Bunny's
67
+ replay of the queue binding fails with `NOT_FOUND`, the broker closes the
68
+ channel the consumer lived on, and the application keeps running without
69
+ consuming anything, with nothing louder than an error line in Bunny's log.
70
+ It took a manual restart to get it back.
71
+
72
+ Once Bunny reports the connection recovered, the bus now forgets what it
73
+ knew of the topology and subscribes again every listener whose consumer did
74
+ not come back, declaring the exchange and queue as requested -- durable,
75
+ since the transient ones are gone. Listeners whose consumer survived are left
76
+ alone. An application's own `recovery_completed` connection option is still
77
+ called, first.
78
+
79
+ ### `Bus::Bunny::Async#alive?`
80
+
81
+ True when the bus is connected and every listener still has a consumer on an
82
+ open channel. A recovery that fails to subscribe a listener again leaves it
83
+ dead and logs the error; Bunny's own recovery may also never complete at all.
84
+ `alive?` reports both, which makes it the right thing for a liveness probe to
85
+ check:
86
+
87
+ ```ruby
88
+ map '/health/bus' do
89
+ run ->(_env) { bus.async.alive? ? [204, {}, []] : [503, {}, ["Bus is not consuming"]] }
90
+ end
91
+ ```
92
+
1
93
  ## 2.1.0 - 2026-09-30
2
94
 
3
95
  Ruby 4.0 support, docker images for several ruby versions, and the Bunny
@@ -28,6 +28,15 @@ module Startback
28
28
  # A warning is logged, and the durable declaration takes effect on its
29
29
  # own at the next broker restart, transient objects not surviving one.
30
30
  #
31
+ # Listeners **survive a broker restart**. Bunny recovers the topology
32
+ # it recorded, but not what was adopted passively: rebinding such a
33
+ # queue fails with NOT_FOUND and closes the channel its consumer lives
34
+ # on. Once Bunny reports the connection recovered, the bus therefore
35
+ # subscribes again every listener whose consumer did not come back.
36
+ # `alive?` tells whether every listener still consumes, which is what
37
+ # a liveness probe wants to ask: Bunny's own recovery may also never
38
+ # complete at all.
39
+ #
31
40
  # Examples:
32
41
  #
33
42
  # # Connects to RabbitMQ using all default options
@@ -102,6 +111,8 @@ module Startback
102
111
  @options = DEFAULT_OPTIONS.merge(options)
103
112
  @topology = {}
104
113
  @topology_lock = Mutex.new
114
+ @listeners = []
115
+ @listeners_lock = Mutex.new
105
116
  connect if @options[:autoconnect]
106
117
  end
107
118
  attr_reader :options
@@ -114,6 +125,7 @@ module Startback
114
125
  conn = options[:connection_options] || options[:url]
115
126
  try_max_times(10) do
116
127
  @bunny = ::Bunny.new(conn)
128
+ @bunny.after_recovery_completed { recovery_completed(conn) }
117
129
  @bunny.start
118
130
  channel # make sure we already create the channel
119
131
  log(:info, {op: "#{self.class.name}#connect", op_data: conn}, options[:context])
@@ -132,6 +144,13 @@ module Startback
132
144
  @bunny && @bunny.connected?
133
145
  end
134
146
 
147
+ # Whether the bus is connected and every listener still consumes:
148
+ # its channel is open and the broker did not cancel its consumer.
149
+ # False is what a liveness probe should restart the process on.
150
+ def alive?
151
+ !!connected? && listeners.all?(&:alive?)
152
+ end
153
+
135
154
  def channel
136
155
  unless @bunny
137
156
  raise Startback::Errors::Error, "Please connect your bus first, or use autoconnect: true"
@@ -163,19 +182,67 @@ module Startback
163
182
  def listen(type, processor = nil, listener = nil, &bl)
164
183
  raise ArgumentError, "A listener must be provided" unless listener || bl
165
184
 
166
- fanout = declare(:fanout, type.to_s, fanout_options)
167
- queue = declare(:queue, (processor || "main").to_s, queue_options)
185
+ subscription = Subscription.new(type.to_s, (processor || "main").to_s, listener || bl)
186
+ subscribe(subscription)
187
+ @listeners_lock.synchronize { @listeners << subscription }
188
+ subscription.consumer
189
+ end
190
+
191
+ protected
192
+
193
+ # What `listen` was asked for, kept to subscribe again after a
194
+ # broker restart, along with the consumer currently serving it.
195
+ class Subscription < Struct.new(:type, :processor, :listener, :consumer)
196
+ def alive?
197
+ channel = consumer && consumer.channel
198
+ !!channel && channel.open? && channel.consumers.key?(consumer.consumer_tag)
199
+ end
200
+ end
201
+
202
+ def listeners
203
+ @listeners_lock.synchronize { @listeners.dup }
204
+ end
205
+
206
+ def subscribe(subscription)
207
+ fanout = declare(:fanout, subscription.type, fanout_options)
208
+ queue = declare(:queue, subscription.processor, queue_options)
168
209
  # Bound by name on purpose: declaring the queue may have renewed
169
210
  # the channel, in which case `fanout` belongs to a closed one.
170
211
  queue.bind(fanout.name)
171
- queue.subscribe do |delivery_info, properties, body|
212
+ subscription.consumer = queue.subscribe do |delivery_info, properties, body|
172
213
  stop_errors(self, "listen") do
173
- (listener || bl).call(body)
214
+ subscription.listener.call(body)
174
215
  end
175
216
  end
176
217
  end
177
218
 
178
- protected
219
+ # Called by Bunny once it reconnected and replayed the topology it
220
+ # recorded, after the application's own `recovery_completed`
221
+ # callback, if any.
222
+ #
223
+ # What Bunny replayed may no longer match the broker -- objects
224
+ # adopted passively are not replayed at all -- so the topology is
225
+ # probed anew, and the listeners left without a consumer subscribe
226
+ # again. One that fails stays dead, and `alive?` reports it.
227
+ def recovery_completed(conn)
228
+ conn[:recovery_completed].call if conn.is_a?(Hash) && conn[:recovery_completed]
229
+
230
+ @topology_lock.synchronize { @topology = {} }
231
+ listeners.reject(&:alive?).each do |subscription|
232
+ renew_channel!
233
+ subscribe(subscription)
234
+ log(:info, {
235
+ op: "#{self.class.name}#recovery_completed",
236
+ op_data: { type: subscription.type, processor: subscription.processor, msg: "Subscribed again" }
237
+ }, options[:context])
238
+ rescue StandardError => ex
239
+ log(:error, {
240
+ op: "#{self.class.name}#recovery_completed",
241
+ op_data: { type: subscription.type, processor: subscription.processor },
242
+ error: ex
243
+ }, options[:context])
244
+ end
245
+ end
179
246
 
180
247
  # Declares an exchange (`kind` = :fanout) or a queue (:queue) with
181
248
  # `options`, adopting one that already exists with other properties
@@ -2,7 +2,7 @@ module Startback
2
2
  module Version
3
3
  MAJOR = 2
4
4
  MINOR = 1
5
- TINY = 0
5
+ TINY = 2
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -6,9 +6,18 @@ module Startback
6
6
  # This rack middleware automatically mark response as non being cacheable
7
7
  # in development, and being cacheble in production.
8
8
  #
9
- # The headers to set in development and production can be passed at
10
- # construction, as well as whether the development environment must be
11
- # forced. This class may also be configured through environment variables:
9
+ # An error response is never made cacheable, in either mode. Whether a
10
+ # request is answered 4xx usually depends on that very request -- a header,
11
+ # a cookie, a credential -- while a cache in front of the application keys
12
+ # on the url alone, so one client's error would otherwise be served to
13
+ # everyone asking for the url next. It is marked `no-store` rather than
14
+ # merely left alone, because 404 and 410 are heuristically cacheable
15
+ # (RFC 9111) and a shared cache may keep them with no header to go by.
16
+ #
17
+ # The headers to set in development, in production and on an error can be
18
+ # passed at construction, as well as whether the development environment
19
+ # must be forced. This class may also be configured through environment
20
+ # variables:
12
21
  #
13
22
  # - RACK_ENV: when "production" use the production headers, otherwise use
14
23
  # the development ones
@@ -16,6 +25,8 @@ module Startback
16
25
  # to use in development mode
17
26
  # - STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL: Cache-Control header
18
27
  # to use in production mode
28
+ # - STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL: Cache-Control header to use
29
+ # on a response of status 400 and up
19
30
  #
20
31
  # Example:
21
32
  #
@@ -31,6 +42,9 @@ module Startback
31
42
  # # Set production headers manually
32
43
  # use Autocaching, { :production => "public, no-cache, no-store" }
33
44
  #
45
+ # # Let a shared cache keep a not found for a minute
46
+ # use Autocaching, { :error => "public, max-age=60" }
47
+ #
34
48
  class AutoCaching
35
49
 
36
50
  # Cache-Control header to use in development mode
@@ -41,6 +55,13 @@ module Startback
41
55
  PRODUCTION_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_PRODUCTION_CACHE_CONTROL'] ||\
42
56
  "public, must-revalidate, max-age=3600, s-max-age=3600"
43
57
 
58
+ # Cache-Control header to use on an error response, in either mode
59
+ ERROR_CACHE_CONTROL = ENV['STARTBACK_AUTOCACHING_ERROR_CACHE_CONTROL'] ||\
60
+ "no-store"
61
+
62
+ # Lowest status this middleware considers an error
63
+ ERROR_STATUS = 400
64
+
44
65
  def initialize(app, development = nil, cache_headers = {})
45
66
  development, cache_headers = nil, development if development.is_a?(Hash)
46
67
  @app = app
@@ -50,7 +71,7 @@ module Startback
50
71
 
51
72
  def call(env)
52
73
  status, headers, body = @app.call(env)
53
- [status, patch_response_headers(headers), body]
74
+ [status, patch_response_headers(status, headers), body]
54
75
  end
55
76
 
56
77
  protected
@@ -58,9 +79,14 @@ module Startback
58
79
  # Rack::Headers is used so that the defaults set here are actually
59
80
  # overriden by the downstream application, whatever the case it uses
60
81
  # for its own header names.
61
- def patch_response_headers(hs)
62
- defaults = development? ? @cache_headers[:development] : @cache_headers[:production]
63
- Rack::Headers[defaults].merge(hs)
82
+ def patch_response_headers(status, hs)
83
+ Rack::Headers[defaults_for(status)].merge(hs)
84
+ end
85
+
86
+ def defaults_for(status)
87
+ return @cache_headers[:error] if status >= ERROR_STATUS
88
+
89
+ development? ? @cache_headers[:development] : @cache_headers[:production]
64
90
  end
65
91
 
66
92
  def development?
@@ -78,6 +104,9 @@ module Startback
78
104
  },
79
105
  production: {
80
106
  "cache-control" => PRODUCTION_CACHE_CONTROL
107
+ },
108
+ error: {
109
+ "cache-control" => ERROR_CACHE_CONTROL
81
110
  }
82
111
  }
83
112
  end
@@ -0,0 +1,48 @@
1
+ require 'rack'
2
+
3
+ module Startback
4
+ module Web
5
+ #
6
+ # This Rack middleware drops the content type of a request that carries no
7
+ # body, that is, of a GET or a HEAD.
8
+ #
9
+ # A `Content-Type: multipart/form-data; boundary=...` on a GET makes Rack
10
+ # parse a form out of a body that is not there and raise
11
+ # `Rack::Multipart::EmptyContentError`, which Sinatra turns into a 400
12
+ # before the route is ever reached. The answer therefore has nothing to do
13
+ # with what the url serves, and crawlers send exactly that header. Rack is
14
+ # inconsistent here rather than strict: the same request with
15
+ # `Content-Length: 0` parses to no parameters at all.
16
+ #
17
+ # Mount it on an application whose GETs and HEADs ignore the request's
18
+ # content type, which is the usual case -- content negotiation reads
19
+ # `Accept`. An application with a GET route that loads a body (see
20
+ # `Startback::Web::Api#loaded_body`) would see that body become
21
+ # unsupported, hence the opt-in.
22
+ #
23
+ # Examples:
24
+ #
25
+ # Rack::Builder.new do
26
+ # use Startback::Web::IgnoreContentTypeWithoutBody
27
+ # end
28
+ #
29
+ class IgnoreContentTypeWithoutBody
30
+
31
+ BODYLESS_METHODS = %w[GET HEAD]
32
+
33
+ def self.ignore_content_type?(env)
34
+ BODYLESS_METHODS.include?(env["REQUEST_METHOD"]) && env.key?("CONTENT_TYPE")
35
+ end
36
+
37
+ def initialize(app)
38
+ @app = app
39
+ end
40
+
41
+ def call(env)
42
+ env.delete("CONTENT_TYPE") if self.class.ignore_content_type?(env)
43
+ @app.call(env)
44
+ end
45
+
46
+ end # class IgnoreContentTypeWithoutBody
47
+ end # module Web
48
+ end # module Startback
@@ -259,6 +259,105 @@ module Startback
259
259
 
260
260
  end
261
261
 
262
+ describe "staying alive" do
263
+
264
+ # What a broker restart leaves behind when the queue was adopted
265
+ # passively: Bunny does not replay it, rebinding fails, and the
266
+ # consumer is gone. Deleting the queue from elsewhere gets the same
267
+ # outcome -- the broker cancels the consumer -- without a restart.
268
+ def kill_consumer!
269
+ BunnyBroker.delete_topology(queues: [processor])
270
+ end
271
+
272
+ # Bunny calls this once it reconnected and replayed its topology.
273
+ def complete_recovery!(b)
274
+ b.instance_variable_get(:@bunny).send(:notify_of_recovery_completion)
275
+ end
276
+
277
+ it 'is not alive before connecting' do
278
+ expect(bus.alive?).to eql(false)
279
+ end
280
+
281
+ it 'is alive once connected and listening' do
282
+ b = connected_bus
283
+ b.listen(type, processor) { |body| }
284
+ expect(b.alive?).to eql(true)
285
+ end
286
+
287
+ it 'is not alive once disconnected' do
288
+ b = connected_bus
289
+ b.listen(type, processor) { |body| }
290
+ b.disconnect
291
+ expect(b.alive?).to eql(false)
292
+ end
293
+
294
+ it 'is not alive when a consumer is gone' do
295
+ b = connected_bus
296
+ b.listen(type, processor) { |body| }
297
+ kill_consumer!
298
+
299
+ expect(BunnyBroker.wait_for { !b.alive? }).to eql(true)
300
+ end
301
+
302
+ it 'subscribes dead listeners again once a recovery completed' do
303
+ b = connected_bus
304
+ seen = Queue.new
305
+ b.listen(type, processor) { |body| seen << body }
306
+ kill_consumer!
307
+ BunnyBroker.wait_for { !b.alive? }
308
+
309
+ complete_recovery!(b)
310
+
311
+ expect(b.alive?).to eql(true)
312
+ b.emit(Event.new(type, { id: 12 }))
313
+ received = BunnyBroker.wait_for { seen.pop unless seen.empty? }
314
+ expect(received).not_to be_nil
315
+ expect(JSON.parse(received)["data"]).to eql({ "id" => 12 })
316
+ end
317
+
318
+ it 'declares the topology as requested when subscribing again' do
319
+ # An exchange adopted passively before the restart is gone after
320
+ # it: the durable declaration must win then, not the memoized
321
+ # `passive: true`, which would fail with NOT_FOUND.
322
+ b = connected_bus
323
+ b.listen(type, processor) { |body| }
324
+ b.instance_variable_get(:@topology)[[:fanout, type]] = { passive: true }
325
+ BunnyBroker.delete_topology(exchanges: [type], queues: [processor])
326
+ BunnyBroker.wait_for { !b.alive? }
327
+
328
+ complete_recovery!(b)
329
+
330
+ expect(b.alive?).to eql(true)
331
+ expect {
332
+ ch = b.channel
333
+ ch.fanout(type, durable: true, passive: true)
334
+ ch.queue(processor, durable: true, passive: true)
335
+ }.not_to raise_error
336
+ end
337
+
338
+ it 'leaves live listeners alone' do
339
+ b = connected_bus
340
+ consumer = b.listen(type, processor) { |body| }
341
+
342
+ complete_recovery!(b)
343
+
344
+ expect(b.send(:listeners).first.consumer).to equal(consumer)
345
+ end
346
+
347
+ it "still calls the application's own recovery_completed" do
348
+ called = Queue.new
349
+ b = connected_bus(connection_options: ::Bunny::Session.parse_uri(url).merge(
350
+ recovery_completed: -> { called << true }
351
+ ))
352
+ b.listen(type, processor) { |body| }
353
+
354
+ complete_recovery!(b)
355
+
356
+ expect(called.empty?).to eql(false)
357
+ end
358
+
359
+ end
360
+
262
361
  describe "the asynchronous contract" do
263
362
 
264
363
  it 'hides emit errors from the emitter' do
@@ -62,6 +62,64 @@ module Startback
62
62
  end
63
63
  end
64
64
 
65
+ context 'when the application answers an error in production' do
66
+ def cache_control_for(status)
67
+ app = Rack::Builder.new do
68
+ use AutoCaching, false
69
+ run ->(env){ [status, {}, ["Hello error"]] }
70
+ end.to_app
71
+ _, headers, _ = app.call(Rack::MockRequest.env_for("/"))
72
+ headers["cache-control"]
73
+ end
74
+
75
+ # A cache in front of the application keys on the url, while the error
76
+ # is about this request; the production policy would hand it to every
77
+ # later visitor of the url.
78
+ it 'forbids storing a bad request' do
79
+ expect(cache_control_for(400)).to eql("no-store")
80
+ end
81
+
82
+ it 'forbids storing a not found, heuristically cacheable otherwise' do
83
+ expect(cache_control_for(404)).to eql("no-store")
84
+ end
85
+
86
+ it 'forbids storing a server error' do
87
+ expect(cache_control_for(500)).to eql("no-store")
88
+ end
89
+
90
+ it 'still makes a redirect cacheable' do
91
+ expect(cache_control_for(301)).to eql("public, must-revalidate, max-age=3600, s-max-age=3600")
92
+ end
93
+ end
94
+
95
+ context 'when the error policy is set manually' do
96
+ def app
97
+ Rack::Builder.new do
98
+ use AutoCaching, false, error: "public, max-age=60"
99
+ run ->(env){ [404, {}, ["Hello error"]] }
100
+ end
101
+ end
102
+
103
+ it 'uses it rather than the default one' do
104
+ get '/'
105
+ expect(last_response['Cache-Control']).to eql("public, max-age=60")
106
+ end
107
+ end
108
+
109
+ context 'when the application sets its own Cache-Control on an error' do
110
+ def app
111
+ Rack::Builder.new do
112
+ use AutoCaching, false
113
+ run ->(env){ [404, {"cache-control" => "public, max-age=60"}, ["Hello error"]] }
114
+ end
115
+ end
116
+
117
+ it 'lets the application win, as on a success' do
118
+ get '/'
119
+ expect(last_response['Cache-Control']).to eql("public, max-age=60")
120
+ end
121
+ end
122
+
65
123
  context 'when a Cache-Control header is already set by the app' do
66
124
  def app
67
125
  Rack::Builder.new do
@@ -0,0 +1,91 @@
1
+ require 'spec_helper'
2
+ require 'sinatra/base'
3
+ require 'startback/web/ignore_content_type_without_body'
4
+
5
+ module Startback
6
+ module Web
7
+ describe IgnoreContentTypeWithoutBody do
8
+ include Rack::Test::Methods
9
+
10
+ MULTIPART_CTYPE = "multipart/form-data; boundary=----WebKitFormBoundaryJ2mHj"
11
+
12
+ context 'what the application sees' do
13
+ # Reported as a header rather than a body, so that a HEAD can be
14
+ # asserted on the same way as a GET.
15
+ def app
16
+ Rack::Builder.new do
17
+ use IgnoreContentTypeWithoutBody
18
+ run ->(env){ [200, { "x-seen-content-type" => env["CONTENT_TYPE"].to_s }, []] }
19
+ end
20
+ end
21
+
22
+ def seen_content_type
23
+ last_response["x-seen-content-type"]
24
+ end
25
+
26
+ it 'hides the content type a GET has no business sending' do
27
+ get '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
28
+ expect(seen_content_type).to eql("")
29
+ end
30
+
31
+ it 'hides it from a HEAD too' do
32
+ head '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
33
+ expect(seen_content_type).to eql("")
34
+ end
35
+
36
+ it 'leaves the content type of a POST alone, body or no body' do
37
+ post '/', {}, "CONTENT_TYPE" => MULTIPART_CTYPE
38
+ expect(seen_content_type).to eql(MULTIPART_CTYPE)
39
+ end
40
+ end
41
+
42
+ # Rack only parses the form, and only raises, when a body is there to
43
+ # read and no content length says how long it is -- which is what a
44
+ # server hands over for a GET. Rack::Test provides neither on its own.
45
+ def get_with_bogus_content_type
46
+ get '/', {}, {
47
+ "CONTENT_TYPE" => MULTIPART_CTYPE,
48
+ "rack.input" => StringIO.new('')
49
+ }
50
+ end
51
+
52
+ class Page < Sinatra::Base
53
+ set :raise_errors, true
54
+ set :show_exceptions, false
55
+
56
+ get('/') { "hello" }
57
+ end
58
+
59
+ context 'on a sinatra application that serves a GET' do
60
+ def app
61
+ Rack::Builder.new do
62
+ use IgnoreContentTypeWithoutBody
63
+ run Page
64
+ end
65
+ end
66
+
67
+ it 'serves the page instead of raising on the absent body' do
68
+ get_with_bogus_content_type
69
+ expect(last_response.status).to eql(200)
70
+ expect(last_response.body).to eql("hello")
71
+ end
72
+ end
73
+
74
+ context 'on the same application, unprotected' do
75
+ def app
76
+ Page
77
+ end
78
+
79
+ # Which is what this middleware exists for. The day Rack stops telling
80
+ # an empty multipart body apart from a missing one, this fails and the
81
+ # middleware can go.
82
+ it 'answers a 400 that has nothing to do with the url' do
83
+ get_with_bogus_content_type
84
+ expect(last_response.status).to eql(400)
85
+ expect(last_response.body).to include("Invalid multipart/form-data")
86
+ end
87
+ end
88
+
89
+ end # IgnoreContentTypeWithoutBody
90
+ end # module Web
91
+ end # module Startback
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: startback
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bernard Lambeau
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -529,6 +529,7 @@ files:
529
529
  - lib/startback/web/catch_all.rb
530
530
  - lib/startback/web/cors_headers.rb
531
531
  - lib/startback/web/health_check.rb
532
+ - lib/startback/web/ignore_content_type_without_body.rb
532
533
  - lib/startback/web/middleware.rb
533
534
  - lib/startback/web/prometheus.rb
534
535
  - lib/startback/web/shield.rb
@@ -569,6 +570,7 @@ files:
569
570
  - spec/unit/web/test_catch_all.rb
570
571
  - spec/unit/web/test_cors_headers.rb
571
572
  - spec/unit/web/test_healthcheck.rb
573
+ - spec/unit/web/test_ignore_content_type_without_body.rb
572
574
  - tasks/test.rake
573
575
  homepage: https://www.enspirit.be
574
576
  licenses: