nestful 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +71 -0
- data/Rakefile +14 -0
- data/VERSION +1 -0
- data/lib/nestful/connection.rb +306 -0
- data/lib/nestful/exceptions.rb +69 -0
- data/lib/nestful/formats/blank_format.rb +13 -0
- data/lib/nestful/formats/form_format.rb +17 -0
- data/lib/nestful/formats/json_format.rb +24 -0
- data/lib/nestful/formats/multipart_format.rb +77 -0
- data/lib/nestful/formats/xml_format.rb +34 -0
- data/lib/nestful/formats.rb +31 -0
- data/lib/nestful/request/callbacks.rb +28 -0
- data/lib/nestful/request.rb +119 -0
- data/lib/nestful/resource.rb +30 -0
- data/lib/nestful.rb +44 -0
- data/nestful.gemspec +57 -0
- metadata +93 -0
data/.gitignore
ADDED
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Alexander MacCaw (info@eribium.org)
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,71 @@
|
|
1
|
+
Nestful is a simple Ruby HTTP/REST client with a sane API.
|
2
|
+
|
3
|
+
## Features
|
4
|
+
|
5
|
+
* Simple API
|
6
|
+
* File buffering
|
7
|
+
* Before/Progress/After Callbacks
|
8
|
+
* JSON & XML requests
|
9
|
+
* Multipart requests (file uploading)
|
10
|
+
* Resource API
|
11
|
+
* Proxy support
|
12
|
+
* SSL support
|
13
|
+
|
14
|
+
## Options
|
15
|
+
|
16
|
+
Request options:
|
17
|
+
|
18
|
+
* headers (hash)
|
19
|
+
* params (hash)
|
20
|
+
* buffer (true/false)
|
21
|
+
* method (:get/:post/:put/:delete/:head)
|
22
|
+
|
23
|
+
Connection options:
|
24
|
+
|
25
|
+
* proxy
|
26
|
+
* user
|
27
|
+
* password
|
28
|
+
* auth_type
|
29
|
+
* timeout
|
30
|
+
* ssl_options
|
31
|
+
|
32
|
+
## API
|
33
|
+
|
34
|
+
### GET request
|
35
|
+
|
36
|
+
Nestful.get 'http://example.com' #=> "body"
|
37
|
+
|
38
|
+
### POST request
|
39
|
+
|
40
|
+
Nestful.post 'http://example.com', :format => :form #=> "body"
|
41
|
+
|
42
|
+
### Parameters
|
43
|
+
|
44
|
+
Nestful.get 'http://example.com', :params => {:nestled => {:params => 1}}
|
45
|
+
|
46
|
+
### JSON request
|
47
|
+
|
48
|
+
Nestful.get 'http://example.com', :format => :json #=> {:json_hash => 1}
|
49
|
+
Nestful.json_get 'http://example.com' #=> {:json_hash => 1}
|
50
|
+
Nestful.post 'http://example.com', :format => :json, :params => {:q => 'test'} #=> {:json_hash => 1}
|
51
|
+
|
52
|
+
### Resource
|
53
|
+
|
54
|
+
Nestful::Resource.new('http://example.com')['assets'][1].get(:format => :xml) #=> {:xml_hash => 1}
|
55
|
+
|
56
|
+
### Buffer download, return Tempfile
|
57
|
+
|
58
|
+
Nestful.get 'http://example.com/file.jpg', :buffer => true #=> <File ...>
|
59
|
+
|
60
|
+
### Callbacks
|
61
|
+
|
62
|
+
Nestful.get 'http://www.google.co.uk', :buffer => true, :progress => Proc.new {|conn, total, size| p total; p size }
|
63
|
+
Nestful::Request.before_request {|conn| }
|
64
|
+
Nestful::Request.after_request {|conn, response| }
|
65
|
+
|
66
|
+
### Multipart post
|
67
|
+
|
68
|
+
Nestful.post 'http://example.com', :format => :multipart, :params => {:file => File.open('README')}
|
69
|
+
|
70
|
+
## Credits
|
71
|
+
Large parts of the connection code were taken from ActiveResource
|
data/Rakefile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
begin
|
2
|
+
require 'jeweler'
|
3
|
+
Jeweler::Tasks.new do |gemspec|
|
4
|
+
gemspec.name = "nestful"
|
5
|
+
gemspec.summary = "Simple Ruby HTTP/REST client with a sane API"
|
6
|
+
gemspec.email = "info@eribium.org"
|
7
|
+
gemspec.homepage = "http://github.com/maccman/nestful"
|
8
|
+
gemspec.description = "Simple Ruby HTTP/REST client with a sane API"
|
9
|
+
gemspec.authors = ["Alex MacCaw"]
|
10
|
+
gemspec.add_dependency("activesupport", ">= 3.0.0.beta")
|
11
|
+
end
|
12
|
+
rescue LoadError
|
13
|
+
puts "Jeweler not available. Install it with: sudo gem install jeweler"
|
14
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.1
|
@@ -0,0 +1,306 @@
|
|
1
|
+
require 'net/https'
|
2
|
+
require 'date'
|
3
|
+
require 'time'
|
4
|
+
require 'uri'
|
5
|
+
|
6
|
+
module Nestful
|
7
|
+
class Connection
|
8
|
+
|
9
|
+
HTTP_FORMAT_HEADER_NAMES = {
|
10
|
+
:get => 'Accept',
|
11
|
+
:put => 'Content-Type',
|
12
|
+
:post => 'Content-Type',
|
13
|
+
:delete => 'Accept',
|
14
|
+
:head => 'Accept'
|
15
|
+
}
|
16
|
+
|
17
|
+
attr_reader :site, :user, :password, :auth_type, :timeout, :proxy, :ssl_options
|
18
|
+
attr_accessor :format
|
19
|
+
|
20
|
+
class << self
|
21
|
+
def requests
|
22
|
+
@@requests ||= []
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# The +site+ parameter is required and will set the +site+
|
27
|
+
# attribute to the URI for the remote resource service.
|
28
|
+
def initialize(site, format = Formats::XmlFormat.new)
|
29
|
+
raise ArgumentError, 'Missing site URI' unless site
|
30
|
+
@user = @password = nil
|
31
|
+
@uri_parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI
|
32
|
+
self.site = site
|
33
|
+
self.format = format
|
34
|
+
end
|
35
|
+
|
36
|
+
# Set URI for remote service.
|
37
|
+
def site=(site)
|
38
|
+
@site = site.is_a?(URI) ? site : @uri_parser.parse(site)
|
39
|
+
@user = @uri_parser.unescape(@site.user) if @site.user
|
40
|
+
@password = @uri_parser.unescape(@site.password) if @site.password
|
41
|
+
end
|
42
|
+
|
43
|
+
# Set the proxy for remote service.
|
44
|
+
def proxy=(proxy)
|
45
|
+
@proxy = proxy.is_a?(URI) ? proxy : @uri_parser.parse(proxy)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Sets the user for remote service.
|
49
|
+
def user=(user)
|
50
|
+
@user = user
|
51
|
+
end
|
52
|
+
|
53
|
+
# Sets the password for remote service.
|
54
|
+
def password=(password)
|
55
|
+
@password = password
|
56
|
+
end
|
57
|
+
|
58
|
+
# Sets the auth type for remote service.
|
59
|
+
def auth_type=(auth_type)
|
60
|
+
@auth_type = legitimize_auth_type(auth_type)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Sets the number of seconds after which HTTP requests to the remote service should time out.
|
64
|
+
def timeout=(timeout)
|
65
|
+
@timeout = timeout
|
66
|
+
end
|
67
|
+
|
68
|
+
# Hash of options applied to Net::HTTP instance when +site+ protocol is 'https'.
|
69
|
+
def ssl_options=(opts={})
|
70
|
+
@ssl_options = opts
|
71
|
+
end
|
72
|
+
|
73
|
+
# Executes a GET request.
|
74
|
+
# Used to get (find) resources.
|
75
|
+
def get(path, headers = {}, &block)
|
76
|
+
with_auth { request(:get, path, build_request_headers(headers, :get, self.site.merge(path)), &block) }
|
77
|
+
end
|
78
|
+
|
79
|
+
# Executes a DELETE request (see HTTP protocol documentation if unfamiliar).
|
80
|
+
# Used to delete resources.
|
81
|
+
def delete(path, headers = {}, &block)
|
82
|
+
with_auth { request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path)), &block) }
|
83
|
+
end
|
84
|
+
|
85
|
+
# Executes a PUT request (see HTTP protocol documentation if unfamiliar).
|
86
|
+
# Used to update resources.
|
87
|
+
def put(path, body = '', headers = {}, &block)
|
88
|
+
with_auth { request(:put, path, body, build_request_headers(headers, :put, self.site.merge(path)), &block) }
|
89
|
+
end
|
90
|
+
|
91
|
+
# Executes a POST request.
|
92
|
+
# Used to create new resources.
|
93
|
+
def post(path, body = '', headers = {}, &block)
|
94
|
+
with_auth { request(:post, path, body, build_request_headers(headers, :post, self.site.merge(path)), &block) }
|
95
|
+
end
|
96
|
+
|
97
|
+
# Executes a HEAD request.
|
98
|
+
# Used to obtain meta-information about resources, such as whether they exist and their size (via response headers).
|
99
|
+
def head(path, headers = {}, &block)
|
100
|
+
with_auth { request(:head, path, build_request_headers(headers, :head, self.site.merge(path)), &block) }
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
# Makes a request to the remote service.
|
105
|
+
def request(method, path, *arguments)
|
106
|
+
body = nil
|
107
|
+
body = arguments.shift if [:put, :post].include?(method)
|
108
|
+
headers = arguments.shift || {}
|
109
|
+
|
110
|
+
method = Net::HTTP.const_get(method.to_s.capitalize)
|
111
|
+
method = method.new(path)
|
112
|
+
|
113
|
+
if body
|
114
|
+
if body.respond_to?(:read)
|
115
|
+
method.body_stream = body
|
116
|
+
else
|
117
|
+
method.body = body
|
118
|
+
end
|
119
|
+
|
120
|
+
if body.respond_to?(:size)
|
121
|
+
headers['Content-Length'] ||= body.size
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
headers.each {|name, value|
|
126
|
+
next unless value
|
127
|
+
method.add_field(name, value)
|
128
|
+
}
|
129
|
+
|
130
|
+
http.start do |stream|
|
131
|
+
stream.request(method) {|rsp|
|
132
|
+
handle_response(rsp)
|
133
|
+
yield(rsp) if block_given?
|
134
|
+
rsp
|
135
|
+
}
|
136
|
+
end
|
137
|
+
|
138
|
+
rescue Timeout::Error => e
|
139
|
+
raise TimeoutError.new(e.message)
|
140
|
+
rescue OpenSSL::SSL::SSLError => e
|
141
|
+
raise SSLError.new(e.message)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Handles response and error codes from the remote service.
|
145
|
+
def handle_response(response)
|
146
|
+
case response.code.to_i
|
147
|
+
when 301,302
|
148
|
+
raise(Redirection.new(response))
|
149
|
+
when 200...400
|
150
|
+
response
|
151
|
+
when 400
|
152
|
+
raise(BadRequest.new(response))
|
153
|
+
when 401
|
154
|
+
raise(UnauthorizedAccess.new(response))
|
155
|
+
when 403
|
156
|
+
raise(ForbiddenAccess.new(response))
|
157
|
+
when 404
|
158
|
+
raise(ResourceNotFound.new(response))
|
159
|
+
when 405
|
160
|
+
raise(MethodNotAllowed.new(response))
|
161
|
+
when 409
|
162
|
+
raise(ResourceConflict.new(response))
|
163
|
+
when 410
|
164
|
+
raise(ResourceGone.new(response))
|
165
|
+
when 422
|
166
|
+
raise(ResourceInvalid.new(response))
|
167
|
+
when 401...500
|
168
|
+
raise(ClientError.new(response))
|
169
|
+
when 500...600
|
170
|
+
raise(ServerError.new(response))
|
171
|
+
else
|
172
|
+
raise(ConnectionError.new(response, "Unknown response code: #{response.code}"))
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
# Creates new Net::HTTP instance for communication with the
|
177
|
+
# remote service and resources.
|
178
|
+
def http
|
179
|
+
configure_http(new_http)
|
180
|
+
end
|
181
|
+
|
182
|
+
def new_http
|
183
|
+
if @proxy
|
184
|
+
Net::HTTP.new(@site.host, @site.port, @proxy.host, @proxy.port, @proxy.user, @proxy.password)
|
185
|
+
else
|
186
|
+
Net::HTTP.new(@site.host, @site.port)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def configure_http(http)
|
191
|
+
http = apply_ssl_options(http)
|
192
|
+
|
193
|
+
# Net::HTTP timeouts default to 60 seconds.
|
194
|
+
if @timeout
|
195
|
+
http.open_timeout = @timeout
|
196
|
+
http.read_timeout = @timeout
|
197
|
+
end
|
198
|
+
|
199
|
+
http
|
200
|
+
end
|
201
|
+
|
202
|
+
def apply_ssl_options(http)
|
203
|
+
return http unless @site.is_a?(URI::HTTPS)
|
204
|
+
|
205
|
+
http.use_ssl = true
|
206
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
207
|
+
return http unless defined?(@ssl_options)
|
208
|
+
|
209
|
+
http.ca_path = @ssl_options[:ca_path] if @ssl_options[:ca_path]
|
210
|
+
http.ca_file = @ssl_options[:ca_file] if @ssl_options[:ca_file]
|
211
|
+
|
212
|
+
http.cert = @ssl_options[:cert] if @ssl_options[:cert]
|
213
|
+
http.key = @ssl_options[:key] if @ssl_options[:key]
|
214
|
+
|
215
|
+
http.cert_store = @ssl_options[:cert_store] if @ssl_options[:cert_store]
|
216
|
+
http.ssl_timeout = @ssl_options[:ssl_timeout] if @ssl_options[:ssl_timeout]
|
217
|
+
|
218
|
+
http.verify_mode = @ssl_options[:verify_mode] if @ssl_options[:verify_mode]
|
219
|
+
http.verify_callback = @ssl_options[:verify_callback] if @ssl_options[:verify_callback]
|
220
|
+
http.verify_depth = @ssl_options[:verify_depth] if @ssl_options[:verify_depth]
|
221
|
+
|
222
|
+
http
|
223
|
+
end
|
224
|
+
|
225
|
+
def default_header
|
226
|
+
@default_header ||= {}
|
227
|
+
end
|
228
|
+
|
229
|
+
# Builds headers for request to remote service.
|
230
|
+
def build_request_headers(headers, http_method, uri)
|
231
|
+
authorization_header(http_method, uri).update(default_header).update(http_format_header(http_method)).update(headers)
|
232
|
+
end
|
233
|
+
|
234
|
+
def response_auth_header
|
235
|
+
@response_auth_header ||= ""
|
236
|
+
end
|
237
|
+
|
238
|
+
def with_auth
|
239
|
+
retried ||= false
|
240
|
+
yield
|
241
|
+
rescue UnauthorizedAccess => e
|
242
|
+
raise if retried || auth_type != :digest
|
243
|
+
@response_auth_header = e.response['WWW-Authenticate']
|
244
|
+
retried = true
|
245
|
+
retry
|
246
|
+
end
|
247
|
+
|
248
|
+
def authorization_header(http_method, uri)
|
249
|
+
if @user || @password
|
250
|
+
if auth_type == :digest
|
251
|
+
{ 'Authorization' => digest_auth_header(http_method, uri) }
|
252
|
+
else
|
253
|
+
{ 'Authorization' => 'Basic ' + ["#{@user}:#{@password}"].pack('m').delete("\r\n") }
|
254
|
+
end
|
255
|
+
else
|
256
|
+
{}
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
def digest_auth_header(http_method, uri)
|
261
|
+
params = extract_params_from_response
|
262
|
+
|
263
|
+
ha1 = Digest::MD5.hexdigest("#{@user}:#{params['realm']}:#{@password}")
|
264
|
+
ha2 = Digest::MD5.hexdigest("#{http_method.to_s.upcase}:#{uri.path}")
|
265
|
+
|
266
|
+
params.merge!('cnonce' => client_nonce)
|
267
|
+
request_digest = Digest::MD5.hexdigest([ha1, params['nonce'], "0", params['cnonce'], params['qop'], ha2].join(":"))
|
268
|
+
"Digest #{auth_attributes_for(uri, request_digest, params)}"
|
269
|
+
end
|
270
|
+
|
271
|
+
def client_nonce
|
272
|
+
Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535)))
|
273
|
+
end
|
274
|
+
|
275
|
+
def extract_params_from_response
|
276
|
+
params = {}
|
277
|
+
if response_auth_header =~ /^(\w+) (.*)/
|
278
|
+
$2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 }
|
279
|
+
end
|
280
|
+
params
|
281
|
+
end
|
282
|
+
|
283
|
+
def auth_attributes_for(uri, request_digest, params)
|
284
|
+
[
|
285
|
+
%Q(username="#{@user}"),
|
286
|
+
%Q(realm="#{params['realm']}"),
|
287
|
+
%Q(qop="#{params['qop']}"),
|
288
|
+
%Q(uri="#{uri.path}"),
|
289
|
+
%Q(nonce="#{params['nonce']}"),
|
290
|
+
%Q(nc="0"),
|
291
|
+
%Q(cnonce="#{params['cnonce']}"),
|
292
|
+
%Q(opaque="#{params['opaque']}"),
|
293
|
+
%Q(response="#{request_digest}")].join(", ")
|
294
|
+
end
|
295
|
+
|
296
|
+
def http_format_header(http_method)
|
297
|
+
{HTTP_FORMAT_HEADER_NAMES[http_method] => format.mime_type}
|
298
|
+
end
|
299
|
+
|
300
|
+
def legitimize_auth_type(auth_type)
|
301
|
+
return :basic if auth_type.nil?
|
302
|
+
auth_type = auth_type.to_sym
|
303
|
+
[:basic, :digest].include?(auth_type) ? auth_type : :basic
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
module Nestful
|
2
|
+
class ConnectionError < StandardError # :nodoc:
|
3
|
+
attr_reader :response
|
4
|
+
|
5
|
+
def initialize(response, message = nil)
|
6
|
+
@response = response
|
7
|
+
@message = message
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_s
|
11
|
+
message = "Failed."
|
12
|
+
message << " Response code = #{response.code}." if response.respond_to?(:code)
|
13
|
+
message << " Response message = #{response.message}." if response.respond_to?(:message)
|
14
|
+
message
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# Raised when a Timeout::Error occurs.
|
19
|
+
class TimeoutError < ConnectionError
|
20
|
+
def initialize(message)
|
21
|
+
@message = message
|
22
|
+
end
|
23
|
+
def to_s; @message ;end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Raised when a OpenSSL::SSL::SSLError occurs.
|
27
|
+
class SSLError < ConnectionError
|
28
|
+
def initialize(message)
|
29
|
+
@message = message
|
30
|
+
end
|
31
|
+
def to_s; @message ;end
|
32
|
+
end
|
33
|
+
|
34
|
+
# 3xx Redirection
|
35
|
+
class Redirection < ConnectionError # :nodoc:
|
36
|
+
def to_s; response['Location'] ? "#{super} => #{response['Location']}" : super; end
|
37
|
+
end
|
38
|
+
|
39
|
+
# 4xx Client Error
|
40
|
+
class ClientError < ConnectionError; end # :nodoc:
|
41
|
+
|
42
|
+
# 400 Bad Request
|
43
|
+
class BadRequest < ClientError; end # :nodoc
|
44
|
+
|
45
|
+
# 401 Unauthorized
|
46
|
+
class UnauthorizedAccess < ClientError; end # :nodoc
|
47
|
+
|
48
|
+
# 403 Forbidden
|
49
|
+
class ForbiddenAccess < ClientError; end # :nodoc
|
50
|
+
|
51
|
+
# 404 Not Found
|
52
|
+
class ResourceNotFound < ClientError; end # :nodoc:
|
53
|
+
|
54
|
+
# 409 Conflict
|
55
|
+
class ResourceConflict < ClientError; end # :nodoc:
|
56
|
+
|
57
|
+
# 410 Gone
|
58
|
+
class ResourceGone < ClientError; end # :nodoc:
|
59
|
+
|
60
|
+
# 5xx Server Error
|
61
|
+
class ServerError < ConnectionError; end # :nodoc:
|
62
|
+
|
63
|
+
# 405 Method Not Allowed
|
64
|
+
class MethodNotAllowed < ClientError # :nodoc:
|
65
|
+
def allowed_methods
|
66
|
+
@response['Allow'].split(',').map { |verb| verb.strip.downcase.to_sym }
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'active_support/json'
|
3
|
+
|
4
|
+
module Nestful
|
5
|
+
module Formats
|
6
|
+
class JsonFormat < Format
|
7
|
+
def extension
|
8
|
+
"json"
|
9
|
+
end
|
10
|
+
|
11
|
+
def mime_type
|
12
|
+
"application/json"
|
13
|
+
end
|
14
|
+
|
15
|
+
def encode(hash, options = nil)
|
16
|
+
ActiveSupport::JSON.encode(hash, options)
|
17
|
+
end
|
18
|
+
|
19
|
+
def decode(json)
|
20
|
+
ActiveSupport::JSON.decode(json)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require "active_support/secure_random"
|
2
|
+
|
3
|
+
module Nestful
|
4
|
+
module Formats
|
5
|
+
class MultipartFormat < Format
|
6
|
+
EOL = "\r\n"
|
7
|
+
|
8
|
+
attr_reader :boundary, :stream
|
9
|
+
|
10
|
+
def initialize(*args)
|
11
|
+
super
|
12
|
+
@boundary = ActiveSupport::SecureRandom.hex(10)
|
13
|
+
@stream = Tempfile.new("nf.#{rand(1000)}")
|
14
|
+
@stream.binmode
|
15
|
+
end
|
16
|
+
|
17
|
+
def mime_type
|
18
|
+
%Q{multipart/form-data; boundary=#{boundary}}
|
19
|
+
end
|
20
|
+
|
21
|
+
def encode(params, options = nil, namespace = nil)
|
22
|
+
to_multipart(params)
|
23
|
+
stream.write(EOL + "--" + boundary + "--" + EOL)
|
24
|
+
stream.flush
|
25
|
+
stream.rewind
|
26
|
+
stream
|
27
|
+
end
|
28
|
+
|
29
|
+
def decode(body)
|
30
|
+
body
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
def to_multipart(params, namespace = nil)
|
35
|
+
params.each do |key, value|
|
36
|
+
key = namespace ? "#{namespace}[#{key}]" : key
|
37
|
+
|
38
|
+
# Support nestled params
|
39
|
+
if value.is_a?(Hash)
|
40
|
+
to_multipart(value, key)
|
41
|
+
next
|
42
|
+
end
|
43
|
+
|
44
|
+
stream.write("--" + boundary + EOL)
|
45
|
+
|
46
|
+
if value.is_a?(File) || value.is_a?(StringIO)
|
47
|
+
create_file_field(key, value)
|
48
|
+
else
|
49
|
+
create_field(key, value)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def create_file_field(key, value)
|
55
|
+
stream.write(%Q{Content-Disposition: format-data; name="#{key}"; filename="#{filename(value)}"} + EOL)
|
56
|
+
stream.write(%Q{Content-Type: application/octet-stream} + EOL)
|
57
|
+
stream.write(%Q{Content-Transfer-Encoding: binary} + EOL)
|
58
|
+
stream.write(EOL)
|
59
|
+
while data = value.read(8124)
|
60
|
+
stream.write(data)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def create_field(key, value)
|
65
|
+
stream.write(%Q{Content-Disposition: form-data; name="#{key}"} + EOL)
|
66
|
+
stream.write(EOL)
|
67
|
+
stream.write(value)
|
68
|
+
end
|
69
|
+
|
70
|
+
def filename(body)
|
71
|
+
return body.original_filename if body.respond_to?(:original_filename)
|
72
|
+
return File.basename(body.path) if body.respond_to?(:path)
|
73
|
+
"Unknown"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'active_support/core_ext/hash/conversions'
|
2
|
+
|
3
|
+
module Nestful
|
4
|
+
module Formats
|
5
|
+
class XmlFormat < Format
|
6
|
+
def extension
|
7
|
+
"xml"
|
8
|
+
end
|
9
|
+
|
10
|
+
def mime_type
|
11
|
+
"application/xml"
|
12
|
+
end
|
13
|
+
|
14
|
+
def encode(hash, options={})
|
15
|
+
hash.to_xml(options)
|
16
|
+
end
|
17
|
+
|
18
|
+
def decode(xml)
|
19
|
+
from_xml_data(Hash.from_xml(xml))
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
# Manipulate from_xml Hash, because xml_simple is not exactly what we
|
24
|
+
# want for Active Resource.
|
25
|
+
def from_xml_data(data)
|
26
|
+
if data.is_a?(Hash) && data.keys.size == 1
|
27
|
+
data.values.first
|
28
|
+
else
|
29
|
+
data
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Nestful
|
2
|
+
module Formats
|
3
|
+
class Format
|
4
|
+
def extension
|
5
|
+
end
|
6
|
+
|
7
|
+
def mime_type
|
8
|
+
end
|
9
|
+
|
10
|
+
def encode(*args)
|
11
|
+
end
|
12
|
+
|
13
|
+
def decode(*args)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
autoload :BlankFormat, 'nestful/formats/blank_format'
|
18
|
+
autoload :MultipartFormat, 'nestful/formats/multipart_format'
|
19
|
+
autoload :FormFormat, 'nestful/formats/form_format'
|
20
|
+
autoload :XmlFormat, 'nestful/formats/xml_format'
|
21
|
+
autoload :JsonFormat, 'nestful/formats/json_format'
|
22
|
+
|
23
|
+
# Lookup the format class from a mime type reference symbol. Example:
|
24
|
+
#
|
25
|
+
# Nestful::Formats[:xml] # => Nestful::Formats::XmlFormat
|
26
|
+
# Nestful::Formats[:json] # => Nestful::Formats::JsonFormat
|
27
|
+
def self.[](mime_type_reference)
|
28
|
+
Nestful::Formats.const_get(ActiveSupport::Inflector.camelize(mime_type_reference.to_s) + "Format")
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Nestful
|
2
|
+
class Request
|
3
|
+
module Callbacks
|
4
|
+
CALLBACKS = [
|
5
|
+
:before_request,
|
6
|
+
:after_request,
|
7
|
+
:progress
|
8
|
+
]
|
9
|
+
|
10
|
+
def self.included(base)
|
11
|
+
CALLBACKS.each do |callback|
|
12
|
+
base.instance_eval(<<-EOS, __FILE__, __LINE__ + 1)
|
13
|
+
def #{callback}(method = nil, &block)
|
14
|
+
callbacks(:#{callback}) << (method||block)
|
15
|
+
end
|
16
|
+
EOS
|
17
|
+
|
18
|
+
base.class_eval(<<-EOS, __FILE__, __LINE__ + 1)
|
19
|
+
def #{callback}(method = nil, &block)
|
20
|
+
callbacks(:#{callback}) << (method||block)
|
21
|
+
end
|
22
|
+
alias_method :#{callback}=, :#{callback}
|
23
|
+
EOS
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
module Nestful
|
2
|
+
class Request
|
3
|
+
def self.callbacks(type = nil) #:nodoc:
|
4
|
+
@callbacks ||= {}
|
5
|
+
return @callbacks unless type
|
6
|
+
@callbacks[type] ||= []
|
7
|
+
end
|
8
|
+
|
9
|
+
attr_reader :url, :options, :format
|
10
|
+
attr_accessor :params, :body, :buffer, :method, :headers, :callbacks
|
11
|
+
|
12
|
+
# Connection options
|
13
|
+
attr_accessor :proxy, :user, :password, :auth_type, :timeout, :ssl_options
|
14
|
+
|
15
|
+
def initialize(url, options = {})
|
16
|
+
@url = url
|
17
|
+
@options = options
|
18
|
+
@options.each {|key, val|
|
19
|
+
method = "#{key}="
|
20
|
+
send(method, val) if respond_to?(method)
|
21
|
+
}
|
22
|
+
self.method ||= :get
|
23
|
+
self.format ||= :blank
|
24
|
+
self.headers ||= {}
|
25
|
+
self.params ||= {}
|
26
|
+
self.body ||= ''
|
27
|
+
end
|
28
|
+
|
29
|
+
def format=(mime_or_format)
|
30
|
+
@format = mime_or_format.is_a?(Symbol) ?
|
31
|
+
Formats[mime_or_format].new : mime_or_format
|
32
|
+
end
|
33
|
+
|
34
|
+
def connection
|
35
|
+
conn = Connection.new(uri, format)
|
36
|
+
conn.proxy = proxy if proxy
|
37
|
+
conn.user = user if user
|
38
|
+
conn.password = password if password
|
39
|
+
conn.auth_type = auth_type if auth_type
|
40
|
+
conn.timeout = timeout if timeout
|
41
|
+
conn.ssl_options = ssl_options if ssl_options
|
42
|
+
conn
|
43
|
+
end
|
44
|
+
|
45
|
+
def uri
|
46
|
+
http_url = url.match(/^http/) ? url : "http://#{url}"
|
47
|
+
uri = URI.parse(http_url)
|
48
|
+
uri.path = "/" if uri.path.empty?
|
49
|
+
if format && format.extension
|
50
|
+
uri.path += ".#{format.extension}"
|
51
|
+
end
|
52
|
+
uri
|
53
|
+
end
|
54
|
+
|
55
|
+
def path
|
56
|
+
uri.path
|
57
|
+
end
|
58
|
+
|
59
|
+
def query_path
|
60
|
+
query_path = path
|
61
|
+
if params.any?
|
62
|
+
query_path += "?"
|
63
|
+
query_path += params.to_param
|
64
|
+
end
|
65
|
+
query_path
|
66
|
+
end
|
67
|
+
|
68
|
+
def execute
|
69
|
+
callback(:before_request, self)
|
70
|
+
result = nil
|
71
|
+
if [:post, :put].include?(method)
|
72
|
+
connection.send(method, path, encoded, headers) {|res| result = decoded(res) }
|
73
|
+
else
|
74
|
+
connection.send(method, query_path, headers) {|res| result = decoded(res) }
|
75
|
+
end
|
76
|
+
callback(:after_request, self, result)
|
77
|
+
result
|
78
|
+
end
|
79
|
+
|
80
|
+
protected
|
81
|
+
def encoded
|
82
|
+
format.encode(params.any? ? params : body)
|
83
|
+
end
|
84
|
+
|
85
|
+
def decoded(result)
|
86
|
+
if buffer
|
87
|
+
data = Tempfile.new("nfr.#{rand(1000)}")
|
88
|
+
size = 0
|
89
|
+
total = result.content_length
|
90
|
+
|
91
|
+
result.read_body {|chunk|
|
92
|
+
callback(:progress, self, total, size += chunk.size)
|
93
|
+
data.write(chunk)
|
94
|
+
}
|
95
|
+
|
96
|
+
data.rewind
|
97
|
+
data
|
98
|
+
else
|
99
|
+
data = result.body
|
100
|
+
format ? format.decode(data) : data
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def callbacks(type = nil)
|
105
|
+
@callbacks ||= {}
|
106
|
+
return @callbacks unless type
|
107
|
+
@callbacks[type] ||= []
|
108
|
+
end
|
109
|
+
|
110
|
+
def callback(type, *args)
|
111
|
+
procs = self.class.callbacks(type) + callbacks(type)
|
112
|
+
procs.compact.each {|c| c.call(*args) }
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
class Request
|
117
|
+
include Callbacks
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Nestful
|
2
|
+
class Resource
|
3
|
+
attr_reader :url
|
4
|
+
|
5
|
+
def initialize(url, options = {})
|
6
|
+
@url = url
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
def [](suburl)
|
11
|
+
self.class.new(URI.join(url, suburl).to_s)
|
12
|
+
end
|
13
|
+
|
14
|
+
def get(options = {})
|
15
|
+
Nestful.get(url, options.merge(@options))
|
16
|
+
end
|
17
|
+
|
18
|
+
def post(options = {})
|
19
|
+
Nestful.post(url, options.merge(@options))
|
20
|
+
end
|
21
|
+
|
22
|
+
def json_get(params = nil)
|
23
|
+
get(:format => :json, :params => params)
|
24
|
+
end
|
25
|
+
|
26
|
+
def json_post(params = nil)
|
27
|
+
post(:format => :json, :params => params)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/nestful.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "uri"
|
3
|
+
require "tempfile"
|
4
|
+
|
5
|
+
require "active_support/core_ext/object/to_param"
|
6
|
+
require "active_support/core_ext/object/to_query"
|
7
|
+
require "active_support/inflector"
|
8
|
+
|
9
|
+
$:.unshift(File.dirname(__FILE__))
|
10
|
+
|
11
|
+
require "nestful/exceptions"
|
12
|
+
require "nestful/formats"
|
13
|
+
require "nestful/connection"
|
14
|
+
require "nestful/request/callbacks"
|
15
|
+
require "nestful/request"
|
16
|
+
require "nestful/resource"
|
17
|
+
|
18
|
+
module Nestful
|
19
|
+
extend self
|
20
|
+
|
21
|
+
def get(url, options = {})
|
22
|
+
Request.new(url, options.merge(:method => :get)).execute
|
23
|
+
end
|
24
|
+
|
25
|
+
def post(url, options = {})
|
26
|
+
Request.new(url, options.merge(:method => :post)).execute
|
27
|
+
end
|
28
|
+
|
29
|
+
def put(url, options = {})
|
30
|
+
Request.new(url, options.merge(:method => :put)).execute
|
31
|
+
end
|
32
|
+
|
33
|
+
def delete(url, options = {})
|
34
|
+
Request.new(url, options.merge(:method => :delete)).execute
|
35
|
+
end
|
36
|
+
|
37
|
+
def json_get(url, params = nil)
|
38
|
+
get(url, :format => :json, :params => params)
|
39
|
+
end
|
40
|
+
|
41
|
+
def json_post(url, params = nil)
|
42
|
+
post(url, :format => :json, :params => params)
|
43
|
+
end
|
44
|
+
end
|
data/nestful.gemspec
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
# Generated by jeweler
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
3
|
+
# Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command
|
4
|
+
# -*- encoding: utf-8 -*-
|
5
|
+
|
6
|
+
Gem::Specification.new do |s|
|
7
|
+
s.name = %q{nestful}
|
8
|
+
s.version = "0.0.1"
|
9
|
+
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
|
11
|
+
s.authors = ["Alex MacCaw"]
|
12
|
+
s.date = %q{2010-04-21}
|
13
|
+
s.description = %q{Simple Ruby HTTP/REST client with a sane API}
|
14
|
+
s.email = %q{info@eribium.org}
|
15
|
+
s.extra_rdoc_files = [
|
16
|
+
"README.markdown"
|
17
|
+
]
|
18
|
+
s.files = [
|
19
|
+
".gitignore",
|
20
|
+
"MIT-LICENSE",
|
21
|
+
"README.markdown",
|
22
|
+
"Rakefile",
|
23
|
+
"VERSION",
|
24
|
+
"lib/nestful.rb",
|
25
|
+
"lib/nestful/connection.rb",
|
26
|
+
"lib/nestful/exceptions.rb",
|
27
|
+
"lib/nestful/formats.rb",
|
28
|
+
"lib/nestful/formats/blank_format.rb",
|
29
|
+
"lib/nestful/formats/form_format.rb",
|
30
|
+
"lib/nestful/formats/json_format.rb",
|
31
|
+
"lib/nestful/formats/multipart_format.rb",
|
32
|
+
"lib/nestful/formats/xml_format.rb",
|
33
|
+
"lib/nestful/request.rb",
|
34
|
+
"lib/nestful/request/callbacks.rb",
|
35
|
+
"lib/nestful/resource.rb",
|
36
|
+
"nestful.gemspec"
|
37
|
+
]
|
38
|
+
s.homepage = %q{http://github.com/maccman/nestful}
|
39
|
+
s.rdoc_options = ["--charset=UTF-8"]
|
40
|
+
s.require_paths = ["lib"]
|
41
|
+
s.rubygems_version = %q{1.3.6}
|
42
|
+
s.summary = %q{Simple Ruby HTTP/REST client with a sane API}
|
43
|
+
|
44
|
+
if s.respond_to? :specification_version then
|
45
|
+
current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
|
46
|
+
s.specification_version = 3
|
47
|
+
|
48
|
+
if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
|
49
|
+
s.add_runtime_dependency(%q<activesupport>, [">= 3.0.0.beta"])
|
50
|
+
else
|
51
|
+
s.add_dependency(%q<activesupport>, [">= 3.0.0.beta"])
|
52
|
+
end
|
53
|
+
else
|
54
|
+
s.add_dependency(%q<activesupport>, [">= 3.0.0.beta"])
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
metadata
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: nestful
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 0
|
8
|
+
- 1
|
9
|
+
version: 0.0.1
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Alex MacCaw
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2010-04-21 00:00:00 +01:00
|
18
|
+
default_executable:
|
19
|
+
dependencies:
|
20
|
+
- !ruby/object:Gem::Dependency
|
21
|
+
name: activesupport
|
22
|
+
prerelease: false
|
23
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
segments:
|
28
|
+
- 3
|
29
|
+
- 0
|
30
|
+
- 0
|
31
|
+
- beta
|
32
|
+
version: 3.0.0.beta
|
33
|
+
type: :runtime
|
34
|
+
version_requirements: *id001
|
35
|
+
description: Simple Ruby HTTP/REST client with a sane API
|
36
|
+
email: info@eribium.org
|
37
|
+
executables: []
|
38
|
+
|
39
|
+
extensions: []
|
40
|
+
|
41
|
+
extra_rdoc_files:
|
42
|
+
- README.markdown
|
43
|
+
files:
|
44
|
+
- .gitignore
|
45
|
+
- MIT-LICENSE
|
46
|
+
- README.markdown
|
47
|
+
- Rakefile
|
48
|
+
- VERSION
|
49
|
+
- lib/nestful.rb
|
50
|
+
- lib/nestful/connection.rb
|
51
|
+
- lib/nestful/exceptions.rb
|
52
|
+
- lib/nestful/formats.rb
|
53
|
+
- lib/nestful/formats/blank_format.rb
|
54
|
+
- lib/nestful/formats/form_format.rb
|
55
|
+
- lib/nestful/formats/json_format.rb
|
56
|
+
- lib/nestful/formats/multipart_format.rb
|
57
|
+
- lib/nestful/formats/xml_format.rb
|
58
|
+
- lib/nestful/request.rb
|
59
|
+
- lib/nestful/request/callbacks.rb
|
60
|
+
- lib/nestful/resource.rb
|
61
|
+
- nestful.gemspec
|
62
|
+
has_rdoc: true
|
63
|
+
homepage: http://github.com/maccman/nestful
|
64
|
+
licenses: []
|
65
|
+
|
66
|
+
post_install_message:
|
67
|
+
rdoc_options:
|
68
|
+
- --charset=UTF-8
|
69
|
+
require_paths:
|
70
|
+
- lib
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
segments:
|
76
|
+
- 0
|
77
|
+
version: "0"
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
segments:
|
83
|
+
- 0
|
84
|
+
version: "0"
|
85
|
+
requirements: []
|
86
|
+
|
87
|
+
rubyforge_project:
|
88
|
+
rubygems_version: 1.3.6
|
89
|
+
signing_key:
|
90
|
+
specification_version: 3
|
91
|
+
summary: Simple Ruby HTTP/REST client with a sane API
|
92
|
+
test_files: []
|
93
|
+
|