Skip to content

Commit 612f172

Browse files
authored
Allow tests to pass on both Rack 2 and Rack 3 (#477)
Fix a few other Rack 2/3 incompatibilities that we found here after merging all the other Rack 3 support changes: - Stop replacing the session object with a plain symbolized hash. This was causing various errors from deep inside Rack. Instead, _wrap_ the session object in a new `Hanami::Action::Request::Session`, which delegates to the session and handles the translation of symbolized key access. - Ensure `Rack::Response` sets an appropriate content length after we run our own `Response#body=` writer. - Replace `Rack::File` (which has been removed) with `Rack::Files`. These are functionally identical, it's just a new name. - Handle more header downcasing. - Handle some response status strings that subtly differ between Rack 2/3. - Update tests for new minimum session secret length.
1 parent 5887df8 commit 612f172

File tree

19 files changed

+247
-81
lines changed

19 files changed

+247
-81
lines changed

hanami-controller.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
2020
spec.metadata["rubygems_mfa_required"] = "true"
2121
spec.required_ruby_version = ">= 3.1"
2222

23-
spec.add_dependency "rack", ">= 2.0"
23+
spec.add_dependency "rack", ">= 2.1"
2424
spec.add_dependency "hanami-utils", "~> 2.2"
2525
spec.add_dependency "dry-configurable", "~> 1.0", "< 2"
2626
spec.add_dependency "dry-core", "~> 1.0"

lib/hanami/action.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require "hanami/utils/string"
88
require "rack"
99
require "rack/utils"
10+
require "hanami/action/rack_utils"
1011
require "zeitwerk"
1112

1213
require_relative "action/constants"
@@ -561,7 +562,7 @@ def _run_after_callbacks(*args)
561562
#
562563
# # Both Content-Type and X-No-Pass are removed because they're not allowed
563564
def keep_response_header?(header)
564-
ENTITY_HEADERS.include?(header)
565+
ENTITY_HEADERS.include?(header.downcase)
565566
end
566567

567568
# @since 2.0.0

lib/hanami/action/constants.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,14 @@ class Action
5050
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.5
5151
# @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html
5252
ENTITY_HEADERS = {
53-
"Allow" => true,
54-
"Content-Encoding" => true,
55-
"Content-Language" => true,
56-
"Content-Location" => true,
57-
"Content-MD5" => true,
58-
"Content-Range" => true,
59-
"Expires" => true,
60-
"Last-Modified" => true,
53+
"allow" => true,
54+
"content-encoding" => true,
55+
"content-language" => true,
56+
"content-location" => true,
57+
"content-md5" => true,
58+
"content-range" => true,
59+
"expires" => true,
60+
"last-modified" => true,
6161
"extension-header" => true
6262
}.freeze
6363

lib/hanami/action/params.rb

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -362,13 +362,7 @@ def _extract_params
362362
# @since 0.7.0
363363
# @api private
364364
def _router_params(fallback = {})
365-
env.fetch(ROUTER_PARAMS) do
366-
if session = fallback.delete(Action::RACK_SESSION)
367-
fallback[Action::RACK_SESSION] = Utils::Hash.deep_symbolize(session)
368-
end
369-
370-
fallback
371-
end
365+
env.fetch(ROUTER_PARAMS, fallback)
372366
end
373367
end
374368
end

lib/hanami/action/rack/file.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# frozen_string_literal: true
22

3-
require "rack/file"
3+
require "rack/files"
44

55
module Hanami
66
class Action
@@ -21,7 +21,7 @@ class File
2121
# @since 0.4.3
2222
# @api private
2323
def initialize(path, root)
24-
@file = ::Rack::File.new(root.to_s)
24+
@file = ::Rack::Files.new(root.to_s)
2525
@path = path.to_s
2626
end
2727

lib/hanami/action/rack_utils.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module Hanami
4+
class Action
5+
# @since 2.2.0
6+
# @api private
7+
def self.rack_3?
8+
defined?(::Rack::Headers)
9+
end
10+
end
11+
end

lib/hanami/action/request.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def session_enabled?
5656

5757
# Returns the session for the request.
5858
#
59-
# @return [Hash] the session object
59+
# @return [Hanami::Request::Session] the session object
6060
#
6161
# @raise [MissingSessionError] if the session is not enabled
6262
#
@@ -70,7 +70,7 @@ def session
7070
raise Hanami::Action::MissingSessionError.new("Hanami::Action::Request#session")
7171
end
7272

73-
super
73+
@session ||= Session.new(super)
7474
end
7575

7676
# Returns the flash for the request.

lib/hanami/action/request/session.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
require "forwardable"
4+
5+
module Hanami
6+
class Action
7+
class Request < ::Rack::Request
8+
# Wrapper for Rack-provided sessions, allowing access using symbol keys.
9+
#
10+
# @since 2.3.0
11+
# @api public
12+
class Session
13+
extend Forwardable
14+
15+
def_delegators \
16+
:@session,
17+
:clear,
18+
:delete,
19+
:empty?,
20+
:size,
21+
:length,
22+
:each,
23+
:to_h,
24+
:inspect,
25+
:keys,
26+
:values
27+
28+
def initialize(session)
29+
@session = session
30+
end
31+
32+
def [](key)
33+
@session[key.to_s]
34+
end
35+
36+
def []=(key, value)
37+
@session[key.to_s] = value
38+
end
39+
40+
def key?(key)
41+
@session.key?(key.to_s)
42+
end
43+
44+
alias_method :has_key?, :key?
45+
alias_method :include?, :key?
46+
47+
def ==(other)
48+
Utils::Hash.deep_symbolize(@session) == Utils::Hash.deep_symbolize(other)
49+
end
50+
51+
private
52+
53+
# Provides a fallback for any methods not handled by the def_delegators.
54+
def method_missing(method_name, *args, &block)
55+
if @session.respond_to?(method_name)
56+
@session.send(method_name, *args, &block)
57+
else
58+
super
59+
end
60+
end
61+
62+
def respond_to_missing?(method_name, include_private = false)
63+
@session.respond_to?(method_name, include_private) || super
64+
end
65+
end
66+
end
67+
end
68+
end

lib/hanami/action/response.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,15 @@ def initialize(request:, config:, content_type: nil, env: {}, headers: {}, view_
7171
# @api public
7272
def body=(str)
7373
@length = 0
74-
@body = EMPTY_BODY.dup
74+
@body = EMPTY_BODY.dup
75+
76+
return if str.nil? || str == EMPTY_BODY
7577

7678
if str.is_a?(::Rack::Files::BaseIterator)
7779
@body = str
80+
buffered_body! # Ensure appropriate content-length is set
7881
else
79-
write(str) unless str.nil? || str == EMPTY_BODY
82+
write(str)
8083
end
8184
end
8285

spec/integration/hanami/controller/head_spec.rb

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ def response
2020

2121
expect(response.status).to be(200)
2222
expect(response.body.to_s).to be_empty
23-
expect(headers).to include(["Content-Type", "application/octet-stream; charset=utf-8"])
24-
expect(headers).to include(%w[Content-Length 5])
23+
expect(headers).to include([rack_header("Content-Type"), "application/octet-stream; charset=utf-8"])
24+
25+
# In Rack 2, the Content-Length header is inferred from the body, even for HEAD requests.
26+
# In Rack 3, the Content-Length header is no longer automatically set.
27+
content_length = Hanami::Action.rack_3? ? "0" : "5"
28+
29+
expect(headers).to include([rack_header("Content-Length"), content_length])
2530
end
2631

2732
it "allows to bypass restriction on custom headers" do
@@ -31,11 +36,11 @@ def response
3136
expect(response.body).to eq("")
3237

3338
headers = response.headers.to_a
34-
expect(headers).to include(["Last-Modified", "Fri, 27 Nov 2015 13:32:36 GMT"])
35-
expect(headers).to include(%w[X-Rate-Limit 4000])
39+
expect(headers).to include([rack_header("Last-Modified"), "Fri, 27 Nov 2015 13:32:36 GMT"])
40+
expect(headers).to include([rack_header("X-Rate-Limit"), "4000"])
3641

37-
expect(headers).to_not include(%w[X-No-Pass true])
38-
expect(headers).to_not include(["Content-Type", "application/octet-stream; charset=utf-8"])
42+
expect(headers).to_not include([rack_header("X-No-Pass"), "true"])
43+
expect(headers).to_not include([rack_header("Content-Type"), "application/octet-stream; charset=utf-8"])
3944
end
4045

4146
HTTP_TEST_STATUSES_WITHOUT_BODY.each do |code|
@@ -46,93 +51,93 @@ def response
4651

4752
expect(response.status).to be(code)
4853
expect(response.body).to eq("")
49-
expect(response.headers.to_a).to_not include(%w[X-Frame-Options DENY])
54+
expect(response.headers.to_a).to_not include([rack_header("X-Frame-Options"), "DENY"])
5055
end
5156

5257
it "doesn't send Content-Length header" do
5358
get "/code/#{code}"
5459

5560
expect(response.status).to be(code)
56-
expect(response.headers).to_not have_key("Content-Length")
61+
expect(response.headers).to_not have_key(rack_header("Content-Length"))
5762
end
5863
else
5964
it "does send body and default headers" do
6065
get "/code/#{code}"
6166

6267
expect(response.status).to be(code)
6368
expect(response.body).to_not be_empty
64-
expect(response.headers.to_a).to_not include(%w[X-Frame-Options DENY])
69+
expect(response.headers.to_a).to_not include([rack_header("X-Frame-Options"), "DENY"])
6570
end
6671

6772
it "does send Content-Length header" do
6873
get "/code/#{code}"
6974

7075
expect(response.status).to be(code)
71-
expect(response.headers).to have_key("Content-Length")
76+
expect(response.headers).to have_key(rack_header("Content-Length"))
7277
end
7378
end
7479

7580
it "sends Allow header" do
7681
get "/code/#{code}"
7782

78-
expect(response.status).to be(code)
79-
expect(response.headers["Allow"]).to eq("GET, HEAD")
83+
expect(response.status).to be(code)
84+
expect(response.headers[rack_header("Allow")]).to eq("GET, HEAD")
8085
end
8186

8287
it "sends Content-Encoding header" do
8388
get "/code/#{code}"
8489

85-
expect(response.status).to be(code)
86-
expect(response.headers["Content-Encoding"]).to eq("identity")
90+
expect(response.status).to be(code)
91+
expect(response.headers[rack_header("Content-Encoding")]).to eq("identity")
8792
end
8893

8994
it "sends Content-Language header" do
9095
get "/code/#{code}"
9196

92-
expect(response.status).to be(code)
93-
expect(response.headers["Content-Language"]).to eq("en")
97+
expect(response.status).to be(code)
98+
expect(response.headers[rack_header("Content-Language")]).to eq("en")
9499
end
95100

96101
it "doesn't send Content-Length header" do
97102
get "/code/#{code}"
98103

99104
expect(response.status).to be(code)
100-
expect(response.headers.keys).to_not include("Content-Length")
105+
expect(response.headers.keys).to_not include(rack_header("Content-Length"))
101106
end
102107

103108
it "doesn't send Content-Type header" do
104109
get "/code/#{code}"
105110

106111
expect(response.status).to be(code)
107-
expect(response.headers.keys).to_not include("Content-Type")
112+
expect(response.headers.keys).to_not include(rack_header("Content-Type"))
108113
end
109114

110115
it "sends Content-Location header" do
111116
get "/code/#{code}"
112117

113-
expect(response.status).to be(code)
114-
expect(response.headers["Content-Location"]).to eq("relativeURI")
118+
expect(response.status).to be(code)
119+
expect(response.headers[rack_header("Content-Location")]).to eq("relativeURI")
115120
end
116121

117122
it "sends Content-MD5 header" do
118123
get "/code/#{code}"
119124

120-
expect(response.status).to be(code)
121-
expect(response.headers["Content-MD5"]).to eq("c13367945d5d4c91047b3b50234aa7ab")
125+
expect(response.status).to be(code)
126+
expect(response.headers[rack_header("Content-MD5")]).to eq("c13367945d5d4c91047b3b50234aa7ab")
122127
end
123128

124129
it "sends Expires header" do
125130
get "/code/#{code}"
126131

127-
expect(response.status).to be(code)
128-
expect(response.headers["Expires"]).to eq("Thu, 01 Dec 1994 16:00:00 GMT")
132+
expect(response.status).to be(code)
133+
expect(response.headers[rack_header("Expires")]).to eq("Thu, 01 Dec 1994 16:00:00 GMT")
129134
end
130135

131136
it "sends Last-Modified header" do
132137
get "/code/#{code}"
133138

134-
expect(response.status).to be(code)
135-
expect(response.headers["Last-Modified"]).to eq("Wed, 21 Jan 2015 11:32:10 GMT")
139+
expect(response.status).to be(code)
140+
expect(response.headers[rack_header("Last-Modified")]).to eq("Wed, 21 Jan 2015 11:32:10 GMT")
136141
end
137142
end
138143
end

0 commit comments

Comments
 (0)