forked from rack/rack-contrib
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Rack::NestedParams; based on Rails UrlEncodedPairParser
* Cleaned up whitespace errors [rtomayko] * Added note to README [rtomayko]
- Loading branch information
Showing
4 changed files
with
192 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
require 'cgi' | ||
require 'strscan' | ||
|
||
module Rack | ||
# Rack middleware for parsing POST/PUT body data into nested parameters | ||
class NestedParams | ||
|
||
CONTENT_TYPE = 'CONTENT_TYPE'.freeze | ||
POST_BODY = 'rack.input'.freeze | ||
FORM_INPUT = 'rack.request.form_input'.freeze | ||
FORM_HASH = 'rack.request.form_hash'.freeze | ||
FORM_VARS = 'rack.request.form_vars'.freeze | ||
|
||
# supported content type | ||
URL_ENCODED = 'application/x-www-form-urlencoded'.freeze | ||
|
||
def initialize(app) | ||
@app = app | ||
end | ||
|
||
def call(env) | ||
if form_vars = env[FORM_VARS] | ||
env[FORM_HASH] = parse_query_parameters(form_vars) | ||
elsif env[CONTENT_TYPE] == URL_ENCODED | ||
post_body = env[POST_BODY] | ||
env[FORM_INPUT] = post_body | ||
env[FORM_HASH] = parse_query_parameters(post_body.read) | ||
post_body.rewind if post_body.respond_to?(:rewind) | ||
end | ||
@app.call(env) | ||
end | ||
|
||
## the rest is nabbed from Rails ## | ||
|
||
def parse_query_parameters(query_string) | ||
return {} if query_string.nil? or query_string.empty? | ||
|
||
pairs = query_string.split('&').collect do |chunk| | ||
next if chunk.empty? | ||
key, value = chunk.split('=', 2) | ||
next if key.empty? | ||
value = value.nil? ? nil : CGI.unescape(value) | ||
[ CGI.unescape(key), value ] | ||
end.compact | ||
|
||
UrlEncodedPairParser.new(pairs).result | ||
end | ||
|
||
class UrlEncodedPairParser < StringScanner | ||
attr_reader :top, :parent, :result | ||
|
||
def initialize(pairs = []) | ||
super('') | ||
@result = {} | ||
pairs.each { |key, value| parse(key, value) } | ||
end | ||
|
||
KEY_REGEXP = %r{([^\[\]=&]+)} | ||
BRACKETED_KEY_REGEXP = %r{\[([^\[\]=&]+)\]} | ||
|
||
# Parse the query string | ||
def parse(key, value) | ||
self.string = key | ||
@top, @parent = result, nil | ||
|
||
# First scan the bare key | ||
key = scan(KEY_REGEXP) or return | ||
key = post_key_check(key) | ||
|
||
# Then scan as many nestings as present | ||
until eos? | ||
r = scan(BRACKETED_KEY_REGEXP) or return | ||
key = self[1] | ||
key = post_key_check(key) | ||
end | ||
|
||
bind(key, value) | ||
end | ||
|
||
private | ||
# After we see a key, we must look ahead to determine our next action. Cases: | ||
# | ||
# [] follows the key. Then the value must be an array. | ||
# = follows the key. (A value comes next) | ||
# & or the end of string follows the key. Then the key is a flag. | ||
# otherwise, a hash follows the key. | ||
def post_key_check(key) | ||
if scan(/\[\]/) # a[b][] indicates that b is an array | ||
container(key, Array) | ||
nil | ||
elsif check(/\[[^\]]/) # a[b] indicates that a is a hash | ||
container(key, Hash) | ||
nil | ||
else # End of key? We do nothing. | ||
key | ||
end | ||
end | ||
|
||
# Add a container to the stack. | ||
def container(key, klass) | ||
type_conflict! klass, top[key] if top.is_a?(Hash) && top.key?(key) && ! top[key].is_a?(klass) | ||
value = bind(key, klass.new) | ||
type_conflict! klass, value unless value.is_a?(klass) | ||
push(value) | ||
end | ||
|
||
# Push a value onto the 'stack', which is actually only the top 2 items. | ||
def push(value) | ||
@parent, @top = @top, value | ||
end | ||
|
||
# Bind a key (which may be nil for items in an array) to the provided value. | ||
def bind(key, value) | ||
if top.is_a? Array | ||
if key | ||
if top[-1].is_a?(Hash) && ! top[-1].key?(key) | ||
top[-1][key] = value | ||
else | ||
top << {key => value} | ||
end | ||
push top.last | ||
return top[key] | ||
else | ||
top << value | ||
return value | ||
end | ||
elsif top.is_a? Hash | ||
key = CGI.unescape(key) | ||
parent << (@top = {}) if top.key?(key) && parent.is_a?(Array) | ||
top[key] ||= value | ||
return top[key] | ||
else | ||
raise ArgumentError, "Don't know what to do: top is #{top.inspect}" | ||
end | ||
end | ||
|
||
def type_conflict!(klass, value) | ||
raise TypeError, "Conflicting types for parameter containers. Expected an instance of #{klass} but found an instance of #{value.class}. This can be caused by colliding Array and Hash parameters like qs[]=value&qs[key]=value. (The parameters received were #{value.inspect}.)" | ||
end | ||
end | ||
|
||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
require 'rack/mock' | ||
require 'rack/contrib/nested_params' | ||
require 'rack/methodoverride' | ||
|
||
context Rack::NestedParams do | ||
|
||
App = lambda { |env| [200, {'Content-Type' => 'text/plain'}, Rack::Request.new(env)] } | ||
|
||
def env_for_post_with_headers(path, headers, body) | ||
Rack::MockRequest.env_for(path, {:method => "POST", :input => body}.merge(headers)) | ||
end | ||
|
||
def form_post(params, content_type = 'application/x-www-form-urlencoded') | ||
params = Rack::Utils.build_query(params) if Hash === params | ||
env_for_post_with_headers('/', {'CONTENT_TYPE' => content_type}, params) | ||
end | ||
|
||
def middleware | ||
Rack::NestedParams.new(App) | ||
end | ||
|
||
specify "should handle requests with POST body Content-Type of application/x-www-form-urlencoded" do | ||
req = middleware.call(form_post({'foo[bar][baz]' => 'nested'})).last | ||
req.POST.should.equal({"foo" => { "bar" => { "baz" => "nested" }}}) | ||
end | ||
|
||
specify "should not parse requests with other Content-Type" do | ||
req = middleware.call(form_post({'foo[bar][baz]' => 'nested'}, 'text/plain')).last | ||
req.POST.should.equal({}) | ||
end | ||
|
||
specify "should work even after another middleware already parsed the request" do | ||
app = Rack::MethodOverride.new(middleware) | ||
req = app.call(form_post({'_method' => 'put', 'foo[bar]' => 'nested'})).last | ||
req.POST.should.equal({'_method' => 'put', "foo" => { "bar" => "nested" }}) | ||
req.put?.should.equal true | ||
end | ||
|
||
specify "should make first boolean have precedence even after request already parsed" do | ||
app = Rack::MethodOverride.new(middleware) | ||
req = app.call(form_post("foo=1&foo=0")).last | ||
req.POST.should.equal({"foo" => '1'}) | ||
end | ||
|
||
end |