connection.rb 9.0 KB
Newer Older
1
require 'active_support/core_ext/benchmark'
2
require 'active_support/core_ext/uri'
3
require 'active_support/core_ext/object/inclusion'
4 5 6 7 8 9
require 'net/https'
require 'date'
require 'time'
require 'uri'

module ActiveResource
10 11 12
  # Class to handle connections to remote web services.
  # This class is used by ActiveResource::Base to interface with REST
  # services.
13
  class Connection
14 15 16 17

    HTTP_FORMAT_HEADER_NAMES = {  :get => 'Accept',
      :put => 'Content-Type',
      :post => 'Content-Type',
18
      :patch => 'Content-Type',
19 20
      :delete => 'Accept',
      :head => 'Accept'
21 22
    }

P
pivotal 已提交
23
    attr_reader :site, :user, :password, :auth_type, :timeout, :proxy, :ssl_options
24
    attr_accessor :format
25

26 27 28 29 30 31
    class << self
      def requests
        @@requests ||= []
      end
    end

32 33
    # The +site+ parameter is required and will set the +site+
    # attribute to the URI for the remote resource service.
34
    def initialize(site, format = ActiveResource::Formats::JsonFormat)
35
      raise ArgumentError, 'Missing site URI' unless site
36
      @user = @password = nil
37
      self.site = site
38
      self.format = format
39
    end
40

41
    # Set URI for remote service.
42
    def site=(site)
43
      @site = site.is_a?(URI) ? site : URI.parse(site)
44 45
      @user = URI.parser.unescape(@site.user) if @site.user
      @password = URI.parser.unescape(@site.password) if @site.password
46 47
    end

M
Marshall Huss 已提交
48 49
    # Set the proxy for remote service.
    def proxy=(proxy)
50
      @proxy = proxy.is_a?(URI) ? proxy : URI.parse(proxy)
M
Marshall Huss 已提交
51 52
    end

P
Pratik Naik 已提交
53
    # Sets the user for remote service.
54 55 56 57
    def user=(user)
      @user = user
    end

P
Pratik Naik 已提交
58
    # Sets the password for remote service.
59 60
    def password=(password)
      @password = password
61
    end
62

P
pivotal 已提交
63 64 65 66 67
    # Sets the auth type for remote service.
    def auth_type=(auth_type)
      @auth_type = legitimize_auth_type(auth_type)
    end

P
Pratik Naik 已提交
68
    # Sets the number of seconds after which HTTP requests to the remote service should time out.
69 70 71 72
    def timeout=(timeout)
      @timeout = timeout
    end

73 74 75 76 77
    # Hash of options applied to Net::HTTP instance when +site+ protocol is 'https'.
    def ssl_options=(opts={})
      @ssl_options = opts
    end

P
Pratik Naik 已提交
78
    # Executes a GET request.
79
    # Used to get (find) resources.
80
    def get(path, headers = {})
81
      with_auth { request(:get, path, build_request_headers(headers, :get, self.site.merge(path))) }
82
    end
83

P
Pratik Naik 已提交
84
    # Executes a DELETE request (see HTTP protocol documentation if unfamiliar).
85
    # Used to delete resources.
86
    def delete(path, headers = {})
P
pivotal 已提交
87
      with_auth { request(:delete, path, build_request_headers(headers, :delete, self.site.merge(path))) }
88
    end
89

90 91 92 93 94 95
    # 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

P
Pratik Naik 已提交
96
    # Executes a PUT request (see HTTP protocol documentation if unfamiliar).
97
    # Used to update resources.
98
    def put(path, body = '', headers = {})
P
pivotal 已提交
99
      with_auth { request(:put, path, body.to_s, build_request_headers(headers, :put, self.site.merge(path))) }
100 101
    end

P
Pratik Naik 已提交
102
    # Executes a POST request.
103
    # Used to create new resources.
104
    def post(path, body = '', headers = {})
P
pivotal 已提交
105
      with_auth { request(:post, path, body.to_s, build_request_headers(headers, :post, self.site.merge(path))) }
106
    end
107

P
Pratik Naik 已提交
108
    # Executes a HEAD request.
109 110
    # Used to obtain meta-information about resources, such as whether they exist and their size (via response headers).
    def head(path, headers = {})
P
pivotal 已提交
111
      with_auth { request(:head, path, build_request_headers(headers, :head, self.site.merge(path))) }
112 113
    end

114
    private
P
Pratik Naik 已提交
115
      # Makes a request to the remote service.
116
      def request(method, path, *arguments)
117
        result = ActiveSupport::Notifications.instrument("request.active_resource") do |payload|
118 119 120
          payload[:method]      = method
          payload[:request_uri] = "#{site.scheme}://#{site.host}:#{site.port}#{path}"
          payload[:result]      = http.send(method, path, *arguments)
121
        end
122
        handle_response(result)
123 124
      rescue Timeout::Error => e
        raise TimeoutError.new(e.message)
125 126
      rescue OpenSSL::SSL::SSLError => e
        raise SSLError.new(e.message)
127
      end
128

P
Pratik Naik 已提交
129
      # Handles response and error codes from the remote service.
130
      def handle_response(response)
131
        case response.code.to_i
132
          when 301, 302, 303, 307
133
            raise(Redirection.new(response))
134
          when 200...400
135
            response
136 137 138 139 140 141
          when 400
            raise(BadRequest.new(response))
          when 401
            raise(UnauthorizedAccess.new(response))
          when 403
            raise(ForbiddenAccess.new(response))
142 143
          when 404
            raise(ResourceNotFound.new(response))
144 145
          when 405
            raise(MethodNotAllowed.new(response))
146 147
          when 409
            raise(ResourceConflict.new(response))
148 149
          when 410
            raise(ResourceGone.new(response))
150 151
          when 422
            raise(ResourceInvalid.new(response))
152
          when 401...500
153 154 155 156 157 158 159 160
            raise(ClientError.new(response))
          when 500...600
            raise(ServerError.new(response))
          else
            raise(ConnectionError.new(response, "Unknown response code: #{response.code}"))
        end
      end

P
Pratik Naik 已提交
161
      # Creates new Net::HTTP instance for communication with the
162
      # remote service and resources.
163
      def http
164 165 166 167 168 169 170 171 172 173 174 175
        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
          Net::HTTP.new(@site.host, @site.port)
        end
      end

      def configure_http(http)
J
Jeremy Kemper 已提交
176 177 178 179 180 181
        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
182
        end
183
      end
184

185
      def apply_ssl_options(http)
J
Jeremy Kemper 已提交
186 187 188 189
        http.tap do |https|
          # Skip config if site is already a https:// URI.
          if defined? @ssl_options
            http.use_ssl = true
190

J
Jeremy Kemper 已提交
191 192
            # Default to no cert verification (WTF? FIXME)
            http.verify_mode = OpenSSL::SSL::VERIFY_NONE
193

J
Jeremy Kemper 已提交
194 195 196 197
            # All the SSL options have corresponding http settings.
            @ssl_options.each { |key, value| http.send "#{key}=", value }
          end
        end
198 199
      end

200
      def default_header
201
        @default_header ||= {}
202
      end
203

204
      # Builds headers for request to remote service.
P
pivotal 已提交
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
      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
233
      end
234

P
pivotal 已提交
235 236 237
      def digest_auth_header(http_method, uri)
        params = extract_params_from_response

238 239 240
        request_uri = uri.path
        request_uri << "?#{uri.query}" if uri.query

P
pivotal 已提交
241
        ha1 = Digest::MD5.hexdigest("#{@user}:#{params['realm']}:#{@password}")
242
        ha2 = Digest::MD5.hexdigest("#{http_method.to_s.upcase}:#{request_uri}")
P
pivotal 已提交
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271

        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(", ")
272
      end
273

274 275 276 277
      def http_format_header(http_method)
        {HTTP_FORMAT_HEADER_NAMES[http_method] => format.mime_type}
      end

P
pivotal 已提交
278 279 280
      def legitimize_auth_type(auth_type)
        return :basic if auth_type.nil?
        auth_type = auth_type.to_sym
281
        auth_type.in?([:basic, :digest]) ? auth_type : :basic
P
pivotal 已提交
282
      end
283
  end
284
end