http_tools 0.3.0 → 0.4.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/README.rdoc +3 -3
- data/bench/parser/large_response_bench.rb +40 -0
- data/bench/transfer_encoding_chunked_bench.rb +4 -4
- data/example/http_client.rb +24 -139
- data/example/http_server.rb +1 -14
- data/lib/http_tools.rb +7 -5
- data/lib/http_tools/builder.rb +23 -11
- data/lib/http_tools/encoding.rb +4 -2
- data/lib/http_tools/parser.rb +136 -57
- data/profile/parser/large_response_profile.rb +16 -0
- data/test/builder/request_test.rb +21 -3
- data/test/builder/response_test.rb +14 -1
- data/test/cover.rb +3 -3
- data/test/parser/request_test.rb +32 -6
- data/test/parser/response_test.rb +123 -5
- metadata +6 -6
- data/example/simple_http_client.rb +0 -67
- data/lib/http_tools/errors.rb +0 -6
data/README.rdoc
CHANGED
@@ -25,7 +25,7 @@ possible.
|
|
25
25
|
puts parser.status_code + " " + parser.request_method
|
26
26
|
puts parser.header.inspect
|
27
27
|
end
|
28
|
-
parser.on(:
|
28
|
+
parser.on(:finish) {print parser.body}
|
29
29
|
|
30
30
|
parser << "HTTP/1.1 200 OK\r\n"
|
31
31
|
parser << "Content-Length: 20\r\n\r\n"
|
@@ -45,11 +45,11 @@ mixin or class methods on HTTPTools::Encoding.
|
|
45
45
|
=== Example
|
46
46
|
|
47
47
|
HTTPTools::Encoding.www_form_encode({"query" => "fish", "lang" => "en"})
|
48
|
-
#=> "lang=en
|
48
|
+
#=> "query=fish&lang=en"
|
49
49
|
|
50
50
|
include HTTPTools::Encoding
|
51
51
|
www_form_decode("lang=en&query=fish")
|
52
|
-
#=> {"
|
52
|
+
#=> {"lang" => "en", "query" => "fish"}
|
53
53
|
|
54
54
|
== HTTPTools::Builder
|
55
55
|
|
@@ -0,0 +1,40 @@
|
|
1
|
+
base = File.expand_path(File.dirname(__FILE__) + '/../../lib')
|
2
|
+
require base + '/http_tools'
|
3
|
+
require 'benchmark'
|
4
|
+
|
5
|
+
Benchmark.bm(26) do |x|
|
6
|
+
body = "x" * 1024 * 1024
|
7
|
+
chunks = []
|
8
|
+
64.times {|i| chunks << body[i * 64, body.length / 64]}
|
9
|
+
|
10
|
+
header = "HTTP/1.1 200 OK\r\nDate: Mon, 06 Jun 2011 14:55:51 GMT\r\nServer: Apache/2.2.17 (Unix) mod_ssl/2.2.17 OpenSSL/0.9.8l DAV/2 mod_fastcgi/2.4.2\r\nLast-Modified: Mon, 06 Jun 2011 14:55:49 GMT\r\nETag: \"3f18045-400-4a50c4c87c740\"\r\nAccept-Ranges: bytes\r\nContent-Length: #{body.length}\r\nContent-Type: text/plain\r\n\r\n"
|
11
|
+
x.report("content_length") do
|
12
|
+
200.times do
|
13
|
+
parser = HTTPTools::Parser.new
|
14
|
+
parser << header
|
15
|
+
chunks.each {|chunk| parser << chunk}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
header = "HTTP/1.1 200 OK\r\nDate: Mon, 06 Jun 2011 14:55:51 GMT\r\nServer: Apache/2.2.17 (Unix) mod_ssl/2.2.17 OpenSSL/0.9.8l DAV/2 mod_fastcgi/2.4.2\r\nLast-Modified: Mon, 06 Jun 2011 14:55:49 GMT\r\nETag: \"3f18045-400-4a50c4c87c740\"\r\nAccept-Ranges: bytes\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n"
|
20
|
+
x.report("close") do
|
21
|
+
200.times do
|
22
|
+
parser = HTTPTools::Parser.new
|
23
|
+
parser << header
|
24
|
+
chunks.each {|chunk| parser << chunk}
|
25
|
+
parser.finish
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
header = "HTTP/1.1 200 OK\r\nDate: Mon, 06 Jun 2011 14:55:51 GMT\r\nServer: Apache/2.2.17 (Unix) mod_ssl/2.2.17 OpenSSL/0.9.8l DAV/2 mod_fastcgi/2.4.2\r\nLast-Modified: Mon, 06 Jun 2011 14:55:49 GMT\r\nETag: \"3f18045-400-4a50c4c87c740\"\r\nAccept-Ranges: bytes\r\nTransfer-Encoding: chunked\r\nContent-Type: text/plain\r\n\r\n"
|
30
|
+
chunks << nil
|
31
|
+
chunks.map!(&HTTPTools::Encoding.method(:transfer_encoding_chunked_encode))
|
32
|
+
x.report("chunked") do
|
33
|
+
200.times do
|
34
|
+
parser = HTTPTools::Parser.new
|
35
|
+
parser << header
|
36
|
+
chunks.each {|chunk| parser << chunk}
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
@@ -3,24 +3,24 @@ require base + '/http_tools'
|
|
3
3
|
require 'benchmark'
|
4
4
|
|
5
5
|
Benchmark.bm(36) do |x|
|
6
|
+
encoded = "1\r\na\r\n" * 100 + "0\r\n"
|
6
7
|
x.report("lots of very short chunks") do
|
7
|
-
encoded = "1\r\na\r\n" * 100 + "0\r\n"
|
8
8
|
1_000.times do
|
9
9
|
HTTPTools::Encoding.transfer_encoding_chunked_decode(encoded)
|
10
10
|
end
|
11
11
|
end
|
12
12
|
|
13
|
+
encoded = "16\r\n<h1>Hello world</h1>\r\n\r\n12\r\n<p>Lorem ipsum</p>\r\n" * 50 + "0\r\n"
|
13
14
|
x.report("slightly less slightly longer chunks") do
|
14
|
-
encoded = "16\r\n<h1>Hello world</h1>\r\n\r\n12\r\n<p>Lorem ipsum</p>\r\n" * 50 + "0\r\n"
|
15
15
|
1_000.times do
|
16
16
|
HTTPTools::Encoding.transfer_encoding_chunked_decode(encoded)
|
17
17
|
end
|
18
18
|
end
|
19
19
|
|
20
|
+
encoded = "2710\r\n#{"a" * 10000}\r\n" * 2 + "0\r\n"
|
20
21
|
x.report("a couple of big chunks") do
|
21
|
-
encoded = "2710\r\n#{"a" * 10000}\r\n" * 2 + "0\r\n"
|
22
22
|
1_000.times do
|
23
23
|
HTTPTools::Encoding.transfer_encoding_chunked_decode(encoded)
|
24
24
|
end
|
25
25
|
end
|
26
|
-
end
|
26
|
+
end
|
data/example/http_client.rb
CHANGED
@@ -1,6 +1,4 @@
|
|
1
|
-
require 'uri'
|
2
1
|
require 'socket'
|
3
|
-
require 'stringio'
|
4
2
|
require 'rubygems'
|
5
3
|
require 'http_tools'
|
6
4
|
|
@@ -12,170 +10,57 @@ require 'http_tools'
|
|
12
10
|
# puts "#{response.status} #{response.message}"
|
13
11
|
# puts response.headers.inspect
|
14
12
|
# puts response.body
|
15
|
-
#
|
16
|
-
# Streaming response:
|
17
|
-
# client.get(uri.path) do |response|
|
18
|
-
# puts "#{response.status} #{response.message}"
|
19
|
-
# response.stream do |chunk|
|
20
|
-
# print chunk
|
21
|
-
# end
|
22
|
-
# end
|
23
13
|
#
|
24
14
|
module HTTP
|
25
15
|
class Client
|
26
|
-
|
27
|
-
|
28
|
-
CONTENT_TYPE = "Content-Type".freeze
|
29
|
-
CONTENT_LENGTH = "Content-Length".freeze
|
30
|
-
WWW_FORM = "application/x-www-form-urlencoded".freeze
|
31
|
-
|
32
|
-
attr_writer :keepalive
|
16
|
+
Response = Struct.new(:status, :message, :headers, :body)
|
33
17
|
|
34
18
|
def initialize(host, port=80)
|
35
|
-
@host = host
|
36
|
-
@port = port
|
37
|
-
@pipeline = []
|
38
|
-
end
|
39
|
-
|
40
|
-
def socket
|
41
|
-
@socket ||= TCPSocket.new(@host, @port)
|
19
|
+
@host, @port = host, port
|
42
20
|
end
|
43
21
|
|
44
22
|
def head(path, headers={})
|
45
23
|
request(:head, path, nil, headers, false)
|
46
24
|
end
|
47
25
|
|
48
|
-
def get(path, headers={}
|
49
|
-
request(:get, path, nil, headers
|
26
|
+
def get(path, headers={})
|
27
|
+
request(:get, path, nil, headers)
|
50
28
|
end
|
51
29
|
|
52
|
-
def post(path, body=
|
53
|
-
|
54
|
-
unless body.respond_to?(:read)
|
55
|
-
if headers[CONTENT_TYPE] == WWW_FORM && body.respond_to?(:map) &&
|
56
|
-
!body.kind_of?(String)
|
57
|
-
body = www_form_encode(body)
|
58
|
-
end
|
59
|
-
body = StringIO.new(body.to_s)
|
60
|
-
end
|
61
|
-
if headers[CONTENT_LENGTH]
|
62
|
-
# ok
|
63
|
-
elsif body.respond_to?(:length)
|
64
|
-
headers[CONTENT_LENGTH] = body.length
|
65
|
-
elsif body.respond_to?(:stat)
|
66
|
-
headers[CONTENT_LENGTH] = body.stat.size
|
67
|
-
else
|
68
|
-
raise "Content-Length must be supplied"
|
69
|
-
end
|
70
|
-
|
71
|
-
request(:post, path, body, headers, &block)
|
30
|
+
def post(path, body=nil, headers={})
|
31
|
+
request(:post, path, body, headers)
|
72
32
|
end
|
73
33
|
|
74
|
-
def
|
75
|
-
|
76
|
-
yield self
|
77
|
-
pipeline_requests(@pipeline)
|
78
|
-
ensure
|
79
|
-
@pipelining = false
|
34
|
+
def put(path, body=nil, headers={})
|
35
|
+
request(:put, path, body, headers)
|
80
36
|
end
|
81
37
|
|
82
|
-
def
|
83
|
-
|
84
|
-
end
|
85
|
-
|
86
|
-
def keepalive
|
87
|
-
self.keepalive, original = true, keepalive?
|
88
|
-
yield self
|
89
|
-
ensure
|
90
|
-
self.keepalive = original
|
38
|
+
def delete(path, headers={})
|
39
|
+
request(:delete, path, nil, headers)
|
91
40
|
end
|
92
41
|
|
93
42
|
private
|
94
|
-
def request(method, path, body=nil, headers={}, response_has_body=true
|
95
|
-
request = {
|
96
|
-
:method => method,
|
97
|
-
:path => path,
|
98
|
-
:body => body,
|
99
|
-
:headers => headers,
|
100
|
-
:response_has_body => response_has_body,
|
101
|
-
:block => b}
|
102
|
-
if @pipelining
|
103
|
-
@pipeline << request
|
104
|
-
nil
|
105
|
-
else
|
106
|
-
pipeline_requests([request]).first
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
|
-
def pipeline_requests(requests)
|
43
|
+
def request(method, path, body=nil, headers={}, response_has_body=true)
|
111
44
|
parser = HTTPTools::Parser.new
|
112
|
-
parser.
|
113
|
-
|
114
|
-
|
115
|
-
parser.on(:finish) do |remainder|
|
116
|
-
if responses.length < requests.length
|
117
|
-
parser.reset
|
118
|
-
parser << remainder.lstrip if remainder
|
119
|
-
throw :reset
|
120
|
-
end
|
121
|
-
end
|
122
|
-
parser.on(:header) do
|
123
|
-
request = requests[responses.length]
|
124
|
-
parser.force_no_body = !request[:response_has_body]
|
125
|
-
response = Response.new(parser.status_code, parser.message)
|
126
|
-
response.headers = parser.header
|
127
|
-
parser.on(:stream) {|chunk| response.receive_chunk(chunk)}
|
128
|
-
responses.push(response)
|
129
|
-
end
|
45
|
+
parser.force_no_body = !response_has_body
|
46
|
+
response = nil
|
130
47
|
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
socket << body.read(1024 * 16) until body.eof?
|
135
|
-
end
|
48
|
+
parser.on(:finish) do
|
49
|
+
code, message = parser.status_code, parser.message
|
50
|
+
response = Response.new(code, message, parser.header, parser.body)
|
136
51
|
end
|
137
52
|
|
53
|
+
socket = TCPSocket.new(@host, @port)
|
54
|
+
socket << HTTPTools::Builder.request(method, @host, path, headers)
|
55
|
+
socket << body if body
|
138
56
|
begin
|
139
|
-
|
57
|
+
parser << socket.sysread(1024 * 16)
|
140
58
|
rescue EOFError
|
141
|
-
|
142
|
-
parser.finish
|
143
|
-
break
|
59
|
+
break parser.finish
|
144
60
|
end until parser.finished?
|
61
|
+
socket.close
|
145
62
|
|
146
|
-
|
147
|
-
responses
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
class Response
|
152
|
-
attr_reader :status, :message
|
153
|
-
attr_accessor :headers, :body
|
154
|
-
|
155
|
-
def initialize(status, message, headers={}, body="")
|
156
|
-
@status = status
|
157
|
-
@message = message
|
158
|
-
@headers = headers
|
159
|
-
@body = body
|
160
|
-
end
|
161
|
-
|
162
|
-
def stream(&block)
|
163
|
-
@stream_callback = block
|
164
|
-
nil
|
165
|
-
end
|
166
|
-
|
167
|
-
def receive_chunk(chunk) # :nodoc:
|
168
|
-
body << chunk
|
169
|
-
@stream_callback.call(chunk) if @stream_callback
|
170
|
-
end
|
171
|
-
|
172
|
-
def inspect
|
173
|
-
bytesize = body.respond_to?(:bytesize) ? body.bytesize : body.to_s.length
|
174
|
-
"#<Response #{status} #{message}: #{bytesize} bytes>"
|
175
|
-
end
|
176
|
-
|
177
|
-
def to_s
|
178
|
-
body.to_s
|
63
|
+
response
|
179
64
|
end
|
180
65
|
end
|
181
|
-
end
|
66
|
+
end
|
data/example/http_server.rb
CHANGED
@@ -1,11 +1,9 @@
|
|
1
1
|
require 'socket'
|
2
|
-
require 'stringio'
|
3
2
|
require 'rubygems'
|
4
3
|
require 'http_tools'
|
5
4
|
|
6
5
|
module HTTP
|
7
6
|
class Server
|
8
|
-
RACK_INPUT = "rack.input".freeze
|
9
7
|
CONNECTION = "Connection".freeze
|
10
8
|
KEEP_ALIVE = "Keep-Alive".freeze
|
11
9
|
CLOSE = "close".freeze
|
@@ -39,16 +37,9 @@ module HTTP
|
|
39
37
|
private
|
40
38
|
def on_connection(socket)
|
41
39
|
parser = HTTPTools::Parser.new
|
42
|
-
env, input = nil
|
43
40
|
|
44
|
-
parser.on(:header) do
|
45
|
-
input = StringIO.new
|
46
|
-
env = parser.env.merge!(RACK_INPUT => input).merge!(@instance_env)
|
47
|
-
end
|
48
|
-
parser.on(:stream) {|chunk| input << chunk}
|
49
41
|
parser.on(:finish) do
|
50
|
-
|
51
|
-
status, header, body = @app.call(env)
|
42
|
+
status, header, body = @app.call(parser.env.merge!(@instance_env))
|
52
43
|
keep_alive = keep_alive?(parser.version, parser.header[CONNECTION])
|
53
44
|
header[CONNECTION] = keep_alive ? KEEP_ALIVE : CLOSE
|
54
45
|
socket << HTTPTools::Builder.response(status, header)
|
@@ -74,7 +65,3 @@ module HTTP
|
|
74
65
|
|
75
66
|
end
|
76
67
|
end
|
77
|
-
|
78
|
-
HTTP::Server.run(proc do |env|
|
79
|
-
[200, {"Content-Length" => "5"}, "Hello"]
|
80
|
-
end)
|
data/lib/http_tools.rb
CHANGED
@@ -84,11 +84,13 @@ module HTTPTools
|
|
84
84
|
505 => "HTTP Version Not Supported"}.freeze
|
85
85
|
STATUS_DESCRIPTIONS.values.each {|val| val.freeze}
|
86
86
|
|
87
|
+
# :stopdoc: hide from rdoc as it makes a mess
|
87
88
|
STATUS_LINES = Hash.new do |hash, key|
|
88
89
|
code = if key.kind_of?(Integer) then key else STATUS_CODES[key] end
|
89
90
|
description = STATUS_DESCRIPTIONS[code]
|
90
91
|
hash[key] = "#{code} #{description}"
|
91
92
|
end
|
93
|
+
# :startdoc:
|
92
94
|
|
93
95
|
METHODS = %W{GET POST HEAD PUT DELETE OPTIONS TRACE CONNECT}.freeze
|
94
96
|
|
@@ -96,15 +98,15 @@ module HTTPTools
|
|
96
98
|
100.upto(199) {|status_code| NO_BODY[status_code] = true}
|
97
99
|
NO_BODY.freeze
|
98
100
|
|
99
|
-
|
101
|
+
Error = Class.new(StandardError)
|
102
|
+
ParseError = Class.new(Error)
|
103
|
+
EndOfMessageError = Class.new(ParseError)
|
104
|
+
MessageIncompleteError = Class.new(EndOfMessageError)
|
105
|
+
EmptyMessageError = Class.new(MessageIncompleteError)
|
100
106
|
|
101
107
|
require_base = File.dirname(__FILE__) + '/http_tools/'
|
102
108
|
autoload :Encoding, require_base + 'encoding'
|
103
109
|
autoload :Parser, require_base + 'parser'
|
104
110
|
autoload :Builder, require_base + 'builder'
|
105
|
-
autoload :ParseError, require_base + 'errors'
|
106
|
-
autoload :EndOfMessageError, require_base + 'errors'
|
107
|
-
autoload :MessageIncompleteError, require_base + 'errors'
|
108
|
-
autoload :EmptyMessageError, require_base + 'errors'
|
109
111
|
|
110
112
|
end
|
data/lib/http_tools/builder.rb
CHANGED
@@ -4,8 +4,6 @@ module HTTPTools
|
|
4
4
|
# responses. It can be used as a mixin or class methods on HTTPTools::Builder.
|
5
5
|
#
|
6
6
|
module Builder
|
7
|
-
KEY_VALUE = "%s: %s\r\n".freeze
|
8
|
-
|
9
7
|
module_function
|
10
8
|
|
11
9
|
# :call-seq: Builder.response(status, headers={}) -> string
|
@@ -13,11 +11,15 @@ module HTTPTools
|
|
13
11
|
# Returns a HTTP status line and headers. Status can be a HTTP status code
|
14
12
|
# as an integer, or a HTTP status message as a lowercase, underscored
|
15
13
|
# symbol.
|
16
|
-
# Builder.response(200, "Content-Type" => "text/html")
|
17
|
-
#=> "HTTP/1.1 200 ok\r\nContent-Type: text/html\r\n\r\n"
|
14
|
+
# Builder.response(200, "Content-Type" => "text/html")
|
15
|
+
# #=> "HTTP/1.1 200 ok\r\nContent-Type: text/html\r\n\r\n"
|
16
|
+
#
|
17
|
+
# Builder.response(:internal_server_error)
|
18
|
+
# #=> "HTTP/1.1 500 Internal Server Error\r\n\r\n"
|
18
19
|
#
|
19
|
-
#
|
20
|
-
|
20
|
+
# To send multiple headers with the same name:
|
21
|
+
# Builder.response(:ok, "Set-Cookie" => ["a=b", "c=d"])
|
22
|
+
# Builder.response(:ok, "Set-Cookie" => "a=b\nc=d")
|
21
23
|
#
|
22
24
|
def response(status, headers={})
|
23
25
|
"HTTP/1.1 #{STATUS_LINES[status]}\r\n#{format_headers(headers)}\r\n"
|
@@ -26,10 +28,11 @@ module HTTPTools
|
|
26
28
|
# :call-seq: Builder.request(method, host, path="/", headers={}) -> string
|
27
29
|
#
|
28
30
|
# Returns a HTTP request line and headers.
|
29
|
-
# Builder.request(:get, "example.com")
|
30
|
-
#=> "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
31
|
+
# Builder.request(:get, "example.com")
|
32
|
+
# #=> "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
31
33
|
#
|
32
|
-
# Builder.request(:post, "example.com", "/form", "Accept" => "text/html")
|
34
|
+
# Builder.request(:post, "example.com", "/form", "Accept" => "text/html")
|
35
|
+
#\
|
33
36
|
#=> "POST" /form HTTP/1.1\r\nHost: example.com\r\nAccept: text/html\r\n\r\n"
|
34
37
|
#
|
35
38
|
def request(method, host, path="/", headers={})
|
@@ -37,8 +40,17 @@ module HTTPTools
|
|
37
40
|
format_headers(headers)}\r\n"
|
38
41
|
end
|
39
42
|
|
40
|
-
def format_headers(headers)
|
41
|
-
headers.
|
43
|
+
def format_headers(headers, buffer="")
|
44
|
+
headers.each do |key, value|
|
45
|
+
if value.respond_to?(:each_line)
|
46
|
+
value.each_line {|val| buffer << "#{key}: #{val.chomp}\r\n"}
|
47
|
+
elsif value.respond_to?(:each)
|
48
|
+
value.each {|val| buffer << "#{key}: #{val}\r\n"}
|
49
|
+
else
|
50
|
+
buffer << "#{key}: #{value}\r\n"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
buffer
|
42
54
|
end
|
43
55
|
private :format_headers
|
44
56
|
class << self
|
data/lib/http_tools/encoding.rb
CHANGED
@@ -7,6 +7,7 @@ module HTTPTools
|
|
7
7
|
# used as a mixin or class methods on HTTPTools::Encoding.
|
8
8
|
#
|
9
9
|
module Encoding
|
10
|
+
# :stopdoc:
|
10
11
|
HEX_BIG_ENDIAN_2_BYTES = "H2".freeze
|
11
12
|
HEX_BIG_ENDIAN_REPEATING = "H*".freeze
|
12
13
|
PERCENT = "%".freeze
|
@@ -15,6 +16,7 @@ module HTTPTools
|
|
15
16
|
AMPERSAND = "&".freeze
|
16
17
|
EQUALS = "=".freeze
|
17
18
|
CHUNK_FORMAT = "%x\r\n%s\r\n".freeze
|
19
|
+
# :startdoc:
|
18
20
|
|
19
21
|
module_function
|
20
22
|
|
@@ -44,7 +46,7 @@ module HTTPTools
|
|
44
46
|
#
|
45
47
|
# Takes a Hash and converts it to a String as if it was a HTML form being
|
46
48
|
# submitted, eg
|
47
|
-
# {"query" => "fish", "lang" => "en"} becomes "lang=en
|
49
|
+
# {"query" => "fish", "lang" => "en"} becomes "query=fish&lang=en"
|
48
50
|
#
|
49
51
|
# To get multiple key value pairs with the same key use an array as the
|
50
52
|
# value, eg
|
@@ -64,7 +66,7 @@ module HTTPTools
|
|
64
66
|
#
|
65
67
|
# Takes a String resulting from a HTML form being submitted, and converts it
|
66
68
|
# to a hash,
|
67
|
-
# eg "lang=en&query=fish" becomes {"
|
69
|
+
# eg "lang=en&query=fish" becomes {"lang" => "en", "query" => "fish"}
|
68
70
|
#
|
69
71
|
# Multiple key value pairs with the same key will become a single key with
|
70
72
|
# an array value, eg "lang=en&lang=fr" becomes {"lang" => ["en", "fr"]}
|