resourceful 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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