tap-http 0.2.1 → 0.3.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/History CHANGED
@@ -1,3 +1,15 @@
1
+ == 0.3.0 / 2009-02-19
2
+
3
+ Rework of cgi scripts a tap controllers. Nearly complete
4
+ internal refactoring (this is not a backwards compatible
5
+ release).
6
+
7
+ * Reworked CGI scripts as Tap Controllers
8
+ * Reworked Dispatch as the Request module
9
+ * Added Get and Submit tasks
10
+ * Reworked HttpTest to allow specification of a
11
+ Rack application
12
+
1
13
  == 0.2.1 / 2009-02-17
2
14
 
3
15
  Minor updates to utilize Tap 0.12.0
data/README CHANGED
@@ -16,85 +16,24 @@ they allow the capture and resubmission of web forms.
16
16
 
17
17
  === Usage
18
18
 
19
- TapHttp submits http requests using the Tap::Http::Dispatch module. Headers,
20
- parameters, and other configurations may be specified, but the only required
21
- field is :url.
19
+ TapHttp submits http requests using the Tap::Http::Request module. Headers,
20
+ parameters, and other configurations may be specified, but only a request
21
+ method and uri are required.
22
22
 
23
23
  include Tap::Http
24
24
 
25
- res = Dispatch.submit(
26
- :params => {'q' => 'tap-http'},
27
- :url => 'http://www.google.com/search')
28
-
25
+ res = Request.get('http://www.google.com/search')
29
26
  res.body[0,80] # => "<!doctype html><head><title>tap-http - Google Search</title><style>body{backgrou"
30
27
 
31
- === Getting Http Configurations
28
+ === Submitting Web Forms
32
29
 
33
- More complicated http requests may be captured and resubmitted using a
34
- combination of tools that redirects web forms to a tap server and reformats
35
- the request as YAML. To do so:
36
-
37
- * Install {Firefox}[http://www.mozilla.com/en-US/firefox/]
38
- * Install {Ubiquity}[http://labs.mozilla.com/2008/08/introducing-ubiquity/]
39
- * Install {redirect-http}[http://gist.github.com/25932]
40
-
41
- Start a tap server from the command line (of course tap-http must be installed):
30
+ Http requests from web forms may be captured and resubmitted using a combination
31
+ of tools. To do so start a tap server from the command line (of course tap-http
32
+ must be installed):
42
33
 
43
34
  % tap server
44
35
 
45
- Now in the browser, go to a web form like {google}[http://www.google.com/] and
46
- invoke the redirection.
47
-
48
- * Bring up Ubiquity in Firefox by pressing 'option+space'
49
- * Enter the command: 'redirect-http http://localhost:8080/http_to_yaml'
50
-
51
- You should see a notice that the form is being redirected. Fill out the form
52
- and submit as normal; the redirect command will send the form to the tap server
53
- instead of performing the original action. The tap server returns a yaml file
54
- with the http configuration.
55
-
56
- # Copy and paste into a configuration file. Multiple configs
57
- # can be added to a single file to perform batch submission.
58
- - headers:
59
- Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
60
- Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
61
- Accept-Encoding: gzip,deflate
62
- Accept-Language: en-us,en;q=0.5
63
- Connection: keep-alive
64
- Host: www.google.com
65
- Keep-Alive: "300"
66
- Referer: http://www.google.com/
67
- User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.0.4) Gecko/2008102920 Firefox/3.0.4
68
- params:
69
- aq: f
70
- btnG: Google Search
71
- hl: en
72
- oq: ""
73
- q: tap-http
74
- request_method: GET
75
- url: http://www.google.com/search
76
- version: 1.1
77
-
78
- Save the file as 'request.yml' and resubmit the form using the Tap::Http::Dispatch
79
- task.
80
-
81
- % rap load requests.yml --:i dispatch --+ dump --no-audit
82
- I[10:51:40] load request.yml
83
- I[10:51:40] GET http://www.google.com/search
84
- I[10:51:41] OK
85
- # date: 2008-11-25 10:51:41
86
- ---
87
- tap/http/request (2772040):
88
- - - !ruby/object:Net::HTTPOK
89
- body: !binary |
90
- H4sIAAAAAAAC/6xabXPbNhL+3l/B0BeN1FAUJfktoihf07pppmkm06TXu0lz
91
- HZAEScQgQZOQZVfhf79dgBRJS4ndmRvPSAC42F3sPvsCyssnoQjkXU6NRKZ8
92
- tUwoCVdLySSnK0nycSJlboyNl0LEnBrvKCmCZDnRz5elvIMvX4R3W58EV3Eh
93
- 1lm4OIqiyA0EF8XiyHEc...
94
-
95
- Note the result is encoded as gzip, as per the parameters. As with all tasks,
96
- the request results could be passed into a workflow. Alternatively, the
97
- configuration could be used to submit the request using Dispatch.
36
+ Now open a browser and work through the {tutorial}[http://localhost:8080/capture].
98
37
 
99
38
  === Bugs/Known Issues
100
39
 
@@ -0,0 +1,55 @@
1
+ require 'tap/controller'
2
+ require 'tap/http/utils'
3
+
4
+ class CaptureController < Tap::Controller
5
+
6
+ def index
7
+ render 'index.erb'
8
+ end
9
+
10
+ def say
11
+ "<pre>#{request.params['words']}</pre>"
12
+ end
13
+
14
+ # Echos back redirected HTTP requests as YAML, suitable for use with the
15
+ # Tap::Http::Submit task. All HTTP parameters and headers are echoed back
16
+ # directly, except for the '__original_action' parameter which is used in
17
+ # conjuction with the 'Referer' header to reconstruct the original url of
18
+ # the request. The '__original_action' parameter is not echoed.
19
+ def http_to_yaml
20
+ headers = {}
21
+ request.env.each_pair do |key, value|
22
+ headers[key] = value unless key =~ /^(rack|tap)/
23
+ end
24
+ params = request.params
25
+
26
+ original_action = params.delete("__original_action").to_s
27
+ referer = headers['Referer'].to_s
28
+ uri = Tap::Http::Utils.determine_url(original_action, referer)
29
+ headers['Host'] = URI.parse(uri).host
30
+
31
+ config = {
32
+ 'headers' => headers,
33
+ 'params' => params,
34
+ 'uri' => uri,
35
+ 'request_method' => request.request_method
36
+ }
37
+
38
+ response['Content-Type'] = "text/plain"
39
+ %Q{# Save as a configuration file. Resubmit using:
40
+ #
41
+ # % rap load <config_file> --: submit --: dump --no-audit
42
+ #
43
+ #{YAML.dump(config)}
44
+ }
45
+ end
46
+
47
+ def echo
48
+ headers = {}
49
+ request.env.each_pair do |key, value|
50
+ headers[key] = value unless key =~ /^(rack|tap)/
51
+ end
52
+
53
+ "<pre>#{headers.to_yaml}#{request.params.to_yaml}</pre>"
54
+ end
55
+ end
@@ -0,0 +1,19 @@
1
+ require 'tap/http/request'
2
+
3
+ module Tap
4
+ module Http
5
+ # Tap::Http::Get::manifest gets the uri
6
+ # Submits an Http request to the specified uri and returns the message body.
7
+ class Get < Tap::Task
8
+ include Request
9
+
10
+ def process(uri)
11
+ log :get, uri
12
+ res = Request.get(uri)
13
+ log(nil, res.message)
14
+ res.body
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,263 @@
1
+ require 'tap/http/utils'
2
+ require 'net/http'
3
+ require 'rack/utils'
4
+
5
+ module Tap
6
+ module Http
7
+
8
+ # Request is a module for submitting HTTP requests. Request take a request
9
+ # method, a uri, and an options hash allowing these parameters:
10
+ #
11
+ # headers:: a hash of headers
12
+ # params:: a hash of parameters, values may be arrays when multiple
13
+ # values are assigned to a single key
14
+ # version: the HTTP version, by default 1.1
15
+ # redirection_limit:: the number of redirections allowed, by default 10
16
+ #
17
+ module Request
18
+ module_function
19
+
20
+ # Constructs and submits an http request to the uri using the specified
21
+ # request method and options (see above). Currently only get and post
22
+ # are supported as request methods. A block may be given to receive the
23
+ # Net::HTTP and request just prior to submission.
24
+ #
25
+ # Returns the Net::HTTP response.
26
+ def request(request_method, uri, opts={})
27
+ uri = "http://#{uri}" unless uri.to_s =~ /^http/
28
+ uri = URI.parse(uri) unless uri.kind_of?(URI)
29
+ uri.path = "/" if uri.path.empty?
30
+
31
+ headers = opts[:headers] || {}
32
+ params = opts[:params] || {}
33
+
34
+ # construct the request
35
+ request = case request_method.to_s
36
+ when /^get$/i then construct_get(uri, headers, params)
37
+ when /^post$/i then construct_post(uri, headers, params)
38
+ else
39
+ raise ArgumentError, "unsupported request method: #{request_method}"
40
+ end
41
+
42
+ # set the http version
43
+ version = opts[:version] || 1.1
44
+ version_method = "version_#{version.to_s.gsub(".", "_")}".to_sym
45
+ if ::Net::HTTP.respond_to?(version_method)
46
+ ::Net::HTTP.send(version_method)
47
+ else
48
+ raise ArgumentError, "unsupported HTTP version: #{version}"
49
+ end
50
+
51
+ # submit the request
52
+ res = ::Net::HTTP.new(uri.host, uri.port).start do |http|
53
+ yield(http, request) if block_given?
54
+ http.request(request)
55
+ end
56
+
57
+ # fetch redirections
58
+ redirection_limit = opts[:redirection_limit] || 10
59
+ redirection_limit ? fetch_redirection(res, redirection_limit) : res
60
+ end
61
+
62
+ # Shortcut to submit a get request.
63
+ def get(uri, opts={})
64
+ request(:get, uri, opts)
65
+ end
66
+
67
+ # Shortcut to submit a post request.
68
+ def post(uri, opts={})
69
+ request(:post, uri, opts)
70
+ end
71
+
72
+ def escape(str)
73
+ Rack::Utils.escape(str)
74
+ end
75
+
76
+ # Constructs a Net::HTTP::Post query, setting headers and parameters.
77
+ #
78
+ # ==== Supported Content Types:
79
+ #
80
+ # - application/x-www-form-urlencoded (the default)
81
+ # - multipart/form-data
82
+ #
83
+ # The multipart/form-data content type may specify a boundary. If no
84
+ # boundary is specified, a randomly generated boundary will be used
85
+ # to delimit the parameters.
86
+ #
87
+ # post = construct_post(
88
+ # URI.parse('http://some.url/'),
89
+ # {:content_type => 'multipart/form-data; boundary=1234'},
90
+ # {:key => 'value'})
91
+ #
92
+ # post.body
93
+ # # => %Q{--1234\r
94
+ # # Content-Disposition: form-data; name="key"\r
95
+ # # \r
96
+ # # value\r
97
+ # # --1234--\r
98
+ # # }
99
+ #
100
+ # (Note the carriage returns are required in multipart content)
101
+ #
102
+ # The content-length header is determined automatically from the
103
+ # formatted request body; manually specified content-length headers
104
+ # will be overridden.
105
+ #
106
+ def construct_post(uri, headers, params)
107
+ req = ::Net::HTTP::Post.new( "#{uri.path}#{format_query(uri)}" )
108
+ headers = headerize_keys(headers)
109
+ content_type = headers['Content-Type']
110
+
111
+ case content_type
112
+ when nil, /^application\/x-www-form-urlencoded$/i
113
+ req.body = format_www_form_urlencoded(params)
114
+ headers['Content-Type'] ||= "application/x-www-form-urlencoded"
115
+ headers['Content-Length'] = req.body.length
116
+
117
+ when /^multipart\/form-data(;\s*boundary=(.*))?$/i
118
+ # extract the boundary if it exists
119
+ boundary = $2 || rand.to_s[2..20]
120
+
121
+ req.body = format_multipart_form_data(params, boundary)
122
+ headers['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
123
+ headers['Content-Length'] = req.body.length
124
+
125
+ else
126
+ raise ArgumentError, "unsupported Content-Type for POST: #{content_type}"
127
+ end
128
+
129
+ headers.each_pair {|key, value| req[key] = value }
130
+ req
131
+ end
132
+
133
+ # Constructs a Net::HTTP::Get query. All parameters in uri and params are
134
+ # encoded and added to the request URI.
135
+ #
136
+ # get = construct_get(URI.parse('http://some.url/path'), {}, {:key => 'value'})
137
+ # get.path # => "/path?key=value"
138
+ #
139
+ def construct_get(uri, headers, params)
140
+ req = ::Net::HTTP::Get.new( "#{uri.path}#{format_query(uri, params)}" )
141
+ headerize_keys(headers).each_pair {|key, value| req[key] = value }
142
+ req
143
+ end
144
+
145
+ # Checks the type of the response; if it is a redirection, fetches the
146
+ # redirection. Otherwise return the response.
147
+ #
148
+ # Notes:
149
+ # - Fetch will recurse up to the input redirection limit (default 10)
150
+ # - Responses that are not Net::HTTPRedirection or Net::HTTPSuccess
151
+ # raise an error.
152
+ def fetch_redirection(res, limit=10)
153
+ raise 'exceeded the redirection limit' if limit < 1
154
+
155
+ case res
156
+ when ::Net::HTTPRedirection
157
+ redirect = ::Net::HTTP.get_response( URI.parse(res['location']) )
158
+ fetch_redirection(redirect, limit - 1)
159
+ when ::Net::HTTPSuccess
160
+ res
161
+ else
162
+ raise StandardError, res.error!
163
+ end
164
+ end
165
+
166
+ # Constructs a URI query string from the uri and the input parameters.
167
+ # Multiple values for a parameter may be specified using an array.
168
+ #
169
+ # format_query(URI.parse('http://some.url/path'), {:key => 'value'})
170
+ # # => "?key=value"
171
+ #
172
+ # format_query(URI.parse('http://some.url/path?one=1'), {:two => '2'})
173
+ # # => "?one=1&two=2"
174
+ #
175
+ def format_query(uri, params={})
176
+ query = []
177
+ query << uri.query if uri.query
178
+ params.each_pair do |key, values|
179
+ values = [values] unless values.kind_of?(Array)
180
+ values.each { |value| query << "#{escape(key)}=#{escape(value)}" }
181
+ end
182
+ "#{query.empty? ? '' : '?'}#{query.join('&')}"
183
+ end
184
+
185
+ # Formats params as 'application/x-www-form-urlencoded' for use as the
186
+ # body of a post request. Multiple values for a parameter may be
187
+ # specified using an array. The result is obviously URI encoded.
188
+ #
189
+ # format_www_form_urlencoded(:key => 'value with spaces')
190
+ # # => "key=value+with+spaces"
191
+ #
192
+ def format_www_form_urlencoded(params={})
193
+ query = []
194
+ params.each_pair do |key, values|
195
+ values = [values] unless values.kind_of?(Array)
196
+ values.each { |value| query << "#{escape(key)}=#{escape(value)}" }
197
+ end
198
+ query.join('&')
199
+ end
200
+
201
+ # Formats params as 'multipart/form-data' using the specified boundary,
202
+ # for use as the body of a post request. Multiple values for a parameter
203
+ # may be specified using an array. All newlines include a carriage
204
+ # return for proper formatting.
205
+ #
206
+ # format_multipart_form_data(:key => 'value')
207
+ # # => %Q{--1234567890\r
208
+ # # Content-Disposition: form-data; name="key"\r
209
+ # # \r
210
+ # # value\r
211
+ # # --1234567890--\r
212
+ # # }
213
+ #
214
+ # To specify a file, use a hash of file-related headers.
215
+ #
216
+ # format_multipart_form_data(:key => {
217
+ # 'Content-Type' => 'text/plain',
218
+ # 'Filename' => "path/to/file.txt"}
219
+ # )
220
+ # # => %Q{--1234567890\r
221
+ # # Content-Disposition: form-data; name="key"; filename="path/to/file.txt"\r
222
+ # # Content-Type: text/plain\r
223
+ # # \r
224
+ # # \r
225
+ # # --1234567890--\r
226
+ # # }
227
+ #
228
+ def format_multipart_form_data(params, boundary="1234567890")
229
+ body = []
230
+ params.each_pair do |key, values|
231
+ values = [values] unless values.kind_of?(Array)
232
+
233
+ values.each do |value|
234
+ body << case value
235
+ when Hash
236
+ hash = headerize_keys(value)
237
+ filename = hash.delete('Filename') || ""
238
+ content = File.exists?(filename) ? File.read(filename) : ""
239
+
240
+ header = "Content-Disposition: form-data; name=\"#{key.to_s}\"; filename=\"#{filename}\"\r\n"
241
+ hash.each_pair { |key, value| header << "#{key}: #{value}\r\n" }
242
+ "#{header}\r\n#{content}\r\n"
243
+ else
244
+ %Q{Content-Disposition: form-data; name="#{key.to_s}"\r\n\r\n#{value.to_s}\r\n}
245
+ end
246
+ end
247
+ end
248
+
249
+ body.collect {|p| "--#{boundary}\r\n#{p}" }.join('') + "--#{boundary}--\r\n"
250
+ end
251
+
252
+ # Helper to headerize the keys of a hash to headers.
253
+ # See Utils#headerize.
254
+ def headerize_keys(hash)
255
+ result = {}
256
+ hash.each_pair do |key, value|
257
+ result[Utils.headerize(key)] = value
258
+ end
259
+ result
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,31 @@
1
+ require 'tap/http/request'
2
+
3
+ module Tap
4
+ module Http
5
+ # Tap::Http::Submit::manifest submits a captured http request
6
+ class Submit < Tap::Task
7
+
8
+ def process(opts)
9
+ opts = symbolize(opts)
10
+
11
+ request_method = opts.delete(:request_method) || 'GET'
12
+ uri = opts.delete(:uri)
13
+
14
+ log request_method, uri
15
+ res = Request.request(request_method, uri, opts)
16
+ log(nil, res.message)
17
+ res.body
18
+ end
19
+
20
+ protected
21
+
22
+ # taken from ActiveSupport
23
+ def symbolize(hash) # :nodoc:
24
+ hash.inject({}) do |options, (key, value)|
25
+ options[(key.to_sym rescue key) || key] = value
26
+ options
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,8 @@
1
+ require 'rack'
1
2
  require 'webrick'
2
- require 'singleton'
3
+ require 'thread'
3
4
  require 'tap/test/subset_test'
4
- require 'pp'
5
+ require 'stringio'
5
6
 
6
7
  module Tap
7
8
  module Test
@@ -11,42 +12,24 @@ module Tap
11
12
  # validate echoed requests.
12
13
  #
13
14
  module HttpTest
14
-
15
- # Server echos back all requests.
16
- class Server
17
- include Singleton
18
- include WEBrick
19
-
20
- attr_accessor :server_thread, :server
15
+
16
+ class MockServer
17
+ def initialize(body, status=200, headers={})
18
+ @response = [status, headers, [body]]
19
+ end
21
20
 
22
- def initialize
23
- # set the default log level to warn to prevent general access logs, unless otherwise specified
24
- log_level = ENV["WEB_LOG_LEVEL"] ? ENV["WEB_LOG_LEVEL"].upcase : "WARN"
25
- logger = Log.new($stderr, Log.const_get( log_level ) )
26
-
27
- self.server = HTTPServer.new(:Port => 2000,
28
- :Logger => logger,
29
- :AccessLog => [
30
- [ logger, AccessLog::COMMON_LOG_FORMAT ],
31
- [ logger, AccessLog::REFERER_LOG_FORMAT ]])
32
-
33
- server.mount_proc("/") do |req, res|
34
- res.body << req.request_line
35
- res.body << req.raw_header.join('')
36
-
37
- # an extra line must be added to delimit the headers from the body.
38
- if req.body
39
- res.body << "\r\n"
40
- res.body << req.body
41
- end
42
-
43
- res['Content-Type'] = "text/html"
44
- end
21
+ def call(env)
22
+ @response
45
23
  end
46
-
47
- # Starts the server on a new thread.
48
- def start_web_server
49
- self.server_thread ||= Thread.new { server.start }
24
+ end
25
+
26
+ class EchoServer
27
+ def self.call(env)
28
+ body = env['rack.input'].read
29
+ headers = {}
30
+ env.each_pair {|key, value| headers[key] = [value] unless key =~ /^rack/ }
31
+
32
+ [200, headers, [body]]
50
33
  end
51
34
  end
52
35
 
@@ -54,96 +37,28 @@ module Tap
54
37
  base.send(:include, Tap::Test::SubsetTest)
55
38
  end
56
39
 
57
- # WEB subset of tests. Starts HTTPTest::Server if necessary
58
- # and yields to the block.
59
- def web_test
60
- subset_test("WEB", "w") do
61
- Server.instance.start_web_server
62
- yield
63
- end
40
+ def default_config(log_dev=StringIO.new(''))
41
+ common_logger = WEBrick::Log.new(log_dev, WEBrick::Log.const_get(:WARN) )
42
+ {
43
+ :Port => 2000,
44
+ :Logger => common_logger,
45
+ :AccessLog => common_logger
46
+ }
64
47
  end
65
48
 
66
- REQUEST_ATTRIBUTES = %w{
67
- request_method http_version
68
-
69
- host port path
70
- script_name path_info
71
-
72
- header cookies query
73
- accept accept_charset
74
- accept_encoding accept_language
75
-
76
- user
77
- addr peeraddr
78
- attributes
79
- keep_alive}
80
-
81
- UNCHECKED_REQUEST_ATTRIBUTES = %w{
82
- request_line
83
- unparsed_uri
84
- request_uri
85
- request_time
86
- raw_header
87
- query_string}
88
-
89
- # Parses expected and actual as an http request (using Tap::Net.parse_http_request)
90
- # and asserts that all of the REQUEST_ATTRIBUTES are equal. See the parse_http_request
91
- # documentation for some important notes, particularly involving "\r\n" vs "\n" and
92
- # post requests.
93
- def assert_request_equal(expected, actual)
94
- e = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
95
- e.parse( StringIO.new(expected) )
96
-
97
- a = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP)
98
- a.parse( StringIO.new(actual) )
99
-
100
- errors = []
101
- REQUEST_ATTRIBUTES.each do |attribute|
102
- exp = e.send(attribute)
103
- act = a.send(attribute)
104
- next if exp == act
105
- errors << "<#{PP.singleline_pp(exp, '')}> expected for #{attribute} but was:\n<#{PP.singleline_pp(act, '')}>."
106
- end
107
-
108
- if errors.empty?
109
- # this rather unecessary assertion is used simply to
110
- # make assert_request_equal cause an assertion.
111
- assert errors.empty?
112
- else
113
- flunk errors.join("\n")
49
+ def web_test(app=EchoServer, config=default_config)
50
+ subset_test("WEB", "w") do
51
+ begin
52
+ server = ::WEBrick::HTTPServer.new(config);
53
+ server.mount("/", Rack::Handler::WEBrick, app);
54
+ Thread.new { server.start }
55
+ yield
56
+ ensure
57
+ server.shutdown
58
+ end
114
59
  end
115
60
  end
116
61
 
117
- # Convenience method that strips str, strips each line of str, and
118
- # then rejoins them using "\r\n" as is typical of HTTP messages.
119
- #
120
- # strip_align %Q{
121
- # GET /echo HTTP/1.1
122
- # Accept: */*
123
- # Host: localhost:2000}
124
- #
125
- # # => "GET /echo HTTP/1.1\r\nAccept: */*\r\nHost: localhost:2000\r\n"
126
- def strip_align(str)
127
- str.strip.split(/\r?\n/).collect do |line|
128
- "#{line.strip}\r\n"
129
- end.compact.join('')
130
- end
131
-
132
- # Turns a hash of parameters into an encoded HTTP query string.
133
- # Multiple values for a given key can be specified by an array.
134
- #
135
- # to_query('key' => 'value', 'array' => ['one', 'two']) # => "array=one&array=two&key=value"
136
- #
137
- # Note: the order of the parameters in the result is determined
138
- # by hash.each_pair and is thus fairly unpredicatable.
139
- def to_query(hash)
140
- query = []
141
- hash.each_pair do |key,values|
142
- values = values.kind_of?(Array) ? values : [values]
143
- values.each { |value| query << "#{key}=#{value}" }
144
- end
145
- URI.encode(query.join('&'))
146
- end
147
62
  end
148
63
  end
149
64
  end