rets4r 0.8.2

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/TODO ADDED
@@ -0,0 +1,26 @@
1
+ Fix
2
+ * Unit Tests (We need METADATA!)
3
+
4
+ Add support for
5
+ * Standard (non-compact) XML
6
+
7
+ Add
8
+ * More Examples
9
+ * More Documentation
10
+ * Reply Codes (Readable Meaning)
11
+ * A search convenience method that makes subsequent requests if maxrows is true.
12
+
13
+ Verify
14
+ * 1.7 Compliance and support
15
+
16
+ Check
17
+ * GetMetadata
18
+
19
+ Possible To-do Items
20
+ * RETS 1.0
21
+ * RETS 2.0 (May not be necessary since it is now SOAP based, but it would be nice to have a consistent API)
22
+ * Running a RETS Server
23
+ * Update Actions
24
+ * Password Change Transaction
25
+ * Get Transaction
26
+ * HTTPS Support
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/ruby
2
+ #
3
+ # This is an example of how to use the RETS client to retrieve an objet.
4
+ #
5
+ # You will need to set the necessary variables below.
6
+ #
7
+ #############################################################################################
8
+ # Settings
9
+
10
+ rets_url = 'http://server.com/my/rets/url'
11
+ username = 'username'
12
+ password = 'password'
13
+
14
+ # GetObject Settings
15
+ resource = 'Property'
16
+ object_type = 'Photo'
17
+ resource_id = 'id:*'
18
+
19
+ #############################################################################################
20
+ $:.unshift 'lib'
21
+
22
+ require 'rets4r'
23
+ require 'logger'
24
+
25
+ def handle_object(object)
26
+ case object.info['Content-Type']
27
+ when 'image/jpeg' then extension = 'jpg'
28
+ when 'image/gif' then extension = 'gif'
29
+ when 'image/png' then extension = 'png'
30
+ else extension = 'unknown'
31
+ end
32
+
33
+ File.open("#{object.info['Content-ID']}_#{object.info['Object-ID']}.#{extension}", 'w') do |f|
34
+ f.write(object.data)
35
+ end
36
+ end
37
+
38
+ client = RETS4R::Client.new(rets_url)
39
+
40
+ client.login(username, password) do |login_result|
41
+
42
+ if login_result.success?
43
+ ## Method 1
44
+ # Get objects using a block
45
+ client.get_object(resource, object_type, resource_id) do |object|
46
+ handle_object(object)
47
+ end
48
+
49
+ ## Method 2
50
+ # Get objects using a return value
51
+ results = client.get_object(resource, object_type, resource_id)
52
+
53
+ results.each do |object|
54
+ handle_object(object)
55
+ end
56
+ else
57
+ puts "We were unable to log into the RETS server."
58
+ puts "Please check that you have set the login variables correctly."
59
+ end
60
+ end
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/ruby
2
+ #
3
+ # This is an example of how to use the RETS client to log in and out of a server.
4
+ #
5
+ # You will need to set the necessary variables below.
6
+ #
7
+ #############################################################################################
8
+ # Settings
9
+
10
+ rets_url = 'http://server.com/my/rets/url'
11
+ username = 'username'
12
+ password = 'password'
13
+
14
+ #############################################################################################
15
+ $:.unshift 'lib'
16
+
17
+ require 'rets4r'
18
+ require 'logger'
19
+
20
+ client = RETS4R::Client.new(rets_url)
21
+ client.logger = Logger.new(STDOUT)
22
+
23
+ login_result = client.login(username, password)
24
+
25
+ if login_result.success?
26
+ puts "We successfully logged into the RETS server!"
27
+
28
+ # Print the action URL results (if any)
29
+ puts login_result.secondary_response
30
+
31
+ client.logout
32
+
33
+ puts "We just logged out of the server."
34
+ else
35
+ puts "We were unable to log into the RETS server."
36
+ puts "Please check that you have set the login variables correctly."
37
+ end
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/ruby
2
+ #
3
+ # This is an example of how to use the RETS client to login to a server and retrieve metadata. It
4
+ # also makes use of passing blocks to client methods and demonstrates how to set the output format.
5
+ #
6
+ # You will need to set the necessary variables below.
7
+ #
8
+ #############################################################################################
9
+ # Settings
10
+
11
+ rets_url = 'http://server.com/my/rets/url'
12
+ username = 'username'
13
+ password = 'password'
14
+
15
+ #############################################################################################
16
+ $:.unshift 'lib'
17
+
18
+ require 'rets4r'
19
+
20
+ RETS4R::Client.new(rets_url) do |client|
21
+ client.login(username, password) do |login_result|
22
+ if login_result.success?
23
+ puts "Logged in successfully!"
24
+
25
+ # We want the raw metadata, so we need to set the output to raw XML.
26
+ client.set_output RETS4R::Client::OUTPUT_RAW
27
+ metadata = ''
28
+
29
+ begin
30
+ metadata = client.get_metadata
31
+ rescue
32
+ puts "Unable to get metadata: '#{$!}'"
33
+ end
34
+
35
+ File.open('metadata.xml', 'w') do |file|
36
+ file.write metadata
37
+ end
38
+ else
39
+ puts "Unable to login: '#{login_result.reply_text}'."
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/ruby
2
+ #
3
+ # This is an example of how to use the RETS client to perform a basic search.
4
+ #
5
+ # You will need to set the necessary variables below.
6
+ #
7
+ #############################################################################################
8
+ # Settings
9
+
10
+ rets_url = 'http://server.com/my/rets/url'
11
+ username = 'username'
12
+ password = 'password'
13
+
14
+ rets_resource = 'Property'
15
+ rets_class = 'Residential'
16
+ rets_query = '(RetsField=Value)'
17
+
18
+ #############################################################################################
19
+ $:.unshift 'lib'
20
+
21
+ require 'rets4r'
22
+
23
+ client = RETS4R::Client.new(rets_url)
24
+
25
+ logger = Logger.new($stdout)
26
+ logger.level = Logger::WARN
27
+ client.logger = logger
28
+
29
+ login_result = client.login(username, password)
30
+
31
+ if login_result.success?
32
+ puts "We successfully logged into the RETS server!"
33
+
34
+ options = {'Limit' => 5}
35
+
36
+ client.search(rets_resource, rets_class, rets_query, options) do |result|
37
+ result.data.each do |row|
38
+ puts row.inspect
39
+ puts
40
+ end
41
+ end
42
+
43
+ client.logout
44
+
45
+ puts "We just logged out of the server."
46
+ else
47
+ puts "We were unable to log into the RETS server."
48
+ puts "Please check that you have set the login variables correctly."
49
+ end
50
+
51
+ logger.close
@@ -0,0 +1 @@
1
+ require 'rets4r/client'
@@ -0,0 +1,69 @@
1
+ require 'digest/md5'
2
+
3
+ module RETS4R
4
+ class Auth
5
+ # This is the primary method that would normally be used, and while it
6
+ def Auth.authenticate(response, username, password, uri, method, requestId, useragent, nc = 0)
7
+ authHeader = Auth.parse_header(response['www-authenticate'])
8
+
9
+ cnonce = cnonce(useragent, password, requestId, authHeader['nonce'])
10
+
11
+ authHash = calculate_digest(username, password, authHeader['realm'], authHeader['nonce'], method, uri, authHeader['qop'], cnonce, nc)
12
+
13
+ header = ''
14
+ header << "Digest username=\"#{username}\", "
15
+ header << "realm=\"#{authHeader['realm']}\", "
16
+ header << "qop=\"#{authHeader['qop']}\", "
17
+ header << "uri=\"#{uri}\", "
18
+ header << "nonce=\"#{authHeader['nonce']}\", "
19
+ header << "nc=#{('%08x' % nc)}, "
20
+ header << "cnonce=\"#{cnonce}\", "
21
+ header << "response=\"#{authHash}\", "
22
+ header << "opaque=\"#{authHeader['opaque']}\""
23
+
24
+ return header
25
+ end
26
+
27
+ def Auth.calculate_digest(username, password, realm, nonce, method, uri, qop = false, cnonce = false, nc = 0)
28
+ a1 = "#{username}:#{realm}:#{password}"
29
+ a2 = "#{method}:#{uri}"
30
+
31
+ response = '';
32
+
33
+ requestId = Auth.request_id unless requestId
34
+
35
+ if (qop)
36
+ throw ArgumentException, 'qop requires a cnonce to be provided.' unless cnonce
37
+
38
+ response = Digest::MD5.hexdigest("#{Digest::MD5.hexdigest(a1)}:#{nonce}:#{('%08x' % nc)}:#{cnonce}:#{qop}:#{Digest::MD5.hexdigest(a2)}")
39
+ else
40
+ response = Digest::MD5.hexdigest("#{Digest::MD5.hexdigest(a1)}:#{nonce}:#{Digest::MD5.hexdigest(a2)}")
41
+ end
42
+
43
+ return response
44
+ end
45
+
46
+ def Auth.parse_header(header)
47
+ type = header[0, header.index(' ')]
48
+ args = header[header.index(' '), header.length].strip.split(',')
49
+
50
+ parts = {'type' => type}
51
+
52
+ args.each do |arg|
53
+ name, value = arg.split('=')
54
+
55
+ parts[name.downcase] = value.tr('"', '')
56
+ end
57
+
58
+ return parts
59
+ end
60
+
61
+ def Auth.request_id
62
+ Digest::MD5.hexdigest(Time.new.to_f.to_s)
63
+ end
64
+
65
+ def Auth.cnonce(useragent, password, requestId, nonce)
66
+ Digest::MD5.hexdigest("#{useragent}:#{password}:#{requestId}:#{nonce}")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,563 @@
1
+ # RETS4R Client
2
+ #
3
+ # Copyright (c) 2006 Scott Patterson <scott.patterson@digitalaun.com>
4
+ #
5
+ # This program is copyrighted free software by Scott Patterson. You can
6
+ # redistribute it and/or modify it under the same terms of Ruby's license;
7
+ # either the dual license version in 2003 (see the file RUBYS), or any later
8
+ # version.
9
+ #
10
+ # TODO
11
+ # 1.0 Support (Adding this support should be fairly easy)
12
+ # 2.0 Support (Adding this support will be very difficult since it is a completely different methodology)
13
+ # Case-insensitive header
14
+
15
+ require 'digest/md5'
16
+ require 'net/http'
17
+ require 'uri'
18
+ require 'cgi'
19
+ require 'rets4r/auth'
20
+ require 'rets4r/client/dataobject'
21
+ require 'thread'
22
+ require 'logger'
23
+
24
+ module RETS4R
25
+ class Client
26
+ OUTPUT_RAW = 0 # Nothing done. Simply returns the XML.
27
+ OUTPUT_DOM = 1 # Returns a DOM object (REXML) **** NO LONGER SUPPORTED! ****
28
+ OUTPUT_RUBY = 2 # Returns a RETS::Data object
29
+
30
+ METHOD_GET = 'GET'
31
+ METHOD_POST = 'POST'
32
+ METHOD_HEAD = 'HEAD'
33
+
34
+ DEFAULT_OUTPUT = OUTPUT_RUBY
35
+ DEFAULT_METHOD = METHOD_GET
36
+ DEFAULT_RETRY = 2
37
+ DEFAULT_USER_AGENT = 'RETS4R/0.8.2'
38
+ DEFAULT_RETS_VERSION = '1.7'
39
+ SUPPORTED_RETS_VERSIONS = ['1.5', '1.7']
40
+ CAPABILITY_LIST = ['Action', 'ChangePassword', 'GetObject', 'Login', 'LoginComplete', 'Logout', 'Search', 'GetMetadata', 'Update']
41
+ SUPPORTED_PARSERS = [] # This will be populated by parsers as they load
42
+
43
+ attr_accessor :mimemap, :logger
44
+
45
+ # We load our parsers here so that they can modify the client class appropriately. Because
46
+ # the default parser will be the first parser to list itself in the DEFAULT_PARSER array,
47
+ # we need to require them in the order of preference. Hence, XMLParser is loaded first because
48
+ # it is preferred to REXML since it is much faster.
49
+ require 'rets4r/client/parser/xmlparser'
50
+ require 'rets4r/client/parser/rexml'
51
+
52
+ # Set it as the first
53
+ DEFAULT_PARSER = SUPPORTED_PARSERS[0]
54
+
55
+ # Constructor
56
+ #
57
+ # Requires the URL to the RETS server and takes an optional output format. The output format
58
+ # determines the type of data returned by the various RETS transaction methods.
59
+ def initialize(url, output = DEFAULT_OUTPUT)
60
+ raise Unsupported.new('DOM output is no longer supported.') if output == OUTPUT_DOM
61
+
62
+ @urls = { 'Login' => URI.parse(url) }
63
+ @nc = 0
64
+ @headers = {
65
+ 'User-Agent' => DEFAULT_USER_AGENT,
66
+ 'Accept' => '*/*',
67
+ 'RETS-Version' => "RETS/#{DEFAULT_RETS_VERSION}",
68
+ 'RETS-Session-ID' => '0'
69
+ }
70
+ @request_method = DEFAULT_METHOD
71
+ @parser_class = DEFAULT_PARSER
72
+ @semaphore = Mutex.new
73
+ @output = output
74
+
75
+ self.mimemap = {
76
+ 'image/jpeg' => 'jpg',
77
+ 'image/gif' => 'gif'
78
+ }
79
+
80
+ if block_given?
81
+ yield self
82
+ end
83
+ end
84
+
85
+ # We only allow external read access to URLs because they are internally set based on the
86
+ # results of various queries.
87
+ def urls
88
+ @urls
89
+ end
90
+
91
+ # Parses the provided XML returns it in the specified output format.
92
+ # Requires an XML string and takes an optional output format to override the instance output
93
+ # format variable. We current create a new parser each time, which seems a bit wasteful, but
94
+ # it allows for the parser to be changed in the middle of a session as well as XML::Parser
95
+ # requiring a new instance for each execution...that could be encapsulated within its parser
96
+ # class,though, so we should benchmark and see if it will make a big difference with the
97
+ # REXML parse, which I doubt.
98
+ def parse(xml, output = false)
99
+ if xml == ''
100
+ trans = Transaction.new()
101
+ trans.reply_code = -1
102
+ trans.reply_text = 'No transaction body was returned!'
103
+ end
104
+
105
+ if output == OUTPUT_RAW || @output == OUTPUT_RAW
106
+ xml
107
+ else
108
+ begin
109
+ parser = @parser_class.new
110
+ parser.logger = logger
111
+ parser.output = output ? output : @output
112
+
113
+ parser.parse(xml)
114
+ rescue
115
+ raise ParserException.new($!)
116
+ end
117
+ end
118
+ end
119
+
120
+ # Setup Methods (accessors and mutators)
121
+ def set_output(output = DEFAULT_OUTPUT)
122
+ @output = output
123
+ end
124
+
125
+ def get_output
126
+ @output
127
+ end
128
+
129
+ def set_parser_class(klass, force = false)
130
+ if force || SUPPORTED_PARSERS.include?(klass)
131
+ @parser_class = klass
132
+ else
133
+ message = "The parser class '#{klass}' is not supported!"
134
+ logger.debug(message) if logger
135
+
136
+ raise Unsupported.new(message)
137
+ end
138
+ end
139
+
140
+ def get_parser_class
141
+ @parser_class
142
+ end
143
+
144
+ def set_header(name, value)
145
+ if value.nil? then
146
+ @headers.delete(name)
147
+ else
148
+ @headers[name] = value
149
+ end
150
+
151
+ logger.debug("Set header '#{name}' to '#{value}'") if logger
152
+ end
153
+
154
+ def get_header(name)
155
+ @headers[name]
156
+ end
157
+
158
+ def set_user_agent(name)
159
+ set_header('User-Agent', name)
160
+ end
161
+
162
+ def get_user_agent
163
+ get_header('User-Agent')
164
+ end
165
+
166
+ def set_rets_version(version)
167
+ if (SUPPORTED_RETS_VERSIONS.include? version)
168
+ set_header('RETS-Version', "RETS/#{version}")
169
+ else
170
+ raise Unsupported.new("The client does not support RETS version '#{version}'.")
171
+ end
172
+ end
173
+
174
+ def get_rets_version
175
+ (get_header('RETS-Version') || "").gsub("RETS/", "")
176
+ end
177
+
178
+ def set_request_method(method)
179
+ @request_method = method
180
+ end
181
+
182
+ def get_request_method
183
+ @request_method
184
+ end
185
+
186
+ # Provide more Ruby-like attribute accessors instead of get/set methods
187
+ alias_method :user_agent=, :set_user_agent
188
+ alias_method :user_agent, :get_user_agent
189
+ alias_method :request_method=, :set_request_method
190
+ alias_method :request_method, :get_request_method
191
+ alias_method :rets_version=, :set_rets_version
192
+ alias_method :rets_version, :get_rets_version
193
+ alias_method :parser_class=, :set_parser_class
194
+ alias_method :parser_class, :get_parser_class
195
+ alias_method :output=, :set_output
196
+ alias_method :output, :get_output
197
+
198
+ #### RETS Transaction Methods ####
199
+ #
200
+ # Most of these transaction methods mirror the RETS specification methods, so if you are
201
+ # unsure what they mean, you should check the RETS specification. The latest version can be
202
+ # found at http://www.rets.org
203
+
204
+ # Attempts to log into the server using the provided username and password.
205
+ #
206
+ # If called with a block, the results of the login action are yielded,
207
+ # and logout is called when the block returns. In that case, #login
208
+ # returns the block's value. If called without a block, returns the
209
+ # result.
210
+ #
211
+ # As specified in the RETS specification, the Action URL is called and
212
+ # the results made available in the #secondary_results accessor of the
213
+ # results object.
214
+ def login(username, password) #:yields: login_results
215
+ @username = username
216
+ @password = password
217
+
218
+ # We are required to set the Accept header to this by the RETS 1.5 specification.
219
+ set_header('Accept', '*/*')
220
+
221
+ response = request(@urls['Login'])
222
+
223
+ # Parse response to get other URLS
224
+ results = self.parse(response.body, OUTPUT_RUBY)
225
+
226
+ if (results.success?)
227
+ CAPABILITY_LIST.each do |capability|
228
+ next unless results.response[capability]
229
+ base = @urls['Login'].clone
230
+ base.path = results.response[capability]
231
+
232
+ @urls[capability] = base
233
+ end
234
+
235
+ logger.debug("Capability URL List: #{@urls.inspect}") if logger
236
+ else
237
+ raise LoginError.new(response.message + "(#{results.reply_code}: #{results.reply_text})")
238
+ end
239
+
240
+ if @output != OUTPUT_RUBY
241
+ results = self.parse(response.body)
242
+ end
243
+
244
+ # Perform the mandatory get request on the action URL.
245
+ results.secondary_response = perform_action_url
246
+
247
+ # We only yield
248
+ if block_given?
249
+ begin
250
+ yield results
251
+ ensure
252
+ self.logout
253
+ end
254
+ else
255
+ results
256
+ end
257
+ end
258
+
259
+ # Logs out of the RETS server.
260
+ def logout()
261
+ # If no logout URL is provided, then we assume that logout is not necessary (not to
262
+ # mention impossible without a URL). We don't throw an exception, though, but we might
263
+ # want to if this becomes an issue in the future.
264
+
265
+ request(@urls['Logout']) if @urls['Logout']
266
+ end
267
+
268
+ # Requests Metadata from the server. An optional type and id can be specified to request
269
+ # subsets of the Metadata. Please see the RETS specification for more details on this.
270
+ # The format variable tells the server which format to return the Metadata in. Unless you
271
+ # need the raw metadata in a specified format, you really shouldn't specify the format.
272
+ #
273
+ # If called with a block, yields the results and returns the value of the block, or
274
+ # returns the metadata directly.
275
+ def get_metadata(type = 'METADATA-SYSTEM', id = '*', format = 'COMPACT')
276
+ header = {
277
+ 'Accept' => 'text/xml,text/plain;q=0.5'
278
+ }
279
+
280
+ data = {
281
+ 'Type' => type,
282
+ 'ID' => id,
283
+ 'Format' => format
284
+ }
285
+
286
+ response = request(@urls['GetMetadata'], data, header)
287
+
288
+ result = self.parse(response.body)
289
+
290
+ if block_given?
291
+ yield result
292
+ else
293
+ result
294
+ end
295
+ end
296
+
297
+ # Performs a GetObject transaction on the server. For details on the arguments, please see
298
+ # the RETS specification on GetObject requests.
299
+ #
300
+ # This method either returns an Array of DataObject instances, or yields each DataObject
301
+ # as it is created. If a block is given, the number of objects yielded is returned.
302
+ def get_object(resource, type, id, location = 1) #:yields: data_object
303
+ header = {
304
+ 'Accept' => mimemap.keys.join(',')
305
+ }
306
+
307
+ data = {
308
+ 'Resource' => resource,
309
+ 'Type' => type,
310
+ 'ID' => id,
311
+ 'Location' => location.to_s
312
+ }
313
+
314
+ response = request(@urls['GetObject'], data, header)
315
+ results = block_given? ? 0 : []
316
+
317
+ if response['content-type'].include?('multipart/parallel')
318
+ content_type = process_content_type(response['content-type'])
319
+
320
+ parts = response.body.split("\r\n--#{content_type['boundary']}")
321
+ parts.shift # Get rid of the initial boundary
322
+
323
+ parts.each do |part|
324
+ (raw_header, raw_data) = part.split("\r\n\r\n")
325
+
326
+ next unless raw_data
327
+
328
+ data_header = process_header(raw_header)
329
+ data_object = DataObject.new(data_header, raw_data)
330
+
331
+ if block_given?
332
+ yield data_object
333
+ results += 1
334
+ else
335
+ results << data_object
336
+ end
337
+ end
338
+ else
339
+ info = {
340
+ 'content-type' => response['content-type'], # Compatibility shim. Deprecated.
341
+ 'Content-Type' => response['content-type'],
342
+ 'Object-ID' => response['Object-ID'],
343
+ 'Content-ID' => response['Content-ID']
344
+ }
345
+
346
+ if response['Content-Length'].to_i > 100
347
+ data_object = DataObject.new(info, response.body)
348
+
349
+ if block_given?
350
+ yield data_object
351
+ results += 1
352
+ else
353
+ results << data_object
354
+ end
355
+ end
356
+ end
357
+
358
+ results
359
+ end
360
+
361
+ # Peforms a RETS search transaction. Again, please see the RETS specification for details
362
+ # on what these parameters mean. The options parameter takes a hash of options that will
363
+ # added to the search statement.
364
+ def search(search_type, klass, query, options = false)
365
+ header = {}
366
+
367
+ # Required Data
368
+ data = {
369
+ 'SearchType' => search_type,
370
+ 'Class' => klass,
371
+ 'Query' => query,
372
+ 'QueryType' => 'DMQL2',
373
+ 'Format' => 'COMPACT',
374
+ 'Count' => '0'
375
+ }
376
+
377
+ # Options
378
+ #--
379
+ # We might want to switch this to merge!, but I've kept it like this for now because it
380
+ # explicitly casts each value as a string prior to performing the search, so we find out now
381
+ # if can't force a value into the string context. I suppose it doesn't really matter when
382
+ # that happens, though...
383
+ #++
384
+ options.each { |k,v| data[k] = v.to_s } if options
385
+
386
+ response = request(@urls['Search'], data, header)
387
+
388
+ results = self.parse(response.body)
389
+
390
+ if block_given?
391
+ yield results
392
+ else
393
+ return results
394
+ end
395
+ end
396
+
397
+ private
398
+
399
+ def process_content_type(text)
400
+ content = {}
401
+
402
+ field_start = text.index(';')
403
+
404
+ content['content-type'] = text[0 ... field_start].strip
405
+ fields = text[field_start..-1]
406
+
407
+ parts = text.split(';')
408
+
409
+ parts.each do |part|
410
+ (name, value) = part.split('=')
411
+
412
+ content[name.strip] = value ? value.strip : value
413
+ end
414
+
415
+ content
416
+ end
417
+
418
+ # Processes the HTTP header
419
+ #--
420
+ # Could we switch over to using CGI for this?
421
+ #++
422
+ def process_header(raw)
423
+ header = {}
424
+
425
+ raw.each do |line|
426
+ (name, value) = line.split(':')
427
+
428
+ header[name.strip] = value.strip if name && value
429
+ end
430
+
431
+ header
432
+ end
433
+
434
+ # Given a hash, it returns a URL encoded query string.
435
+ def create_query_string(hash)
436
+ parts = hash.map {|key,value| "#{CGI.escape(key)}=#{CGI.escape(value)}"}
437
+ return parts.join('&')
438
+ end
439
+
440
+ # This is the primary transaction method, which the other public methods make use of.
441
+ # Given a url for the transaction (endpoint) it makes a request to the RETS server.
442
+ #
443
+ #--
444
+ # This needs to be better documented, but for now please see the public transaction methods
445
+ # for how to make use of this method.
446
+ #++
447
+ def request(url, data = {}, header = {}, method = @request_method, retry_auth = DEFAULT_RETRY)
448
+ response = ''
449
+
450
+ @semaphore.lock
451
+
452
+ http = Net::HTTP.new(url.host, url.port)
453
+
454
+ if logger && logger.debug?
455
+ http.set_debug_output HTTPDebugLogger.new(logger)
456
+ end
457
+
458
+ http.start do |http|
459
+ begin
460
+ uri = url.path
461
+
462
+ if ! data.empty? && method == METHOD_GET
463
+ uri += "?#{create_query_string(data)}"
464
+ end
465
+
466
+ headers = @headers
467
+ headers.merge(header) unless header.empty?
468
+
469
+ logger.debug(headers.inspect) if logger
470
+
471
+ @semaphore.unlock
472
+
473
+ response = http.get(uri, headers)
474
+
475
+ @semaphore.lock
476
+
477
+ if response.code == '401'
478
+ # Authentication is required
479
+ raise AuthRequired
480
+ else
481
+ cookies = []
482
+ if set_cookies = response.get_fields('set-cookie') then
483
+ set_cookies.each do |cookie|
484
+ cookies << cookie.split(";").first
485
+ end
486
+ end
487
+ set_header('Cookie', cookies.join("; ")) unless cookies.empty?
488
+ set_header('RETS-Session-ID', response['RETS-Session-ID']) if response['RETS-Session-ID']
489
+ end
490
+ rescue AuthRequired
491
+ @nc += 1
492
+
493
+ if retry_auth > 0
494
+ retry_auth -= 1
495
+ set_header('Authorization', Auth.authenticate(response, @username, @password, url.path, method, @headers['RETS-Request-ID'], get_user_agent, @nc))
496
+ retry
497
+ end
498
+ end
499
+
500
+ logger.debug(response.body) if logger
501
+ end
502
+
503
+ @semaphore.unlock if @semaphore.locked?
504
+
505
+ return response
506
+ end
507
+
508
+ # If an action URL is present in the URL capability list, it calls that action URL and returns the
509
+ # raw result. Throws a generic RETSException if it is unable to follow the URL.
510
+ def perform_action_url
511
+ begin
512
+ if @urls.has_key?('Action')
513
+ return request(@urls['Action'], {}, {}, METHOD_GET)
514
+ end
515
+ rescue
516
+ raise RETSException.new("Unable to follow action URL: '#{$!}'.")
517
+ end
518
+ end
519
+
520
+ # Provides a proxy class to allow for net/http to log its debug to the logger.
521
+ class HTTPDebugLogger
522
+ def initialize(logger)
523
+ @logger = logger
524
+ end
525
+
526
+ def <<(data)
527
+ @logger.debug(data)
528
+ end
529
+ end
530
+
531
+ #### Exceptions ####
532
+
533
+ # This exception should be thrown when a generic client error is encountered.
534
+ class ClientException < Exception
535
+ end
536
+
537
+ # This exception should be thrown when there is an error with the parser, which is
538
+ # considered a subcomponent of the RETS client. It also includes the XML data that
539
+ # that was being processed at the time of the exception.
540
+ class ParserException < ClientException
541
+ attr_accessor :file
542
+ end
543
+
544
+ # The client does not currently support a specified action.
545
+ class Unsupported < ClientException
546
+ end
547
+
548
+ # A general RETS level exception was encountered. This would include HTTP and RETS
549
+ # specification level errors as well as informative mishaps such as authentication being
550
+ # required for access.
551
+ class RETSException < Exception
552
+ end
553
+
554
+ # There was a problem with logging into the RETS server.
555
+ class LoginError < RETSException
556
+ end
557
+
558
+ # For internal client use only, it is thrown when the a RETS request is made but a password
559
+ # is prompted for.
560
+ class AuthRequired < RETSException
561
+ end
562
+ end
563
+ end