lib/atom/http.rb in atom-tools-1.0.0 vs lib/atom/http.rb in atom-tools-2.0.0

- old
+ new

@@ -1,9 +1,11 @@ require "net/http" require "net/https" require "uri" +require "atom/cache" + require "sha1" require "digest/md5" module URI # :nodoc: all class Generic; def to_uri; self; end; end @@ -12,11 +14,11 @@ class String # :nodoc: def to_uri; URI.parse(self); end end module Atom - UA = "atom-tools 1.0.0" + UA = "atom-tools 2.0.0" module DigestAuth CNONCE = Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535))) @@nonce_count = -1 @@ -64,20 +66,20 @@ if qop.nil? response = kd(h(a1), params[:nonce] + ":" + h(a2)) else @@nonce_count += 1 - nc = ('%08x' % @@nonce_count) - + nc = ('%08x' % @@nonce_count) + # XXX auth-int data = "#{params[:nonce]}:#{nc}:#{CNONCE}:#{"auth"}:#{h(a2)}" response = kd(h(a1), data) end header = %Q<Digest username="#{user}", uri="#{req.path}", realm="#{params[:realm]}", response="#{response}", nonce="#{params[:nonce]}"> - + if params[:opaque] header += %Q<, opaque="#{params[:opaque]}"> end if params[:algorithm] != "MD5" @@ -103,29 +105,29 @@ # An object which handles the details of HTTP - particularly # authentication and caching (neither of which are fully implemented). # # This object can be used on its own, or passed to an Atom::Service, # Atom::Collection or Atom::Feed, where it will be used for requests. - # + # # All its HTTP methods return a Net::HTTPResponse class HTTP include DigestAuth # used by the default #when_auth attr_accessor :user, :pass # the token used for Google's AuthSub authentication attr_accessor :token - # when set to :basic, :wsse or :authsub, this will send an - # Authentication header with every request instead of waiting for a - # challenge from the server. - # + # when set to :basic, :wsse or :authsub, this will send an + # Authentication header with every request instead of waiting for a + # challenge from the server. + # # be careful; always_auth :basic will send your username and # password in plain text to every URL this object requests. # - # :digest won't work, since Digest authentication requires an + # :digest won't work, since Digest authentication requires an # initial challenge to generate a response # # defaults to nil attr_accessor :always_auth @@ -134,11 +136,22 @@ # defaults to false, which will transparently redirect GET requests # but return a Net::HTTPRedirection object when the server # indicates to redirect a POST/PUT/DELETE attr_accessor :allow_all_redirects - def initialize # :nodoc: + # if set, 'cache' should be a directory for a disk cache, or an object + # with the same interface as Atom::FileCache + def initialize cache = nil + if cache.is_a? String + @cache = FileCache.new(cache) + elsif cache + @cache = cache + else + @cache = NilCache.new + end + + # initialize default #when_auth @get_auth_details = lambda do |abs_url, realm| if @user and @pass [@user, @pass] else nil @@ -148,11 +161,11 @@ # GETs an url def get url, headers = {} http_request(url, Net::HTTP::Get, nil, headers) end - + # POSTs body to an url def post url, body, headers = {} http_request(url, Net::HTTP::Post, body, headers) end @@ -234,11 +247,11 @@ nonce = rand(16**32).to_s(16) nonce_enc = [nonce].pack('m').chomp now = Time.now.gmtime.iso8601 digest = [Digest::SHA1.digest(nonce + now + pass)].pack("m").chomp - + req['X-WSSE'] = %Q<UsernameToken Username="#{user}", PasswordDigest="#{digest}", Nonce="#{nonce_enc}", Created="#{now}"> req["Authorization"] = 'WSSE profile="UsernameToken"' end def authsub_authenticate req, url @@ -255,83 +268,150 @@ [ user, pass ] end # performs a generic HTTP request. - def http_request(url_s, method, body = nil, init_headers = {}, www_authenticate = nil, redirect_limit = 5) - req, url = new_request(url_s, method, init_headers) - + def http_request(url_s, method, body = nil, headers = {}, www_authenticate = nil, redirect_limit = 5) + cachekey = url_s.to_s + + cached_value = @cache[cachekey] + if cached_value + sock = Net::BufferedIO.new(StringIO.new(cached_value)) + info = Net::HTTPResponse.read_new(sock) + info.reading_body(sock, true) {} + + if method == Net::HTTP::Put and info.key? 'etag' and not headers['If-Match'] + headers['If-Match'] = info['etag'] + end + end + + if cached_value and not [Net::HTTP::Get, Net::HTTP::Head].member? method + @cache.delete(cachekey) + elsif cached_value + entry_disposition = _entry_disposition(info, headers) + + if entry_disposition == :FRESH + info.extend Atom::HTTPResponse + + return info + elsif entry_disposition == :STALE + if info.key? 'etag' and not headers['If-None-Match'] + headers['If-None-Match'] = info['etag'] + end + if info.key? 'last-modified' and not headers['Last-Modified'] + headers['If-Modified-Since'] = info['last-modified'] + end + end + end + + req, url = new_request(url_s, method, headers) + # two reasons to authenticate; if @always_auth self.send("#{@always_auth}_authenticate", req, url) elsif www_authenticate - # XXX multiple challenges, multiple headers - param_string = www_authenticate.sub!(/^(\w+) /, "") - auth_method = ($~[1].downcase + "_authenticate").to_sym - - if self.respond_to? auth_method, true # includes private methods - self.send(auth_method, req, url, param_string) - else - raise "No support for #{$~[1]} authentication" - end + dispatch_authorization www_authenticate, req, url end http_obj = Net::HTTP.new(url.host, url.port) http_obj.use_ssl = true if url.scheme == "https" res = http_obj.start do |h| h.request(req, body) end + # a bit of added convenience + res.extend Atom::HTTPResponse + case res when Net::HTTPUnauthorized - if @always_auth or www_authenticate or not res["WWW-Authenticate"] # XXX and not stale (Digest only) + if @always_auth or www_authenticate or not res["WWW-Authenticate"] # XXX and not stale (Digest only) # we've tried the credentials you gave us once # and failed, or the server gave us no way to fix it raise Unauthorized, "Your authorization was rejected" else # once more, with authentication - res = http_request(url_s, method, body, init_headers, res["WWW-Authenticate"]) + res = http_request(url_s, method, body, headers, res["WWW-Authenticate"]) if res.kind_of? Net::HTTPUnauthorized raise Unauthorized, "Your authorization was rejected" end end when Net::HTTPRedirection - if res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method) + if res.code == "304" and method == Net::HTTP::Get + res.end2end_headers.each { |k| info[k] = res[k] } + + res = info + + res["Content-Length"] = res.body.length + + res.extend Atom::HTTPResponse + + _updateCache(headers, res, @cache, cachekey) + elsif res["Location"] and (allow_all_redirects or [Net::HTTP::Get, Net::HTTP::Head].member? method) raise HTTPException, "Too many redirects" if redirect_limit.zero? - res = http_request res["Location"], method, body, init_headers, nil, (redirect_limit - 1) + res = http_request res["Location"], method, body, headers, nil, (redirect_limit - 1) end + when Net::HTTPOK, Net::HTTPNonAuthoritativeInformation + unless res.key? 'Content-Location' + res['Content-Location'] = url_s + end + _updateCache(headers, res, @cache, cachekey) end - # a bit of added convenience - res.extend Atom::HTTPResponse - res end - + def new_request(url_string, method, init_headers = {}) headers = { "User-Agent" => UA }.merge(init_headers) - + url = url_string.to_uri - + rel = url.path rel += "?" + url.query if url.query [method.new(rel, headers), url] end + + def dispatch_authorization www_authenticate, req, url + param_string = www_authenticate.sub(/^(\w+) /, "") + auth_method = ($~[1].downcase + "_authenticate").to_sym + + if self.respond_to? auth_method, true # includes private methods + self.send(auth_method, req, url, param_string) + else + # didn't support the first offered, find the next header + next_to_try = www_authenticate.sub(/.* ([\w]+ )/, '\1') + if next_to_try == www_authenticate + # this was the last WWW-Authenticate header + raise Atom::Unauthorized, "No support for offered authentication types" + else + dispatch_authorization next_to_try, req, url + end + end + end end module HTTPResponse + HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade'] + # this should probably support ranges (eg. text/*) def validate_content_type( valid ) raise Atom::HTTPException, "HTTP response contains no Content-Type!" if not self.content_type or self.content_type.empty? media_type = self.content_type.split(";").first unless valid.member? media_type.downcase raise Atom::WrongMimetype, "unexpected response Content-Type: #{media_type.inspect}. should be one of: #{valid.inspect}" end + end + + def end2end_headers + hopbyhop = HOP_BY_HOP + if self['connection'] + hopbyhop += self['connection'].split(',').map { |x| x.strip } + end + @header.keys.reject { |x| hopbyhop.member? x.downcase } end end end