tap-http 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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