paul-resourceful 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (35) hide show
  1. data/MIT-LICENSE +21 -0
  2. data/Manifest.txt +34 -0
  3. data/README.markdown +86 -0
  4. data/Rakefile +14 -0
  5. data/lib/resourceful.rb +29 -0
  6. data/lib/resourceful/authentication_manager.rb +107 -0
  7. data/lib/resourceful/cache_manager.rb +174 -0
  8. data/lib/resourceful/header.rb +31 -0
  9. data/lib/resourceful/http_accessor.rb +85 -0
  10. data/lib/resourceful/net_http_adapter.rb +60 -0
  11. data/lib/resourceful/options_interpreter.rb +78 -0
  12. data/lib/resourceful/request.rb +63 -0
  13. data/lib/resourceful/resource.rb +266 -0
  14. data/lib/resourceful/response.rb +175 -0
  15. data/lib/resourceful/stubbed_resource_proxy.rb +47 -0
  16. data/lib/resourceful/util.rb +6 -0
  17. data/lib/resourceful/version.rb +1 -0
  18. data/resourceful.gemspec +30 -0
  19. data/spec/acceptance_shared_specs.rb +49 -0
  20. data/spec/acceptance_spec.rb +408 -0
  21. data/spec/resourceful/authentication_manager_spec.rb +249 -0
  22. data/spec/resourceful/cache_manager_spec.rb +211 -0
  23. data/spec/resourceful/header_spec.rb +38 -0
  24. data/spec/resourceful/http_accessor_spec.rb +125 -0
  25. data/spec/resourceful/net_http_adapter_spec.rb +96 -0
  26. data/spec/resourceful/options_interpreter_spec.rb +94 -0
  27. data/spec/resourceful/request_spec.rb +186 -0
  28. data/spec/resourceful/resource_spec.rb +600 -0
  29. data/spec/resourceful/response_spec.rb +238 -0
  30. data/spec/resourceful/stubbed_resource_proxy_spec.rb +58 -0
  31. data/spec/simple_http_server_shared_spec.rb +160 -0
  32. data/spec/simple_http_server_shared_spec_spec.rb +212 -0
  33. data/spec/spec.opts +3 -0
  34. data/spec/spec_helper.rb +14 -0
  35. metadata +98 -0
@@ -0,0 +1,31 @@
1
+ # A case-normalizing Hash, adjusting on [] and []=.
2
+ # Shamelessly swiped from Rack
3
+ module Resourceful
4
+ class Header < Hash
5
+ def initialize(hash={})
6
+ hash.each { |k, v| self[k] = v }
7
+ end
8
+
9
+ def to_hash
10
+ {}.replace(self)
11
+ end
12
+
13
+ def [](k)
14
+ super capitalize(k)
15
+ end
16
+
17
+ def []=(k, v)
18
+ super capitalize(k), v
19
+ end
20
+
21
+ def has_key?(k)
22
+ super capitalize(k)
23
+ end
24
+
25
+ def capitalize(k)
26
+ k.to_s.downcase.gsub(/^.|[-_\s]./) { |x| x.upcase }.gsub('_', '-')
27
+ end
28
+ end
29
+ end
30
+
31
+
@@ -0,0 +1,85 @@
1
+ require 'net/http'
2
+
3
+ require 'resourceful/version'
4
+ require 'resourceful/options_interpreter'
5
+ require 'resourceful/authentication_manager'
6
+ require 'resourceful/cache_manager'
7
+ require 'resourceful/resource'
8
+ require 'resourceful/stubbed_resource_proxy'
9
+
10
+ module Resourceful
11
+ # This is an imitation Logger used when no real logger is
12
+ # registered. This allows most of the code to assume that there
13
+ # is always a logger available, which significantly improved the
14
+ # readability of the logging related code.
15
+ class BitBucketLogger
16
+ def warn(*args); end
17
+ def info(*args); end
18
+ def debug(*args); end
19
+ end
20
+
21
+ # This is the simplest logger. It just writes everything to STDOUT.
22
+ class StdOutLogger
23
+ def warn(*args); puts args; end
24
+ def info(*args); puts args; end
25
+ def debug(*args); puts args; end
26
+ end
27
+
28
+ # This class provides a simple interface to the functionality
29
+ # provided by the Resourceful library. Conceptually this object
30
+ # acts a collection of all the resources available via HTTP.
31
+ class HttpAccessor
32
+ RESOURCEFUL_USER_AGENT_TOKEN = "Resourceful/#{RESOURCEFUL_VERSION}(Ruby/#{RUBY_VERSION})"
33
+
34
+ # A logger object to which messages about the activities of this
35
+ # object will be written. This should be an object that responds
36
+ # to +#info(message)+ and +#debug(message)+.
37
+ #
38
+ # Errors will not be logged. Instead an exception will be raised
39
+ # and the application code should log it if appropriate.
40
+ attr_accessor :logger, :cache_manager
41
+
42
+ attr_reader :auth_manager
43
+
44
+ attr_reader :user_agent_tokens
45
+
46
+ INIT_OPTIONS = OptionsInterpreter.new do
47
+ option(:logger, :default => Resourceful::BitBucketLogger.new)
48
+ option(:user_agent, :default => []) {|ua| [ua].flatten}
49
+ option(:cache_manager, :default => NullCacheManager.new)
50
+ end
51
+
52
+ # Initializes a new HttpAccessor. Valid options:
53
+ #
54
+ # +:logger+:: A Logger object that the new HTTP accessor should
55
+ # send log messages
56
+ #
57
+ # +:user_agent+:: One or more additional user agent tokens to
58
+ # added to the user agent string.
59
+ def initialize(options = {})
60
+ @user_agent_tokens = [RESOURCEFUL_USER_AGENT_TOKEN]
61
+
62
+ INIT_OPTIONS.interpret(options) do |opts|
63
+ @user_agent_tokens.push(*opts[:user_agent].reverse)
64
+ self.logger = opts[:logger]
65
+ @auth_manager = AuthenticationManager.new()
66
+ @cache_manager = opts[:cache_manager]
67
+ end
68
+ end
69
+
70
+ # Returns the string that identifies this HTTP accessor. If you
71
+ # want to add a token to the user agent string simply add the new
72
+ # token to the end of +#user_agent_tokens+.
73
+ def user_agent_string
74
+ user_agent_tokens.reverse.join(' ')
75
+ end
76
+
77
+ # Returns a resource object representing the resource indicated
78
+ # by the specified URI. A resource object will be created if necessary.
79
+ def resource(uri, opts = {})
80
+ resource = Resource.new(self, uri, opts)
81
+ end
82
+ alias [] resource
83
+
84
+ end
85
+ end
@@ -0,0 +1,60 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ require 'addressable/uri'
4
+
5
+ require 'pathname'
6
+ require Pathname(__FILE__).dirname + 'header'
7
+
8
+ module Addressable
9
+ class URI
10
+ def absolute_path
11
+ absolute_path = ""
12
+ absolute_path << self.path.to_s
13
+ absolute_path << "?#{self.query}" if self.query != nil
14
+ absolute_path << "##{self.fragment}" if self.fragment != nil
15
+ return absolute_path
16
+ end
17
+ end
18
+ end
19
+
20
+ module Resourceful
21
+
22
+ class NetHttpAdapter
23
+ def self.make_request(method, uri, body = nil, header = nil)
24
+ uri = uri.is_a?(Addressable::URI) ? uri : Addressable::URI.parse(uri)
25
+
26
+ req = net_http_request_class(method).new(uri.absolute_path)
27
+ header.each { |k,v| req[k] = v } if header
28
+ conn = Net::HTTP.new(uri.host, uri.port)
29
+ conn.use_ssl = (/https/i === uri.scheme)
30
+ begin
31
+ conn.start
32
+ res = conn.request(req, body)
33
+ ensure
34
+ conn.finish
35
+ end
36
+
37
+ [ Integer(res.code),
38
+ Resourceful::Header.new(res.header.to_hash),
39
+ res.body
40
+ ]
41
+ ensure
42
+
43
+ end
44
+
45
+ private
46
+
47
+ def self.net_http_request_class(method)
48
+ case method
49
+ when :get then Net::HTTP::Get
50
+ when :head then Net::HTTP::Head
51
+ when :post then Net::HTTP::Post
52
+ when :put then Net::HTTP::Put
53
+ when :delete then Net::HTTP::Delete
54
+ end
55
+
56
+ end
57
+
58
+ end
59
+
60
+ end
@@ -0,0 +1,78 @@
1
+ require 'set'
2
+
3
+ module Resourceful
4
+ # Class that supports a declarative way to pick apart an options
5
+ # hash.
6
+ #
7
+ # OptionsInterpreter.new do
8
+ # option(:accept) { |accept| [accept].flatten.map{|m| m.to_str} }
9
+ # option(:http_header_fields, :default => {})
10
+ # end.interpret(:accept => 'this/that')
11
+ # # => {:accept => ['this/that'], :http_header_fields => { } }
12
+ #
13
+ # The returned hash contains :accept with the pass accept option
14
+ # value transformed into an array and :http_header_fields with its
15
+ # default value.
16
+ #
17
+ # OptionsInterpreter.new do
18
+ # option(:max_redirects)
19
+ # end.interpret(:foo => 1, :bar => 2)
20
+ # # Raises ArgumentError: Unrecognized options: foo, bar
21
+ #
22
+ # If options are passed that are not defined an exception is raised.
23
+ #
24
+ class OptionsInterpreter
25
+ def self.interpret(options_hash, &block)
26
+ interpreter = self.new(options_hash)
27
+ interpreter.instance_eval(&block)
28
+
29
+ interpreter.interpret
30
+ end
31
+
32
+ def initialize(&block)
33
+ @handlers = Hash.new
34
+
35
+ instance_eval(&block) if block_given?
36
+ end
37
+
38
+ def interpret(options_hash, &block)
39
+ unless (unrecognized_options = (options_hash.keys - supported_options)).empty?
40
+ raise ArgumentError, "Unrecognized options: #{unrecognized_options.join(", ")}"
41
+ end
42
+
43
+ options = Hash.new
44
+ handlers.each do |opt_name, a_handler|
45
+ opt_val = a_handler.call(options_hash)
46
+ options[opt_name] = opt_val if opt_val
47
+ end
48
+
49
+ yield(options) if block_given?
50
+
51
+ options
52
+ end
53
+
54
+ def option(name, opts = {}, &block)
55
+
56
+ passed_value_fetcher = if opts[:default]
57
+ default_value = opts[:default]
58
+ lambda{|options_hash| options_hash[name] || default_value}
59
+ else
60
+ lambda{|options_hash| options_hash[name]}
61
+ end
62
+
63
+ handlers[name] = if block_given?
64
+ lambda{|options_hash| (val = passed_value_fetcher.call(options_hash)) ? block.call(val) : nil}
65
+ else
66
+ passed_value_fetcher
67
+ end
68
+ end
69
+
70
+ def supported_options
71
+ handlers.keys
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :handlers
77
+ end
78
+ end
@@ -0,0 +1,63 @@
1
+ require 'pathname'
2
+ require 'benchmark'
3
+ require Pathname(__FILE__).dirname + 'response'
4
+ require Pathname(__FILE__).dirname + 'net_http_adapter'
5
+
6
+ module Resourceful
7
+
8
+ class Request
9
+
10
+ REDIRECTABLE_METHODS = [:get, :head]
11
+
12
+ attr_accessor :method, :resource, :body, :header
13
+ attr_reader :request_time
14
+
15
+ def initialize(http_method, resource, body = nil, header = nil)
16
+ @method, @resource, @body = http_method, resource, body
17
+ @header = header.is_a?(Resourceful::Header) ? header : Resourceful::Header.new(header || {})
18
+
19
+ @header['Accept-Encoding'] = 'gzip, identity'
20
+ # 'Host' is a required HTTP/1.1 header, so set it if it isn't already
21
+ @header['Host'] ||= Addressable::URI.parse(resource.uri).host
22
+
23
+ # Setting the date isn't a bad idea, either
24
+ @header['Date'] ||= Time.now.httpdate
25
+ end
26
+
27
+ def response
28
+ @request_time = Time.now
29
+
30
+ http_resp = NetHttpAdapter.make_request(@method, @resource.uri, @body, @header)
31
+ response = Resourceful::Response.new(uri, *http_resp)
32
+ response.request_time = @request_time
33
+
34
+ response.authoritative = true
35
+ response
36
+ end
37
+
38
+ def should_be_redirected?
39
+ if resource.on_redirect.nil?
40
+ return true if method.in? REDIRECTABLE_METHODS
41
+ false
42
+ else
43
+ resource.on_redirect.call(self, response)
44
+ end
45
+ end
46
+
47
+ def set_validation_headers(response)
48
+ @header['If-None-Match'] = response.header['ETag'] if response.header.has_key?('ETag')
49
+ @header['If-Modified-Since'] = response.header['Last-Modified'] if response.header.has_key?('Last-Modified')
50
+ @header['Cache-Control'] = 'max-age=0' if response.header.has_key?('Cache-Control') and response.header['Cache-Control'].include?('must-revalidate')
51
+ end
52
+
53
+ def uri
54
+ resource.uri
55
+ end
56
+
57
+ def logger
58
+ resource.logger
59
+ end
60
+
61
+ end
62
+
63
+ end
@@ -0,0 +1,266 @@
1
+ require 'pathname'
2
+ require Pathname(__FILE__).dirname + 'request'
3
+
4
+ module Resourceful
5
+
6
+ # This exception used to indicate that the request did not succeed.
7
+ # The HTTP response is included so that the appropriate actions can
8
+ # be taken based on the details of that response
9
+ class UnsuccessfulHttpRequestError < Exception
10
+ attr_reader :http_response, :http_request
11
+
12
+ # Initialize new error from the HTTP request and response attributes.
13
+ def initialize(http_request, http_response)
14
+ super("#{http_request.method} request to <#{http_request.uri}> failed with code #{http_response.code}")
15
+ @http_request = http_request
16
+ @http_response = http_response
17
+ end
18
+ end
19
+
20
+ class Resource
21
+ attr_reader :accessor
22
+ attr_accessor :default_options
23
+
24
+ # Build a new resource for a uri
25
+ #
26
+ # @param accessor<HttpAccessor>
27
+ # The parent http accessor
28
+ # @param uri<String, Addressable::URI>
29
+ # The uri for the location of the resource
30
+ def initialize(accessor, uri, options = {})
31
+ @accessor, @uris = accessor, [uri]
32
+ @default_options = options
33
+ @on_redirect = nil
34
+ end
35
+
36
+ # The uri used to identify this resource. This is almost always the uri
37
+ # used to create the resource, but in the case of a permanent redirect, this
38
+ # will always reflect the lastest uri.
39
+ #
40
+ # @return Addressable::URI
41
+ # The current uri of the resource
42
+ def effective_uri
43
+ @uris.first
44
+ end
45
+ alias uri effective_uri
46
+
47
+ # When performing a redirect, this callback will be executed first. If the callback
48
+ # returns true, then the redirect is followed, otherwise it is not. The request that
49
+ # triggered the redirect and the response will be passed into the block. This can be
50
+ # used to update any links on the client side.
51
+ #
52
+ # Example:
53
+ #
54
+ # author_resource.on_redirect do |req, resp|
55
+ # post.author_uri = resp.header['Location']
56
+ # end
57
+ #
58
+ # @yieldparam callback<request, response>
59
+ # The action to be executed when a request results in a redirect. Yields the
60
+ # current request and result objects to the callback.
61
+ #
62
+ # @raise ArgumentError if called without a block
63
+ def on_redirect(&callback)
64
+ if block_given?
65
+ @on_redirect = callback
66
+ else
67
+ @on_redirect
68
+ end
69
+ end
70
+
71
+ # Performs a GET on the resource, following redirects as neccessary, and retriving
72
+ # it from the local cache if its available and valid.
73
+ #
74
+ # @return [Response] The Response to the final request made.
75
+ #
76
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
77
+ # success, ie the final request returned a 2xx response code
78
+ def get(header = {})
79
+ log_request_with_time "GET [#{uri}]" do
80
+ do_read_request(:get, header)
81
+ end
82
+ end
83
+
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 = "", options = {})
101
+ raise ArgumentError, ":content_type must be specified" unless options.has_key?(:content_type)
102
+
103
+ log_request_with_time "POST [#{uri}]" do
104
+ do_write_request(:post, data, options)
105
+ end
106
+ end
107
+
108
+ # :call-seq:
109
+ # put(data = "", :content_type => mime_type)
110
+ #
111
+ # Performs a PUT with the given data to the resource, following redirects as
112
+ # neccessary.
113
+ #
114
+ # @param [String] data
115
+ # The body of the data to be posted
116
+ # @param [Hash] options
117
+ # Options to pass into the request header. At the least, :content_type is required.
118
+ #
119
+ # @return [Response] The response to the final request made.
120
+ #
121
+ # @raise [ArgumentError] unless :content-type is specified in options
122
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
123
+ # success, ie the final request returned a 2xx response code
124
+ def put(data = "", options = {})
125
+ raise ArgumentError, ":content_type must be specified" unless options.has_key?(:content_type)
126
+
127
+ log_request_with_time "PUT [#{uri}]" do
128
+ do_write_request(:put, data, options)
129
+ end
130
+ end
131
+
132
+ # Performs a DELETE on the resource, following redirects as neccessary.
133
+ #
134
+ # @return <Response>
135
+ #
136
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
137
+ # success, ie the final request returned a 2xx response code
138
+ def delete(options = {})
139
+ log_request_with_time "DELETE [#{uri}]" do
140
+ do_write_request(:delete, {}, options)
141
+ end
142
+ end
143
+
144
+ # Performs a read request (HEAD, GET). Users should use the #get, etc methods instead.
145
+ #
146
+ # This method handles all the work of following redirects.
147
+ #
148
+ # @param method<Symbol> The method to perform
149
+ #
150
+ # @return <Response>
151
+ #
152
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
153
+ # success, ie the final request returned a 2xx response code
154
+ #
155
+ def do_read_request(method, header = {})
156
+ request = Resourceful::Request.new(method, self, nil, default_options.merge(header))
157
+ accessor.auth_manager.add_credentials(request)
158
+
159
+ cached_response = accessor.cache_manager.lookup(request)
160
+ if cached_response
161
+ logger.info(" Retrieved from cache")
162
+ if not cached_response.stale?
163
+ # We're done!
164
+ return cached_response
165
+ else
166
+ logger.info(" Cache entry is stale")
167
+ request.set_validation_headers(cached_response)
168
+ end
169
+ end
170
+
171
+ response = request.response
172
+
173
+ if response.is_not_modified?
174
+ logger.info(" Resource not modified")
175
+ cached_response.header.merge!(response.header)
176
+ cached_response.request_time = response.request_time
177
+ response = cached_response
178
+ response.authoritative = true
179
+ end
180
+
181
+ if response.is_redirect? and request.should_be_redirected?
182
+ if response.is_permanent_redirect?
183
+ @uris.unshift response.header['Location'].first
184
+ logger.info(" Permanently redirected to #{uri} - Storing new location.")
185
+ response = do_read_request(method, header)
186
+ else
187
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
188
+ logger.info(" Redirected to #{redirected_resource.uri} - Storing new location.")
189
+ response = redirected_resource.do_read_request(method, header)
190
+ end
191
+ end
192
+
193
+ if response.is_not_authorized? && !@already_tried_with_auth
194
+ @already_tried_with_auth = true
195
+ accessor.auth_manager.associate_auth_info(response)
196
+ logger.info("Authentication Required. Retrying with auth info")
197
+ response = do_read_request(method, header)
198
+ end
199
+
200
+ raise UnsuccessfulHttpRequestError.new(request,response) unless response.is_success?
201
+
202
+ accessor.cache_manager.store(request, response) if response.is_success?
203
+
204
+ return response
205
+ end
206
+
207
+ # Performs a write request (POST, PUT, DELETE). Users should use the #post, etc
208
+ # methods instead.
209
+ #
210
+ # This method handles all the work of following redirects.
211
+ #
212
+ # @param [Symbol] method The method to perform
213
+ # @param [String] data Body of the http request.
214
+ # @param [Hash] header Header for the HTTP resquest.
215
+ #
216
+ # @return [Response]
217
+ #
218
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
219
+ # success, ie the final request returned a 2xx response code
220
+ def do_write_request(method, data = nil, header = {})
221
+ request = Resourceful::Request.new(method, self, data, default_options.merge(header))
222
+ accessor.auth_manager.add_credentials(request)
223
+
224
+ response = request.response
225
+
226
+ if response.is_redirect? and request.should_be_redirected?
227
+ if response.is_permanent_redirect?
228
+ @uris.unshift response.header['Location'].first
229
+ response = do_write_request(method, data, header)
230
+ elsif response.code == 303 # see other, must use GET for new location
231
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
232
+ response = redirected_resource.do_read_request(:get, header)
233
+ else
234
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
235
+ response = redirected_resource.do_write_request(method, data, header)
236
+ end
237
+ end
238
+
239
+ if response.is_not_authorized? && !@already_tried_with_auth
240
+ @already_tried_with_auth = true
241
+ accessor.auth_manager.associate_auth_info(response)
242
+ logger.debug("Authentication Required. Retrying with auth info")
243
+ response = do_write_request(method, data, header)
244
+ end
245
+
246
+ raise UnsuccessfulHttpRequestError.new(request,response) unless response.is_success?
247
+
248
+ accessor.cache_manager.invalidate(uri)
249
+ return response
250
+ end
251
+
252
+ def log_request_with_time(msg, indent = 2)
253
+ logger.info(" " * indent + msg)
254
+ result = nil
255
+ time = Benchmark.measure { result = yield }
256
+ logger.info(" " * indent + "-> Returned #{result.code} in %.4fs" % time.real)
257
+ result
258
+ end
259
+
260
+ def logger
261
+ accessor.logger
262
+ end
263
+
264
+ end
265
+
266
+ end