Class: RETS::HTTP

Inherits:
Object
  • Object
show all
Defined in:
lib/rets/http.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(args) ⇒ HTTP

Creates a new HTTP instance which will automatically handle authenting to the RETS server.



10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/rets/http.rb', line 10

def initialize(args)
  @headers = {"User-Agent" => "Ruby RETS/v#{RETS::VERSION}"}
  @request_count = 0
  @config = {:http => {}}.merge(args)
  @rets_data, @cookie_list = {}, {}

  if @config[:useragent] and @config[:useragent][:name]
    @headers["User-Agent"] = @config[:useragent][:name]
  end

  if @config[:rets_version]
    @rets_data[:version] = @config[:rets_version]
    self.setup_ua_authorization(:version => @config[:rets_version])
  end

  if @config[:auth_mode] == :basic
    @auth_mode = @config.delete(:auth_mode)
  end
end

Instance Attribute Details

#login_uriObject

Returns the value of attribute login_uri



6
7
8
# File 'lib/rets/http.rb', line 6

def 
  @login_uri
end

Instance Method Details

#create_basicObject

Creates a HTTP basic header.



117
118
119
# File 'lib/rets/http.rb', line 117

def create_basic
   "Basic " << ["#{@config[:username]}:#{@config[:password]}"].pack("m").delete("\r\n")
end

#create_digest(method, request_uri) ⇒ Object

Creates a HTTP digest header.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/rets/http.rb', line 81

def create_digest(method, request_uri)
  # http://en.wikipedia.org/wiki/Digest_access_authentication
  first = Digest::MD5.hexdigest("#{@config[:username]}:#{@digest["realm"]}:#{@config[:password]}")
  second = Digest::MD5.hexdigest("#{method}:#{request_uri}")

  # Using the "newer" authentication QOP
  if @digest_type.include?("auth")
    cnonce = Digest::MD5.hexdigest("#{@headers["User-Agent"]}:#{@config[:password]}:#{@request_count}:#{@digest["nonce"]}")
    hash = Digest::MD5.hexdigest("#{first}:#{@digest["nonce"]}:#{"%08X" % @request_count}:#{cnonce}:#{@digest["qop"]}:#{second}")
  # Nothing specified, so default to the old one
  elsif @digest_type.empty?
    hash = Digest::MD5.hexdigest("#{first}:#{@digest["nonce"]}:#{second}")
  else
    raise RETS::HTTPError, "Cannot determine auth type for server (#{@digest_type.join(",")})"
  end

  http_digest = "Digest username=\"#{@config[:username]}\", "
  http_digest << "realm=\"#{@digest["realm"]}\", "
  http_digest << "nonce=\"#{@digest["nonce"]}\", "
  http_digest << "uri=\"#{request_uri}\", "
  http_digest << "algorithm=MD5, " unless @digest_type.empty?
  http_digest << "response=\"#{hash}\", "
  http_digest << "opaque=\"#{@digest["opaque"]}\""

  unless @digest_type.empty?
    http_digest << ", "
    http_digest << "qop=\"#{@digest["qop"]}\", "
    http_digest << "nc=#{"%08X" % @request_count}, "
    http_digest << "cnonce=\"#{cnonce}\""
  end

  http_digest
end

#get_digest(header) ⇒ Object



53
54
55
56
57
58
59
60
61
62
# File 'lib/rets/http.rb', line 53

def get_digest(header)
  return unless header

  header.each do |text|
    mode, text = text.split(" ", 2)
    return text if mode == "Digest"
  end

  nil
end

#get_rets_response(rets) ⇒ String

Finds the ReplyText and ReplyCode attributes in the response



128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/rets/http.rb', line 128

def get_rets_response(rets)
  code, text = nil, nil
  rets.attributes.each do |attr|
    key = attr.first.downcase
    if key == "replycode"
      code = attr.last.value
    elsif key == "replytext"
      text = attr.last.value
    end
  end

  return code, text
end

#request(args, &block) ⇒ Object

Sends a request to the RETS server.

Options Hash (args):

  • :url (URI)

    URI to request data from

  • :params (Hash, Optional)

    Query string to include with the request

  • :read_timeout (Integer, Optional)

    How long to wait for the socket to return data before timing out

Raises:



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
233
234
235
236
237
238
239
240
241
242
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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/rets/http.rb', line 171

def request(args, &block)
  if args[:params]
    url_terminator = (args[:url].request_uri.include?("?")) ? "&" : "?"
    request_uri = "#{args[:url].request_uri}#{url_terminator}"
    args[:params].each do |k, v|
      request_uri << "#{k}=#{url_encode(v.to_s)}&" if v
    end
  else
    request_uri = args[:url].request_uri
  end

  headers = args[:headers]
  if args[:disable_compression]
    headers ||= {}
    headers["Accept-Encoding"] = "identity"
  end

  # Digest will change every time due to how its setup
  @request_count += 1
  if @auth_mode == :digest
    if headers
      headers["Authorization"] = create_digest("GET", request_uri)
    else
      headers = {"Authorization" => create_digest("GET", request_uri)}
    end
  end

  headers = headers ? @headers.merge(headers) : @headers

  if !@config[:http][:proxy]
    http = ::Net::HTTP.new(args[:url].host, args[:url].port)
  else
    http = ::Net::HTTP.new(args[:url].host, args[:url].port, @config[:http][:proxy][:address], @config[:http][:proxy][:port], @config[:http][:proxy][:username], @config[:http][:proxy][:password])
  end

  http.read_timeout = args[:read_timeout] if args[:read_timeout]
  http.set_debug_output(@config[:debug_output]) if @config[:debug_output]

  if args[:url].scheme == "https"
    http.use_ssl = true
    http.verify_mode = @config[:http][:verify_mode] || OpenSSL::SSL::VERIFY_NONE
    http.ca_file = @config[:http][:ca_file] if @config[:http][:ca_file]
    http.ca_path = @config[:http][:ca_path] if @config[:http][:ca_path]
  end

  http.start do
    http.request_get(request_uri, headers) do |response|
      # Pass along the cookies
      # Some servers will continually call Set-Cookie with the same value for every single request
      # to avoid authentication problems from cookies being stomped over (which is sad, nobody likes having their cookies crushed).
      # We keep a hash of every cookie set and only update it if something changed
      if response.header["set-cookie"]
        cookies_changed = nil

        response.header.get_fields("set-cookie").each do |cookie|
          key, value = cookie.split(";").first.split("=")
          key.strip!

          # Sometimes we can get a nil value from raprets
          unless value
            cookies_changed = true if @cookie_list[key]
            @cookie_list.delete(key)
            next
          end

          value.strip!

          # If it's a RETS-Session-ID, it needs to be shoved into the RETS-UA-Authorization field
          # Save the RETS-Session-ID so it can be used with RETS-UA-Authorization
          if key.downcase == "rets-session-id"
            @rets_data[:session_id] = value
            self.setup_ua_authorization(@rets_data) if @rets_data[:version]
          end

          cookies_changed = true if @cookie_list[key] != value
          @cookie_list[key] = value
        end

        if cookies_changed
          @headers.merge!("Cookie" => @cookie_list.map {|k, v| "#{k}=#{v}"}.join("; "))
        end
      end

      # Rather than returning HTTP 401 when User-Agent authentication is needed, Retsiq returns HTTP 200
      # with RETS error 20037. If we get a 20037, will let it pass through and handle it as if it was a HTTP 401.
      # Retsiq apparently returns a 20041 now instead of a 20037 for the same use case.
      # StratusRETS returns 20052 for an expired season
      rets_code = nil
      if response.code != "401" and ( response.code != "200" or args[:check_response] )
        if response.body =~ /<RETS/i
          rets_code, text = self.get_rets_response(Nokogiri::XML(response.body).xpath("//RETS").first)
          unless rets_code == "20037" or rets_code == "20041" or rets_code == "20052" or rets_code == "0"
            raise RETS::APIError.new("#{rets_code}: #{text}", rets_code, text)
          end

        elsif !args[:check_response]
          raise RETS::HTTPError.new("#{response.code}: #{response.message}", response.code, response.message)
        end
      end

      # Strictly speaking, we do not need to set a RETS-Version in most cases, if RETS-UA-Authorization is not used
      # It makes more sense to be safe and set it. Innovia at least does not set this until authentication is successful
      # which is why this check is also here for HTTP 200s and not just 401s
      if response.code == "200" and !@rets_data[:version] and response.header["rets-version"] != ""
        @rets_data[:version] = response.header["rets-version"]
      end

      # Digest can become stale requiring us to reload data
      if @auth_mode == :digest and response.header["www-authenticate"] =~ /stale=true/i
        save_digest(get_digest(response.header.get_fields("www-authenticate")))

        args[:block] ||= block
        return self.request(args)

      elsif response.code == "401" or rets_code == "20037" or rets_code == "20041" or rets_code == "20052"
        raise RETS::Unauthorized, "Cannot login, check credentials" if ( @auth_mode and @retried_request ) or ( @retried_request and rets_code == "20037" )
        @retried_request = true

        # We already have an auth mode, and the request wasn't retried.
        # Meaning we know that we had a successful authentication but something happened so we should relogin.
        if @auth_mode
          @headers.delete("Cookie")
          @cookie_list = {}

          self.request(:url => )
          return self.request(args.merge(:block => block))
        end

        # Find a valid way of authenticating to the server as some will support multiple methods
        if response.header.get_fields("www-authenticate") and !response.header.get_fields("www-authenticate").empty?
          digest = get_digest(response.header.get_fields("www-authenticate"))
          if digest
            save_digest(digest)
            @auth_mode = :digest
          else
            @headers.merge!("Authorization" => create_basic)
            @auth_mode = :basic
          end

          unless @auth_mode
            raise RETS::HTTPError.new("Cannot authenticate, no known mode found", response.code)
          end
        end

        # Check if we need to deal with User-Agent authorization
        if response.header["rets-version"] and response.header["rets-version"] != ""
          @rets_data[:version] = response.header["rets-version"]

        # If we get a 20037 error, it could be due to not having a RETS-Version set
        # Under Innovia, passing RETS/1.7 will cause some errors
        # because they don't pass the RETS-Version header until a successful login which is a HTTP 200
        # They also don't use RETS-UA-Authorization, and it's better to not imply the RETS-Version header
        # unless necessary, so will only do it for 20037 errors now.
        elsif !@rets_data[:version] and rets_code == "20037"
          @rets_data[:version] = "RETS/1.7"
        end

        self.setup_ua_authorization(@rets_data)

        args[:block] ||= block
        return self.request(args)

      # We just tried to auth and don't have access to the original block in yieldable form
      elsif args[:block]
        @retried_request = nil
        args.delete(:block).call(response)

      elsif block_given?
        @retried_request = nil
        yield response
      end
    end
  end
end

#save_digest(header) ⇒ Object

Creates and manages the HTTP digest auth if the WWW-Authorization header is passed, then it will overwrite what it knows about the auth data.



67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rets/http.rb', line 67

def save_digest(header)
  @request_count = 0

  @digest = {}
  header.split(",").each do |line|
    k, v = line.strip.split("=", 2)
    @digest[k] = (k != "algorithm" and k != "stale") && v[1..-2] || v
  end

  @digest_type = @digest["qop"] ? @digest["qop"].split(",") : []
end

#setup_ua_authorization(args) ⇒ Object

Handles managing the relevant RETS-UA-Authorization headers

Options Hash (args):

  • :version (String)

    RETS Version

  • :session_id (String, Optional)

    RETS Session ID



148
149
150
151
152
153
154
155
156
157
158
# File 'lib/rets/http.rb', line 148

def setup_ua_authorization(args)
  # Most RETS implementations don't care about RETS-Version for RETS-UA-Authorization, they don't require RETS-Version in general.
  # Rapattoni require RETS-Version even without RETS-UA-Authorization, so will try and set the header when possible from the HTTP request rather than implying it.
  # Interealty requires RETS-Version for RETS-UA-Authorization, so will fake it when we get an 20037 error
  @headers["RETS-Version"] = args[:version] if args[:version]

  if @headers["RETS-Version"] and @config[:useragent] and @config[:useragent][:password]
     = Digest::MD5.hexdigest("#{@config[:useragent][:name]}:#{@config[:useragent][:password]}")
    @headers.merge!("RETS-UA-Authorization" => "Digest #{Digest::MD5.hexdigest("#{}::#{args[:session_id]}:#{@headers["RETS-Version"]}")}")
  end
end

#url_encode(str) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/rets/http.rb', line 30

def url_encode(str)
  encoded_string = ""
  str.each_char do |char|
    case char
    when "+"
      encoded_string << "%2b"
    when "="
      encoded_string << "%3d"
    when "?"
      encoded_string << "%3f"
    when "&"
      encoded_string << "%26"
    when "%"
      encoded_string << "%25"
    when ","
      encoded_string << "%2C"
    else
      encoded_string << char
    end
  end
  encoded_string
end