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/CHANGELOG +50 -0
- data/CONTRIBUTORS +3 -0
- data/GPL +340 -0
- data/LICENSE +7 -0
- data/README +36 -0
- data/RUBYS +56 -0
- data/TODO +26 -0
- data/examples/client_get_object.rb +60 -0
- data/examples/client_login.rb +37 -0
- data/examples/client_metadata.rb +42 -0
- data/examples/client_search.rb +51 -0
- data/lib/rets4r.rb +1 -0
- data/lib/rets4r/auth.rb +69 -0
- data/lib/rets4r/client.rb +563 -0
- data/lib/rets4r/client/data.rb +14 -0
- data/lib/rets4r/client/dataobject.rb +20 -0
- data/lib/rets4r/client/metadata.rb +15 -0
- data/lib/rets4r/client/metadataindex.rb +82 -0
- data/lib/rets4r/client/parser.rb +135 -0
- data/lib/rets4r/client/parser/rexml.rb +75 -0
- data/lib/rets4r/client/parser/xmlparser.rb +95 -0
- data/lib/rets4r/client/transaction.rb +34 -0
- data/test/client/data/1.5/error.xml +1 -0
- data/test/client/data/1.5/invalid_compact.xml +4 -0
- data/test/client/data/1.5/login.xml +16 -0
- data/test/client/data/1.5/metadata.xml +0 -0
- data/test/client/data/1.5/search_compact.xml +8 -0
- data/test/client/data/1.5/search_unescaped_compact.xml +8 -0
- data/test/client/parser/tc_rexml.rb +17 -0
- data/test/client/parser/tc_xmlparser.rb +21 -0
- data/test/client/tc_auth.rb +57 -0
- data/test/client/tc_client.rb +268 -0
- data/test/client/tc_metadataindex.rb +36 -0
- data/test/client/test_parser.rb +109 -0
- data/test/client/ts_all.rb +8 -0
- data/test/ts_all.rb +1 -0
- data/test/ts_client.rb +1 -0
- metadata +96 -0
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
|
data/lib/rets4r.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require 'rets4r/client'
|
data/lib/rets4r/auth.rb
ADDED
|
@@ -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
|