startback 1.2.3 → 2.0.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.
data/README.md CHANGED
@@ -14,10 +14,11 @@ Currently,
14
14
 
15
15
  ## Public API
16
16
 
17
- This gem uses semantics versioning and has reached it's 1.0 version. The public
18
- API is defined as follows:
17
+ This gem uses semantic versioning. The public API is defined as follows:
19
18
 
20
19
  * All ruby classes, require path, constructor arguments, and public methods.
21
20
 
22
21
  * The `enspirit/startback:api` and `enspirit/startback:web` docker images and
23
22
  main `CMD`.
23
+
24
+ Upgrading across a major version? See [UPGRADING.md](UPGRADING.md).
data/UPGRADING.md ADDED
@@ -0,0 +1,342 @@
1
+ # Upgrading Startback
2
+
3
+ ## From 1.2.x to 2.0.0
4
+
5
+ **Startback's own API has not changed.** Every class, require path, constructor
6
+ argument and public method behaves as it did in 1.2.x. What changed is the
7
+ dependency floor: Sinatra 4, and therefore Rack 3, are now required, and the
8
+ other dependencies moved to their latest major.
9
+
10
+ So almost everything below is about *your* application code meeting Rack 3 and
11
+ Sinatra 4, not about Startback. Each section is written as: what you will see,
12
+ why, and what to do.
13
+
14
+ Rough budget: a small API service usually needs **two changes** -- setting
15
+ `RACK_ENV`, and lowercasing any response triples it builds by hand. The rest
16
+ depends on what you use.
17
+
18
+ ---
19
+
20
+ ## Before you start
21
+
22
+ | | |
23
+ |---|---|
24
+ | Ruby | **>= 3.2** is now enforced by the gemspec. 3.1 is end of life. |
25
+ | webspicy | **Must move to 1.x.** See [webspicy](#10-webspicy-must-move-to-1x) -- this one fails to install, it does not degrade quietly. |
26
+ | Everything else | Installs fine; behaviour changes are listed below. |
27
+
28
+ Start with:
29
+
30
+ ```sh
31
+ bundle update startback
32
+ bundle exec rake test # or whatever runs your suite
33
+ ```
34
+
35
+ Most failures will be issue 1 or issue 2.
36
+
37
+ ---
38
+
39
+ ## 1. Every request returns `403 Host not permitted`
40
+
41
+ **You will see** every request failing with status 403 and a `text/plain` body
42
+ reading `Host not permitted` -- in your test suite first, and in local
43
+ development if you reach the app through anything other than `localhost`.
44
+
45
+ **Why.** Sinatra 4.1 added `Rack::Protection::HostAuthorization` (for
46
+ CVE-2024-21510). In the `development` environment it only accepts `localhost`,
47
+ `*.localhost`, `*.test` and IP literals as `Host`. `development` is the
48
+ environment Sinatra picks when **neither `RACK_ENV` nor `APP_ENV` is set**,
49
+ which is the common case in test suites and docker-compose.
50
+
51
+ Test suites are hit systematically because `Rack::Test` sends requests to
52
+ `example.org`, and so does webspicy's `RackTestClient`.
53
+
54
+ **Production is not affected**: outside `development`, the permitted list is
55
+ empty, which means "allow everything".
56
+
57
+ **Fix, for test suites** -- set the environment before Sinatra is loaded, i.e.
58
+ at the very top of `spec_helper.rb` (or your webspicy `config.rb`), *above* the
59
+ `require`s:
60
+
61
+ ```ruby
62
+ ENV['RACK_ENV'] ||= 'test'
63
+
64
+ require 'startback'
65
+ ```
66
+
67
+ **Fix, for local development behind a custom hostname** -- either set
68
+ `RACK_ENV` in your docker-compose/`.env`, or declare the hosts:
69
+
70
+ ```ruby
71
+ class MyApi < Startback::Web::Api
72
+ set :host_authorization, { permitted_hosts: ['.my-app.internal', '.localhost'] }
73
+ end
74
+ ```
75
+
76
+ A leading dot matches subdomains. Passing an empty list disables the check
77
+ entirely -- reasonable for a service that only ever sits behind a trusted
78
+ reverse proxy, but it is opting out of a CVE fix, so do it deliberately.
79
+
80
+ ---
81
+
82
+ ## 2. A response header appears twice, or a middleware stops seeing it
83
+
84
+ **You will see** responses carrying, say, both `Cache-Control` and
85
+ `cache-control` with different values; or a middleware that used to read a
86
+ header no longer finding it; or a caching proxy behaving oddly.
87
+
88
+ **Why.** The Rack 3 SPEC states that response header keys *"must not contain
89
+ uppercase ASCII characters (A-Z)"*. Rack 3 middleware therefore looks headers
90
+ up in lowercase. A triple you build by hand with `"Content-Type"` is a
91
+ different key from the `"content-type"` everything else uses, so instead of
92
+ overriding, it coexists.
93
+
94
+ Nothing raises. This is a silent behaviour change, which is what makes it worth
95
+ hunting for deliberately.
96
+
97
+ **Fix.** Lowercase the header names in any response triple your code builds:
98
+
99
+ ```ruby
100
+ # before
101
+ [200, { "Content-Type" => "application/json" }, [body]]
102
+
103
+ # after
104
+ [200, { "content-type" => "application/json" }, [body]]
105
+ ```
106
+
107
+ Grep for it:
108
+
109
+ ```sh
110
+ grep -rnE '"(Content-Type|Cache-Control|Location|Content-Length|X-[A-Za-z-]+)"\s*=>' app lib
111
+ ```
112
+
113
+ You do **not** need to change:
114
+
115
+ * `content_type :json` and friends inside a Sinatra route -- Sinatra normalizes.
116
+ * Reading headers from a response object (`response['Content-Type']`) --
117
+ `Rack::Headers` is case-insensitive on read.
118
+ * Startback's own middlewares. `AutoCaching`, `CorsHeaders`, `HealthCheck`,
119
+ `Shield` and `CatchAll` were all fixed in this release; `AutoCaching` and
120
+ `CorsHeaders` had exactly this duplication bug.
121
+
122
+ ---
123
+
124
+ ## 3. `undefined method 'each' for an instance of String`
125
+
126
+ **You will see** that error, or a blank response body.
127
+
128
+ **Why.** Rack 3 requires a response body to respond to `each` or `call`. A bare
129
+ String is no longer a valid body.
130
+
131
+ **Fix.** Wrap it:
132
+
133
+ ```ruby
134
+ # before
135
+ [404, { "content-type" => "text/plain" }, "NotFound"]
136
+
137
+ # after
138
+ [404, { "content-type" => "text/plain" }, ["NotFound"]]
139
+ ```
140
+
141
+ ---
142
+
143
+ ## 4. `uninitialized constant` for a Rack 2 class
144
+
145
+ Rack 3 removed a number of constants. If your app or a third-party middleware
146
+ uses one, it fails at load time:
147
+
148
+ | Removed | Use instead |
149
+ |---|---|
150
+ | `Rack::Utils::HeaderHash` | `Rack::Headers` |
151
+ | `Rack::File` | `Rack::Files` |
152
+ | `Rack::Session::Cookie` | the `rack-session` gem (Sinatra already depends on it) |
153
+ | `Rack::Handler` | `Rackup::Handler`, from the `rackup` gem |
154
+
155
+ If the failure comes from a gem rather than your code, check whether it has a
156
+ Rack 3 compatible release. This is the most common reason an upgrade stalls,
157
+ and it is nothing Startback can shield you from.
158
+
159
+ Sinatra 4 also dropped the `IndifferentHash` initializer, disabled
160
+ `session_hijacking` protection by default, and removed
161
+ `Rack::Protection::EncryptedCookie` (cookies are still encrypted, by
162
+ `Rack::Session::Cookie`). And if you start the server by running the app file
163
+ directly rather than through `config.ru` + puma, you now need the `rackup` gem
164
+ in your Gemfile.
165
+
166
+ ---
167
+
168
+ ## 5. Puma: lifecycle hooks renamed, and a new default bind
169
+
170
+ Puma goes from 6 to 8, crossing two majors. Startback never loads puma itself
171
+ -- it ships it for you -- so nothing here is detectable by Startback's tests.
172
+
173
+ **Puma 7 renamed every lifecycle hook.** If your `puma.rb` uses the old names
174
+ they are simply not called, silently:
175
+
176
+ | Before | After |
177
+ |---|---|
178
+ | `on_worker_boot` | `before_worker_boot` |
179
+ | `on_worker_shutdown` | `before_worker_shutdown` |
180
+ | `on_restart` | `before_restart` |
181
+ | `on_booted` | `after_booted` |
182
+ | `on_stopped` | `after_stopped` |
183
+ | `on_refork` | `before_refork` |
184
+ | `on_thread_start` | `before_thread_start` |
185
+
186
+ This matters most for database connection handling, which is usually exactly
187
+ what those hooks do.
188
+
189
+ **Puma 7 also** made `preload_app!` the default in clustered mode, and requires
190
+ a config instance to be `clamp`-ed before values are read.
191
+
192
+ **Puma 8** changed the default production bind from `0.0.0.0` to `::` when an
193
+ IPv6 interface is available. In a container that publishes ports over IPv4
194
+ only, this can make the service unreachable. Bind explicitly if you care:
195
+
196
+ ```ruby
197
+ # puma.rb
198
+ bind 'tcp://0.0.0.0:3000'
199
+ ```
200
+
201
+ **Not ready?** `gem 'puma', '~> 6.0'` in your Gemfile. Startback accepts
202
+ `>= 6.0.2, < 9.0`.
203
+
204
+ ---
205
+
206
+ ## 6. `undefined method 'fast_generate' for module JSON`
207
+
208
+ **Why.** json 3 removed `JSON.fast_generate`.
209
+
210
+ **Fix.** `JSON.generate`. It is the same output; `fast_generate` only skipped
211
+ the circular-reference check.
212
+
213
+ Startback used it internally in `Security::RateLimiter` and
214
+ `Caching::EntityCache#encode_key`, and both now use `JSON.generate`. **The
215
+ generated strings are identical**, so cache entries and rate-limit counters
216
+ survive the upgrade -- no cache flush needed.
217
+
218
+ ---
219
+
220
+ ## 7. jwt 2 to 3
221
+
222
+ Only relevant if your application uses JWT; Startback ships the gem but never
223
+ loads it. jwt 3 is a real break:
224
+
225
+ * RSA keys must be **at least 2048 bits**. Shorter keys now raise.
226
+ * Base64 decoding follows RFC 4648 strictly; tolerantly-encoded tokens that
227
+ used to decode now fail.
228
+ * The payload cannot be read before the signature is verified.
229
+ * `HS512256` is gone.
230
+ * Custom algorithms must include `JWT::JWA::SigningAlgorithm`.
231
+ * Since 3.3: if you rescue `JWT::DecodeError`, `JWT::IncorrectAlgorithm` or
232
+ `ArgumentError` **around `JWT.encode`**, rescue `JWT::EncodeError` instead.
233
+ Decoding is unaffected.
234
+
235
+ Read jwt's own `UPGRADING.md` before taking it. **Not ready?**
236
+ `gem 'jwt', '~> 2.1'`. Startback accepts `>= 2.1, < 4.0`.
237
+
238
+ ---
239
+
240
+ ## 8. finitio 0.12 to 1.0: check your `.fio` schemas
241
+
242
+ Two removals affect schemas, and one of them changes meaning silently:
243
+
244
+ * `Fixnum` and `Bignum` are gone from `finitio/data`. Use `Integer`. This one
245
+ fails loudly.
246
+ * **`FalseClass` was a bug and is now fixed.** It used to be an alias of
247
+ `.TrueClass`, so it accepted `true` and rejected `false`. If a schema of
248
+ yours worked around that -- writing `FalseClass` where it meant a *true*
249
+ value -- it now means the opposite.
250
+
251
+ Grep before upgrading:
252
+
253
+ ```sh
254
+ grep -rn "Fixnum\|Bignum\|FalseClass" --include=*.fio .
255
+ ```
256
+
257
+ **Not ready?** `gem 'finitio', '~> 0.12'`. Startback accepts `>= 0.12, < 2.0`.
258
+
259
+ ---
260
+
261
+ ## 9. bunny 2 to 3, if you use the event bus
262
+
263
+ Applies to `Startback::Event::Bus::Bunny::Async` only.
264
+
265
+ * Versioned delivery tags are removed.
266
+ * Passive declarations (`passive: true`) are no longer replayed by topology
267
+ recovery.
268
+ * The `openssl` gem >= 3.3 is now required, which means a native build --
269
+ watch slim/alpine images.
270
+
271
+ **Heads up on coverage:** Startback's test matrix has no RabbitMQ, so the Bunny
272
+ bus is upgraded but *unverified by the suite*. If you use it, exercise it in a
273
+ staging environment rather than trusting the green build.
274
+
275
+ **Not ready?** `gem 'bunny', '~> 2.14'`. Startback accepts `>= 2.14, < 4.0`.
276
+
277
+ ---
278
+
279
+ ## 10. webspicy must move to 1.x
280
+
281
+ **You will see** `bundle install` fail outright:
282
+
283
+ ```
284
+ Because every version of startback depends on rack-robustness >= 2.0, < 3.0
285
+ and webspicy >= 0.25.0 depends on rack-robustness >= 1.2, < 2.0,
286
+ every version of startback is incompatible with webspicy >= 0.25.0.
287
+ ```
288
+
289
+ **Why.** Every webspicy 0.27.x release caps `finitio < 0.13`, `http < 6.0` and
290
+ `rack-robustness < 2.0`. webspicy 1.0 widened all three.
291
+
292
+ **Fix.** `gem 'webspicy', '>= 1.0', '< 2.0'`.
293
+
294
+ There is no way around this one, and no quiet degradation: bundler refuses to
295
+ resolve.
296
+
297
+ Coming from 0.26 or earlier, note that webspicy validates unstructured
298
+ response bodies against `output_schema` since 0.27, where it used to skip
299
+ them. A content-negotiating endpoint may need its schema widened accordingly
300
+ (e.g. `[Todo]|Csv` for one serving both JSON and CSV).
301
+
302
+ ---
303
+
304
+ ## Opting out, gem by gem
305
+
306
+ Startback deliberately declares **wide ranges** for the dependencies it does
307
+ not use itself, so you can stay on an older major while still upgrading
308
+ Startback. Add the pin to your own Gemfile:
309
+
310
+ | Gem | Startback accepts | Pin to stay put |
311
+ |---|---|---|
312
+ | puma | `>= 6.0.2, < 9.0` | `gem 'puma', '~> 6.0'` |
313
+ | jwt | `>= 2.1, < 4.0` | `gem 'jwt', '~> 2.1'` |
314
+ | http | `>= 5.0, < 7.0` | `gem 'http', '~> 5.0'` |
315
+ | bunny | `>= 2.14, < 4.0` | `gem 'bunny', '~> 2.14'` |
316
+ | finitio | `>= 0.12, < 2.0` | `gem 'finitio', '~> 0.12'` |
317
+ | json | `>= 2.6, < 4.0` | `gem 'json', '~> 2.6'` |
318
+
319
+ These cannot be opted out of, because Startback's own code depends on them:
320
+
321
+ | Gem | Required |
322
+ |---|---|
323
+ | sinatra | `>= 4.0, < 5.0` -- the middlewares use `Rack::Headers`, Rack 3 only |
324
+ | rack-robustness | `>= 2.0, < 3.0` -- `Shield` and `CatchAll` subclass it |
325
+ | Ruby | `>= 3.2` |
326
+
327
+ ---
328
+
329
+ ## Checklist
330
+
331
+ - [ ] Ruby >= 3.2
332
+ - [ ] `RACK_ENV` set in the test suite, at the top of `spec_helper.rb`
333
+ - [ ] Response triples built by hand use lowercase header names
334
+ - [ ] Response bodies are arrays, not bare Strings
335
+ - [ ] No `Rack::Utils::HeaderHash` / `Rack::File` / `Rack::Handler` left, in
336
+ your code or your gems
337
+ - [ ] `puma.rb` lifecycle hooks renamed; bind address checked
338
+ - [ ] No `JSON.fast_generate` left
339
+ - [ ] `.fio` schemas grepped for `Fixnum`, `Bignum`, `FalseClass`
340
+ - [ ] webspicy on 1.x
341
+ - [ ] jwt / bunny reviewed, or pinned
342
+ - [ ] Bunny event bus exercised somewhere real, since CI does not cover it
@@ -144,10 +144,10 @@ module Startback
144
144
 
145
145
  # Encodes a context free key to an actual cache key.
146
146
  #
147
- # Default implementation uses JSON.fast_generate but MAY be
147
+ # Default implementation uses JSON.generate but MAY be
148
148
  # overriden.
149
149
  def encode_key(context_free_key)
150
- JSON.fast_generate(context_free_key)
150
+ JSON.generate(context_free_key)
151
151
  end
152
152
 
153
153
  # Returns whether `cached` entity seems fresh enough to
@@ -104,7 +104,7 @@ module Startback
104
104
  op_class: op.class.name.to_s,
105
105
  value: value,
106
106
  }
107
- JSON.fast_generate(key)
107
+ JSON.generate(key)
108
108
  end
109
109
 
110
110
  def defaults
@@ -1,8 +1,8 @@
1
1
  module Startback
2
2
  module Version
3
- MAJOR = 1
4
- MINOR = 2
5
- TINY = 3
3
+ MAJOR = 2
4
+ MINOR = 0
5
+ TINY = 0
6
6
  end
7
7
  VERSION = "#{Version::MAJOR}.#{Version::MINOR}.#{Version::TINY}"
8
8
  end
@@ -1,3 +1,5 @@
1
+ require 'rack'
2
+
1
3
  module Startback
2
4
  module Web
3
5
  #
@@ -53,8 +55,12 @@ module Startback
53
55
 
54
56
  protected
55
57
 
58
+ # Rack::Headers is used so that the defaults set here are actually
59
+ # overriden by the downstream application, whatever the case it uses
60
+ # for its own header names.
56
61
  def patch_response_headers(hs)
57
- (development? ? @cache_headers[:development] : @cache_headers[:production]).merge(hs)
62
+ defaults = development? ? @cache_headers[:development] : @cache_headers[:production]
63
+ Rack::Headers[defaults].merge(hs)
58
64
  end
59
65
 
60
66
  def development?
@@ -68,16 +74,16 @@ module Startback
68
74
  def default_headers
69
75
  {
70
76
  development: {
71
- "Cache-Control" => DEVELOPMENT_CACHE_CONTROL
77
+ "cache-control" => DEVELOPMENT_CACHE_CONTROL
72
78
  },
73
79
  production: {
74
- "Cache-Control" => PRODUCTION_CACHE_CONTROL
80
+ "cache-control" => PRODUCTION_CACHE_CONTROL
75
81
  }
76
82
  }
77
83
  end
78
84
 
79
85
  def normalize_headers(h)
80
- Hash[h.map{|k,v| [k, v.is_a?(Hash) ? v : {"Cache-Control" => v} ] }]
86
+ Hash[h.map{|k,v| [k, v.is_a?(Hash) ? v : {"cache-control" => v} ] }]
81
87
  end
82
88
 
83
89
  end # class AutoCaching
@@ -1,3 +1,5 @@
1
+ require 'rack'
2
+
1
3
  module Startback
2
4
  module Web
3
5
  #
@@ -62,7 +64,8 @@ module Startback
62
64
  headers = cors_headers(origin).merge(headers)
63
65
  end
64
66
  if env['REQUEST_METHOD'] == 'OPTIONS'
65
- headers['Content-Length'] = '0'
67
+ headers = Rack::Headers[headers]
68
+ headers['content-length'] = '0'
66
69
  status, headers, body = [204, headers, []]
67
70
  end
68
71
  [status, headers, body]
@@ -70,8 +73,11 @@ module Startback
70
73
 
71
74
  private
72
75
 
76
+ # Rack::Headers is used so that the CORS headers set here are actually
77
+ # overriden by the downstream application, whatever the case it uses
78
+ # for its own header names.
73
79
  def cors_headers(origin)
74
- headers = @options[:headers].dup
80
+ headers = Rack::Headers[@options[:headers]]
75
81
  if bounce = do_bounce(origin)
76
82
  headers['Access-Control-Allow-Origin'] = bounce
77
83
  else
@@ -32,7 +32,7 @@ module Startback
32
32
 
33
33
  def call(env)
34
34
  if debug_msg = check!(env)
35
- [ 200, { "Content-Type" => "text/plain" }, Array(debug_msg) ]
35
+ [ 200, { "content-type" => "text/plain" }, Array(debug_msg) ]
36
36
  else
37
37
  [ 204, {}, [] ]
38
38
  end
data/spec/spec_helper.rb CHANGED
@@ -1,3 +1,9 @@
1
+ # Sinatra 4 restricts the Host header to localhost-like values in the
2
+ # `development` environment, which is the one used when RACK_ENV is unset.
3
+ # Rack::Test issues requests against `example.org`, hence the need to be
4
+ # explicit about running in test mode here.
5
+ ENV["RACK_ENV"] ||= "test"
6
+
1
7
  require 'startback'
2
8
  require 'startback/caching'
3
9
  require 'startback/event'
@@ -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