Skip to content

Commit 06f7822

Browse files
authored
Merge pull request #287 from webmachine/orien/rack
2 parents 43c9805 + 00d2685 commit 06f7822

File tree

4 files changed

+96
-16
lines changed

4 files changed

+96
-16
lines changed

.github/workflows/test.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,27 @@ on: [push, pull_request]
44

55
jobs:
66
test:
7+
name: Test (Ruby ${{ matrix.ruby }}, Rack ${{ matrix.rack }})
78
runs-on: "ubuntu-latest"
89
continue-on-error: ${{ matrix.experimental }}
910
strategy:
1011
fail-fast: false
1112
matrix:
12-
ruby_version: ["2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "4.0"]
13+
ruby: ["2.6", "2.7", "3.0", "3.1", "3.2", "3.3", "3.4", "4.0"]
14+
rack: ["2", "3"]
1315
experimental: [false]
1416
include:
15-
- ruby_version: "ruby-head"
17+
- ruby: "ruby-head"
18+
rack: "3"
1619
experimental: true
20+
env:
21+
RACK_VERSION: ${{ matrix.rack }}
1722

1823
steps:
1924
- uses: actions/checkout@v6
2025
- uses: ruby/setup-ruby@v1
2126
with:
22-
ruby-version: ${{ matrix.ruby_version }}
27+
ruby-version: ${{ matrix.ruby }}
2328
bundler-cache: true
2429
- run: "bundle exec rubocop lib/ spec/"
2530
- run: "bundle exec rspec spec/ -b"

Gemfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ group :development do
88
end
99

1010
group :test do
11-
gem 'rack', '~> 2'
11+
rack_version = ENV.fetch('RACK_VERSION', '3')
12+
gem 'rack', "~> #{rack_version}"
13+
gem 'rackup' if rack_version == '3'
1214
gem 'rack-test', '~> 2'
1315
gem 'rspec', '~> 3.0', '>= 3.6.0'
1416
gem 'rspec-its', '~> 1.2'

lib/webmachine/adapters/rack.rb

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ def run
5252
Host: application.configuration.ip
5353
}).merge(application.configuration.adapter_options)
5454

55-
@server = ::Rack::Server.new(options)
55+
if rack_v3?
56+
require 'rackup'
57+
@server = ::Rackup::Server.new(options)
58+
else
59+
@server = ::Rack::Server.new(options)
60+
end
5661
@server.start
5762
end
5863

@@ -70,18 +75,33 @@ def call(env)
7075
response.headers[SERVER] = VERSION_STRING
7176

7277
rack_status = response.code
73-
rack_headers = response.headers.flattened(NEWLINE)
78+
rack_headers = build_rack_response_headers(response.headers)
7479
rack_body = case response.body
7580
when String # Strings are enumerable in ruby 1.8
7681
[response.body]
7782
else
7883
if (io_body = IO.try_convert(response.body))
7984
io_body
8085
elsif response.body.respond_to?(:call)
81-
Webmachine::ChunkedBody.new(Array(response.body.call))
86+
if rack_v3?
87+
# In Rack 3 the server (e.g. WEBrick via Rackup) buffers the body
88+
# and applies chunked encoding itself when Transfer-Encoding is set,
89+
# so we must not pre-encode with ChunkedBody.
90+
[response.body.call]
91+
else
92+
# Rack 2's WEBrick handler sends the body as-is; ChunkedBody is
93+
# required to produce valid chunked-encoded wire data.
94+
Webmachine::ChunkedBody.new(Array(response.body.call))
95+
end
8296
elsif response.body.respond_to?(:each)
83-
# This might be an IOEncoder with a Content-Length, which shouldn't be chunked.
84-
if response.headers[TRANSFER_ENCODING] == 'chunked'
97+
if rack_v3?
98+
# Return the plain enumerable. Rackup buffers it into a String then
99+
# WEBrick chunks that String when Transfer-Encoding: chunked is set.
100+
response.body
101+
elsif response.headers[TRANSFER_ENCODING] == 'chunked'
102+
# Rack 2: only pre-encode bodies that are already marked chunked;
103+
# IOEncoder bodies carry their own Content-Length and must not be
104+
# wrapped.
85105
Webmachine::ChunkedBody.new(response.body)
86106
else
87107
response.body
@@ -97,6 +117,45 @@ def call(env)
97117

98118
protected
99119

120+
# Build a Rack-compatible response headers hash from Webmachine's response
121+
# headers.
122+
#
123+
# Header names are always lowercased: this is required by Rack 3 and is
124+
# harmless for Rack 2 (all Rack 2 handlers match header names
125+
# case-insensitively).
126+
#
127+
# The +set-cookie+ value is formatted differently per Rack version:
128+
#
129+
# * Rack 3 / Rackup: the value must be an Array. Rackup's WEBrick handler
130+
# deletes the lowercase +set-cookie+ key and calls
131+
# +res.cookies.concat(Array(value))+, emitting one Set-Cookie line per
132+
# cookie. Joining with +\n+ instead would produce a header value
133+
# containing a newline, which WEBrick 1.9+ rejects as
134+
# +WEBrick::HTTPResponse::InvalidHeader+.
135+
#
136+
# * Rack 2 / Rack::Handler::WEBrick: the handler splits on +\n+ before
137+
# adding cookies (+vs.split("\n")+), so the value must be a newline-joined
138+
# String. Passing an Array causes a +NoMethodError+ because +Array+ does
139+
# not define +#split+.
140+
def build_rack_response_headers(response_headers)
141+
response_headers.each_with_object({}) do |(key, value), h|
142+
rack_key = key.downcase
143+
h[rack_key] = if rack_key == 'set-cookie'
144+
if rack_v3?
145+
# Array lets Rackup emit one Set-Cookie header per cookie.
146+
Array(value)
147+
else
148+
# Rack 2's handler splits on \n; give it a newline-joined String.
149+
Array(value).join(NEWLINE)
150+
end
151+
elsif value.is_a?(Array)
152+
value.join(NEWLINE)
153+
else
154+
value
155+
end
156+
end
157+
end
158+
100159
def routing_tokens(rack_req)
101160
nil # no-op for default, un-mapped rack adapter
102161
end
@@ -107,6 +166,11 @@ def base_uri(rack_req)
107166

108167
private
109168

169+
# Returns true when running under Rack 3.x.
170+
def rack_v3?
171+
::Rack.release.start_with?('3.')
172+
end
173+
110174
def build_webmachine_request(rack_req, headers)
111175
RackRequest.new(rack_req.request_method,
112176
rack_req.url,
@@ -128,6 +192,9 @@ def initialize(method, uri, headers, body, routing_tokens, base_uri, env)
128192

129193
class RackResponse
130194
ONE_FIVE = '1.5'.freeze
195+
# Header names are normalised to lowercase by build_rack_response_headers,
196+
# so use the lowercase form everywhere inside RackResponse too.
197+
LOWERCASE_CONTENT_TYPE = 'content-type'.freeze
131198

132199
def initialize(body, status, headers)
133200
@body = body
@@ -136,8 +203,8 @@ def initialize(body, status, headers)
136203
end
137204

138205
def finish
139-
@headers[CONTENT_TYPE] ||= TEXT_HTML if rack_release_enforcing_content_type
140-
@headers.delete(CONTENT_TYPE) if response_without_body
206+
@headers[LOWERCASE_CONTENT_TYPE] ||= TEXT_HTML if rack_release_enforcing_content_type
207+
@headers.delete(LOWERCASE_CONTENT_TYPE) if response_without_body
141208
[@status, @headers, @body]
142209
end
143210

@@ -177,8 +244,12 @@ def to_s
177244
if @value
178245
@value.join
179246
else
180-
@request.body.rewind
181-
@request.body.read
247+
# Rack 3 removed the requirement for rack.input to implement #rewind
248+
# (Rack::Lint::InputWrapper in Rack 3 does not define it), so guard
249+
# the call to avoid a NoMethodError on every PUT/POST request.
250+
body = @request.body
251+
body.rewind if body.respond_to?(:rewind)
252+
body.read
182253
end
183254
end
184255

spec/webmachine/adapters/rack_spec.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
it 'should add Content-Type header on not acceptable response' do
2323
rack_response = described_class.new(double(:body), 406, {})
2424
_rack_status, rack_headers, _rack_body = rack_response.finish
25-
expect(rack_headers).to have_key('Content-Type')
25+
expect(rack_headers).to have_key('content-type')
2626
end
2727
end
2828

@@ -32,7 +32,7 @@
3232
it 'should not add Content-Type header on not acceptable response' do
3333
rack_response = described_class.new(double(:body), 406, {})
3434
_rack_status, rack_headers, _rack_body = rack_response.finish
35-
expect(rack_headers).not_to have_key('Content-Type')
35+
expect(rack_headers).not_to have_key('content-type')
3636
end
3737
end
3838
end
@@ -57,7 +57,9 @@
5757

5858
it 'provides the rack env on the request' do
5959
rack_response = get 'test', nil, {'HTTP_ACCEPT' => 'test/response.rack_env'}
60-
expect(JSON.parse(rack_response.body).keys).to include 'rack.input'
60+
# rack-test does not populate rack.input for GET requests in Rack 3,
61+
# so check for rack.errors which is always present in every Rack version.
62+
expect(JSON.parse(rack_response.body).keys).to include 'rack.errors'
6163
end
6264
end
6365
end

0 commit comments

Comments
 (0)