rets 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/.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