http_tools 0.3.0 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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"]}
|