resourceful 0.2

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.
@@ -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 }
27
+ end
28
+ end
29
+ end
30
+
31
+
@@ -0,0 +1,79 @@
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
+
12
+ # This class provides a simple interface to the functionality
13
+ # provided by the Resourceful library. Conceptually this object
14
+ # acts a collection of all the resources available via HTTP.
15
+ class HttpAccessor
16
+ RESOURCEFUL_USER_AGENT_TOKEN = "Resourceful/#{RESOURCEFUL_VERSION}(Ruby/#{RUBY_VERSION})"
17
+
18
+ # This is an imitation Logger used when no real logger is
19
+ # registered. This allows most of the code to assume that there
20
+ # is always a logger available, which significantly improved the
21
+ # readability of the logging related code.
22
+ class BitBucketLogger
23
+ def warn(*args); end
24
+ def info(*args); end
25
+ def debug(*args); end
26
+ end
27
+
28
+ # A logger object to which messages about the activities of this
29
+ # object will be written. This should be an object that responds
30
+ # to +#info(message)+ and +#debug(message)+.
31
+ #
32
+ # Errors will not be logged. Instead an exception will be raised
33
+ # and the application code should log it if appropriate.
34
+ attr_accessor :logger
35
+
36
+ attr_reader :auth_manager, :cache_manager
37
+
38
+ attr_reader :user_agent_tokens
39
+
40
+ INIT_OPTIONS = OptionsInterpreter.new do
41
+ option(:logger, :default => BitBucketLogger.new)
42
+ option(:user_agent, :default => []) {|ua| [ua].flatten}
43
+ option(:cache_manager, :default => NullCacheManager.new)
44
+ end
45
+
46
+ # Initializes a new HttpAccessor. Valid options:
47
+ #
48
+ # +:logger+:: A Logger object that the new HTTP accessor should
49
+ # send log messages
50
+ #
51
+ # +:user_agent+:: One or more additional user agent tokens to
52
+ # added to the user agent string.
53
+ def initialize(options = {})
54
+ @user_agent_tokens = [RESOURCEFUL_USER_AGENT_TOKEN]
55
+
56
+ INIT_OPTIONS.interpret(options) do |opts|
57
+ @user_agent_tokens.push(*opts[:user_agent].reverse)
58
+ self.logger = opts[:logger]
59
+ @auth_manager = AuthenticationManager.new()
60
+ @cache_manager = opts[:cache_manager]
61
+ end
62
+ end
63
+
64
+ # Returns the string that identifies this HTTP accessor. If you
65
+ # want to add a token to the user agent string simply add the new
66
+ # token to the end of +#user_agent_tokens+.
67
+ def user_agent_string
68
+ user_agent_tokens.reverse.join(' ')
69
+ end
70
+
71
+ # Returns a resource object representing the resource indicated
72
+ # by the specified URI. A resource object will be created if necessary.
73
+ def resource(uri)
74
+ resource = Resource.new(self, uri)
75
+ end
76
+ alias [] resource
77
+
78
+ end
79
+ end
@@ -0,0 +1,57 @@
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 = self.path.to_s
12
+ absolute_path << "?#{self.query}" if self.query != nil
13
+ return absolute_path
14
+ end
15
+ end
16
+ end
17
+
18
+ module Resourceful
19
+
20
+ class NetHttpAdapter
21
+ def self.make_request(method, uri, body = nil, header = nil)
22
+ uri = uri.is_a?(Addressable::URI) ? uri : Addressable::URI.parse(uri)
23
+
24
+ req = net_http_request_class(method).new(uri.absolute_path)
25
+ header.each { |k,v| req[k] = v } if header
26
+ conn = Net::HTTP.new(uri.host, uri.port)
27
+ conn.use_ssl = (/https/i === uri.scheme)
28
+ begin
29
+ conn.start
30
+ res = conn.request(req, body)
31
+ ensure
32
+ conn.finish
33
+ end
34
+
35
+ [ Integer(res.code),
36
+ Resourceful::Header.new(res.header.to_hash),
37
+ res.body
38
+ ]
39
+ ensure
40
+
41
+ end
42
+
43
+ private
44
+
45
+ def self.net_http_request_class(method)
46
+ case method
47
+ when :get then Net::HTTP::Get
48
+ when :post then Net::HTTP::Post
49
+ when :put then Net::HTTP::Put
50
+ when :delete then Net::HTTP::Delete
51
+ end
52
+
53
+ end
54
+
55
+ end
56
+
57
+ 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,64 @@
1
+ require 'pathname'
2
+ require Pathname(__FILE__).dirname + 'response'
3
+ require Pathname(__FILE__).dirname + 'net_http_adapter'
4
+
5
+ module Resourceful
6
+
7
+ class Request
8
+
9
+ REDIRECTABLE_METHODS = [:get, :head]
10
+
11
+ attr_accessor :method, :resource, :body, :header
12
+ attr_reader :request_time
13
+
14
+ def initialize(http_method, resource, body = nil, header = nil)
15
+ @method, @resource, @body = http_method, resource, body
16
+ @header = header.is_a?(Resourceful::Header) ? header : Resourceful::Header.new(header || {})
17
+
18
+ @header['Accept-Encoding'] = 'gzip, identity'
19
+ end
20
+
21
+ def response
22
+ @request_time = Time.now
23
+
24
+ cached_response = resource.accessor.cache_manager.lookup(self)
25
+ return cached_response if cached_response and not cached_response.stale?
26
+
27
+ set_validation_headers(cached_response) if cached_response and cached_response.stale?
28
+
29
+ http_resp = NetHttpAdapter.make_request(@method, @resource.uri, @body, @header)
30
+ response = Resourceful::Response.new(uri, *http_resp)
31
+
32
+ if response.code == 304
33
+ cached_response.header.merge(response.header)
34
+ response = cached_response
35
+ end
36
+
37
+ resource.accessor.cache_manager.store(self, response)
38
+
39
+ response.authoritative = true
40
+ response
41
+ end
42
+
43
+ def should_be_redirected?
44
+ if resource.on_redirect.nil?
45
+ return true if method.in? REDIRECTABLE_METHODS
46
+ false
47
+ else
48
+ resource.on_redirect.call(self, response)
49
+ end
50
+ end
51
+
52
+ def set_validation_headers(response)
53
+ @header['If-None-Match'] = response.header['ETag'] if response.header.has_key?('ETag')
54
+ @header['If-Modified-Since'] = response.header['Last-Modified'] if response.header.has_key?('Last-Modified')
55
+ @header['Cache-Control'] = 'max-age=0' if response.header.has_key?('Cache-Control') and response.header['Cache-Control'].include?('must-revalidate')
56
+ end
57
+
58
+ def uri
59
+ resource.uri
60
+ end
61
+
62
+ end
63
+
64
+ end
@@ -0,0 +1,216 @@
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
+ #--
14
+ # @private
15
+ def initialize(http_request, http_response)
16
+ super("#{http_request.method} request to <#{http_request.uri}> failed with code #{http_response.code}")
17
+ @http_request = http_request
18
+ @http_response = http_response
19
+ end
20
+ end
21
+
22
+ class Resource
23
+ attr_reader :accessor
24
+
25
+ # Build a new resource for a uri
26
+ #
27
+ # @param accessor<HttpAccessor>
28
+ # The parent http accessor
29
+ # @param uri<String, Addressable::URI>
30
+ # The uri for the location of the resource
31
+ def initialize(accessor, uri)
32
+ @accessor, @uris = accessor, [uri]
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
79
+ do_read_request(:get)
80
+ end
81
+
82
+ # :call-seq:
83
+ # post(data = "", :content_type => mime_type)
84
+ #
85
+ # Performs a POST with the given data to the resource, following redirects as
86
+ # neccessary.
87
+ #
88
+ # @param [String] data
89
+ # The body of the data to be posted
90
+ # @param [Hash] options
91
+ # Options to pass into the request header. At the least, :content_type is required.
92
+ #
93
+ # @return [Response] The Response to the final request that was made.
94
+ #
95
+ # @raise [ArgumentError] unless :content-type is specified in options
96
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
97
+ # success, ie the final request returned a 2xx response code
98
+ def post(data = "", options = {})
99
+ raise ArgumentError, ":content_type must be specified" unless options.has_key?(:content_type)
100
+
101
+ do_write_request(:post, data, {'Content-Type' => options[:content_type]})
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 = "", options = {})
121
+ raise ArgumentError, ":content_type must be specified" unless options.has_key?(:content_type)
122
+
123
+ do_write_request(:put, data, {'Content-Type' => options[:content_type]})
124
+ end
125
+
126
+ # Performs a DELETE on the resource, following redirects as neccessary.
127
+ #
128
+ # @return <Response>
129
+ #
130
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
131
+ # success, ie the final request returned a 2xx response code
132
+ def delete
133
+ do_write_request(:delete, {}, nil)
134
+ end
135
+
136
+ # Performs a read request (HEAD, GET). Users should use the #get, etc methods instead.
137
+ #
138
+ # This method handles all the work of following redirects.
139
+ #
140
+ # @param method<Symbol> The method to perform
141
+ #
142
+ # @return <Response>
143
+ #
144
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
145
+ # success, ie the final request returned a 2xx response code
146
+ #
147
+ # --
148
+ # @private
149
+ def do_read_request(method)
150
+ request = Resourceful::Request.new(method, self)
151
+ accessor.auth_manager.add_credentials(request)
152
+
153
+ response = request.response
154
+
155
+ if response.is_redirect? and request.should_be_redirected?
156
+ if response.is_permanent_redirect?
157
+ @uris.unshift response.header['Location'].first
158
+ response = do_read_request(method)
159
+ else
160
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
161
+ response = redirected_resource.do_read_request(method)
162
+ end
163
+ end
164
+
165
+ if response.is_not_authorized? && !@already_tried_with_auth
166
+ @already_tried_with_auth = true
167
+ accessor.auth_manager.associate_auth_info(response)
168
+ response = do_read_request(method)
169
+ end
170
+
171
+ raise UnsuccessfulHttpRequestError.new(request,response) unless response.is_success?
172
+
173
+ return response
174
+ end
175
+
176
+ # Performs a write request (POST, PUT, DELETE). Users should use the #post, etc
177
+ # methods instead.
178
+ #
179
+ # This method handles all the work of following redirects.
180
+ #
181
+ # @param [Symbol] method The method to perform
182
+ # @param [String] data Body of the http request.
183
+ # @param [Hash] header Header for the HTTP resquest.
184
+ #
185
+ # @return [Response]
186
+ #
187
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
188
+ # success, ie the final request returned a 2xx response code
189
+ # --
190
+ # @private
191
+ def do_write_request(method, data = nil, header = {})
192
+ request = Resourceful::Request.new(method, self, data, header)
193
+ accessor.auth_manager.add_credentials(request)
194
+
195
+ response = request.response
196
+
197
+ if response.is_redirect? and request.should_be_redirected?
198
+ if response.is_permanent_redirect?
199
+ @uris.unshift response.header['Location'].first
200
+ response = do_write_request(method, data, header)
201
+ elsif response.code == 303 # see other, must use GET for new location
202
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
203
+ response = redirected_resource.do_read_request(:get)
204
+ else
205
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
206
+ response = redirected_resource.do_write_request(method, data, header)
207
+ end
208
+ end
209
+
210
+ raise UnsuccessfulHttpRequestError.new(request,response) unless response.is_success?
211
+ return response
212
+ end
213
+
214
+ end
215
+
216
+ end