nestful 0.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.
- 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
|
+
|