lib/active_resource/connection.rb in embark-journey-0.0.20 vs lib/active_resource/connection.rb in embark-journey-0.0.21

- old
+ new

@@ -1,47 +1,294 @@ -require 'active_resource/connection' -ActiveResource::Connection +require 'active_support/core_ext/benchmark' +require 'active_support/core_ext/uri' +require 'active_support/core_ext/object/inclusion' +require 'net/https' +require 'date' +require 'time' +require 'uri' module ActiveResource + # Class to handle connections to remote web services. + # This class is used by ActiveResource::Base to interface with REST + # services. class Connection - def handle_response(response) - if response.respond_to?(:header) && (response.header["content-encoding"] == 'gzip') - begin - response.instance_variable_set('@body', ActiveSupport::Gzip.decompress(response.body)) - rescue Exception => e - raise(BadRequest.new(response)) + HTTP_FORMAT_HEADER_NAMES = { :get => 'Accept', + :put => 'Content-Type', + :post => 'Content-Type', + :patch => 'Content-Type', + :delete => 'Accept', + :head => 'Accept' + } + + attr_reader :site, :user, :password, :auth_type, :timeout, :proxy, :ssl_options + attr_accessor :format + + class << self + def requests + @@requests ||= [] + end + end + + # The +site+ parameter is required and will set the +site+ + # attribute to the URI for the remote resource service. + def initialize(site, format = ActiveResource::Formats::JsonFormat) + raise ArgumentError, 'Missing site URI' unless site + @proxy = @user = @password = nil + self.site = site + self.format = format + end + + # Set URI for remote service. + def site=(site) + @site = site.is_a?(URI) ? site : URI.parse(site) + @ssl_options ||= {} if @site.is_a?(URI::HTTPS) + @user = URI.parser.unescape(@site.user) if @site.user + @password = URI.parser.unescape(@site.password) if @site.password + end + + # Set the proxy for remote service. + def proxy=(proxy) + @proxy = proxy.is_a?(URI) ? proxy : URI.parse(proxy) + end + + # Sets the user for remote service. + def user=(user) + @user = user + end + + # Sets the password for remote service. + def password=(password) + @password = password + end + + # Sets the auth type for remote service. + def auth_type=(auth_type) + @auth_type = legitimize_auth_type(auth_type) + end + + # Sets the number of seconds after which HTTP requests to the remote service should time out. + def timeout=(timeout) + @timeout = timeout + end + + # Hash of options applied to Net::HTTP instance when +site+ protocol is 'https'. + def ssl_options=(options) + @ssl_options = options + end + + # Executes a GET request. + # Used to get (find) resources. + def get(path, headers = {}) + with_auth { request(:get, path, build_request_headers(headers, :get, self.site.merge(path))) } + end + + # Executes a DELETE request (see HTTP protocol documentation if unfamiliar). + # Used to delete resources. + def delete(path, headers = {}) + with_auth { request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path))) } + end + + # Executes a PATCH request (see HTTP protocol documentation if unfamiliar). + # Used to update resources. + def patch(path, body = '', headers = {}) + with_auth { request(:patch, path, body.to_s, build_request_headers(headers, :patch, self.site.merge(path))) } + end + + # Executes a PUT request (see HTTP protocol documentation if unfamiliar). + # Used to update resources. + def put(path, body = '', headers = {}) + with_auth { request(:put, path, body.to_s, build_request_headers(headers, :put, self.site.merge(path))) } + end + + # Executes a POST request. + # Used to create new resources. + def post(path, body = '', headers = {}) + with_auth { request(:post, path, body.to_s, build_request_headers(headers, :post, self.site.merge(path))) } + end + + # Executes a HEAD request. + # Used to obtain meta-information about resources, such as whether they exist and their size (via response headers). + def head(path, headers = {}) + with_auth { request(:head, path, build_request_headers(headers, :head, self.site.merge(path))) } + end + + private + # Makes a request to the remote service. + def request(method, path, *arguments) + result = ActiveSupport::Notifications.instrument("request.active_resource") do |payload| + payload[:method] = method + payload[:request_uri] = "#{site.scheme}://#{site.host}:#{site.port}#{path}" + payload[:result] = http.send(method, path, *arguments) end + handle_response(result) + rescue Timeout::Error => e + raise TimeoutError.new(e.message) + rescue OpenSSL::SSL::SSLError => e + raise SSLError.new(e.message) end - case response.code.to_i - when 301, 302, 303, 307 - raise(Redirection.new(response)) - when 200...400 - response - when 400 - raise(BadRequest.new(response)) - when 401 - raise(UnauthorizedAccess.new(response)) - when 403 - raise(ForbiddenAccess.new(response)) - when 404 - raise(ResourceNotFound.new(response)) - when 405 - raise(MethodNotAllowed.new(response)) - when 409 - raise(ResourceConflict.new(response)) - when 410 - raise(ResourceGone.new(response)) - when 422 - raise(ResourceInvalid.new(response)) - when 401...500 - raise(ClientError.new(response)) - when 500...600 - raise(ServerError.new(response)) + # Handles response and error codes from the remote service. + def handle_response(response) + if response.respond_to?(:header) && (response.header["content-encoding"] == 'gzip') + begin + response.instance_variable_set('@body', ActiveSupport::Gzip.decompress(response.body)) + rescue Exception => e + raise(BadRequest.new(response)) + end + end + + + case response.code.to_i + when 301, 302, 303, 307 + raise(Redirection.new(response)) + when 200...400 + response + when 400 + raise(BadRequest.new(response)) + when 401 + raise(UnauthorizedAccess.new(response)) + when 403 + raise(ForbiddenAccess.new(response)) + when 404 + raise(ResourceNotFound.new(response)) + when 405 + raise(MethodNotAllowed.new(response)) + when 409 + raise(ResourceConflict.new(response)) + when 410 + raise(ResourceGone.new(response)) + when 422 + raise(ResourceInvalid.new(response)) + when 401...500 + raise(ClientError.new(response)) + when 500...600 + raise(ServerError.new(response)) + else + raise(ConnectionError.new(response, "Unknown response code: #{response.code}")) + end + end + + # Creates new Net::HTTP instance for communication with the + # remote service and resources. + def http + configure_http(new_http) + end + + def new_http + if @proxy + Net::HTTP.new(@site.host, @site.port, @proxy.host, @proxy.port, @proxy.user, @proxy.password) else - raise(ConnectionError.new(response, "Unknown response code: #{response.code}")) + Net::HTTP.new(@site.host, @site.port) + end end - end + def configure_http(http) + apply_ssl_options(http).tap do |https| + # Net::HTTP timeouts default to 60 seconds. + if defined? @timeout + https.open_timeout = @timeout + https.read_timeout = @timeout + end + end + end + + def apply_ssl_options(http) + http.tap do |https| + # Skip config if site is already a https:// URI. + if defined? @ssl_options + http.use_ssl = true + + # Default to no cert verification (WTF? FIXME) + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + + # All the SSL options have corresponding http settings. + @ssl_options.each { |key, value| http.send "#{key}=", value } + end + end + end + + def default_header + @default_header ||= {} + end + + # Builds headers for request to remote service. + def build_request_headers(headers, http_method, uri) + authorization_header(http_method, uri).update(default_header).update(http_format_header(http_method)).update(headers) + end + + def response_auth_header + @response_auth_header ||= "" + end + + def with_auth + retried ||= false + yield + rescue UnauthorizedAccess => e + raise if retried || auth_type != :digest + @response_auth_header = e.response['WWW-Authenticate'] + retried = true + retry + end + + def authorization_header(http_method, uri) + if @user || @password + if auth_type == :digest + { 'Authorization' => digest_auth_header(http_method, uri) } + else + { 'Authorization' => 'Basic ' + ["#{@user}:#{@password}"].pack('m').delete("\r\n") } + end + else + {} + end + end + + def digest_auth_header(http_method, uri) + params = extract_params_from_response + + request_uri = uri.path + request_uri << "?#{uri.query}" if uri.query + + ha1 = Digest::MD5.hexdigest("#{@user}:#{params['realm']}:#{@password}") + ha2 = Digest::MD5.hexdigest("#{http_method.to_s.upcase}:#{request_uri}") + + params.merge!('cnonce' => client_nonce) + request_digest = Digest::MD5.hexdigest([ha1, params['nonce'], "0", params['cnonce'], params['qop'], ha2].join(":")) + "Digest #{auth_attributes_for(uri, request_digest, params)}" + end + + def client_nonce + Digest::MD5.hexdigest("%x" % (Time.now.to_i + rand(65535))) + end + + def extract_params_from_response + params = {} + if response_auth_header =~ /^(\w+) (.*)/ + $2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 } + end + params + end + + def auth_attributes_for(uri, request_digest, params) + [ + %Q(username="#{@user}"), + %Q(realm="#{params['realm']}"), + %Q(qop="#{params['qop']}"), + %Q(uri="#{uri.path}"), + %Q(nonce="#{params['nonce']}"), + %Q(nc="0"), + %Q(cnonce="#{params['cnonce']}"), + %Q(opaque="#{params['opaque']}"), + %Q(response="#{request_digest}")].join(", ") + end + + def http_format_header(http_method) + {HTTP_FORMAT_HEADER_NAMES[http_method] => format.mime_type} + end + + def legitimize_auth_type(auth_type) + return :basic if auth_type.nil? + auth_type = auth_type.to_sym + auth_type.in?([:basic, :digest]) ? auth_type : :basic + end end end