resourceful 0.2.1 → 0.3.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.
@@ -37,16 +37,17 @@ module Resourceful
37
37
  #
38
38
  # Errors will not be logged. Instead an exception will be raised
39
39
  # and the application code should log it if appropriate.
40
- attr_accessor :logger
41
-
42
- attr_reader :auth_manager, :cache_manager
40
+ attr_accessor :logger, :cache_manager
43
41
 
42
+ attr_reader :auth_manager
44
43
  attr_reader :user_agent_tokens
45
44
 
46
45
  INIT_OPTIONS = OptionsInterpreter.new do
47
46
  option(:logger, :default => Resourceful::BitBucketLogger.new)
48
47
  option(:user_agent, :default => []) {|ua| [ua].flatten}
49
48
  option(:cache_manager, :default => NullCacheManager.new)
49
+ option(:authenticator)
50
+ option(:authenticators, :default => [])
50
51
  end
51
52
 
52
53
  # Initializes a new HttpAccessor. Valid options:
@@ -56,6 +57,13 @@ module Resourceful
56
57
  #
57
58
  # +:user_agent+:: One or more additional user agent tokens to
58
59
  # added to the user agent string.
60
+ #
61
+ # +:cache_manager+:: The cache manager this accessor should use.
62
+ #
63
+ # +:authenticator+:: Add a single authenticator for this accessor.
64
+ #
65
+ # +:authenticators+:: Enumerable of the authenticators for this
66
+ # accessor.
59
67
  def initialize(options = {})
60
68
  @user_agent_tokens = [RESOURCEFUL_USER_AGENT_TOKEN]
61
69
 
@@ -64,6 +72,9 @@ module Resourceful
64
72
  self.logger = opts[:logger]
65
73
  @auth_manager = AuthenticationManager.new()
66
74
  @cache_manager = opts[:cache_manager]
75
+
76
+ add_authenticator(opts[:authenticator]) if opts[:authenticator]
77
+ opts[:authenticators].each { |a| add_authenticator(a) }
67
78
  end
68
79
  end
69
80
 
@@ -76,10 +87,14 @@ module Resourceful
76
87
 
77
88
  # Returns a resource object representing the resource indicated
78
89
  # by the specified URI. A resource object will be created if necessary.
79
- def resource(uri)
80
- resource = Resource.new(self, uri)
90
+ def resource(uri, opts = {})
91
+ resource = Resource.new(self, uri, opts)
81
92
  end
82
93
  alias [] resource
83
94
 
95
+ # Adds an Authenticator to the set used by the accessor.
96
+ def add_authenticator(an_authenticator)
97
+ auth_manager.add_auth_handler(an_authenticator)
98
+ end
84
99
  end
85
100
  end
@@ -0,0 +1,85 @@
1
+ require File.dirname(__FILE__) + "/cache_manager"
2
+
3
+ require 'memcache'
4
+ require 'facets/kernel/returning'
5
+
6
+ module Resourceful
7
+ class MemcacheCacheManager < AbstractCacheManager
8
+
9
+ # Create a new Memcached backed cache manager
10
+ #
11
+ # @param [*String] memcache_servers
12
+ # list of all Memcached servers this cache manager should use.
13
+ def initialize(*memcache_servers)
14
+ @memcache = MemCache.new(memcache_servers, :multithread => true)
15
+ end
16
+
17
+ # Finds a previously cached response to the provided request. The
18
+ # response returned may be stale.
19
+ #
20
+ # @param [Resourceful::Request] request
21
+ # The request for which we are looking for a response.
22
+ #
23
+ # @return [Resourceful::Response, nil]
24
+ # A (possibly stale) response for the request provided or nil if
25
+ # no matching response is found.
26
+ def lookup(request)
27
+ resp = cache_entries_for(request)[request]
28
+ return if resp.nil?
29
+
30
+ resp.authoritative = false
31
+
32
+ resp
33
+ end
34
+
35
+ # Store a response in the cache.
36
+ #
37
+ # This method is smart enough to not store responses that cannot be
38
+ # cached (Vary: * or Cache-Control: no-cache, private, ...)
39
+ #
40
+ # @param [Resourceful::Request] request
41
+ # The request used to obtain the response. This is needed so the
42
+ # values from the response's Vary header can be stored.
43
+ # @param [Resourceful::Response] response
44
+ # The response to be stored.
45
+ def store(request, response)
46
+ return unless response.cachable?
47
+
48
+ @memcache[request.to_mc_key] = returning(cache_entries_for(request)) do |entries|
49
+ entries[request] = response
50
+ end
51
+ end
52
+
53
+ # Invalidates a all cached entries for a uri.
54
+ #
55
+ # This is used, for example, to invalidate the cache for a resource
56
+ # that gets POSTed to.
57
+ #
58
+ # @param [String] uri
59
+ # The uri of the resource to be invalidated
60
+ def invalidate(uri)
61
+ @memcache.delete(Digest::MD5.hexdigest(uri))
62
+ end
63
+
64
+
65
+ private
66
+
67
+ ##
68
+ # The memcache proxy.
69
+ attr_reader :memcache
70
+
71
+ def cache_entries_for(a_request)
72
+ @memcache.get(a_request.to_mc_key) || Resourceful::CacheEntryCollection.new
73
+ end
74
+ end
75
+
76
+ module MemCacheKey
77
+ def to_mc_key
78
+ Digest::MD5.hexdigest(uri)
79
+ end
80
+ end
81
+
82
+ class Request
83
+ include MemCacheKey
84
+ end
85
+ end
@@ -47,6 +47,7 @@ module Resourceful
47
47
  def self.net_http_request_class(method)
48
48
  case method
49
49
  when :get then Net::HTTP::Get
50
+ when :head then Net::HTTP::Head
50
51
  when :post then Net::HTTP::Post
51
52
  when :put then Net::HTTP::Put
52
53
  when :delete then Net::HTTP::Delete
@@ -12,11 +12,21 @@ module Resourceful
12
12
  attr_accessor :method, :resource, :body, :header
13
13
  attr_reader :request_time
14
14
 
15
+ # @param [Symbol] http_method
16
+ # :get, :put, :post, :delete or :head
17
+ # @param [Resourceful::Resource] resource
18
+ # @param [String] body
19
+ # @param [Resourceful::Header, Hash] header
15
20
  def initialize(http_method, resource, body = nil, header = nil)
16
21
  @method, @resource, @body = http_method, resource, body
17
22
  @header = header.is_a?(Resourceful::Header) ? header : Resourceful::Header.new(header || {})
18
23
 
19
24
  @header['Accept-Encoding'] = 'gzip, identity'
25
+ # 'Host' is a required HTTP/1.1 header, so set it if it isn't already
26
+ @header['Host'] ||= Addressable::URI.parse(resource.uri).host
27
+
28
+ # Setting the date isn't a bad idea, either
29
+ @header['Date'] ||= Time.now.httpdate
20
30
  end
21
31
 
22
32
  def response
@@ -24,6 +34,7 @@ module Resourceful
24
34
 
25
35
  http_resp = NetHttpAdapter.make_request(@method, @resource.uri, @body, @header)
26
36
  response = Resourceful::Response.new(uri, *http_resp)
37
+ response.request_time = @request_time
27
38
 
28
39
  response.authoritative = true
29
40
  response
@@ -44,6 +55,7 @@ module Resourceful
44
55
  @header['Cache-Control'] = 'max-age=0' if response.header.has_key?('Cache-Control') and response.header['Cache-Control'].include?('must-revalidate')
45
56
  end
46
57
 
58
+ # @return [String] The URI against which this request will be, or was, made.
47
59
  def uri
48
60
  resource.uri
49
61
  end
@@ -10,8 +10,6 @@ module Resourceful
10
10
  attr_reader :http_response, :http_request
11
11
 
12
12
  # Initialize new error from the HTTP request and response attributes.
13
- #--
14
- # @private
15
13
  def initialize(http_request, http_response)
16
14
  super("#{http_request.method} request to <#{http_request.uri}> failed with code #{http_response.code}")
17
15
  @http_request = http_request
@@ -21,6 +19,7 @@ module Resourceful
21
19
 
22
20
  class Resource
23
21
  attr_reader :accessor
22
+ attr_accessor :default_options
24
23
 
25
24
  # Build a new resource for a uri
26
25
  #
@@ -28,8 +27,9 @@ module Resourceful
28
27
  # The parent http accessor
29
28
  # @param uri<String, Addressable::URI>
30
29
  # The uri for the location of the resource
31
- def initialize(accessor, uri)
30
+ def initialize(accessor, uri, options = {})
32
31
  @accessor, @uris = accessor, [uri]
32
+ @default_options = options
33
33
  @on_redirect = nil
34
34
  end
35
35
 
@@ -153,17 +153,17 @@ module Resourceful
153
153
  # success, ie the final request returned a 2xx response code
154
154
  #
155
155
  def do_read_request(method, header = {})
156
- request = Resourceful::Request.new(method, self, nil, header)
156
+ request = Resourceful::Request.new(method, self, nil, default_options.merge(header))
157
157
  accessor.auth_manager.add_credentials(request)
158
158
 
159
159
  cached_response = accessor.cache_manager.lookup(request)
160
160
  if cached_response
161
- logger.debug(" Retrieved from cache")
161
+ logger.info(" Retrieved from cache")
162
162
  if not cached_response.stale?
163
163
  # We're done!
164
164
  return cached_response
165
165
  else
166
- logger.debug(" Cache entry is stale")
166
+ logger.info(" Cache entry is stale")
167
167
  request.set_validation_headers(cached_response)
168
168
  end
169
169
  end
@@ -171,7 +171,9 @@ module Resourceful
171
171
  response = request.response
172
172
 
173
173
  if response.is_not_modified?
174
- cached_response.header.merge(response.header)
174
+ logger.info(" Resource not modified")
175
+ cached_response.header.merge!(response.header)
176
+ cached_response.request_time = response.request_time
175
177
  response = cached_response
176
178
  response.authoritative = true
177
179
  end
@@ -179,9 +181,11 @@ module Resourceful
179
181
  if response.is_redirect? and request.should_be_redirected?
180
182
  if response.is_permanent_redirect?
181
183
  @uris.unshift response.header['Location'].first
184
+ logger.info(" Permanently redirected to #{uri} - Storing new location.")
182
185
  response = do_read_request(method, header)
183
186
  else
184
187
  redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
188
+ logger.info(" Redirected to #{redirected_resource.uri} - Storing new location.")
185
189
  response = redirected_resource.do_read_request(method, header)
186
190
  end
187
191
  end
@@ -189,7 +193,7 @@ module Resourceful
189
193
  if response.is_not_authorized? && !@already_tried_with_auth
190
194
  @already_tried_with_auth = true
191
195
  accessor.auth_manager.associate_auth_info(response)
192
- logger.debug("Authentication Required. Retrying with auth info")
196
+ logger.info("Authentication Required. Retrying with auth info")
193
197
  response = do_read_request(method, header)
194
198
  end
195
199
 
@@ -214,7 +218,7 @@ module Resourceful
214
218
  # @raise [UnsuccessfulHttpRequestError] unless the request is a
215
219
  # success, ie the final request returned a 2xx response code
216
220
  def do_write_request(method, data = nil, header = {})
217
- request = Resourceful::Request.new(method, self, data, header)
221
+ request = Resourceful::Request.new(method, self, data, default_options.merge(header))
218
222
  accessor.auth_manager.add_credentials(request)
219
223
 
220
224
  response = request.response
@@ -113,7 +113,8 @@ module Resourceful
113
113
  if header['Expire']
114
114
  return true if Time.httpdate(header['Expire'].first) < Time.now
115
115
  end
116
- if header['Cache-Control'] and header['Cache-Control'].include?('max-age')
116
+ if header['Cache-Control'] and header['Cache-Control'].first.include?('max-age')
117
+ max_age = header['Cache-Control'].first.split(',').grep(/max-age/).first.split('=').last.to_i
117
118
  return true if current_age > max_age
118
119
  end
119
120
 
@@ -0,0 +1,52 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{resourceful}
5
+ s.version = "0.3.0"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Paul Sadauskas"]
9
+ s.date = %q{2008-12-05}
10
+ s.description = %q{An HTTP library for Ruby that takes advantage of everything HTTP has to offer.}
11
+ s.email = %q{psadauskas@gmail.com}
12
+ s.extra_rdoc_files = ["lib/resourceful.rb", "lib/resourceful/authentication_manager.rb", "lib/resourceful/util.rb", "lib/resourceful/resource.rb", "lib/resourceful/memcache_cache_manager.rb", "lib/resourceful/net_http_adapter.rb", "lib/resourceful/http_accessor.rb", "lib/resourceful/stubbed_resource_proxy.rb", "lib/resourceful/header.rb", "lib/resourceful/cache_manager.rb", "lib/resourceful/options_interpreter.rb", "lib/resourceful/response.rb", "lib/resourceful/request.rb", "README.markdown"]
13
+ s.files = ["lib/resourceful.rb", "lib/resourceful/authentication_manager.rb", "lib/resourceful/util.rb", "lib/resourceful/resource.rb", "lib/resourceful/memcache_cache_manager.rb", "lib/resourceful/net_http_adapter.rb", "lib/resourceful/http_accessor.rb", "lib/resourceful/stubbed_resource_proxy.rb", "lib/resourceful/header.rb", "lib/resourceful/cache_manager.rb", "lib/resourceful/options_interpreter.rb", "lib/resourceful/response.rb", "lib/resourceful/request.rb", "spec/acceptance_shared_specs.rb", "spec/spec.opts", "spec/acceptance_spec.rb", "spec/simple_http_server_shared_spec_spec.rb", "spec/spec_helper.rb", "spec/resourceful/header_spec.rb", "spec/resourceful/authentication_manager_spec.rb", "spec/resourceful/memcache_cache_manager_spec.rb", "spec/resourceful/response_spec.rb", "spec/resourceful/options_interpreter_spec.rb", "spec/resourceful/http_accessor_spec.rb", "spec/resourceful/stubbed_resource_proxy_spec.rb", "spec/resourceful/request_spec.rb", "spec/resourceful/resource_spec.rb", "spec/resourceful/cache_manager_spec.rb", "spec/resourceful/net_http_adapter_spec.rb", "spec/simple_http_server_shared_spec.rb", "Manifest", "Rakefile", "README.markdown", "MIT-LICENSE", "resourceful.gemspec"]
14
+ s.has_rdoc = true
15
+ s.homepage = %q{http://github.com/paul/resourceful}
16
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Resourceful", "--main", "README.markdown"]
17
+ s.require_paths = ["lib"]
18
+ s.rubyforge_project = %q{resourceful}
19
+ s.rubygems_version = %q{1.3.1}
20
+ s.summary = %q{An HTTP library for Ruby that takes advantage of everything HTTP has to offer.}
21
+
22
+ if s.respond_to? :specification_version then
23
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
24
+ s.specification_version = 2
25
+
26
+ if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then
27
+ s.add_runtime_dependency(%q<addressable>, [">= 0"])
28
+ s.add_runtime_dependency(%q<httpauth>, [">= 0"])
29
+ s.add_runtime_dependency(%q<rspec>, [">= 0"])
30
+ s.add_runtime_dependency(%q<facets>, [">= 0"])
31
+ s.add_runtime_dependency(%q<andand>, [">= 0"])
32
+ s.add_development_dependency(%q<thin>, [">= 0"])
33
+ s.add_development_dependency(%q<yard>, [">= 0"])
34
+ else
35
+ s.add_dependency(%q<addressable>, [">= 0"])
36
+ s.add_dependency(%q<httpauth>, [">= 0"])
37
+ s.add_dependency(%q<rspec>, [">= 0"])
38
+ s.add_dependency(%q<facets>, [">= 0"])
39
+ s.add_dependency(%q<andand>, [">= 0"])
40
+ s.add_dependency(%q<thin>, [">= 0"])
41
+ s.add_dependency(%q<yard>, [">= 0"])
42
+ end
43
+ else
44
+ s.add_dependency(%q<addressable>, [">= 0"])
45
+ s.add_dependency(%q<httpauth>, [">= 0"])
46
+ s.add_dependency(%q<rspec>, [">= 0"])
47
+ s.add_dependency(%q<facets>, [">= 0"])
48
+ s.add_dependency(%q<andand>, [">= 0"])
49
+ s.add_dependency(%q<thin>, [">= 0"])
50
+ s.add_dependency(%q<yard>, [">= 0"])
51
+ end
52
+ end
@@ -8,7 +8,7 @@ require Pathname(__FILE__).dirname + 'acceptance_shared_specs'
8
8
  describe Resourceful do
9
9
  it_should_behave_like 'simple http server'
10
10
 
11
- describe 'getting a resource' do
11
+ describe 'working with a resource' do
12
12
  before do
13
13
  @accessor = Resourceful::HttpAccessor.new
14
14
  end
@@ -61,6 +61,38 @@ describe Resourceful do
61
61
  resp.header['Content-Type'].should == ['text/plain']
62
62
  end
63
63
 
64
+ it 'should take an optional default header for reads' do
65
+ resource = @accessor.resource('http://localhost:3000/echo_header', :foo => :bar)
66
+ resp = resource.get
67
+ resp.should be_instance_of(Resourceful::Response)
68
+ resp.code.should == 200
69
+ resp.body.should =~ /"HTTP_FOO"=>"bar"/
70
+ end
71
+
72
+ it 'should take an optional default header for writes' do
73
+ resource = @accessor.resource('http://localhost:3000/echo_header', :foo => :bar)
74
+ resp = resource.post("data", :content_type => 'text/plain')
75
+ resp.should be_instance_of(Resourceful::Response)
76
+ resp.code.should == 200
77
+ resp.body.should =~ /"HTTP_FOO"=>"bar"/
78
+ end
79
+
80
+ it 'should override the default header with any set on a read action' do
81
+ resource = @accessor.resource('http://localhost:3000/echo_header', :foo => :bar)
82
+ resp = resource.get(:foo => :baz)
83
+ resp.should be_instance_of(Resourceful::Response)
84
+ resp.code.should == 200
85
+ resp.body.should =~ /"HTTP_FOO"=>"baz"/
86
+ end
87
+
88
+ it 'should override the default header with any set on a write action' do
89
+ resource = @accessor.resource('http://localhost:3000/echo_header', :foo => :bar)
90
+ resp = resource.post("data", :foo => :baz, :content_type => 'text/plain')
91
+ resp.should be_instance_of(Resourceful::Response)
92
+ resp.code.should == 200
93
+ resp.body.should =~ /"HTTP_FOO"=>"baz"/
94
+ end
95
+
64
96
  describe 'redirecting' do
65
97
 
66
98
  describe 'registering callback' do
@@ -246,6 +278,19 @@ describe Resourceful do
246
278
  resp2.authoritative?.should be_true
247
279
  end
248
280
 
281
+ it 'should revalidate anything that is older than "Cache-Control: max-age" value' do
282
+
283
+ uri = URI.escape('http://localhost:3000/header?{Cache-Control: max-age=1, Date: "Mon, 04 Aug 2008 18:00:00 GMT"}')
284
+ resource = @accessor.resource(uri)
285
+ resp = resource.get
286
+ resp.authoritative?.should be_true
287
+
288
+ resp.expired?.should be_true
289
+
290
+ resp2 = resource.get
291
+ resp2.authoritative?.should be_true
292
+ end
293
+
249
294
  it 'should cache but revalidate anything with "Cache-Control: must-revalidate"' do
250
295
  uri = URI.escape('http://localhost:3000/header?{Cache-Control: must-revalidate}')
251
296
  resource = @accessor.resource(uri)
@@ -166,6 +166,14 @@ end
166
166
  describe Resourceful::DigestAuthenticator do
167
167
 
168
168
  before do
169
+ @header = {'WWW-Authenticate' => ['Digest realm="Test Auth"']}
170
+ @chal = mock('response', :header => @header, :uri => 'http://example.com/foo/bar')
171
+
172
+ @req_header = {}
173
+ @req = mock('request', :header => @req_header,
174
+ :uri => 'http://example.com',
175
+ :method => 'GET')
176
+
169
177
  @auth = Resourceful::DigestAuthenticator.new('Test Auth', 'admin', 'secret')
170
178
  end
171
179
 
@@ -175,12 +183,21 @@ describe Resourceful::DigestAuthenticator do
175
183
  end
176
184
  end
177
185
 
178
- describe "Updating from a challenge response" do
179
- before do
180
- @header = {'WWW-Authenticate' => ['Digest realm="Test Auth"']}
181
- @chal = mock('response', :header => @header, :uri => 'http://example.com/foo/bar')
186
+ describe "Updating credentials from a challenge response" do
187
+
188
+ it "should set the domain from the host part of the challenge response uri" do
189
+ @auth.update_credentials(@chal)
190
+ @auth.domain.should == 'example.com'
182
191
  end
183
192
 
193
+ it "should create an HTTPAuth Digest Challenge from the challenge response WWW-Authenticate header" do
194
+ HTTPAuth::Digest::Challenge.should_receive(:from_header).with(@header['WWW-Authenticate'].first)
195
+ @auth.update_credentials(@chal)
196
+ end
197
+
198
+ end
199
+
200
+ describe "Validating a challenge" do
184
201
  it 'should be valid for a challenge response with scheme "Digest" and the same realm' do
185
202
  @auth.valid_for?(@chal).should be_true
186
203
  end
@@ -201,4 +218,32 @@ describe Resourceful::DigestAuthenticator do
201
218
  end
202
219
  end
203
220
 
221
+ it "should be able to handle requests to the same domain" do
222
+ @auth.instance_variable_set("@domain", 'example.com')
223
+ @auth.can_handle?(@req).should be_true
224
+ end
225
+
226
+ it "should not handle requests to a different domain" do
227
+ @auth.instance_variable_set("@domain", 'example2.com')
228
+ @auth.can_handle?(@req).should be_false
229
+ end
230
+
231
+ it "should add credentials to a request" do
232
+ @auth.update_credentials(@chal)
233
+ @auth.add_credentials_to(@req)
234
+ @req_header.should have_key('Authorization')
235
+ @req_header['Authorization'].should_not be_blank
236
+ end
237
+
238
+ it "should have HTTPAuth::Digest generate the Authorization header" do
239
+ @auth.update_credentials(@chal)
240
+ cred = mock('digest_credentials', :to_header => nil)
241
+
242
+ HTTPAuth::Digest::Credentials.should_receive(:from_challenge).with(
243
+ @auth.challenge, :username => 'admin', :password => 'secret', :method => 'GET', :uri => ''
244
+ ).and_return(cred)
245
+
246
+ cred = @auth.credentials_for(@req)
247
+ end
248
+
204
249
  end