openlogic-resourceful 1.2.0
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/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
|