rets-sarmiena 0.1.0

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.
data/.gitignore ADDED
@@ -0,0 +1,7 @@
1
+ .DS_Store
2
+ pkg/
3
+ *.swp
4
+ *.gem
5
+ .bundle
6
+ Gemfile.lock
7
+ pkg/*
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # rets Changelog
2
+
3
+ ### 0.0.1 / 2011-03-24
4
+
5
+ * Project Created
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in rets-sarmiena.gemspec
4
+ gemspec
data/Manifest.txt ADDED
@@ -0,0 +1,23 @@
1
+ CHANGELOG.md
2
+ Manifest.txt
3
+ README.md
4
+ Rakefile
5
+ bin/rets
6
+ lib/rets.rb
7
+ lib/rets/authentication.rb
8
+ lib/rets/client.rb
9
+ lib/rets/metadata.rb
10
+ lib/rets/metadata/containers.rb
11
+ lib/rets/metadata/lookup_type.rb
12
+ lib/rets/metadata/resource.rb
13
+ lib/rets/metadata/rets_class.rb
14
+ lib/rets/metadata/root.rb
15
+ lib/rets/metadata/table.rb
16
+ lib/rets/parser/compact.rb
17
+ lib/rets/parser/multipart.rb
18
+ test/fixtures.rb
19
+ test/helper.rb
20
+ test/test_client.rb
21
+ test/test_metadata.rb
22
+ test/test_parser_compact.rb
23
+ test/test_parser_multipart.rb
data/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # rets
2
+
3
+ * http://github.com/estately/rets
4
+
5
+ ## DESCRIPTION:
6
+
7
+ A pure-ruby library for fetching data from [RETS] servers.
8
+
9
+ [RETS]: http://www.rets.org
10
+
11
+ ## REQUIREMENTS:
12
+
13
+ * [net-http-persistent]
14
+ * [nokogiri]
15
+
16
+ [net-http-persistent]: http://seattlerb.rubyforge.org/net-http-persistent/
17
+ [nokogiri]: http://nokogiri.org
18
+
19
+ ## LICENSE:
20
+
21
+ (The MIT License)
22
+
23
+ Copyright (c) 2011 Estately, Inc. <opensource@estately.com>
24
+
25
+ Permission is hereby granted, free of charge, to any person obtaining
26
+ a copy of this software and associated documentation files (the
27
+ 'Software'), to deal in the Software without restriction, including
28
+ without limitation the rights to use, copy, modify, merge, publish,
29
+ distribute, sublicense, and/or sell copies of the Software, and to
30
+ permit persons to whom the Software is furnished to do so, subject to
31
+ the following conditions:
32
+
33
+ The above copyright notice and this permission notice shall be included
34
+ in all copies or substantial portions of the Software.
35
+
36
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
37
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
38
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
39
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
40
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
41
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
42
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/bin/rets ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require "pp"
5
+
6
+ require "rubygems"
7
+ require "rets"
8
+
9
+ class RetsCli
10
+ def self.parse(args)
11
+
12
+ actions = %w(metadata search object)
13
+ options = {:count => 5}
14
+
15
+ opts = OptionParser.new do |opts|
16
+ opts.banner = "Usage: #{File.basename($0)} URL [options] [query]"
17
+
18
+ opts.separator ""
19
+ opts.separator "Authentication options:"
20
+
21
+ opts.on("-U", "--username USERNAME", "The username to authenticate with.") do |username|
22
+ options[:username] = username
23
+ end
24
+
25
+ opts.on("-P", "--password [PASSWORD]", "The password to authenticate with.","Prompts if no argument is provided.") do |password|
26
+ options[:password] = password #or prompt # TODO
27
+ end
28
+
29
+ opts.on("-A", "--agent AGENT", "User-Agent header to provide.") do |agent|
30
+ options[:agent] = agent
31
+ end
32
+
33
+ opts.on("-B", "--agent-password [PASSWORD]", "User-Agent password to provide.") do |ua_password|
34
+ options[:ua_password] = ua_password
35
+ end
36
+
37
+ opts.separator ""
38
+ opts.separator "Actions:"
39
+
40
+ opts.on("-p", "--capabilities", "Print capabilities of the RETS server.") do |capabilities|
41
+ options[:capabilities] = capabilities
42
+ end
43
+
44
+ opts.on("-a", "--action ACTION", actions, "Action to perform (#{actions.join(",")}).") do |action|
45
+ options[:action] = action
46
+ end
47
+
48
+ opts.on("-m", "--metadata [FORMAT]", %w(tree long short), "Print metadata.", "Format is short, long or tree.", "Defaults to short.") do |format|
49
+ options[:action] = "metadata"
50
+ options[:metadata_format] = format || "short"
51
+ end
52
+
53
+ opts.separator ""
54
+ opts.separator "Search action options:"
55
+
56
+ opts.on("-r", "--resource NAME", "Name of resource to search for.") do |name|
57
+ options[:resource] = name
58
+ end
59
+
60
+ opts.on("-c", "--class NAME", "Name of class to search for.") do |name|
61
+ options[:class] = name
62
+ end
63
+
64
+ opts.on("-n", "--number LIMIT", Integer, "Return LIMIT results. Defaults to 5.") do |limit|
65
+ options[:limit] = limit
66
+ end
67
+
68
+ opts.separator ""
69
+ opts.separator "Misc options:"
70
+
71
+ opts.on_tail("-v", "--verbose", "Be verbose.") do |verbose|
72
+ logger = Class.new do
73
+ def method_missing(method, *a, &b)
74
+ puts a
75
+ end
76
+ end
77
+
78
+ options[:logger] = logger.new
79
+ end
80
+
81
+ opts.on_tail("-h", "--help", "Show this message") do
82
+ puts opts
83
+ exit
84
+ end
85
+
86
+ opts.on_tail("--version", "Show version") do
87
+ puts Rets::VERSION
88
+ exit
89
+ end
90
+
91
+ end
92
+
93
+ begin
94
+ opts.parse!(args.empty? ? ["-h"] : args)
95
+ rescue OptionParser::InvalidArgument => e
96
+ abort e.message
97
+ end
98
+
99
+ options
100
+ end
101
+
102
+ end
103
+
104
+ options = RetsCli.parse(ARGV)
105
+ url = ARGV[0] or abort "Need login URL"
106
+ query = ARGV[1]
107
+
108
+ client = Rets::Client.new(options.merge(:login_url => url))
109
+
110
+ COUNT = Struct.new(:exclude, :include, :only).new(0,1,2)
111
+
112
+ if options[:capabilities]
113
+ pp client.capabilities
114
+ end
115
+
116
+ case options[:action]
117
+ when "metadata" then
118
+ metadata = client.metadata
119
+
120
+ if options[:metadata_format] != "tree"
121
+ preferred_fields = %w(ClassName SystemName ResourceID StandardName VisibleName MetadataEntryID KeyField)
122
+
123
+
124
+ # All types except system
125
+ types = Rets::METADATA_TYPES.map { |t| t.downcase.to_sym } - [:system]
126
+
127
+ types.each do |type|
128
+ # if RowContainer ...
129
+ rows = metadata[type]
130
+
131
+ puts type.to_s.capitalize
132
+ puts "="*40
133
+
134
+ print_key_value = lambda do |k,v|
135
+ key = "#{k}:".ljust(35)
136
+ value = "#{v}".ljust(35)
137
+
138
+ puts [key, value].join
139
+ end
140
+
141
+ rows.each do |row|
142
+ top, rest = row.partition { |k,v| preferred_fields.include?(k) }
143
+
144
+ top.each(&print_key_value)
145
+
146
+ rest.sort_by{|k,v|k}.each(&print_key_value) if options[:metadata_format] == "long"
147
+
148
+ puts
149
+ end
150
+
151
+ puts
152
+ end
153
+
154
+ # Tree format
155
+ else
156
+ metadata.print_tree
157
+ end
158
+
159
+ when "search" then
160
+ pp client.find(:all,
161
+ :search_type => options[:resource],
162
+ :class => options[:class],
163
+ :query => query,
164
+ :count => COUNT.exclude,
165
+ :limit => options[:limit])
166
+
167
+ when "object" then
168
+
169
+ def write_objects(parts)
170
+ parts.each do |part|
171
+ cid = part.headers["content-id"].to_i
172
+ oid = part.headers["object-id"].to_i
173
+
174
+ File.open("tmp/#{cid}-#{oid}", "wb") do |f|
175
+ puts f.path
176
+
177
+ f.write part.body
178
+ end
179
+ end
180
+ end
181
+
182
+ parts = client.all_objects(
183
+ :resource => "Property",
184
+ :resource_id => 90020062739, # id from KeyField for a given property
185
+ :object_type => "Photo"
186
+ )
187
+
188
+ parts.each { |pt| p pt.headers }
189
+
190
+ write_objects(parts)
191
+
192
+ end
193
+
194
+
@@ -0,0 +1,2 @@
1
+ require "rets-sarmiena/version"
2
+ require "rets"
@@ -0,0 +1,5 @@
1
+ module Rets
2
+ module Sarmiena
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
data/lib/rets.rb ADDED
@@ -0,0 +1,24 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+ require 'cgi'
4
+ require 'digest/md5'
5
+
6
+ require 'rubygems'
7
+ require 'net/http/persistent'
8
+ require 'nokogiri'
9
+
10
+ module Rets
11
+ VERSION = '0.0.1'
12
+
13
+ AuthorizationFailure = Class.new(ArgumentError)
14
+ InvalidRequest = Class.new(ArgumentError)
15
+ MalformedResponse = Class.new(ArgumentError)
16
+ UnknownResponse = Class.new(ArgumentError)
17
+ end
18
+
19
+ require 'rets/authentication'
20
+ require 'rets/metadata'
21
+ require 'rets/parser/compact'
22
+ require 'rets/parser/multipart'
23
+
24
+ require 'rets/client'
@@ -0,0 +1,59 @@
1
+ module Rets
2
+ # Adapted from dbrain's Net::HTTP::DigestAuth gem, and RETS4R auth
3
+ # in order to support RETS' usage of digest authentication.
4
+ module Authentication
5
+ def build_auth(digest_authenticate, uri, nc = 0, method = "POST")
6
+ user = CGI.unescape uri.user
7
+ password = CGI.unescape uri.password
8
+
9
+ digest_authenticate =~ /^(\w+) (.*)/
10
+
11
+ params = {}
12
+ $2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 }
13
+
14
+ cnonce = Digest::MD5.hexdigest "%x" % (Time.now.to_i + rand(65535))
15
+
16
+ digest = calculate_digest(
17
+ user, password, params['realm'], params['nonce'], method, uri.request_uri, params['qop'], cnonce, nc
18
+ )
19
+
20
+ header = [
21
+ %Q(Digest username="#{user}"),
22
+ %Q(realm="#{params['realm']}"),
23
+ %Q(qop="#{params['qop']}"),
24
+ %Q(uri="#{uri.request_uri}"),
25
+ %Q(nonce="#{params['nonce']}"),
26
+ %Q(nc=#{('%08x' % nc)}),
27
+ %Q(cnonce="#{cnonce}"),
28
+ %Q(response="#{digest}"),
29
+ %Q(opaque="#{params['opaque']}"),
30
+ ]
31
+
32
+ header.join(", ")
33
+ end
34
+
35
+ def calculate_digest(user, password, realm, nonce, method, uri, qop, cnonce, nc)
36
+ a1 = Digest::MD5.hexdigest "#{user}:#{realm}:#{password}"
37
+ a2 = Digest::MD5.hexdigest "#{method}:#{uri}"
38
+
39
+ if qop
40
+ Digest::MD5.hexdigest("#{a1}:#{nonce}:#{'%08x' % nc}:#{cnonce}:#{qop}:#{a2}")
41
+ else
42
+ Digest::MD5.hexdigest("#{a1}:#{nonce}:#{a2}")
43
+ end
44
+ end
45
+
46
+ def calculate_user_agent_digest(user_agent, user_agent_password, session_id, version)
47
+ product, _ = user_agent.split("/")
48
+
49
+ a1 = Digest::MD5.hexdigest "#{product}:#{user_agent_password}"
50
+
51
+ Digest::MD5.hexdigest "#{a1}::#{session_id}:#{version}"
52
+ end
53
+
54
+ def build_user_agent_auth(*args)
55
+ %Q(Digest "#{calculate_user_agent_digest(*args)}")
56
+ end
57
+
58
+ end
59
+ end
@@ -0,0 +1,483 @@
1
+ module Rets
2
+ Session = Struct.new(:authorization, :capabilities, :cookies)
3
+
4
+ class Client
5
+ COUNT = Struct.new(:exclude, :include, :only).new(0,1,2)
6
+ DEFAULT_OPTIONS = { :persistent => true }
7
+
8
+ include Authentication
9
+
10
+ attr_accessor :uri, :options, :authorization, :logger
11
+ attr_writer :capabilities, :metadata
12
+
13
+ def initialize(options)
14
+ @capabilities = nil
15
+ @cookies = nil
16
+ @metadata = Metadata::Root.new(self)
17
+
18
+ uri = URI.parse(options[:login_url])
19
+
20
+ uri.user = options.key?(:username) ? CGI.escape(options[:username]) : nil
21
+ uri.password = options.key?(:password) ? CGI.escape(options[:password]) : nil
22
+
23
+ self.options = DEFAULT_OPTIONS.merge(options)
24
+ self.uri = uri
25
+
26
+ self.logger = options[:logger] || FakeLogger.new
27
+
28
+ self.session = options[:session] if options[:session]
29
+ @cached_metadata = options[:metadata] || nil
30
+ end
31
+
32
+
33
+ # Attempts to login by making an empty request to the URL
34
+ # provided in initialize. Returns the capabilities that the
35
+ # RETS server provides, per http://retsdoc.onconfluence.com/display/rets172/4.10+Capability+URL+List.
36
+ def login
37
+ request(uri.path)
38
+ capabilities
39
+ end
40
+
41
+ # Finds records.
42
+ #
43
+ # [quantity] Return the first record, or an array of records.
44
+ # Uses a symbol <tt>:first</tt> or <tt>:all</tt>, respectively.
45
+ #
46
+ # [opts] A hash of arguments used to construct the search query,
47
+ # using the following keys:
48
+ #
49
+ # <tt>:search_type</tt>:: Required. The resource to search for.
50
+ # <tt>:class</tt>:: Required. The class of the resource to search for.
51
+ # <tt>:query</tt>:: Required. The DMQL2 query string to execute.
52
+ # <tt>:limit</tt>:: The number of records to request from the server.
53
+ # <tt>:resolve</tt>:: Provide resolved values that use metadata instead
54
+ # of raw system values.
55
+ #
56
+ # Any other keys are converted to the RETS query format, and passed
57
+ # to the server as part of the query. For instance, the key <tt>:offset</tt>
58
+ # will be sent as +Offset+.
59
+ #
60
+ def find(quantity, opts = {})
61
+ case quantity
62
+ when :first then find_every(opts.merge(:limit => 1)).first
63
+ when :all then find_every(opts)
64
+ else raise ArgumentError, "First argument must be :first or :all"
65
+ end
66
+ end
67
+
68
+ def count(opts = {})
69
+ response = find_every(opts.merge(:count => COUNT.only))
70
+ Parser::Compact.parse_count response.body
71
+ end
72
+
73
+ alias search find
74
+
75
+ def find_every(opts = {})
76
+ search_uri = capability_url("Search")
77
+
78
+ resolve = opts.delete(:resolve)
79
+
80
+ extras = fixup_keys(opts)
81
+
82
+ defaults = {"QueryType" => "DMQL2", "Format" => "COMPACT"}
83
+
84
+ query = defaults.merge(extras)
85
+
86
+ body = build_key_values(query)
87
+
88
+ headers = build_headers.merge(
89
+ "Content-Type" => "application/x-www-form-urlencoded",
90
+ "Content-Length" => body.size.to_s
91
+ )
92
+
93
+ results = if opts[:count] == COUNT.only
94
+ request(search_uri.path, body, headers)
95
+ else
96
+ request_with_compact_response(search_uri.path, body, headers)
97
+ end
98
+
99
+ if resolve
100
+ rets_class = find_rets_class(opts[:search_type], opts[:class])
101
+ decorate_results(results, rets_class)
102
+ else
103
+ results
104
+ end
105
+ end
106
+
107
+ def find_rets_class(resource_name, rets_class_name)
108
+ metadata.build_tree[resource_name].find_rets_class(rets_class_name)
109
+ end
110
+
111
+ def decorate_results(results, rets_class)
112
+ results.map do |result|
113
+ decorate_result(result, rets_class)
114
+ end
115
+ end
116
+
117
+ def decorate_result(result, rets_class)
118
+ result.each do |key, value|
119
+ result[key] = rets_class.find_table(key).resolve(value.to_s)
120
+ end
121
+ end
122
+
123
+
124
+ # Returns an array of all objects associated with the given resource.
125
+ def all_objects(opts = {})
126
+ objects("*", opts)
127
+ end
128
+
129
+ # Returns an array of specified objects.
130
+ def objects(object_ids, opts = {})
131
+ response = case object_ids
132
+ when String then fetch_object(object_ids, opts)
133
+ when Array then fetch_object(object_ids.join(","), opts)
134
+ else raise ArgumentError, "Expected instance of String or Array, but got #{object_ids.inspect}."
135
+ end
136
+
137
+ create_parts_from_response(response)
138
+ end
139
+
140
+ def create_parts_from_response(response)
141
+ content_type = response["content-type"]
142
+
143
+ if content_type.include?("multipart")
144
+ boundary = content_type.scan(/boundary="(.*?)"/).to_s
145
+
146
+ parts = Parser::Multipart.parse(response.body, boundary)
147
+
148
+ logger.debug "Found #{parts.size} parts"
149
+
150
+ return parts
151
+ else
152
+ # fake a multipart for interface compatibility
153
+ headers = {}
154
+ response.each { |k,v| headers[k] = v }
155
+
156
+ part = Parser::Multipart::Part.new(headers, response.body)
157
+
158
+ return [part]
159
+ end
160
+ end
161
+
162
+ # Returns a single object.
163
+ #
164
+ # resource RETS resource as defined in the resource metadata.
165
+ # object_type an object type defined in the object metadata.
166
+ # resource_id the KeyField value of the given resource instance.
167
+ # object_id can be "*", or a comma delimited string of one or more integers.
168
+ def object(object_id, opts = {})
169
+ response = fetch_object(object_id, opts)
170
+
171
+ response.body
172
+ end
173
+
174
+ def fetch_object(object_id, opts = {})
175
+ object_uri = capability_url("GetObject")
176
+
177
+ body = build_key_values(
178
+ "Resource" => opts[:resource],
179
+ "Type" => opts[:object_type],
180
+ "ID" => "#{opts[:resource_id]}:#{object_id}",
181
+ "Location" => 0
182
+ )
183
+
184
+ headers = build_headers.merge(
185
+ "Accept" => "image/jpeg, image/png;q=0.5, image/gif;q=0.1",
186
+ "Content-Type" => "application/x-www-form-urlencoded",
187
+ "Content-Length" => body.size.to_s
188
+ )
189
+
190
+ request(object_uri.path, body, headers)
191
+ end
192
+
193
+ # Changes keys to be camel cased, per the RETS standard for queries.
194
+ def fixup_keys(hash)
195
+ fixed_hash = {}
196
+
197
+ hash.each do |key, value|
198
+ camel_cased_key = key.to_s.capitalize.gsub(/_(\w)/) { $1.upcase }
199
+
200
+ fixed_hash[camel_cased_key] = value
201
+ end
202
+
203
+ fixed_hash
204
+ end
205
+
206
+ def metadata
207
+ if @cached_metadata && @cached_metadata.current?(capabilities["MetadataTimestamp"], capabilities["MetadataVersion"])
208
+ self.metadata = @cached_metadata
209
+ end
210
+ @metadata
211
+ end
212
+
213
+ def metadata_for(type)
214
+ @metadata.for(type)
215
+ end
216
+
217
+ def retrieve_metadata_type(type)
218
+ metadata_uri = capability_url("GetMetadata")
219
+
220
+ body = build_key_values(
221
+ "Format" => "COMPACT",
222
+ "Type" => "METADATA-#{type}",
223
+ "ID" => "0"
224
+ )
225
+
226
+ headers = build_headers.merge(
227
+ "Content-Type" => "application/x-www-form-urlencoded",
228
+ "Content-Length" => body.size.to_s
229
+ )
230
+
231
+ response = request(metadata_uri.path, body, headers)
232
+
233
+ response.body
234
+ end
235
+
236
+ def raw_request(path, body = nil, headers = build_headers, &reader)
237
+ logger.info "posting to #{path}"
238
+
239
+ post = Net::HTTP::Post.new(path, headers)
240
+ post.body = body.to_s
241
+
242
+ logger.debug ""
243
+ logger.debug format_headers(headers)
244
+ logger.debug body.to_s
245
+
246
+ connection_args = [Net::HTTP::Persistent === connection ? uri : nil, post].compact
247
+
248
+ response = connection.request(*connection_args) do |res|
249
+ res.read_body(&reader)
250
+ end
251
+
252
+ handle_cookies(response)
253
+
254
+ logger.debug "Response: (#{response.class})"
255
+ logger.debug ""
256
+ logger.debug format_headers(response.to_hash)
257
+ logger.debug ""
258
+ logger.debug "Body:"
259
+ logger.debug response.body
260
+
261
+ return response
262
+ end
263
+
264
+ def request(*args, &block)
265
+ handle_response(raw_request(*args, &block))
266
+ end
267
+
268
+ def request_with_compact_response(path, body, headers)
269
+ response = request(path, body, headers)
270
+
271
+ Parser::Compact.parse_document response.body
272
+ end
273
+
274
+ def extract_digest_header(response)
275
+ authenticate_headers = response.get_fields("www-authenticate")
276
+ authenticate_headers.detect {|h| h =~ /Digest/}
277
+ end
278
+
279
+ def handle_unauthorized_response(response)
280
+ self.authorization = build_auth(extract_digest_header(response), uri, tries)
281
+
282
+ response = raw_request(uri.path)
283
+
284
+ if Net::HTTPUnauthorized === response
285
+ raise AuthorizationFailure, "Authorization failed, check credentials?"
286
+ else
287
+ self.capabilities = extract_capabilities(Nokogiri.parse(response.body))
288
+ end
289
+ end
290
+
291
+ def handle_response(response)
292
+
293
+ if Net::HTTPUnauthorized === response # 401
294
+ handle_unauthorized_response(response)
295
+
296
+ elsif Net::HTTPSuccess === response # 2xx
297
+ begin
298
+ if !response.body.empty?
299
+ xml = Nokogiri::XML.parse(response.body, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
300
+
301
+ reply_text = xml.xpath("/RETS").attr("ReplyText").value
302
+ reply_code = xml.xpath("/RETS").attr("ReplyCode").value.to_i
303
+
304
+ if reply_code.nonzero?
305
+ raise InvalidRequest, "Got error code #{reply_code} (#{reply_text})."
306
+ end
307
+ end
308
+
309
+ rescue Nokogiri::XML::SyntaxError => e
310
+ logger.debug "Not xml"
311
+
312
+ end
313
+
314
+ else
315
+ raise UnknownResponse, "Unable to handle response #{response.class}"
316
+ end
317
+
318
+ return response
319
+ end
320
+
321
+
322
+ def handle_cookies(response)
323
+ if cookies?(response)
324
+ self.cookies = response.get_fields('set-cookie')
325
+ logger.info "Cookies set to #{cookies.inspect}"
326
+ end
327
+ end
328
+
329
+ def cookies?(response)
330
+ response['set-cookie']
331
+ end
332
+
333
+ def cookies=(cookies)
334
+ @cookies ||= {}
335
+
336
+ cookies.each do |cookie|
337
+ cookie.match(/(\S+)=([^;]+);?/)
338
+
339
+ @cookies[$1] = $2
340
+ end
341
+
342
+ nil
343
+ end
344
+
345
+ def cookies
346
+ return if @cookies.nil? or @cookies.empty?
347
+
348
+ @cookies.map{ |k,v| "#{k}=#{v}" }.join("; ")
349
+ end
350
+
351
+
352
+ def session=(session)
353
+ self.authorization = session.authorization
354
+ self.capabilities = session.capabilities
355
+ self.cookies = session.cookies
356
+ end
357
+
358
+ def session
359
+ Session.new(authorization, capabilities, cookies)
360
+ end
361
+
362
+
363
+ # The capabilies as provided by the RETS server during login.
364
+ #
365
+ # Currently, only the path in the endpoint URLs is used[1]. Host,
366
+ # port, other details remaining constant with those provided to
367
+ # the constructor.
368
+ #
369
+ # [1] In fact, sometimes only a path is returned from the server.
370
+ def capabilities
371
+ @capabilities || login
372
+ end
373
+
374
+ def capability_url(name)
375
+ url = capabilities[name]
376
+
377
+ begin
378
+ capability_uri = URI.parse(url)
379
+ rescue URI::InvalidURIError => e
380
+ raise MalformedResponse, "Unable to parse capability URL: #{url.inspect}"
381
+ end
382
+
383
+ capability_uri
384
+ end
385
+
386
+ def extract_capabilities(document)
387
+ raw_key_values = document.xpath("/RETS/RETS-RESPONSE").text.strip
388
+
389
+ h = Hash.new{|h,k| h.key?(k.downcase) ? h[k.downcase] : nil }
390
+
391
+ # ... :(
392
+ # Feel free to make this better. It has a test.
393
+ raw_key_values.split(/\n/).
394
+ map { |r| r.split(/=/, 2) }.
395
+ each { |k,v| h[k.strip.downcase] = v }
396
+
397
+ h
398
+ end
399
+
400
+
401
+
402
+ def connection
403
+ @connection ||= options[:persistent] ?
404
+ persistent_connection :
405
+ Net::HTTP.new(uri.host, uri.port)
406
+ end
407
+
408
+ def persistent_connection
409
+ conn = Net::HTTP::Persistent.new
410
+
411
+ def conn.idempotent?(*)
412
+ true
413
+ end
414
+
415
+ conn
416
+ end
417
+
418
+
419
+ def user_agent
420
+ options[:agent] || "Client/1.0"
421
+ end
422
+
423
+ def rets_version
424
+ options[:version] || "RETS/1.7.2"
425
+ end
426
+
427
+ def build_headers
428
+ headers = {
429
+ "User-Agent" => user_agent,
430
+ "Host" => "#{uri.host}:#{uri.port}",
431
+ "RETS-Version" => rets_version
432
+ }
433
+
434
+ headers.merge!("Authorization" => authorization) if authorization
435
+ headers.merge!("Cookie" => cookies) if cookies
436
+
437
+ if options[:ua_password]
438
+ headers.merge!(
439
+ "RETS-UA-Authorization" => build_user_agent_auth(
440
+ user_agent, options[:ua_password], "", rets_version))
441
+ end
442
+
443
+ headers
444
+ end
445
+
446
+ def build_key_values(data)
447
+ data.map{|k,v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
448
+ end
449
+
450
+
451
+
452
+ def tries
453
+ @tries ||= 1
454
+
455
+ (@tries += 1) - 1
456
+ end
457
+
458
+ class FakeLogger
459
+ def fatal(*); end
460
+ def error(*); end
461
+ def warn(*); end
462
+ def info(*); end
463
+ def debug(*); end
464
+ end
465
+
466
+ def format_headers(headers)
467
+ out = []
468
+
469
+ headers.each do |name, value|
470
+ if Array === value
471
+ value.each do |v|
472
+ out << "#{name}: #{v}"
473
+ end
474
+ else
475
+ out << "#{name}: #{value}"
476
+ end
477
+ end
478
+
479
+ out.join("\n")
480
+ end
481
+
482
+ end
483
+ end