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.
@@ -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