Skip to content

Commit f904fff

Browse files
authored
Add Rack::Request#form_pairs (rack#2351)
This method returns form data as key-value pairs, preserving duplicate keys that would otherwise be lost when converting to a hash. For example, "a=1&a=2" returns [["a", "1"], ["a", "2"]] instead of {"a" => "2"}.
1 parent 40d68f8 commit f904fff

5 files changed

Lines changed: 205 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ All notable changes to this project will be documented in this file. For info on
66

77
### SPEC Changes
88

9-
- Request environment keys must now be strings. [#2310](https://github.com/rack/rack/issues/2310), [@jeremyevans])
9+
- Request environment keys must now be strings. ([#2310](https://github.com/rack/rack/issues/2310), [@jeremyevans])
1010
- Add `nil` as a valid return from a Response `body.to_path` ([#2318](https://github.com/rack/rack/pull/2318), [@MSP-Greg])
1111

1212
### Added
1313

1414
- Introduce `Rack::VERSION` constant. ([#2199](https://github.com/rack/rack/pull/2199), [@ioquatix])
1515
- ISO-2022-JP encoded parts within MIME Multipart sections of an HTTP request body will now be converted to UTF-8. ([#2245](https://github.com/rack/rack/pull/2245), [@nappa](https://github.com/nappa))
16-
- Add `Rack::Request#query_parser=` to allow setting the query parser to use. [#2349](https://github.com/rack/rack/pull/2349), [@jeremyevans])
16+
- Add `Rack::Request#query_parser=` to allow setting the query parser to use. ([#2349](https://github.com/rack/rack/pull/2349), [@jeremyevans])
17+
- Add `Rack::Request#form_pairs` to access form data as raw key-value pairs, preserving duplicate keys. ([#2351](https://github.com/rack/rack/pull/2351), [@matthewd])
1718

1819
### Changed
1920

@@ -1205,3 +1206,4 @@ Items below this line are from the previously maintained HISTORY.md and NEWS.md
12051206
[@davidstosik]: https://github.com/davidstosik "David Stosik"
12061207
[@earlopain]: https://github.com/earlopain "Earlopain"
12071208
[@wynksaiddestroy]: https://github.com/wynksaiddestroy "Fabian Winkler"
1209+
[@matthewd]: https://github.com/matthewd "Matthew Draper"

lib/rack/query_parser.rb

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,9 @@ def initialize(params_class, param_depth_limit, bytesize_limit: BYTESIZE_LIMIT,
6969
# to parse cookies by changing the characters used in the second parameter
7070
# (which defaults to '&').
7171
def parse_query(qs, separator = nil, &unescaper)
72-
unescaper ||= method(:unescape)
73-
7472
params = make_params
7573

76-
check_query_string(qs, separator).split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p|
77-
next if p.empty?
78-
k, v = p.split('=', 2).map!(&unescaper)
79-
74+
each_query_pair(qs, separator, unescaper) do |k, v|
8075
if cur = params[k]
8176
if cur.class == Array
8277
params[k] << v
@@ -91,6 +86,19 @@ def parse_query(qs, separator = nil, &unescaper)
9186
return params.to_h
9287
end
9388

89+
# Parses a query string by breaking it up at the '&', returning all key-value
90+
# pairs as an array of [key, value] arrays. Unlike parse_query, this preserves
91+
# all duplicate keys rather than collapsing them.
92+
def parse_query_pairs(qs, separator = nil)
93+
pairs = []
94+
95+
each_query_pair(qs, separator) do |k, v|
96+
pairs << [k, v]
97+
end
98+
99+
pairs
100+
end
101+
94102
# parse_nested_query expands a query string into structural types. Supported
95103
# types are Arrays, Hashes and basic value types. It is possible to supply
96104
# query strings with parameters of conflicting types, in this case a
@@ -99,17 +107,11 @@ def parse_query(qs, separator = nil, &unescaper)
99107
def parse_nested_query(qs, separator = nil)
100108
params = make_params
101109

102-
unless qs.nil? || qs.empty?
103-
check_query_string(qs, separator).split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |p|
104-
k, v = p.split('=', 2).map! { |s| unescape(s) }
105-
106-
_normalize_params(params, k, v, 0)
107-
end
110+
each_query_pair(qs, separator) do |k, v|
111+
_normalize_params(params, k, v, 0)
108112
end
109113

110114
return params.to_h
111-
rescue ArgumentError => e
112-
raise InvalidParameterError, e.message, e.backtrace
113115
end
114116

115117
# normalize_params recursively expands parameters into structural types. If
@@ -215,20 +217,35 @@ def params_hash_has_key?(hash, key)
215217
true
216218
end
217219

218-
def check_query_string(qs, sep)
219-
if qs
220-
if qs.bytesize > @bytesize_limit
221-
raise QueryLimitError, "total query size (#{qs.bytesize}) exceeds limit (#{@bytesize_limit})"
222-
end
220+
def each_query_pair(qs, separator, unescaper = nil)
221+
return if !qs || qs.empty?
223222

224-
if (param_count = qs.count(sep.is_a?(String) ? sep : '&')) >= @params_limit
225-
raise QueryLimitError, "total number of query parameters (#{param_count+1}) exceeds limit (#{@params_limit})"
226-
end
223+
if qs.bytesize > @bytesize_limit
224+
raise QueryLimitError, "total query size (#{qs.bytesize}) exceeds limit (#{@bytesize_limit})"
225+
end
226+
227+
pairs = qs.split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP, @params_limit + 1)
228+
229+
if pairs.size > @params_limit
230+
param_count = pairs.size + pairs.last.count(separator || "&")
231+
raise QueryLimitError, "total number of query parameters (#{param_count}) exceeds limit (#{@params_limit})"
232+
end
227233

228-
qs
234+
if unescaper
235+
pairs.each do |p|
236+
next if p.empty?
237+
k, v = p.split('=', 2).map!(&unescaper)
238+
yield k, v
239+
end
229240
else
230-
''
241+
pairs.each do |p|
242+
next if p.empty?
243+
k, v = p.split('=', 2).map! { |s| unescape(s) }
244+
yield k, v
245+
end
231246
end
247+
rescue ArgumentError => e
248+
raise InvalidParameterError, e.message, e.backtrace
232249
end
233250

234251
def unescape(string, encoding = Encoding::UTF_8)

lib/rack/request.rb

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,13 +492,13 @@ def GET
492492
get_header(RACK_REQUEST_QUERY_HASH) || set_header(RACK_REQUEST_QUERY_HASH, parse_query(query_string, '&'))
493493
end
494494

495-
# Returns the data received in the request body.
495+
# Returns the form data pairs received in the request body.
496496
#
497497
# This method support both application/x-www-form-urlencoded and
498498
# multipart/form-data.
499-
def POST
500-
if form_hash = get_header(RACK_REQUEST_FORM_HASH)
501-
return form_hash
499+
def form_pairs
500+
if pairs = get_header(RACK_REQUEST_FORM_PAIRS)
501+
return pairs
502502
elsif error = get_header(RACK_REQUEST_FORM_ERROR)
503503
raise error.class, error.message, cause: error.cause
504504
end
@@ -508,11 +508,10 @@ def POST
508508

509509
# Otherwise, figure out how to parse the input:
510510
if rack_input.nil?
511-
set_header(RACK_REQUEST_FORM_HASH, {})
511+
set_header(RACK_REQUEST_FORM_PAIRS, [])
512512
elsif form_data? || parseable_data?
513513
if pairs = Rack::Multipart.parse_multipart(env, Rack::Multipart::ParamList)
514514
set_header RACK_REQUEST_FORM_PAIRS, pairs
515-
set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs)
516515
else
517516
form_vars = get_header(RACK_INPUT).read
518517

@@ -521,17 +520,33 @@ def POST
521520
form_vars.slice!(-1) if form_vars.end_with?("\0")
522521

523522
set_header RACK_REQUEST_FORM_VARS, form_vars
524-
set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&')
523+
pairs = query_parser.parse_query_pairs(form_vars, '&')
524+
set_header(RACK_REQUEST_FORM_PAIRS, pairs)
525525
end
526526
else
527-
set_header(RACK_REQUEST_FORM_HASH, {})
527+
set_header(RACK_REQUEST_FORM_PAIRS, [])
528528
end
529529
rescue => error
530530
set_header(RACK_REQUEST_FORM_ERROR, error)
531531
raise
532532
end
533533
end
534534

535+
# Returns the data received in the request body.
536+
#
537+
# This method support both application/x-www-form-urlencoded and
538+
# multipart/form-data.
539+
def POST
540+
if form_hash = get_header(RACK_REQUEST_FORM_HASH)
541+
return form_hash
542+
elsif error = get_header(RACK_REQUEST_FORM_ERROR)
543+
raise error.class, error.message, cause: error.cause
544+
end
545+
546+
pairs = form_pairs
547+
set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs)
548+
end
549+
535550
# The union of GET and POST data.
536551
#
537552
# Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params.

test/spec_query_parser.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,33 @@
1212
query_parser.parse_nested_query("a=a").must_equal({"a" => "a"})
1313
query_parser.parse_nested_query("a=").must_equal({"a" => ""})
1414
query_parser.parse_nested_query("a").must_equal({"a" => nil})
15+
query_parser.parse_query_pairs("a=a").must_equal([["a", "a"]])
16+
query_parser.parse_query_pairs("a=").must_equal([["a", ""]])
17+
query_parser.parse_query_pairs("a").must_equal([["a", nil]])
1518
end
1619

1720
it "accepts bytesize_limit to specify maximum size of query string to parse" do
1821
query_parser = Rack::QueryParser.make_default(32, bytesize_limit: 3)
1922
query_parser.parse_query("a=a").must_equal({"a" => "a"})
2023
query_parser.parse_nested_query("a=a").must_equal({"a" => "a"})
2124
query_parser.parse_nested_query("a=a", '&').must_equal({"a" => "a"})
25+
query_parser.parse_query_pairs("a=a").must_equal([["a", "a"]])
2226
proc { query_parser.parse_query("a=aa") }.must_raise Rack::QueryParser::QueryLimitError
2327
proc { query_parser.parse_nested_query("a=aa") }.must_raise Rack::QueryParser::QueryLimitError
2428
proc { query_parser.parse_nested_query("a=aa", '&') }.must_raise Rack::QueryParser::QueryLimitError
29+
proc { query_parser.parse_query_pairs("a=aa") }.must_raise Rack::QueryParser::QueryLimitError
2530
end
2631

2732
it "accepts params_limit to specify maximum number of query parameters to parse" do
2833
query_parser = Rack::QueryParser.make_default(32, params_limit: 2)
2934
query_parser.parse_query("a=a&b=b").must_equal({"a" => "a", "b" => "b"})
3035
query_parser.parse_nested_query("a=a&b=b").must_equal({"a" => "a", "b" => "b"})
3136
query_parser.parse_nested_query("a=a&b=b", '&').must_equal({"a" => "a", "b" => "b"})
37+
query_parser.parse_query_pairs("a=a&b=b").must_equal([["a", "a"], ["b", "b"]])
38+
query_parser.parse_query_pairs("a=1&a=2").must_equal([["a", "1"], ["a", "2"]])
3239
proc { query_parser.parse_query("a=a&b=b&c=c") }.must_raise Rack::QueryParser::QueryLimitError
3340
proc { query_parser.parse_nested_query("a=a&b=b&c=c", '&') }.must_raise Rack::QueryParser::QueryLimitError
3441
proc { query_parser.parse_query("b[]=a&b[]=b&b[]=c") }.must_raise Rack::QueryParser::QueryLimitError
42+
proc { query_parser.parse_query_pairs("a=a&b=b&c=c") }.must_raise Rack::QueryParser::QueryLimitError
3543
end
3644
end

test/spec_request.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,135 @@ def initialize(*)
795795
req.POST.must_equal "foo" => "bar", "quux" => "bla"
796796
end
797797

798+
it "return form_pairs for url-encoded POST data" do
799+
req = make_request \
800+
Rack::MockRequest.env_for("/",
801+
'REQUEST_METHOD' => 'POST', :input => "foo=bar&quux=bla")
802+
req.form_pairs.must_equal [["foo", "bar"], ["quux", "bla"]]
803+
end
804+
805+
it "preserve duplicate keys in form_pairs" do
806+
req = make_request \
807+
Rack::MockRequest.env_for("/",
808+
'REQUEST_METHOD' => 'POST', :input => "foo=1&foo=2&bar=3")
809+
req.form_pairs.must_equal [["foo", "1"], ["foo", "2"], ["bar", "3"]]
810+
end
811+
812+
it "handle empty values in form_pairs" do
813+
req = make_request \
814+
Rack::MockRequest.env_for("/",
815+
'REQUEST_METHOD' => 'POST', :input => "foo=&bar=baz&empty")
816+
req.form_pairs.must_equal [["foo", ""], ["bar", "baz"], ["empty", nil]]
817+
end
818+
819+
it "return empty array for form_pairs with no POST data" do
820+
req = make_request \
821+
Rack::MockRequest.env_for("/", 'REQUEST_METHOD' => 'POST', :input => "")
822+
req.form_pairs.must_equal []
823+
end
824+
825+
it "return empty array for form_pairs with non-form content type" do
826+
req = make_request \
827+
Rack::MockRequest.env_for("/",
828+
'REQUEST_METHOD' => 'POST',
829+
"CONTENT_TYPE" => 'text/plain',
830+
:input => "foo=bar")
831+
req.form_pairs.must_equal []
832+
end
833+
834+
it "raise same error for form_pairs as POST with invalid encoding" do
835+
req = make_request \
836+
Rack::MockRequest.env_for("/",
837+
'REQUEST_METHOD' => 'POST', :input => "a%=1")
838+
lambda { req.form_pairs }.must_raise(Rack::Utils::InvalidParameterError).
839+
message.must_equal "invalid %-encoding (a%)"
840+
end
841+
842+
it "return form_pairs for multipart form data" do
843+
input = <<EOF
844+
--AaB03x\r
845+
content-disposition: form-data; name="reply"\r
846+
\r
847+
yes\r
848+
--AaB03x\r
849+
content-disposition: form-data; name="name"\r
850+
\r
851+
John\r
852+
--AaB03x--\r
853+
EOF
854+
req = make_request Rack::MockRequest.env_for("/",
855+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
856+
"CONTENT_LENGTH" => input.size,
857+
:input => input)
858+
859+
pairs = req.form_pairs
860+
pairs.must_equal [["reply", "yes"], ["name", "John"]]
861+
end
862+
863+
it "preserve duplicate keys in multipart form_pairs" do
864+
input = <<EOF
865+
--AaB03x\r
866+
content-disposition: form-data; name="item"\r
867+
\r
868+
first\r
869+
--AaB03x\r
870+
content-disposition: form-data; name="item"\r
871+
\r
872+
second\r
873+
--AaB03x\r
874+
content-disposition: form-data; name="other"\r
875+
\r
876+
value\r
877+
--AaB03x--\r
878+
EOF
879+
req = make_request Rack::MockRequest.env_for("/",
880+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
881+
"CONTENT_LENGTH" => input.size,
882+
:input => input)
883+
884+
pairs = req.form_pairs
885+
pairs.must_equal [["item", "first"], ["item", "second"], ["other", "value"]]
886+
end
887+
888+
it "include file uploads in multipart form_pairs" do
889+
input = <<EOF
890+
--AaB03x\r
891+
content-disposition: form-data; name="reply"\r
892+
\r
893+
yes\r
894+
--AaB03x\r
895+
content-disposition: form-data; name="fileupload"; filename="test.txt"\r
896+
content-type: text/plain\r
897+
\r
898+
file content\r
899+
--AaB03x--\r
900+
EOF
901+
req = make_request Rack::MockRequest.env_for("/",
902+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
903+
"CONTENT_LENGTH" => input.size,
904+
:input => input)
905+
906+
pairs = req.form_pairs
907+
pairs.length.must_equal 2
908+
pairs[0].must_equal ["reply", "yes"]
909+
pairs[1][0].must_equal "fileupload"
910+
pairs[1][1].must_be_kind_of Hash
911+
pairs[1][1][:filename].must_equal "test.txt"
912+
pairs[1][1][:type].must_equal "text/plain"
913+
end
914+
915+
it "return empty array for empty multipart form_pairs" do
916+
input = <<EOF
917+
--AaB03x--\r
918+
EOF
919+
req = make_request Rack::MockRequest.env_for("/",
920+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
921+
"CONTENT_LENGTH" => input.size,
922+
:input => input)
923+
924+
req.form_pairs.must_equal []
925+
end
926+
798927
it "extract referrer correctly" do
799928
req = make_request \
800929
Rack::MockRequest.env_for("/", "HTTP_REFERER" => "/some/path")

0 commit comments

Comments
 (0)