resourceful 0.2

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