paul-resourceful 0.2.3

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.
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