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,123 @@
|
|
1
|
+
begin
|
2
|
+
require "net/https"
|
3
|
+
rescue LoadError
|
4
|
+
warn "Warning: no such file to load -- net/https. Make sure openssl is installed if you want ssl support"
|
5
|
+
require "net/http"
|
6
|
+
end
|
7
|
+
require "zlib"
|
8
|
+
|
9
|
+
module Hurley
|
10
|
+
class Connection
|
11
|
+
def call(request)
|
12
|
+
net_http_connection(request) do |http|
|
13
|
+
begin
|
14
|
+
Response.new(request) do |res|
|
15
|
+
http_res = perform_request(http, request, res)
|
16
|
+
res.status_code = http_res.code.to_i
|
17
|
+
http_res.each_header do |key, value|
|
18
|
+
res.header[key] = value
|
19
|
+
end
|
20
|
+
|
21
|
+
# net/http only raises exception on 407 with ssl...?
|
22
|
+
if res.status_code == 407
|
23
|
+
raise ConnectionFailed, %(407 "Proxy Authentication Required")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
rescue *NET_HTTP_EXCEPTIONS => err
|
27
|
+
if defined?(OpenSSL) && OpenSSL::SSL::SSLError === err
|
28
|
+
raise SSLError, err
|
29
|
+
else
|
30
|
+
raise ConnectionFailed, err
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
rescue ::Timeout::Error => err
|
36
|
+
raise Timeout, err
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def net_http_connection(request)
|
42
|
+
http = if proxy = request.options.proxy
|
43
|
+
Net::HTTP::Proxy(proxy.host, proxy.port, proxy.user, proxy.password)
|
44
|
+
else
|
45
|
+
Net::HTTP
|
46
|
+
end.new(request.url.host, request.url.port)
|
47
|
+
|
48
|
+
configure_ssl(http, request) if request.url.scheme == Hurley::HTTPS
|
49
|
+
|
50
|
+
if t = request.options.timeout
|
51
|
+
http.read_timeout = http.open_timeout = t
|
52
|
+
end
|
53
|
+
|
54
|
+
if t = request.options.open_timeout
|
55
|
+
http.open_timeout = t
|
56
|
+
end
|
57
|
+
|
58
|
+
yield http
|
59
|
+
end
|
60
|
+
|
61
|
+
def net_http_request(request)
|
62
|
+
http_req = Net::HTTPGenericRequest.new(
|
63
|
+
request.verb.to_s.upcase, # request method
|
64
|
+
!!request.body, # is there a request body
|
65
|
+
:head != request.verb, # is there a response body
|
66
|
+
request.url.request_uri, # request uri path
|
67
|
+
request.header, # request headers
|
68
|
+
)
|
69
|
+
|
70
|
+
if body = request.body_io
|
71
|
+
http_req.body_stream = body
|
72
|
+
end
|
73
|
+
|
74
|
+
http_req
|
75
|
+
end
|
76
|
+
|
77
|
+
def perform_request(http, request, res)
|
78
|
+
if :get == request.verb
|
79
|
+
# prefer `get` to `request` because the former handles gzip (ruby 1.9)
|
80
|
+
http_res = http.get(request.url.request_uri, request.header.to_hash) do |chunk|
|
81
|
+
res.receive_body(chunk)
|
82
|
+
end
|
83
|
+
http_res
|
84
|
+
else
|
85
|
+
http_res = http.request(net_http_request(request))
|
86
|
+
res.receive_body(http_res.body)
|
87
|
+
http_res
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def configure_ssl(http, request)
|
92
|
+
ssl = request.ssl_options
|
93
|
+
http.use_ssl = true
|
94
|
+
http.verify_mode = ssl.openssl_verify_mode
|
95
|
+
http.cert_store = ssl.openssl_cert_store
|
96
|
+
|
97
|
+
http.cert = ssl.openssl_client_cert if ssl.openssl_client_cert
|
98
|
+
http.key = ssl.openssl_client_key if ssl.openssl_client_key
|
99
|
+
http.ca_file = ssl.ca_file if ssl.ca_file
|
100
|
+
http.ca_path = ssl.ca_path if ssl.ca_path
|
101
|
+
http.verify_depth = ssl.verify_depth if ssl.verify_depth
|
102
|
+
http.ssl_version = ssl.version if ssl.version
|
103
|
+
end
|
104
|
+
|
105
|
+
NET_HTTP_EXCEPTIONS = [
|
106
|
+
EOFError,
|
107
|
+
Errno::ECONNABORTED,
|
108
|
+
Errno::ECONNREFUSED,
|
109
|
+
Errno::ECONNRESET,
|
110
|
+
Errno::EHOSTUNREACH,
|
111
|
+
Errno::EINVAL,
|
112
|
+
Errno::ENETUNREACH,
|
113
|
+
Net::HTTPBadResponse,
|
114
|
+
Net::HTTPHeaderSyntaxError,
|
115
|
+
Net::ProtocolError,
|
116
|
+
SocketError,
|
117
|
+
Zlib::GzipFile::Error,
|
118
|
+
]
|
119
|
+
|
120
|
+
NET_HTTP_EXCEPTIONS << OpenSSL::SSL::SSLError if defined?(OpenSSL)
|
121
|
+
NET_HTTP_EXCEPTIONS << Net::OpenTimeout if defined?(Net::OpenTimeout)
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
|
3
|
+
module Hurley
|
4
|
+
class Header
|
5
|
+
def initialize(initial = nil)
|
6
|
+
@hash = {}
|
7
|
+
update(initial) if initial
|
8
|
+
end
|
9
|
+
|
10
|
+
extend Forwardable
|
11
|
+
def_delegators(:@hash,
|
12
|
+
:each,
|
13
|
+
:keys,
|
14
|
+
:size,
|
15
|
+
)
|
16
|
+
|
17
|
+
def [](key)
|
18
|
+
@hash[self.class.canonical(key)]
|
19
|
+
end
|
20
|
+
|
21
|
+
def []=(key, value)
|
22
|
+
@hash[self.class.canonical(key)] = value.to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def key?(key)
|
26
|
+
@hash.key?(self.class.canonical(key))
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete(key)
|
30
|
+
@hash.delete(self.class.canonical(key))
|
31
|
+
end
|
32
|
+
|
33
|
+
def update(hash)
|
34
|
+
hash.each do |key, value|
|
35
|
+
self[key] = value
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def dup
|
40
|
+
self.class.new(@hash.dup)
|
41
|
+
end
|
42
|
+
|
43
|
+
def to_hash
|
44
|
+
@hash
|
45
|
+
end
|
46
|
+
|
47
|
+
def inspect
|
48
|
+
"#<%s %s>" % [
|
49
|
+
self.class.name,
|
50
|
+
@hash.inspect,
|
51
|
+
]
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.canonical(input)
|
55
|
+
KEYS[input] || begin
|
56
|
+
key = input.to_s.tr(UNDERSCORE, DASH)
|
57
|
+
key.downcase!
|
58
|
+
key.gsub!(/(\A|\-)(\S)/) { |s| s.upcase! ; s }
|
59
|
+
key
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def self.add_canonical_key(*canonicals)
|
64
|
+
canonicals.each do |canonical|
|
65
|
+
canonical.freeze
|
66
|
+
KEYS[canonical] = canonical
|
67
|
+
shortcut = canonical.downcase
|
68
|
+
KEYS[shortcut.freeze] = canonical
|
69
|
+
KEYS[shortcut.tr(DASH, UNDERSCORE).to_sym] = canonical
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# hash of "shortcut key" => "canonical header key"
|
76
|
+
# string keys are converted to canonical header names:
|
77
|
+
#
|
78
|
+
# KEYS["content_type"] # => "Content-Type"
|
79
|
+
#
|
80
|
+
KEYS = {"ETag".freeze => "Etag".freeze}
|
81
|
+
DASH = "-".freeze
|
82
|
+
UNDERSCORE = "_".freeze
|
83
|
+
|
84
|
+
# Adds canonical header keys for common headers.
|
85
|
+
#
|
86
|
+
# KEYS[:content_type] # => "Content-Type"
|
87
|
+
# KEYS["Content-Type"] # => "Content-Type"
|
88
|
+
# KEYS["content-type"] # => "Content-Type"
|
89
|
+
#
|
90
|
+
add_canonical_key(
|
91
|
+
"Accept",
|
92
|
+
"Access-Control-Allow-Credentials",
|
93
|
+
"Access-Control-Allow-Origin",
|
94
|
+
"Access-Control-Expose-Headers",
|
95
|
+
"Age",
|
96
|
+
"Authorization",
|
97
|
+
"Cache-Control",
|
98
|
+
"Connection",
|
99
|
+
"Content-Disposition",
|
100
|
+
"Content-Language",
|
101
|
+
"Content-Length",
|
102
|
+
"Content-MD5",
|
103
|
+
"Content-Range",
|
104
|
+
"Content-Security-Policy",
|
105
|
+
"Content-Type",
|
106
|
+
"Cookie",
|
107
|
+
"Date",
|
108
|
+
"Etag",
|
109
|
+
"Expect",
|
110
|
+
"Expires",
|
111
|
+
"From",
|
112
|
+
"Host",
|
113
|
+
"If-Modified-Since",
|
114
|
+
"If-None-Match",
|
115
|
+
"Last-Modified",
|
116
|
+
"Link",
|
117
|
+
"Location",
|
118
|
+
"Origin",
|
119
|
+
"Range",
|
120
|
+
"Referer",
|
121
|
+
"Refresh",
|
122
|
+
"Retry-After",
|
123
|
+
"Server",
|
124
|
+
"Set-Cookie",
|
125
|
+
"Status",
|
126
|
+
"String-Transport-Security",
|
127
|
+
"Trailer",
|
128
|
+
"Transfer-Encoding",
|
129
|
+
"User-Agent",
|
130
|
+
"Upgrade",
|
131
|
+
"Vary",
|
132
|
+
"Via",
|
133
|
+
"WWW-Authenticate",
|
134
|
+
"X-Content-Type-Options",
|
135
|
+
"X-Frame-Options",
|
136
|
+
"X-Powered-By",
|
137
|
+
"X-Served-By",
|
138
|
+
"X-Xss-Protection",
|
139
|
+
)
|
140
|
+
|
141
|
+
# Some weird exceptions
|
142
|
+
KEYS["ETag"] = "Etag"
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,235 @@
|
|
1
|
+
# Taken from multipart-post gem: https://github.com/nicksieger/multipart-post
|
2
|
+
# Removes coupling with net/http
|
3
|
+
|
4
|
+
module Hurley
|
5
|
+
# Convenience methods for dealing with files and IO that are to be uploaded.
|
6
|
+
class UploadIO
|
7
|
+
# Create an upload IO suitable for including in the body hash of a
|
8
|
+
# Hurley::Request.
|
9
|
+
#
|
10
|
+
# Can take two forms. The first accepts a filename and content type, and
|
11
|
+
# opens the file for reading (to be closed by finalizer).
|
12
|
+
#
|
13
|
+
# The second accepts an already-open IO, but also requires a third argument,
|
14
|
+
# the filename from which it was opened (particularly useful/recommended if
|
15
|
+
# uploading directly from a form in a framework, which often save the file to
|
16
|
+
# an arbitrarily named RackMultipart file in /tmp).
|
17
|
+
#
|
18
|
+
# Usage:
|
19
|
+
#
|
20
|
+
# UploadIO.new("file.txt", "text/plain")
|
21
|
+
# UploadIO.new(file_io, "text/plain", "file.txt")
|
22
|
+
#
|
23
|
+
attr_reader :content_type, :original_filename, :local_path, :io, :opts
|
24
|
+
|
25
|
+
def initialize(filename_or_io, content_type, filename = nil, opts = {})
|
26
|
+
io = filename_or_io
|
27
|
+
local_path = nil
|
28
|
+
if io.respond_to?(:read)
|
29
|
+
# in Ruby 1.9.2, StringIOs no longer respond to path
|
30
|
+
# (since they respond to :length, so we don't need their local path, see parts.rb:41)
|
31
|
+
local_path = filename_or_io.respond_to?(:path) ? filename_or_io.path : DEFAULT_LOCAL_PATH
|
32
|
+
else
|
33
|
+
io = File.open(filename_or_io)
|
34
|
+
local_path = filename_or_io
|
35
|
+
end
|
36
|
+
|
37
|
+
filename ||= local_path
|
38
|
+
|
39
|
+
@content_type = content_type
|
40
|
+
@original_filename = File.basename(filename)
|
41
|
+
@local_path = local_path
|
42
|
+
@io = io
|
43
|
+
@opts = opts
|
44
|
+
end
|
45
|
+
|
46
|
+
def method_missing(*args)
|
47
|
+
@io.send(*args)
|
48
|
+
end
|
49
|
+
|
50
|
+
def respond_to?(meth, include_all = false)
|
51
|
+
@io.respond_to?(meth, include_all) || super(meth, include_all)
|
52
|
+
end
|
53
|
+
|
54
|
+
DEFAULT_LOCAL_PATH = "local.path".freeze
|
55
|
+
end
|
56
|
+
|
57
|
+
# Internal helper classes for generating multipart bodies.
|
58
|
+
module Multipart
|
59
|
+
module Part #:nodoc:
|
60
|
+
def self.new(boundary, name, value, header = nil)
|
61
|
+
header ||= {}
|
62
|
+
if file?(value)
|
63
|
+
FilePart.new(boundary, name, value, header)
|
64
|
+
else
|
65
|
+
ParamPart.new(boundary, name, value, header)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.file?(value)
|
70
|
+
value.respond_to?(:content_type) && value.respond_to?(:original_filename)
|
71
|
+
end
|
72
|
+
|
73
|
+
def to_io
|
74
|
+
@io
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
class ParamPart
|
79
|
+
include Part
|
80
|
+
|
81
|
+
def initialize(boundary, name, value, header)
|
82
|
+
@part = build_part(boundary, name, value, header)
|
83
|
+
@io = StringIO.new(@part)
|
84
|
+
end
|
85
|
+
|
86
|
+
def length
|
87
|
+
@part.bytesize
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
|
92
|
+
def build_part(boundary, name, value, header)
|
93
|
+
ctype = if type = header[:content_type]
|
94
|
+
CTYPE_FORMAT % type
|
95
|
+
end
|
96
|
+
|
97
|
+
PART_FORMAT % [
|
98
|
+
boundary,
|
99
|
+
name.to_s,
|
100
|
+
ctype,
|
101
|
+
value.to_s,
|
102
|
+
]
|
103
|
+
end
|
104
|
+
|
105
|
+
CTYPE_FORMAT = "Content-Type: %s\r\n"
|
106
|
+
PART_FORMAT = <<-END
|
107
|
+
--%s\r
|
108
|
+
Content-Disposition: form-data; name="%s"\r
|
109
|
+
%s\r
|
110
|
+
%s\r
|
111
|
+
END
|
112
|
+
end
|
113
|
+
|
114
|
+
# Represents a part to be filled from file IO.
|
115
|
+
class FilePart
|
116
|
+
include Part
|
117
|
+
|
118
|
+
attr_reader :length
|
119
|
+
|
120
|
+
def initialize(boundary, name, io, header)
|
121
|
+
file_length = io.respond_to?(:length) ? io.length : File.size(io.local_path)
|
122
|
+
|
123
|
+
@head = build_head(boundary, name, io.original_filename, io.content_type, file_length,
|
124
|
+
io.respond_to?(:opts) ? io.opts.merge(header) : header)
|
125
|
+
|
126
|
+
@length = @head.bytesize + file_length + FOOT.length
|
127
|
+
@io = CompositeReadIO.new(@length, StringIO.new(@head), io, StringIO.new(FOOT))
|
128
|
+
end
|
129
|
+
|
130
|
+
private
|
131
|
+
|
132
|
+
def build_head(boundary, name, filename, type, content_len, header)
|
133
|
+
content_id = if cid = header[:content_id]
|
134
|
+
CID_FORMAT % cid
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
HEAD_FORMAT % [
|
139
|
+
boundary,
|
140
|
+
header[:content_disposition] || DEFAULT_DISPOSITION,
|
141
|
+
name.to_s,
|
142
|
+
filename.to_s,
|
143
|
+
content_len.to_i,
|
144
|
+
content_id,
|
145
|
+
header[:content_type] || type,
|
146
|
+
header[:content_transfer_encoding] || DEFAULT_TR_ENCODING,
|
147
|
+
]
|
148
|
+
end
|
149
|
+
|
150
|
+
DEFAULT_TR_ENCODING = "binary".freeze
|
151
|
+
DEFAULT_DISPOSITION = "form-data".freeze
|
152
|
+
FOOT = "\r\n".freeze
|
153
|
+
CID_FORMAT = "Content-ID: %s\r\n"
|
154
|
+
HEAD_FORMAT = <<-END
|
155
|
+
--%s\r
|
156
|
+
Content-Disposition: %s; name="%s"; filename="%s"\r
|
157
|
+
Content-Length: %d\r
|
158
|
+
%sContent-Type: %s\r
|
159
|
+
Content-Transfer-Encoding: %s\r
|
160
|
+
\r
|
161
|
+
END
|
162
|
+
end
|
163
|
+
|
164
|
+
# Represents the epilogue or closing boundary.
|
165
|
+
class EpiloguePart
|
166
|
+
include Part
|
167
|
+
|
168
|
+
attr_reader :length
|
169
|
+
|
170
|
+
def initialize(boundary)
|
171
|
+
@part = "--#{boundary}--\r\n\r\n"
|
172
|
+
@io = StringIO.new(@part)
|
173
|
+
@length = @part.bytesize
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# Concatenate together multiple IO objects into a single, composite IO object
|
179
|
+
# for purposes of reading as a single stream.
|
180
|
+
#
|
181
|
+
# Usage:
|
182
|
+
#
|
183
|
+
# crio = CompositeReadIO.new(StringIO.new('one'), StringIO.new('two'), StringIO.new('three'))
|
184
|
+
# puts crio.read # => "onetwothree"
|
185
|
+
class CompositeReadIO
|
186
|
+
attr_reader :length
|
187
|
+
|
188
|
+
def initialize(length = nil, *ios)
|
189
|
+
@ios = ios.flatten
|
190
|
+
|
191
|
+
if length.respond_to?(:read)
|
192
|
+
@ios.unshift(length)
|
193
|
+
else
|
194
|
+
@length = length || -1
|
195
|
+
end
|
196
|
+
|
197
|
+
@index = 0
|
198
|
+
end
|
199
|
+
|
200
|
+
def read(length = nil, outbuf = nil)
|
201
|
+
got_result = false
|
202
|
+
outbuf = outbuf ? outbuf.replace("") : ""
|
203
|
+
|
204
|
+
while io = current_io
|
205
|
+
if result = io.read(length)
|
206
|
+
got_result ||= !result.nil?
|
207
|
+
result.force_encoding(BINARY) if result.respond_to?(:force_encoding)
|
208
|
+
outbuf << result
|
209
|
+
length -= result.length if length
|
210
|
+
break if length == 0
|
211
|
+
end
|
212
|
+
advance_io
|
213
|
+
end
|
214
|
+
|
215
|
+
(!got_result && length) ? nil : outbuf
|
216
|
+
end
|
217
|
+
|
218
|
+
def rewind
|
219
|
+
@ios.each { |io| io.rewind }
|
220
|
+
@index = 0
|
221
|
+
end
|
222
|
+
|
223
|
+
private
|
224
|
+
|
225
|
+
def current_io
|
226
|
+
@ios[@index]
|
227
|
+
end
|
228
|
+
|
229
|
+
def advance_io
|
230
|
+
@index += 1
|
231
|
+
end
|
232
|
+
|
233
|
+
BINARY = "BINARY".freeze
|
234
|
+
end
|
235
|
+
end
|