hurley 0.1
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.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/.travis.yml +28 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +28 -0
- data/LICENSE.md +20 -0
- data/README.md +317 -0
- data/Rakefile +1 -0
- data/contributors.yaml +8 -0
- data/hurley.gemspec +29 -0
- data/lib/hurley.rb +104 -0
- data/lib/hurley/addressable.rb +9 -0
- data/lib/hurley/client.rb +349 -0
- data/lib/hurley/connection.rb +123 -0
- data/lib/hurley/header.rb +144 -0
- data/lib/hurley/multipart.rb +235 -0
- data/lib/hurley/options.rb +142 -0
- data/lib/hurley/query.rb +252 -0
- data/lib/hurley/tasks.rb +111 -0
- data/lib/hurley/test.rb +101 -0
- data/lib/hurley/test/integration.rb +249 -0
- data/lib/hurley/test/server.rb +102 -0
- data/lib/hurley/url.rb +197 -0
- data/script/bootstrap +2 -0
- data/script/package +7 -0
- data/script/test +168 -0
- data/test/client_test.rb +585 -0
- data/test/header_test.rb +108 -0
- data/test/helper.rb +14 -0
- data/test/live/net_http_test.rb +16 -0
- data/test/multipart_test.rb +306 -0
- data/test/query_test.rb +189 -0
- data/test/test_test.rb +38 -0
- data/test/url_test.rb +443 -0
- metadata +181 -0
@@ -0,0 +1,142 @@
|
|
1
|
+
require "openssl"
|
2
|
+
require "securerandom"
|
3
|
+
|
4
|
+
module Hurley
|
5
|
+
class RequestOptions < Struct.new(
|
6
|
+
# Integer or Fixnum number of seconds to wait for one block to be read.
|
7
|
+
:timeout,
|
8
|
+
|
9
|
+
# Integer or Fixnum number of seconds to wait for the connection to open.
|
10
|
+
:open_timeout,
|
11
|
+
|
12
|
+
# String boundary to use for multipart request bodies.
|
13
|
+
:boundary,
|
14
|
+
|
15
|
+
# A SocketBinding specifying the host and/or port of the local client
|
16
|
+
# socket.
|
17
|
+
:bind,
|
18
|
+
|
19
|
+
# Hurley::Url instance of an HTTP Proxy address.
|
20
|
+
:proxy,
|
21
|
+
|
22
|
+
# Integer limit on the number of redirects that are automatically followed.
|
23
|
+
# Default: 5
|
24
|
+
:redirection_limit,
|
25
|
+
|
26
|
+
# Hurley::Query subclass to use for query objects. Defaults to
|
27
|
+
# Hurley::Query.default.
|
28
|
+
:query_class,
|
29
|
+
)
|
30
|
+
|
31
|
+
def redirection_limit
|
32
|
+
self[:redirection_limit] ||= 5
|
33
|
+
end
|
34
|
+
|
35
|
+
def bind=(b)
|
36
|
+
self[:bind] = SocketBinding.parse(b)
|
37
|
+
end
|
38
|
+
|
39
|
+
def build_form(body)
|
40
|
+
query_class.new(body).to_form(self)
|
41
|
+
end
|
42
|
+
|
43
|
+
def boundary
|
44
|
+
self[:boundary] || "Hurley-#{SecureRandom.hex}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def query_class
|
48
|
+
self[:query_class] ||= Query.default
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class SslOptions < Struct.new(
|
53
|
+
# Boolean that specifies whether to skip SSL verification.
|
54
|
+
:skip_verification,
|
55
|
+
|
56
|
+
# Sets the maximum depth for the certificate chain verification.
|
57
|
+
:verify_depth,
|
58
|
+
|
59
|
+
# String path of a CA certification file in PEM format.
|
60
|
+
:ca_file,
|
61
|
+
|
62
|
+
# String path of a CA certification directory containing certifications in PEM format.
|
63
|
+
:ca_path,
|
64
|
+
|
65
|
+
# String client cert contents.
|
66
|
+
:client_cert,
|
67
|
+
|
68
|
+
# String path to client certificate.
|
69
|
+
:client_cert_path,
|
70
|
+
|
71
|
+
# String private key contents.
|
72
|
+
:private_key,
|
73
|
+
|
74
|
+
# String path to private key.
|
75
|
+
:private_key_path,
|
76
|
+
|
77
|
+
# String pass for the private key
|
78
|
+
:private_key_pass,
|
79
|
+
|
80
|
+
# An OpenSSL::X509::Certificate object for a client certificate.
|
81
|
+
:openssl_client_cert,
|
82
|
+
|
83
|
+
# An OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object.
|
84
|
+
:openssl_client_key,
|
85
|
+
|
86
|
+
# The X509::Store to verify peer certificate.
|
87
|
+
:openssl_cert_store,
|
88
|
+
|
89
|
+
# Sets the SSL version. See OpenSSL::SSL::SSLContext::METHODS for available
|
90
|
+
# versions.
|
91
|
+
:version,
|
92
|
+
)
|
93
|
+
|
94
|
+
def skip_verification?
|
95
|
+
self[:skip_verification]
|
96
|
+
end
|
97
|
+
|
98
|
+
def openssl_verify_mode
|
99
|
+
skip_verification ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER
|
100
|
+
end
|
101
|
+
|
102
|
+
def openssl_client_cert
|
103
|
+
self[:openssl_client_cert] ||= begin
|
104
|
+
cert_contents = self[:client_cert] || (self[:client_cert_path] && IO.read(self[:client_cert_path]))
|
105
|
+
return unless cert_contents
|
106
|
+
OpenSSL::X509::Certificate.new(cert_contents)
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def openssl_cert_store
|
111
|
+
self[:openssl_cert_store] ||= OpenSSL::X509::Store.new.tap do |store|
|
112
|
+
store.set_default_paths
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def openssl_private_key
|
117
|
+
@openssl_private_key ||= begin
|
118
|
+
pkey = if pkey_path = self[:private_key_path]
|
119
|
+
File.read(pkey_path)
|
120
|
+
else
|
121
|
+
self[:private_key]
|
122
|
+
end
|
123
|
+
|
124
|
+
return unless pkey
|
125
|
+
|
126
|
+
if OpenSSL::PKey.respond_to?(:read)
|
127
|
+
OpenSSL::PKey.read(pkey, self[:private_key_pass])
|
128
|
+
else
|
129
|
+
OpenSSL::PKey::RSA.new(pkey, self[:private_key_pass])
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
class SocketBinding < Struct.new(:host, :port)
|
136
|
+
def self.parse(bind)
|
137
|
+
h, p = bind.to_s.split(":", 2)
|
138
|
+
p = p.to_i
|
139
|
+
new(h, p.zero? ? nil : p)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
data/lib/hurley/query.rb
ADDED
@@ -0,0 +1,252 @@
|
|
1
|
+
require "cgi"
|
2
|
+
require "forwardable"
|
3
|
+
require "stringio"
|
4
|
+
|
5
|
+
module Hurley
|
6
|
+
class Query
|
7
|
+
def self.default
|
8
|
+
@default ||= Nested
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.parse(raw_query)
|
12
|
+
default.parse(raw_query)
|
13
|
+
end
|
14
|
+
|
15
|
+
def initialize(initial = nil)
|
16
|
+
@hash = {}
|
17
|
+
update(initial) if initial
|
18
|
+
end
|
19
|
+
|
20
|
+
extend Forwardable
|
21
|
+
def_delegators(:@hash,
|
22
|
+
:[], :[]=,
|
23
|
+
:each,
|
24
|
+
:keys,
|
25
|
+
:size,
|
26
|
+
:delete,
|
27
|
+
:key?,
|
28
|
+
)
|
29
|
+
|
30
|
+
def subset_of?(url)
|
31
|
+
query = url.respond_to?(:query) ? url.query : url
|
32
|
+
@hash.keys.all? do |key|
|
33
|
+
query[key] == @hash[key]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def update(absolute)
|
38
|
+
absolute.each do |key, value|
|
39
|
+
@hash[key] = value unless key?(key)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def parse_query(raw_query)
|
44
|
+
raw_query.to_s.split(AMP).each do |pair|
|
45
|
+
escaped_key, escaped_value = pair.split(EQ, 2)
|
46
|
+
key = CGI.unescape(escaped_key)
|
47
|
+
value = escaped_value ? CGI.unescape(escaped_value) : nil
|
48
|
+
send(:decode_pair, key, value)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def multipart?
|
53
|
+
any_multipart?(@hash.values)
|
54
|
+
end
|
55
|
+
|
56
|
+
def to_query_string
|
57
|
+
build_pairs.map!(&:to_s).join(AMP)
|
58
|
+
end
|
59
|
+
|
60
|
+
def to_form(options = nil)
|
61
|
+
if multipart?
|
62
|
+
boundary = (options || RequestOptions.new).boundary
|
63
|
+
return MULTIPART_TYPE % boundary, to_io(boundary)
|
64
|
+
else
|
65
|
+
return FORM_TYPE, StringIO.new(to_query_string)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
alias to_s to_query_string
|
70
|
+
|
71
|
+
def inspect
|
72
|
+
"#<%s %s>" % [
|
73
|
+
self.class.name,
|
74
|
+
@hash.inspect,
|
75
|
+
]
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.inherited(base)
|
79
|
+
super
|
80
|
+
class << base
|
81
|
+
def parse(raw_query)
|
82
|
+
q = new
|
83
|
+
q.parse_query(raw_query)
|
84
|
+
q
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
class Nested < self
|
90
|
+
private
|
91
|
+
|
92
|
+
def decode_pair(key, value)
|
93
|
+
if key !~ END_BRACKET
|
94
|
+
self[key] = value
|
95
|
+
return
|
96
|
+
end
|
97
|
+
|
98
|
+
first_key = key[0, key.index(START_BRACKET)]
|
99
|
+
hash_keys = [first_key, *key.scan(/\[([^\]]+)?\]/).map(&:first)]
|
100
|
+
last_index = hash_keys.size - 1
|
101
|
+
container = self
|
102
|
+
hash_keys.each_with_index do |hash_key, index|
|
103
|
+
if index < last_index
|
104
|
+
if hash_keys[index+1]
|
105
|
+
container = if hash_key
|
106
|
+
container[hash_key] ||= {}
|
107
|
+
else
|
108
|
+
c = {}
|
109
|
+
container << c
|
110
|
+
container = c
|
111
|
+
end
|
112
|
+
else
|
113
|
+
container = container[hash_key] ||= []
|
114
|
+
end
|
115
|
+
else
|
116
|
+
if hash_key
|
117
|
+
container[hash_key] = value
|
118
|
+
else
|
119
|
+
container << value
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def encode_array(pairs, key, escaped_key, value)
|
126
|
+
encode_nested_value(pairs, key, escaped_key, value)
|
127
|
+
end
|
128
|
+
|
129
|
+
def encode_hash(pairs, key, escaped_key, value)
|
130
|
+
value.each do |value_key, item|
|
131
|
+
nested_key = "#{key}[#{value_key}]"
|
132
|
+
nested_escaped_key = "#{escaped_key}%5B#{Url.escape_path(value_key)}%5D"
|
133
|
+
encode_nested_value(pairs, nested_key, nested_escaped_key, item)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def encode_nested_value(pairs, key, escaped_key, value)
|
138
|
+
case value
|
139
|
+
when Array
|
140
|
+
arr_key = "#{key}#{EMPTY_BRACKET}"
|
141
|
+
arr_escaped_key = escaped_key + EMPTY_ESCAPED_BRACKET
|
142
|
+
value.each do |item|
|
143
|
+
encode_nested_value(pairs, arr_key, arr_escaped_key, item)
|
144
|
+
end
|
145
|
+
when Hash
|
146
|
+
value.each do |hash_key, hash_value|
|
147
|
+
nested_key = "#{key}[#{hash_key}]"
|
148
|
+
nested_escaped_key = "#{escaped_key}%5B#{Url.escape_path(hash_key)}%5D"
|
149
|
+
encode_nested_value(pairs, nested_key, nested_escaped_key, hash_value)
|
150
|
+
end
|
151
|
+
else
|
152
|
+
pairs << Pair.new(key, escaped_key, value)
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
class Flat < self
|
158
|
+
private
|
159
|
+
|
160
|
+
def decode_pair(key, value)
|
161
|
+
self[key] = if key?(key)
|
162
|
+
Array(self[key]) << value
|
163
|
+
else
|
164
|
+
value
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def encode_array(pairs, key, escaped_key, value)
|
169
|
+
value.each do |item|
|
170
|
+
pairs << Pair.new(key, escaped_key, item)
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
class Pair < Struct.new(:key, :escaped_key, :value)
|
176
|
+
def to_s
|
177
|
+
if value
|
178
|
+
"#{escaped_key}=#{Url.escape_path(value)}"
|
179
|
+
else
|
180
|
+
escaped_key
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
# Private Hurley::Query methods
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
def any_multipart?(array)
|
190
|
+
array.any? do |v|
|
191
|
+
case v
|
192
|
+
when Array then any_multipart?(v)
|
193
|
+
when Hash then any_multipart?(v.values)
|
194
|
+
else
|
195
|
+
v.respond_to?(:read)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def to_io(boundary, part_headers = nil)
|
201
|
+
parts = []
|
202
|
+
|
203
|
+
part_headers ||= {}
|
204
|
+
build_pairs.each do |pair|
|
205
|
+
parts << Multipart::Part.new(boundary, pair.key, pair.value, part_headers[pair.key])
|
206
|
+
end
|
207
|
+
parts << Multipart::EpiloguePart.new(boundary)
|
208
|
+
ios = []
|
209
|
+
len = 0
|
210
|
+
parts.each do |part|
|
211
|
+
len += part.length
|
212
|
+
ios << part.to_io
|
213
|
+
end
|
214
|
+
|
215
|
+
CompositeReadIO.new(len, *ios)
|
216
|
+
end
|
217
|
+
|
218
|
+
def build_pairs
|
219
|
+
pairs = []
|
220
|
+
@hash.each do |key, value|
|
221
|
+
escaped_key = Url.escape_path(key)
|
222
|
+
case value
|
223
|
+
when nil then pairs << Pair.new(key, escaped_key, nil)
|
224
|
+
when Array
|
225
|
+
encode_array(pairs, key, escaped_key, value)
|
226
|
+
when Hash
|
227
|
+
encode_hash(pairs, key, escaped_key, value)
|
228
|
+
else
|
229
|
+
pairs << Pair.new(key, escaped_key, value)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
pairs
|
233
|
+
end
|
234
|
+
|
235
|
+
def encode_array(pairs, key, escaped_key, value)
|
236
|
+
raise NotImplementedError
|
237
|
+
end
|
238
|
+
|
239
|
+
def encode_hash(pairs, key, escaped_key, value)
|
240
|
+
raise NotImplementedError
|
241
|
+
end
|
242
|
+
|
243
|
+
AMP = "&".freeze
|
244
|
+
EQ = "=".freeze
|
245
|
+
EMPTY_BRACKET = "[]".freeze
|
246
|
+
EMPTY_ESCAPED_BRACKET = "%5B%5D".freeze
|
247
|
+
START_BRACKET = "[".freeze
|
248
|
+
END_BRACKET = /\]\z/
|
249
|
+
FORM_TYPE = "application/x-www-form-urlencoded".freeze
|
250
|
+
MULTIPART_TYPE = "multipart/form-data; boundary=%s".freeze
|
251
|
+
end
|
252
|
+
end
|
data/lib/hurley/tasks.rb
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
require "rake"
|
2
|
+
|
3
|
+
namespace :hurley do
|
4
|
+
desc "Start server for live tests. HURLEY_PORT=4000"
|
5
|
+
task :start_server do
|
6
|
+
without_verbose do
|
7
|
+
require File.expand_path("../test/server", __FILE__)
|
8
|
+
end
|
9
|
+
|
10
|
+
Hurley::Live.start_server(
|
11
|
+
:port => (ENV["HURLEY_PORT"] || 4000).to_i,
|
12
|
+
:ssl_key => ENV["HURLEY_SSL_KEY"],
|
13
|
+
:ssl_file => ENV["HURLEY_SSL_FILE"],
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
desc "Start proxy server for live tests. HURLEY_PORT=4001, HURLEY_PROXY_AUTH=user:pass"
|
18
|
+
task :start_proxy do
|
19
|
+
without_verbose do
|
20
|
+
require "webrick"
|
21
|
+
require "webrick/httpproxy"
|
22
|
+
end
|
23
|
+
|
24
|
+
if found = ENV["HURLEY_PROXY_AUTH"]
|
25
|
+
username, password = ENV["HURLEY_PROXY_AUTH"].split(":", 2)
|
26
|
+
end
|
27
|
+
|
28
|
+
match_credentials = lambda { |credentials|
|
29
|
+
got_username, got_password = credentials.to_s.unpack("m*")[0].split(":", 2)
|
30
|
+
got_username == username && got_password == password
|
31
|
+
}
|
32
|
+
|
33
|
+
log_io = $stdout
|
34
|
+
log_io.sync = true
|
35
|
+
|
36
|
+
webrick_opts = {
|
37
|
+
:Port => (ENV["HURLEY_PORT"] || 4001).to_i,
|
38
|
+
:Logger => WEBrick::Log::new(log_io),
|
39
|
+
:AccessLog => [[log_io, "[%{X-Hurley-Connection}i] %m %U -> %s %b"]],
|
40
|
+
:ProxyAuthProc => lambda { |req, res|
|
41
|
+
if username
|
42
|
+
type, credentials = req.header["proxy-authorization"].first.to_s.split(/\s+/, 2)
|
43
|
+
unless "Basic" == type && match_credentials.call(credentials)
|
44
|
+
res["proxy-authenticate"] = %{Basic realm="testing"}
|
45
|
+
raise WEBrick::HTTPStatus::ProxyAuthenticationRequired
|
46
|
+
end
|
47
|
+
end
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
proxy = WEBrick::HTTPProxyServer.new(webrick_opts)
|
52
|
+
|
53
|
+
trap(:TERM) { proxy.shutdown }
|
54
|
+
trap(:INT) { proxy.shutdown }
|
55
|
+
|
56
|
+
proxy.start
|
57
|
+
end
|
58
|
+
|
59
|
+
desc "Generate test certs for testing Hurley with SSL"
|
60
|
+
task :generate_certs do
|
61
|
+
without_verbose do
|
62
|
+
require "openssl"
|
63
|
+
require "fileutils"
|
64
|
+
end
|
65
|
+
|
66
|
+
$shell = !!ENV["IN_SHELL"]
|
67
|
+
|
68
|
+
# Adapted from WEBrick::Utils. Skips cert extensions so it
|
69
|
+
# can be used as a CA bundle
|
70
|
+
def create_self_signed_cert(bits, cn, comment)
|
71
|
+
rsa = OpenSSL::PKey::RSA.new(bits)
|
72
|
+
cert = OpenSSL::X509::Certificate.new
|
73
|
+
cert.version = 2
|
74
|
+
cert.serial = 1
|
75
|
+
name = OpenSSL::X509::Name.new(cn)
|
76
|
+
cert.subject = name
|
77
|
+
cert.issuer = name
|
78
|
+
cert.not_before = Time.now
|
79
|
+
cert.not_after = Time.now + (365*24*60*60)
|
80
|
+
cert.public_key = rsa.public_key
|
81
|
+
cert.sign(rsa, OpenSSL::Digest::SHA1.new)
|
82
|
+
return [cert, rsa]
|
83
|
+
end
|
84
|
+
|
85
|
+
def write(file, contents, env_var)
|
86
|
+
FileUtils.mkdir_p(File.dirname(file))
|
87
|
+
File.open(file, "w") do |f|
|
88
|
+
f.puts(contents)
|
89
|
+
end
|
90
|
+
puts %(export #{env_var}="#{file}") if $shell
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
# One cert / CA for ease of testing when ignoring verification
|
95
|
+
cert, key = create_self_signed_cert(1024, [["CN", "localhost"]], "Hurley Test CA")
|
96
|
+
write "tmp/hurley-cert.key", key, "HURLEY_SSL_KEY"
|
97
|
+
write "tmp/hurley-cert.crt", cert, "HURLEY_SSL_FILE"
|
98
|
+
|
99
|
+
# And a second CA to prove that verification can fail
|
100
|
+
cert, key = create_self_signed_cert(1024, [["CN", "real-ca.com"]], "A different CA")
|
101
|
+
write "tmp/hurley-different-ca-cert.key", key, "HURLEY_SSL_KEY_ALT"
|
102
|
+
write "tmp/hurley-different-ca-cert.crt", cert, "HURLEY_SSL_FILE_ALT"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def without_verbose
|
107
|
+
old_verbose, $VERBOSE = $VERBOSE, nil
|
108
|
+
yield
|
109
|
+
ensure
|
110
|
+
$VERBOSE = old_verbose
|
111
|
+
end
|