rets-sarmiena 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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