pezra-resourceful 0.5.4

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,72 @@
1
+ module Resourceful
2
+ # Declarative way of interpreting options hashes
3
+ #
4
+ # include OptionsInterpretion
5
+ # def my_method(opts = {})
6
+ # extract_opts(opts) do |opts|
7
+ # host = opts.extract(:host)
8
+ # port = opts.extract(:port, :default => 80) {|p| Integer(p)}
9
+ # end
10
+ # end
11
+ #
12
+ module OptionsInterpretation
13
+ # Interpret an options hash
14
+ #
15
+ # @param [Hash] opts
16
+ # The options to interpret.
17
+ #
18
+ # @yield block that used to interpreter options hash
19
+ #
20
+ # @yieldparam [Resourceful::OptionsInterpretion::OptionsInterpreter] interpeter
21
+ # An interpreter that can be used to extract option information from the options hash.
22
+ def extract_opts(opts, &blk)
23
+ opts = opts.clone
24
+ yield OptionsInterpreter.new(opts)
25
+
26
+ unless opts.empty?
27
+ raise ArgumentError, "Unrecognized options: #{opts.keys.join(", ")}"
28
+ end
29
+
30
+ end
31
+
32
+ class OptionsInterpreter
33
+ def initialize(options_hash)
34
+ @options_hash = options_hash
35
+ end
36
+
37
+ # Extract a particular option.
38
+ #
39
+ # @param [String] name
40
+ # Name of option to extract
41
+ # @param [Hash] interpreter_opts
42
+ # ':default'
43
+ # :: The default value, or an object that responds to #call
44
+ # with the default value.
45
+ # ':required'
46
+ # :: Boolean indicating if this option is required. Default:
47
+ # false if a default is provided; otherwise true.
48
+ def extract(name, interpreter_opts = {}, &blk)
49
+ option_required = !interpreter_opts.has_key?(:default)
50
+ option_required = interpreter_opts[:required] if interpreter_opts.has_key?(:required)
51
+
52
+ raise ArgumentError, "Required option #{name} not provided" if option_required && !@options_hash.has_key?(name)
53
+ # We have the option we need
54
+
55
+ orig_val = @options_hash.delete(name)
56
+
57
+ if block_given?
58
+ yield orig_val
59
+
60
+ elsif orig_val
61
+ orig_val
62
+
63
+ elsif interpreter_opts[:default] && interpreter_opts[:default].respond_to?(:call)
64
+ interpreter_opts[:default].call()
65
+
66
+ elsif interpreter_opts[:default]
67
+ interpreter_opts[:default]
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,234 @@
1
+ require 'pathname'
2
+ require 'benchmark'
3
+ require 'resourceful/response'
4
+ require 'resourceful/net_http_adapter'
5
+ require 'resourceful/exceptions'
6
+
7
+ module Resourceful
8
+
9
+ class Request
10
+
11
+ REDIRECTABLE_METHODS = [:get, :head]
12
+ CACHEABLE_METHODS = [:get, :head]
13
+ INVALIDATING_METHODS = [:post, :put, :delete]
14
+
15
+ attr_accessor :method, :resource, :body, :header
16
+ attr_reader :request_time, :accessor
17
+
18
+ # @param [Symbol] http_method
19
+ # :get, :put, :post, :delete or :head
20
+ # @param [Resourceful::Resource] resource
21
+ # @param [String] body
22
+ # @param [Resourceful::Header, Hash] header
23
+ def initialize(http_method, resource, body = nil, header = nil)
24
+ @method, @resource, @body = http_method, resource, body
25
+ @accessor = @resource.accessor
26
+ @header = header.is_a?(Resourceful::Header) ? header : Resourceful::Header.new(header)
27
+
28
+ # Resourceful handled gzip encoding transparently, so set that up
29
+ @header.accept_encoding ||= 'gzip, identity'
30
+
31
+ # 'Host' is a required HTTP/1.1 header, overrides Host in user-provided headers
32
+ @header.host = @resource.host
33
+
34
+ # Setting the date isn't a bad idea, either
35
+ @header.date ||= Time.now.httpdate
36
+
37
+ # Add any auth credentials we might want
38
+ add_credentials!
39
+
40
+ end
41
+
42
+ # Uses the auth manager to add any valid credentials to this request
43
+ def add_credentials!
44
+ @accessor.auth_manager.add_credentials(self)
45
+ end
46
+
47
+ # Performs all the work. Handles caching, redirects, auth retries, etc
48
+ def fetch_response
49
+ if cached_response
50
+ if needs_revalidation?(cached_response)
51
+ logger.info(" Cache needs revalidation")
52
+ set_validation_headers!(cached_response)
53
+ else
54
+ # We're done!
55
+ return cached_response
56
+ end
57
+ end
58
+
59
+ response = perform!
60
+
61
+ response = revalidate_cached_response(response) if cached_response && response.not_modified?
62
+ response = follow_redirect(response) if should_be_redirected?(response)
63
+ response = retry_with_auth(response) if needs_authorization?(response)
64
+
65
+ raise UnsuccessfulHttpRequestError.new(self, response) if response.error?
66
+
67
+ if cacheable?(response)
68
+ store_in_cache(response)
69
+ elsif invalidates_cache?
70
+ invalidate_cache
71
+ end
72
+
73
+ return response
74
+ end
75
+
76
+ # Should we look for a response to this request in the cache?
77
+ def skip_cache?
78
+ return true unless method.in? CACHEABLE_METHODS
79
+ header.cache_control && header.cache_control.include?('no-cache')
80
+ end
81
+
82
+ # The cached response
83
+ def cached_response
84
+ return if skip_cache?
85
+ return if @cached_response.nil? && @already_checked_cache
86
+ @cached_response ||= begin
87
+ @already_checked_cache = true
88
+ resp = accessor.cache_manager.lookup(self)
89
+ logger.info(" Retrieved from cache") if resp
90
+ resp
91
+ end
92
+ end
93
+
94
+ # Revalidate the cached response with what we got from a 304 response
95
+ def revalidate_cached_response(not_modified_response)
96
+ logger.info(" Resource not modified")
97
+ cached_response.revalidate!(not_modified_response)
98
+ cached_response
99
+ end
100
+
101
+ # Follow a redirect response
102
+ def follow_redirect(response)
103
+ raise MalformedServerResponse.new(self, response) unless response.header.location
104
+ if response.moved_permanently?
105
+ new_uri = response.header.location.first
106
+ logger.info(" Permanently redirected to #{new_uri} - Storing new location.")
107
+ resource.update_uri new_uri
108
+ @header.host = resource.host
109
+ response = fetch_response
110
+ elsif response.see_other? # Always use GET for this redirect, regardless of initial method
111
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
112
+ response = Request.new(:get, redirected_resource, body, header).fetch_response
113
+ else
114
+ redirected_resource = Resourceful::Resource.new(self.accessor, response.header['Location'].first)
115
+ logger.info(" Redirected to #{redirected_resource.uri} - Caching new location.")
116
+ response = Request.new(method, redirected_resource, body, header).fetch_response
117
+ end
118
+ end
119
+
120
+ # Add any auth headers from the response to the auth manager, and try the request again
121
+ def retry_with_auth(response)
122
+ @already_tried_with_auth = true
123
+ logger.info("Authentication Required. Retrying with auth info")
124
+ accessor.auth_manager.associate_auth_info(response)
125
+ add_credentials!
126
+ response = fetch_response
127
+ end
128
+
129
+ # Does this request need to be authorized? Will only be true if we haven't already tried with auth
130
+ def needs_authorization?(response)
131
+ !@already_tried_with_auth && response.unauthorized?
132
+ end
133
+
134
+ # Store the response to this request in the cache
135
+ def store_in_cache(response)
136
+ # RFC2618 - 14.18 : A received message that does not have a Date header
137
+ # field MUST be assigned one by the recipient if the message will be cached
138
+ # by that recipient.
139
+ response.header.date ||= response.response_time.httpdate
140
+
141
+ accessor.cache_manager.store(self, response)
142
+ end
143
+
144
+ # Invalidated the cache for this uri (eg, after a POST)
145
+ def invalidate_cache
146
+ accessor.cache_manager.invalidate(resource.uri)
147
+ end
148
+
149
+ # Is this request & response permitted to be stored in this (private) cache?
150
+ def cacheable?(response)
151
+ return false unless response.success?
152
+ return false unless method.in? CACHEABLE_METHODS
153
+ return false if header.cache_control && header.cache_control.include?('no-store')
154
+ true
155
+ end
156
+
157
+ # Does this request invalidate the cache?
158
+ def invalidates_cache?
159
+ return true if method.in? INVALIDATING_METHODS
160
+ end
161
+
162
+ # Perform the request, with no magic handling of anything.
163
+ def perform!
164
+ @request_time = Time.now
165
+
166
+ http_resp = adapter.make_request(@method, @resource.uri, @body, @header)
167
+ @response = Resourceful::Response.new(uri, *http_resp)
168
+ @response.request_time = @request_time
169
+ @response.authoritative = true
170
+
171
+ @response
172
+ end
173
+
174
+ # Is this a response a redirect, and are we permitted to follow it?
175
+ def should_be_redirected?(response)
176
+ return false unless response.redirect?
177
+ if resource.on_redirect.nil?
178
+ return true if method.in? REDIRECTABLE_METHODS
179
+ false
180
+ else
181
+ resource.on_redirect.call(self, response)
182
+ end
183
+ end
184
+
185
+ # Do we need to revalidate our cache?
186
+ def needs_revalidation?(response)
187
+ return true if forces_revalidation?
188
+ return true if response.stale?
189
+ return true if max_age && response.current_age > max_age
190
+ return true if response.must_be_revalidated?
191
+ false
192
+ end
193
+
194
+ # Set the validation headers of a request based on the response in the cache
195
+ def set_validation_headers!(response)
196
+ @header['If-None-Match'] = response.header['ETag'] if response.header.has_key?('ETag')
197
+ @header['If-Modified-Since'] = response.header['Last-Modified'] if response.header.has_key?('Last-Modified')
198
+ @header['Cache-Control'] = 'max-age=0' if response.must_be_revalidated?
199
+ end
200
+
201
+ # @return [String] The URI against which this request will be, or was, made.
202
+ def uri
203
+ resource.uri
204
+ end
205
+
206
+ # Does this request force us to revalidate the cache?
207
+ def forces_revalidation?
208
+ if max_age == 0 || header.cache_control && cc.include?('no-cache')
209
+ logger.info(" Client forced revalidation")
210
+ true
211
+ else
212
+ false
213
+ end
214
+ end
215
+
216
+ # Indicates the maxmimum response age in seconds we are willing to accept
217
+ #
218
+ # Returns nil if we don't care how old the response is
219
+ def max_age
220
+ if header['Cache-Control'] and header['Cache-Control'].include?('max-age')
221
+ header['Cache-Control'].split(',').grep(/max-age/).first.split('=').last.to_i
222
+ end
223
+ end
224
+
225
+ def logger
226
+ resource.logger
227
+ end
228
+
229
+ def adapter
230
+ accessor.http_adapter
231
+ end
232
+ end
233
+
234
+ end
@@ -0,0 +1,178 @@
1
+ require 'pathname'
2
+ require 'resourceful/request'
3
+
4
+ module Resourceful
5
+
6
+ class Resource
7
+ attr_reader :accessor
8
+
9
+ # Build a new resource for a uri
10
+ #
11
+ # @param accessor<HttpAccessor>
12
+ # The parent http accessor
13
+ # @param uri<String, Addressable::URI>
14
+ # The uri for the location of the resource
15
+ def initialize(accessor, uri, default_header = {})
16
+ @accessor, @uris = accessor, [uri]
17
+ @default_header = Resourceful::Header.new({'User-Agent' => Resourceful::RESOURCEFUL_USER_AGENT_TOKEN}.merge(default_header))
18
+ @on_redirect = nil
19
+ end
20
+
21
+ # The uri used to identify this resource. This is almost always the uri
22
+ # used to create the resource, but in the case of a permanent redirect, this
23
+ # will always reflect the lastest uri.
24
+ #
25
+ # @return Addressable::URI
26
+ # The current uri of the resource
27
+ def effective_uri
28
+ @uris.first
29
+ end
30
+ alias uri effective_uri
31
+
32
+ def default_header(temp_defaults = {})
33
+ temp_defaults.merge(@default_header)
34
+ end
35
+
36
+ # Returns the host for this Resource's current uri
37
+ def host
38
+ Addressable::URI.parse(uri).host
39
+ end
40
+
41
+ # Updates the effective uri after following a permanent redirect
42
+ def update_uri(uri)
43
+ @uris.unshift(uri)
44
+ end
45
+
46
+ # When performing a redirect, this callback will be executed first. If the callback
47
+ # returns true, then the redirect is followed, otherwise it is not. The request that
48
+ # triggered the redirect and the response will be passed into the block. This can be
49
+ # used to update any links on the client side.
50
+ #
51
+ # Example:
52
+ #
53
+ # author_resource.on_redirect do |req, resp|
54
+ # post.author_uri = resp.header['Location']
55
+ # end
56
+ #
57
+ # @yieldparam callback<request, response>
58
+ # The action to be executed when a request results in a redirect. Yields the
59
+ # current request and result objects to the callback.
60
+ #
61
+ # @raise ArgumentError if called without a block
62
+ def on_redirect(&callback)
63
+ if block_given?
64
+ @on_redirect = callback
65
+ else
66
+ @on_redirect
67
+ end
68
+ end
69
+
70
+ # Performs a GET on the resource, following redirects as neccessary, and retriving
71
+ # it from the local cache if its available and valid.
72
+ #
73
+ # @return [Response] The Response to the final request made.
74
+ #
75
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
76
+ # success, ie the final request returned a 2xx response code
77
+ def get(header = {})
78
+ request(:get, nil, header)
79
+ end
80
+
81
+ def head(header = {})
82
+ request(:head, nil, header)
83
+ end
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 = nil, header = {})
101
+ request(:post, data, header)
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, header = {})
121
+ request(:put, data, header)
122
+ end
123
+
124
+ # Performs a DELETE on the resource, following redirects as neccessary.
125
+ #
126
+ # @return <Response>
127
+ #
128
+ # @raise [UnsuccessfulHttpRequestError] unless the request is a
129
+ # success, ie the final request returned a 2xx response code
130
+ def delete(header = {})
131
+ request(:delete, nil, header)
132
+ end
133
+
134
+ def logger
135
+ accessor.logger
136
+ end
137
+
138
+ private
139
+
140
+ # Ensures that the request has a content type header
141
+ def ensure_content_type(body, header)
142
+ return if header.has_key?(:content_type)
143
+
144
+ if body.respond_to?(:content_type)
145
+ header[:content_type] = body.content_type
146
+ return
147
+ end
148
+
149
+ return if default_header.has_key?(:content_type)
150
+
151
+ # could not figure it out
152
+ raise MissingContentType
153
+ end
154
+
155
+ # Actually make the request
156
+ def request(method, data, header)
157
+ ensure_content_type(data, header) if data
158
+
159
+ data = StringIO.new(data) if data.kind_of?(String)
160
+
161
+ log_request_with_time "#{method.to_s.upcase} [#{uri}]" do
162
+ request = Request.new(method, self, data, default_header.merge(header))
163
+ request.fetch_response
164
+ end
165
+ end
166
+
167
+ # Log it took the time to make the request
168
+ def log_request_with_time(msg, indent = 2)
169
+ logger.info(" " * indent + msg)
170
+ result = nil
171
+ time = Benchmark.measure { result = yield }
172
+ logger.info(" " * indent + "-> Returned #{result.code} in %.4fs" % time.real)
173
+ result
174
+ end
175
+
176
+ end
177
+
178
+ end