openlogic-resourceful 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +45 -0
- data/MIT-LICENSE +21 -0
- data/Manifest +46 -0
- data/README.markdown +92 -0
- data/Rakefile +91 -0
- data/lib/resourceful.rb +27 -0
- data/lib/resourceful/abstract_form_data.rb +30 -0
- data/lib/resourceful/authentication_manager.rb +107 -0
- data/lib/resourceful/cache_manager.rb +242 -0
- data/lib/resourceful/exceptions.rb +34 -0
- data/lib/resourceful/header.rb +355 -0
- data/lib/resourceful/http_accessor.rb +103 -0
- data/lib/resourceful/memcache_cache_manager.rb +75 -0
- data/lib/resourceful/multipart_form_data.rb +46 -0
- data/lib/resourceful/net_http_adapter.rb +84 -0
- data/lib/resourceful/promiscuous_basic_authenticator.rb +18 -0
- data/lib/resourceful/request.rb +235 -0
- data/lib/resourceful/resource.rb +179 -0
- data/lib/resourceful/response.rb +221 -0
- data/lib/resourceful/simple.rb +36 -0
- data/lib/resourceful/stubbed_resource_proxy.rb +47 -0
- data/lib/resourceful/urlencoded_form_data.rb +19 -0
- data/lib/resourceful/util.rb +6 -0
- data/openlogic-resourceful.gemspec +51 -0
- data/resourceful.gemspec +51 -0
- data/spec/acceptance/authorization_spec.rb +16 -0
- data/spec/acceptance/caching_spec.rb +190 -0
- data/spec/acceptance/header_spec.rb +24 -0
- data/spec/acceptance/redirecting_spec.rb +12 -0
- data/spec/acceptance/resource_spec.rb +84 -0
- data/spec/acceptance/resourceful_spec.rb +56 -0
- data/spec/acceptance_shared_specs.rb +44 -0
- data/spec/caching_spec.rb +89 -0
- data/spec/old_acceptance_specs.rb +378 -0
- data/spec/resourceful/header_spec.rb +153 -0
- data/spec/resourceful/http_accessor_spec.rb +56 -0
- data/spec/resourceful/multipart_form_data_spec.rb +84 -0
- data/spec/resourceful/promiscuous_basic_authenticator_spec.rb +30 -0
- data/spec/resourceful/resource_spec.rb +20 -0
- data/spec/resourceful/response_spec.rb +51 -0
- data/spec/resourceful/urlencoded_form_data_spec.rb +64 -0
- data/spec/resourceful_spec.rb +79 -0
- data/spec/simple_sinatra_server.rb +74 -0
- data/spec/simple_sinatra_server_spec.rb +98 -0
- data/spec/spec.opts +3 -0
- data/spec/spec_helper.rb +31 -0
- metadata +192 -0
@@ -0,0 +1,179 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'resourceful/request'
|
3
|
+
|
4
|
+
module Resourceful
|
5
|
+
|
6
|
+
class Resource
|
7
|
+
attr_reader :accessor
|
8
|
+
|
9
|
+
# Build a new resource for a uri
|
10
|
+
#
|
11
|
+
# @param accessor<HttpAccessor>
|
12
|
+
# The parent http accessor
|
13
|
+
# @param uri<String, Addressable::URI>
|
14
|
+
# The uri for the location of the resource
|
15
|
+
def initialize(accessor, uri, default_header = {})
|
16
|
+
@accessor, @uris = accessor, [uri]
|
17
|
+
@default_header = Resourceful::Header.new({'User-Agent' => Resourceful::RESOURCEFUL_USER_AGENT_TOKEN}.merge(default_header))
|
18
|
+
@on_redirect = nil
|
19
|
+
end
|
20
|
+
|
21
|
+
# The uri used to identify this resource. This is almost always the uri
|
22
|
+
# used to create the resource, but in the case of a permanent redirect, this
|
23
|
+
# will always reflect the lastest uri.
|
24
|
+
#
|
25
|
+
# @return Addressable::URI
|
26
|
+
# The current uri of the resource
|
27
|
+
def effective_uri
|
28
|
+
@uris.first
|
29
|
+
end
|
30
|
+
alias uri effective_uri
|
31
|
+
|
32
|
+
def default_header(temp_defaults = {})
|
33
|
+
@default_header.merge(temp_defaults)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Returns the host for this Resource's current uri
|
37
|
+
def host
|
38
|
+
Addressable::URI.parse(uri).host
|
39
|
+
end
|
40
|
+
|
41
|
+
# Updates the effective uri after following a permanent redirect
|
42
|
+
def update_uri(uri)
|
43
|
+
@uris.unshift(uri)
|
44
|
+
end
|
45
|
+
|
46
|
+
# When performing a redirect, this callback will be executed first. If the callback
|
47
|
+
# returns true, then the redirect is followed, otherwise it is not. The request that
|
48
|
+
# triggered the redirect and the response will be passed into the block. This can be
|
49
|
+
# used to update any links on the client side.
|
50
|
+
#
|
51
|
+
# Example:
|
52
|
+
#
|
53
|
+
# author_resource.on_redirect do |req, resp|
|
54
|
+
# post.author_uri = resp.header['Location']
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# @yieldparam callback<request, response>
|
58
|
+
# The action to be executed when a request results in a redirect. Yields the
|
59
|
+
# current request and result objects to the callback.
|
60
|
+
#
|
61
|
+
# @raise ArgumentError if called without a block
|
62
|
+
def on_redirect(&callback)
|
63
|
+
if block_given?
|
64
|
+
@on_redirect = callback
|
65
|
+
else
|
66
|
+
@on_redirect
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Performs a GET on the resource, following redirects as neccessary, and retriving
|
71
|
+
# it from the local cache if its available and valid.
|
72
|
+
#
|
73
|
+
# @return [Response] The Response to the final request made.
|
74
|
+
#
|
75
|
+
# @raise [UnsuccessfulHttpRequestError] unless the request is a
|
76
|
+
# success, ie the final request returned a 2xx response code
|
77
|
+
def get(header = {})
|
78
|
+
request(:get, nil, header)
|
79
|
+
end
|
80
|
+
|
81
|
+
def head(header = {})
|
82
|
+
request(:head, nil, header)
|
83
|
+
end
|
84
|
+
# :call-seq:
|
85
|
+
# post(data = "", :content_type => mime_type)
|
86
|
+
#
|
87
|
+
# Performs a POST with the given data to the resource, following redirects as
|
88
|
+
# neccessary.
|
89
|
+
#
|
90
|
+
# @param [String] data
|
91
|
+
# The body of the data to be posted
|
92
|
+
# @param [Hash] options
|
93
|
+
# Options to pass into the request header. At the least, :content_type is required.
|
94
|
+
#
|
95
|
+
# @return [Response] The Response to the final request that was made.
|
96
|
+
#
|
97
|
+
# @raise [ArgumentError] unless :content-type is specified in options
|
98
|
+
# @raise [UnsuccessfulHttpRequestError] unless the request is a
|
99
|
+
# success, ie the final request returned a 2xx response code
|
100
|
+
def post(data = nil, header = {})
|
101
|
+
request(:post, data, header)
|
102
|
+
end
|
103
|
+
|
104
|
+
# :call-seq:
|
105
|
+
# put(data = "", :content_type => mime_type)
|
106
|
+
#
|
107
|
+
# Performs a PUT with the given data to the resource, following redirects as
|
108
|
+
# neccessary.
|
109
|
+
#
|
110
|
+
# @param [String] data
|
111
|
+
# The body of the data to be posted
|
112
|
+
# @param [Hash] options
|
113
|
+
# Options to pass into the request header. At the least, :content_type is required.
|
114
|
+
#
|
115
|
+
# @return [Response] The response to the final request made.
|
116
|
+
#
|
117
|
+
# @raise [ArgumentError] unless :content-type is specified in options
|
118
|
+
# @raise [UnsuccessfulHttpRequestError] unless the request is a
|
119
|
+
# success, ie the final request returned a 2xx response code
|
120
|
+
def put(data, header = {})
|
121
|
+
request(:put, data, header)
|
122
|
+
end
|
123
|
+
|
124
|
+
# Performs a DELETE on the resource, following redirects as neccessary.
|
125
|
+
#
|
126
|
+
# @return <Response>
|
127
|
+
#
|
128
|
+
# @raise [UnsuccessfulHttpRequestError] unless the request is a
|
129
|
+
# success, ie the final request returned a 2xx response code
|
130
|
+
def delete(header = {})
|
131
|
+
request(:delete, nil, header)
|
132
|
+
end
|
133
|
+
|
134
|
+
def logger
|
135
|
+
accessor.logger
|
136
|
+
end
|
137
|
+
|
138
|
+
# Actually make the request
|
139
|
+
def request(method, data, header)
|
140
|
+
header = default_header.merge(header)
|
141
|
+
ensure_content_type(data, header) if data
|
142
|
+
|
143
|
+
data = StringIO.new(data) if data.kind_of?(String)
|
144
|
+
|
145
|
+
log_request_with_time "#{method.to_s.upcase} [#{uri}]" do
|
146
|
+
request = Request.new(method, self, data, header)
|
147
|
+
request.fetch_response
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
# Ensures that the request has a content type header
|
154
|
+
def ensure_content_type(body, header)
|
155
|
+
return if header.has_key?('Content-Type')
|
156
|
+
|
157
|
+
if body.respond_to?(:content_type)
|
158
|
+
header['Content-Type'] = body.content_type
|
159
|
+
return
|
160
|
+
end
|
161
|
+
|
162
|
+
return if default_header.has_key?('Content-Type')
|
163
|
+
|
164
|
+
# could not figure it out
|
165
|
+
raise MissingContentType
|
166
|
+
end
|
167
|
+
|
168
|
+
# Log it took the time to make the request
|
169
|
+
def log_request_with_time(msg, indent = 2)
|
170
|
+
logger.info(" " * indent + msg)
|
171
|
+
result = nil
|
172
|
+
time = Benchmark.measure { result = yield }
|
173
|
+
logger.info(" " * indent + "-> Returned #{result.code} in %.4fs" % time.real)
|
174
|
+
result
|
175
|
+
end
|
176
|
+
|
177
|
+
end
|
178
|
+
|
179
|
+
end
|
@@ -0,0 +1,221 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'time'
|
3
|
+
require 'zlib'
|
4
|
+
|
5
|
+
module Resourceful
|
6
|
+
|
7
|
+
class Response
|
8
|
+
REDIRECT_RESPONSE_CODES = [301,302,303,307]
|
9
|
+
NORMALLY_CACHEABLE_RESPONSE_CODES = [200, 203, 300, 301, 410]
|
10
|
+
|
11
|
+
attr_reader :uri, :code, :header, :body, :response_time
|
12
|
+
alias headers header
|
13
|
+
|
14
|
+
attr_accessor :authoritative, :request_time
|
15
|
+
alias authoritative? authoritative
|
16
|
+
|
17
|
+
def initialize(uri, code, header, body)
|
18
|
+
@uri, @code, @header, @body = uri, code, header, body
|
19
|
+
@response_time = Time.now
|
20
|
+
end
|
21
|
+
|
22
|
+
# Is this a cached response that has expired?
|
23
|
+
#
|
24
|
+
# @return true|false
|
25
|
+
def expired?
|
26
|
+
if header.cache_control and m_age_str = header.cache_control.find{|cc| /^max-age=/ === cc}
|
27
|
+
return current_age > m_age_str[/\d+/].to_i
|
28
|
+
elsif header.expires
|
29
|
+
return Time.httpdate(header.expires) < Time.now
|
30
|
+
end
|
31
|
+
|
32
|
+
false
|
33
|
+
end
|
34
|
+
|
35
|
+
# Is this a cached response that is stale?
|
36
|
+
#
|
37
|
+
# @return true|false
|
38
|
+
def stale?
|
39
|
+
return true if expired?
|
40
|
+
return false unless header.has_field?(Header::CACHE_CONTROL)
|
41
|
+
return true if header.cache_control.any?{|cc| /must-revalidate|no-cache/ === cc}
|
42
|
+
|
43
|
+
false
|
44
|
+
end
|
45
|
+
|
46
|
+
# Is this response cachable?
|
47
|
+
#
|
48
|
+
# @return true|false
|
49
|
+
def cacheable?
|
50
|
+
@cacheable ||= begin
|
51
|
+
@cacheable = true if NORMALLY_CACHEABLE_RESPONSE_CODES.include?(code.to_i)
|
52
|
+
@cacheable = false if header.vary && header.vary.include?('*')
|
53
|
+
@cacheable = false if header.cache_control && header.cache_control.include?('no-cache')
|
54
|
+
@cacheable = true if header.cache_control && header.cache_control.include?('public')
|
55
|
+
@cacheable = true if header.cache_control && header.cache_control.include?('private')
|
56
|
+
@cacheable || false
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Does this response force revalidation?
|
61
|
+
def must_be_revalidated?
|
62
|
+
header.cache_control && header.cache_control.include?('must-revalidate')
|
63
|
+
end
|
64
|
+
|
65
|
+
# Update our headers from a later 304 response
|
66
|
+
def revalidate!(not_modified_response)
|
67
|
+
header.merge!(not_modified_response.header)
|
68
|
+
@request_time = not_modified_response.request_time
|
69
|
+
@response_time = not_modified_response.response_time
|
70
|
+
@authoritative = true
|
71
|
+
end
|
72
|
+
|
73
|
+
# Algorithm taken from RCF2616#13.2.3
|
74
|
+
def current_age
|
75
|
+
age_value = header.age.to_i
|
76
|
+
date_value = Time.httpdate(header.date)
|
77
|
+
now = Time.now
|
78
|
+
|
79
|
+
apparent_age = [0, response_time - date_value].max
|
80
|
+
corrected_received_age = [apparent_age, age_value].max
|
81
|
+
current_age = corrected_received_age + (response_time - request_time) + (now - response_time)
|
82
|
+
end
|
83
|
+
|
84
|
+
def body
|
85
|
+
raise(NotImplementedError, 'Chained encodings are not supported') if header['Content-Encoding'] && header['Content-Encoding'].length > 1
|
86
|
+
encoding = header['Content-Encoding'] && header['Content-Encoding'].first
|
87
|
+
|
88
|
+
case encoding
|
89
|
+
when nil
|
90
|
+
# body is identity encoded; just return it
|
91
|
+
@body
|
92
|
+
when /^\s*gzip\s*$/i
|
93
|
+
gz_in = ::Zlib::GzipReader.new(StringIO.new(@body, 'r'))
|
94
|
+
@body = gz_in.read
|
95
|
+
gz_in.close
|
96
|
+
header.delete('Content-Encoding')
|
97
|
+
@body
|
98
|
+
else
|
99
|
+
raise UnsupportedContentCoding, "Resourceful does not support #{encoding} content coding"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
CODE_NAMES = {
|
104
|
+
100 => "Continue".freeze,
|
105
|
+
101 => "Switching Protocols".freeze,
|
106
|
+
|
107
|
+
200 => "OK".freeze,
|
108
|
+
201 => "Created".freeze,
|
109
|
+
202 => "Accepted".freeze,
|
110
|
+
203 => "Non-Authoritative Information".freeze,
|
111
|
+
204 => "No Content".freeze,
|
112
|
+
205 => "Reset Content".freeze,
|
113
|
+
206 => "Partial Content".freeze,
|
114
|
+
|
115
|
+
300 => "Multiple Choices".freeze,
|
116
|
+
301 => "Moved Permanently".freeze,
|
117
|
+
302 => "Found".freeze,
|
118
|
+
303 => "See Other".freeze,
|
119
|
+
304 => "Not Modified".freeze,
|
120
|
+
305 => "Use Proxy".freeze,
|
121
|
+
307 => "Temporary Redirect".freeze,
|
122
|
+
|
123
|
+
400 => "Bad Request".freeze,
|
124
|
+
401 => "Unauthorized".freeze,
|
125
|
+
402 => "Payment Required".freeze,
|
126
|
+
403 => "Forbidden".freeze,
|
127
|
+
404 => "Not Found".freeze,
|
128
|
+
405 => "Method Not Allowed".freeze,
|
129
|
+
406 => "Not Acceptable".freeze,
|
130
|
+
407 => "Proxy Authentication Required".freeze,
|
131
|
+
408 => "Request Timeout".freeze,
|
132
|
+
409 => "Conflict".freeze,
|
133
|
+
410 => "Gone".freeze,
|
134
|
+
411 => "Length Required".freeze,
|
135
|
+
412 => "Precondition Failed".freeze,
|
136
|
+
413 => "Request Entity Too Large".freeze,
|
137
|
+
414 => "Request-URI Too Long".freeze,
|
138
|
+
415 => "Unsupported Media Type".freeze,
|
139
|
+
416 => "Requested Range Not Satisfiable".freeze,
|
140
|
+
417 => "Expectation Failed".freeze,
|
141
|
+
|
142
|
+
500 => "Internal Server Error".freeze,
|
143
|
+
501 => "Not Implemented".freeze,
|
144
|
+
502 => "Bad Gateway".freeze,
|
145
|
+
503 => "Service Unavailable".freeze,
|
146
|
+
504 => "Gateway Timeout".freeze,
|
147
|
+
505 => "HTTP Version Not Supported".freeze,
|
148
|
+
}.freeze
|
149
|
+
|
150
|
+
CODES = CODE_NAMES.keys
|
151
|
+
|
152
|
+
CODE_NAMES.each do |code, msg|
|
153
|
+
method_name = msg.downcase.gsub(/[- ]/, "_")
|
154
|
+
|
155
|
+
class_eval <<-RUBY
|
156
|
+
def #{method_name}? # def ok?
|
157
|
+
@code == #{code} # @code == 200
|
158
|
+
end # end
|
159
|
+
RUBY
|
160
|
+
end
|
161
|
+
|
162
|
+
# Is the response informational? True for
|
163
|
+
# 1xx series response codes
|
164
|
+
#
|
165
|
+
# @return true|false
|
166
|
+
def informational?
|
167
|
+
@code.in? 100..199
|
168
|
+
end
|
169
|
+
|
170
|
+
# Is the response code sucessful? True for only 2xx series
|
171
|
+
# response codes.
|
172
|
+
#
|
173
|
+
# @return true|false
|
174
|
+
def successful?
|
175
|
+
@code.in? 200..299
|
176
|
+
end
|
177
|
+
alias success? successful?
|
178
|
+
|
179
|
+
# Is the response a redirect? True for
|
180
|
+
# 3xx series response codes
|
181
|
+
#
|
182
|
+
# @return true|false
|
183
|
+
def redirection?
|
184
|
+
@code.in? 300..399
|
185
|
+
end
|
186
|
+
|
187
|
+
# Is the response a actual redirect? True for
|
188
|
+
# 301, 302, 303, 307 response codes
|
189
|
+
#
|
190
|
+
# @return true|false
|
191
|
+
def redirect?
|
192
|
+
@code.in? REDIRECT_RESPONSE_CODES
|
193
|
+
end
|
194
|
+
|
195
|
+
# Is the response the result of a client error? True for
|
196
|
+
# 4xx series response codes
|
197
|
+
#
|
198
|
+
# @return true|false
|
199
|
+
def client_error?
|
200
|
+
@code.in? 400..499
|
201
|
+
end
|
202
|
+
|
203
|
+
# Is the response the result of a server error? True for
|
204
|
+
# 5xx series response codes
|
205
|
+
#
|
206
|
+
# @return true|false
|
207
|
+
def server_error?
|
208
|
+
@code.in? 500..599
|
209
|
+
end
|
210
|
+
|
211
|
+
# Is the response the result of any kind of error? True for
|
212
|
+
# 4xx and 5xx series response codes
|
213
|
+
#
|
214
|
+
# @return true|false
|
215
|
+
def error?
|
216
|
+
server_error? || client_error?
|
217
|
+
end
|
218
|
+
|
219
|
+
end
|
220
|
+
|
221
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Resourceful
|
2
|
+
module Simple
|
3
|
+
def request(method, uri, header = {}, data = nil)
|
4
|
+
default_accessor.resource(uri).request(method, data, header)
|
5
|
+
end
|
6
|
+
|
7
|
+
def default_accessor
|
8
|
+
@default_accessor ||= Resourceful::HttpAccessor.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def add_authenticator(an_authenticator)
|
12
|
+
default_accessor.add_authenticator(an_authenticator)
|
13
|
+
end
|
14
|
+
|
15
|
+
def get(uri, header = {})
|
16
|
+
request(:get, uri, header)
|
17
|
+
end
|
18
|
+
|
19
|
+
def head(uri, header = {})
|
20
|
+
request(:head, uri, header)
|
21
|
+
end
|
22
|
+
|
23
|
+
def delete(uri, header = {})
|
24
|
+
request(:delete, uri, header)
|
25
|
+
end
|
26
|
+
|
27
|
+
def post(uri, data = nil, header = {})
|
28
|
+
request(:post, uri, header, data)
|
29
|
+
end
|
30
|
+
|
31
|
+
def put(uri, data = nil, header = {})
|
32
|
+
request(:put, uri, header, data)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'resourceful/resource'
|
2
|
+
|
3
|
+
module Resourceful
|
4
|
+
class StubbedResourceProxy
|
5
|
+
def initialize(resource, canned_responses)
|
6
|
+
@resource = resource
|
7
|
+
|
8
|
+
@canned_responses = {}
|
9
|
+
|
10
|
+
canned_responses.each do |cr|
|
11
|
+
mime_type = cr[:mime_type]
|
12
|
+
@canned_responses[mime_type] = resp = Net::HTTPOK.new('1.1', '200', 'OK')
|
13
|
+
resp['content-type'] = mime_type.to_str
|
14
|
+
resp.instance_variable_set(:@read, true)
|
15
|
+
resp.instance_variable_set(:@body, cr[:body])
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def get_body(*args)
|
21
|
+
get(*args).body
|
22
|
+
end
|
23
|
+
|
24
|
+
def get(*args)
|
25
|
+
options = args.last.is_a?(Hash) ? args.last : {}
|
26
|
+
|
27
|
+
if accept = [(options[:accept] || '*/*')].flatten.compact
|
28
|
+
accept.each do |mt|
|
29
|
+
return canned_response(mt) || next
|
30
|
+
end
|
31
|
+
@resource.get(*args)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def method_missing(method, *args)
|
36
|
+
@resource.send(method, *args)
|
37
|
+
end
|
38
|
+
|
39
|
+
protected
|
40
|
+
|
41
|
+
def canned_response(mime_type)
|
42
|
+
mime_type = @canned_responses.keys.first if mime_type == '*/*'
|
43
|
+
@canned_responses[mime_type]
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|