rets 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gemtest ADDED
File without changes
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # rets Changelog
2
+
3
+ ### 0.1.0 / 2011-06-23
4
+
5
+ * First public release!
6
+
7
+ ### 0.0.1 / 2011-03-24
8
+
9
+ * Project Created
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,18 @@
1
+ require 'rubygems'
2
+ require 'hoe'
3
+
4
+ Hoe.plugin :git, :doofus
5
+
6
+ Hoe.spec 'rets' do
7
+ developer 'Estately, Inc. Open Source', 'opensource@estately.com'
8
+ developer 'Ben Bleything', 'ben@bleything.net'
9
+
10
+ extra_deps << [ "net-http-persistent", "~> 1.7" ]
11
+ extra_deps << [ "nokogiri", "~> 1.4.4" ]
12
+
13
+ extra_dev_deps << [ "mocha", "~> 0.9.12" ]
14
+
15
+ ### Use markdown for changelog and readme
16
+ self.history_file = 'CHANGELOG.md'
17
+ self.readme_file = 'README.md'
18
+ end
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
+
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.1.0'
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,473 @@
1
+ module Rets
2
+ Session = Struct.new(:authorization, :capabilities, :cookies)
3
+
4
+ class Client
5
+ DEFAULT_OPTIONS = { :persistent => true }
6
+
7
+ include Authentication
8
+
9
+ attr_accessor :uri, :options, :authorization, :logger
10
+ attr_writer :capabilities, :metadata
11
+
12
+ def initialize(options)
13
+ @capabilities = nil
14
+ @cookies = nil
15
+ @metadata = nil
16
+
17
+ uri = URI.parse(options[:login_url])
18
+
19
+ uri.user = options.key?(:username) ? CGI.escape(options[:username]) : nil
20
+ uri.password = options.key?(:password) ? CGI.escape(options[:password]) : nil
21
+
22
+ self.options = DEFAULT_OPTIONS.merge(options)
23
+ self.uri = uri
24
+
25
+ self.logger = options[:logger] || FakeLogger.new
26
+
27
+ self.session = options[:session] if options[:session]
28
+ @cached_metadata = options[:metadata] || nil
29
+ end
30
+
31
+
32
+ # Attempts to login by making an empty request to the URL
33
+ # provided in initialize. Returns the capabilities that the
34
+ # RETS server provides, per http://retsdoc.onconfluence.com/display/rets172/4.10+Capability+URL+List.
35
+ def login
36
+ request(uri.path)
37
+ capabilities
38
+ end
39
+
40
+ # Finds records.
41
+ #
42
+ # [quantity] Return the first record, or an array of records.
43
+ # Uses a symbol <tt>:first</tt> or <tt>:all</tt>, respectively.
44
+ #
45
+ # [opts] A hash of arguments used to construct the search query,
46
+ # using the following keys:
47
+ #
48
+ # <tt>:search_type</tt>:: Required. The resource to search for.
49
+ # <tt>:class</tt>:: Required. The class of the resource to search for.
50
+ # <tt>:query</tt>:: Required. The DMQL2 query string to execute.
51
+ # <tt>:limit</tt>:: The number of records to request from the server.
52
+ # <tt>:resolve</tt>:: Provide resolved values that use metadata instead
53
+ # of raw system values.
54
+ #
55
+ # Any other keys are converted to the RETS query format, and passed
56
+ # to the server as part of the query. For instance, the key <tt>:offset</tt>
57
+ # will be sent as +Offset+.
58
+ #
59
+ def find(quantity, opts = {})
60
+ case quantity
61
+ when :first then find_every(opts.merge(:limit => 1)).first
62
+ when :all then find_every(opts)
63
+ else raise ArgumentError, "First argument must be :first or :all"
64
+ end
65
+ end
66
+
67
+ alias search find
68
+
69
+ def find_every(opts = {})
70
+ search_uri = capability_url("Search")
71
+
72
+ resolve = opts.delete(:resolve)
73
+
74
+ extras = fixup_keys(opts)
75
+
76
+ defaults = {"QueryType" => "DMQL2", "Format" => "COMPACT"}
77
+
78
+ query = defaults.merge(extras)
79
+
80
+ body = build_key_values(query)
81
+
82
+ headers = build_headers.merge(
83
+ "Content-Type" => "application/x-www-form-urlencoded",
84
+ "Content-Length" => body.size.to_s
85
+ )
86
+
87
+ results = request_with_compact_response(search_uri.path, body, headers)
88
+
89
+ if resolve
90
+ rets_class = find_rets_class(opts[:search_type], opts[:class])
91
+ decorate_results(results, rets_class)
92
+ else
93
+ results
94
+ end
95
+ end
96
+
97
+ def find_rets_class(resource_name, rets_class_name)
98
+ metadata.build_tree[resource_name].find_rets_class(rets_class_name)
99
+ end
100
+
101
+ def decorate_results(results, rets_class)
102
+ results.map do |result|
103
+ decorate_result(result, rets_class)
104
+ end
105
+ end
106
+
107
+ def decorate_result(result, rets_class)
108
+ result.each do |key, value|
109
+ result[key] = rets_class.find_table(key).resolve(value.to_s)
110
+ end
111
+ end
112
+
113
+
114
+ # Returns an array of all objects associated with the given resource.
115
+ def all_objects(opts = {})
116
+ objects("*", opts)
117
+ end
118
+
119
+ # Returns an array of specified objects.
120
+ def objects(object_ids, opts = {})
121
+ response = case object_ids
122
+ when String then fetch_object(object_ids, opts)
123
+ when Array then fetch_object(object_ids.join(","), opts)
124
+ else raise ArgumentError, "Expected instance of String or Array, but got #{object_ids.inspect}."
125
+ end
126
+
127
+ create_parts_from_response(response)
128
+ end
129
+
130
+ def create_parts_from_response(response)
131
+ content_type = response["content-type"]
132
+
133
+ if content_type.include?("multipart")
134
+ boundary = content_type.scan(/boundary="(.*?)"/).to_s
135
+
136
+ parts = Parser::Multipart.parse(response.body, boundary)
137
+
138
+ logger.debug "Found #{parts.size} parts"
139
+
140
+ return parts
141
+ else
142
+ # fake a multipart for interface compatibility
143
+ headers = {}
144
+ response.each { |k,v| headers[k] = v }
145
+
146
+ part = Parser::Multipart::Part.new(headers, response.body)
147
+
148
+ return [part]
149
+ end
150
+ end
151
+
152
+ # Returns a single object.
153
+ #
154
+ # resource RETS resource as defined in the resource metadata.
155
+ # object_type an object type defined in the object metadata.
156
+ # resource_id the KeyField value of the given resource instance.
157
+ # object_id can be "*", or a comma delimited string of one or more integers.
158
+ def object(object_id, opts = {})
159
+ response = fetch_object(object_id, opts)
160
+
161
+ response.body
162
+ end
163
+
164
+ def fetch_object(object_id, opts = {})
165
+ object_uri = capability_url("GetObject")
166
+
167
+ body = build_key_values(
168
+ "Resource" => opts[:resource],
169
+ "Type" => opts[:object_type],
170
+ "ID" => "#{opts[:resource_id]}:#{object_id}",
171
+ "Location" => 0
172
+ )
173
+
174
+ headers = build_headers.merge(
175
+ "Accept" => "image/jpeg, image/png;q=0.5, image/gif;q=0.1",
176
+ "Content-Type" => "application/x-www-form-urlencoded",
177
+ "Content-Length" => body.size.to_s
178
+ )
179
+
180
+ request(object_uri.path, body, headers)
181
+ end
182
+
183
+ # Changes keys to be camel cased, per the RETS standard for queries.
184
+ def fixup_keys(hash)
185
+ fixed_hash = {}
186
+
187
+ hash.each do |key, value|
188
+ camel_cased_key = key.to_s.capitalize.gsub(/_(\w)/) { $1.upcase }
189
+
190
+ fixed_hash[camel_cased_key] = value
191
+ end
192
+
193
+ fixed_hash
194
+ end
195
+
196
+ def metadata
197
+ return @metadata if @metadata
198
+
199
+ if @cached_metadata && @cached_metadata.current?(capabilities["MetadataTimestamp"], capabilities["MetadataVersion"])
200
+ self.metadata = @cached_metadata
201
+ else
202
+ metadata_fetcher = lambda { |type| retrieve_metadata_type(type) }
203
+ self.metadata = Metadata::Root.new(&metadata_fetcher)
204
+ end
205
+ end
206
+
207
+ def retrieve_metadata_type(type)
208
+ metadata_uri = capability_url("GetMetadata")
209
+
210
+ body = build_key_values(
211
+ "Format" => "COMPACT",
212
+ "Type" => "METADATA-#{type}",
213
+ "ID" => "0"
214
+ )
215
+
216
+ headers = build_headers.merge(
217
+ "Content-Type" => "application/x-www-form-urlencoded",
218
+ "Content-Length" => body.size.to_s
219
+ )
220
+
221
+ response = request(metadata_uri.path, body, headers)
222
+
223
+ response.body
224
+ end
225
+
226
+ def raw_request(path, body = nil, headers = build_headers, &reader)
227
+ logger.info "posting to #{path}"
228
+
229
+ post = Net::HTTP::Post.new(path, headers)
230
+ post.body = body.to_s
231
+
232
+ logger.debug ""
233
+ logger.debug format_headers(headers)
234
+ logger.debug body.to_s
235
+
236
+ connection_args = [Net::HTTP::Persistent === connection ? uri : nil, post].compact
237
+
238
+ response = connection.request(*connection_args) do |res|
239
+ res.read_body(&reader)
240
+ end
241
+
242
+ handle_cookies(response)
243
+
244
+ logger.debug "Response: (#{response.class})"
245
+ logger.debug ""
246
+ logger.debug format_headers(response.to_hash)
247
+ logger.debug ""
248
+ logger.debug "Body:"
249
+ logger.debug response.body
250
+
251
+ return response
252
+ end
253
+
254
+ def request(*args, &block)
255
+ handle_response(raw_request(*args, &block))
256
+ end
257
+
258
+ def request_with_compact_response(path, body, headers)
259
+ response = request(path, body, headers)
260
+
261
+ Parser::Compact.parse_document response.body
262
+ end
263
+
264
+ def extract_digest_header(response)
265
+ authenticate_headers = response.get_fields("www-authenticate")
266
+ authenticate_headers.detect {|h| h =~ /Digest/}
267
+ end
268
+
269
+ def handle_unauthorized_response(response)
270
+ self.authorization = build_auth(extract_digest_header(response), uri, tries)
271
+
272
+ response = raw_request(uri.path)
273
+
274
+ if Net::HTTPUnauthorized === response
275
+ raise AuthorizationFailure, "Authorization failed, check credentials?"
276
+ else
277
+ self.capabilities = extract_capabilities(Nokogiri.parse(response.body))
278
+ end
279
+ end
280
+
281
+ def handle_response(response)
282
+
283
+ if Net::HTTPUnauthorized === response # 401
284
+ handle_unauthorized_response(response)
285
+
286
+ elsif Net::HTTPSuccess === response # 2xx
287
+ begin
288
+ if !response.body.empty?
289
+ xml = Nokogiri::XML.parse(response.body, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
290
+
291
+ reply_text = xml.xpath("/RETS").attr("ReplyText").value
292
+ reply_code = xml.xpath("/RETS").attr("ReplyCode").value.to_i
293
+
294
+ if reply_code.nonzero?
295
+ raise InvalidRequest, "Got error code #{reply_code} (#{reply_text})."
296
+ end
297
+ end
298
+
299
+ rescue Nokogiri::XML::SyntaxError => e
300
+ logger.debug "Not xml"
301
+
302
+ end
303
+
304
+ else
305
+ raise UnknownResponse, "Unable to handle response #{response.class}"
306
+ end
307
+
308
+ return response
309
+ end
310
+
311
+
312
+ def handle_cookies(response)
313
+ if cookies?(response)
314
+ self.cookies = response.get_fields('set-cookie')
315
+ logger.info "Cookies set to #{cookies.inspect}"
316
+ end
317
+ end
318
+
319
+ def cookies?(response)
320
+ response['set-cookie']
321
+ end
322
+
323
+ def cookies=(cookies)
324
+ @cookies ||= {}
325
+
326
+ cookies.each do |cookie|
327
+ cookie.match(/(\S+)=([^;]+);?/)
328
+
329
+ @cookies[$1] = $2
330
+ end
331
+
332
+ nil
333
+ end
334
+
335
+ def cookies
336
+ return if @cookies.nil? or @cookies.empty?
337
+
338
+ @cookies.map{ |k,v| "#{k}=#{v}" }.join("; ")
339
+ end
340
+
341
+
342
+ def session=(session)
343
+ self.authorization = session.authorization
344
+ self.capabilities = session.capabilities
345
+ self.cookies = session.cookies
346
+ end
347
+
348
+ def session
349
+ Session.new(authorization, capabilities, cookies)
350
+ end
351
+
352
+
353
+ # The capabilies as provided by the RETS server during login.
354
+ #
355
+ # Currently, only the path in the endpoint URLs is used[1]. Host,
356
+ # port, other details remaining constant with those provided to
357
+ # the constructor.
358
+ #
359
+ # [1] In fact, sometimes only a path is returned from the server.
360
+ def capabilities
361
+ @capabilities || login
362
+ end
363
+
364
+ def capability_url(name)
365
+ url = capabilities[name]
366
+
367
+ begin
368
+ capability_uri = URI.parse(url)
369
+ rescue URI::InvalidURIError => e
370
+ raise MalformedResponse, "Unable to parse capability URL: #{url.inspect}"
371
+ end
372
+
373
+ capability_uri
374
+ end
375
+
376
+ def extract_capabilities(document)
377
+ raw_key_values = document.xpath("/RETS/RETS-RESPONSE").text.strip
378
+
379
+ h = Hash.new{|h,k| h.key?(k.downcase) ? h[k.downcase] : nil }
380
+
381
+ # ... :(
382
+ # Feel free to make this better. It has a test.
383
+ raw_key_values.split(/\n/).
384
+ map { |r| r.split(/=/, 2) }.
385
+ each { |k,v| h[k.strip.downcase] = v }
386
+
387
+ h
388
+ end
389
+
390
+
391
+
392
+ def connection
393
+ @connection ||= options[:persistent] ?
394
+ persistent_connection :
395
+ Net::HTTP.new(uri.host, uri.port)
396
+ end
397
+
398
+ def persistent_connection
399
+ conn = Net::HTTP::Persistent.new
400
+
401
+ def conn.idempotent?(*)
402
+ true
403
+ end
404
+
405
+ conn
406
+ end
407
+
408
+
409
+ def user_agent
410
+ options[:agent] || "Client/1.0"
411
+ end
412
+
413
+ def rets_version
414
+ options[:version] || "RETS/1.7.2"
415
+ end
416
+
417
+ def build_headers
418
+ headers = {
419
+ "User-Agent" => user_agent,
420
+ "Host" => "#{uri.host}:#{uri.port}",
421
+ "RETS-Version" => rets_version
422
+ }
423
+
424
+ headers.merge!("Authorization" => authorization) if authorization
425
+ headers.merge!("Cookie" => cookies) if cookies
426
+
427
+ if options[:ua_password]
428
+ headers.merge!(
429
+ "RETS-UA-Authorization" => build_user_agent_auth(
430
+ user_agent, options[:ua_password], "", rets_version))
431
+ end
432
+
433
+ headers
434
+ end
435
+
436
+ def build_key_values(data)
437
+ data.map{|k,v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
438
+ end
439
+
440
+
441
+
442
+ def tries
443
+ @tries ||= 1
444
+
445
+ (@tries += 1) - 1
446
+ end
447
+
448
+ class FakeLogger
449
+ def fatal(*); end
450
+ def error(*); end
451
+ def warn(*); end
452
+ def info(*); end
453
+ def debug(*); end
454
+ end
455
+
456
+ def format_headers(headers)
457
+ out = []
458
+
459
+ headers.each do |name, value|
460
+ if Array === value
461
+ value.each do |v|
462
+ out << "#{name}: #{v}"
463
+ end
464
+ else
465
+ out << "#{name}: #{value}"
466
+ end
467
+ end
468
+
469
+ out.join("\n")
470
+ end
471
+
472
+ end
473
+ end