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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module RETS4R
|
|
2
|
+
class Client
|
|
3
|
+
# Represents a row of data. Nothing more than a glorfied Hash with a custom constructor and a
|
|
4
|
+
# type attribute.
|
|
5
|
+
class Data < ::Hash
|
|
6
|
+
attr_accessor :type
|
|
7
|
+
|
|
8
|
+
def initialize(type = false)
|
|
9
|
+
super
|
|
10
|
+
self.type = type
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module RETS4R
|
|
2
|
+
class Client
|
|
3
|
+
# Represents a RETS object (as returned by the get_object) transaction.
|
|
4
|
+
class DataObject
|
|
5
|
+
attr_accessor :type, :data
|
|
6
|
+
|
|
7
|
+
alias :info :type
|
|
8
|
+
|
|
9
|
+
def initialize(type, data)
|
|
10
|
+
self.type = type
|
|
11
|
+
self.data = data
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def success?
|
|
15
|
+
return true if self.data
|
|
16
|
+
return false
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require 'rets4r/client/data'
|
|
2
|
+
|
|
3
|
+
module RETS4R
|
|
4
|
+
class Client
|
|
5
|
+
# Represents a set of metadata. It is simply an extended Array with type and attributes accessors.
|
|
6
|
+
class Metadata < Array
|
|
7
|
+
attr_accessor :type, :attributes
|
|
8
|
+
|
|
9
|
+
def initialize(type = false)
|
|
10
|
+
self.type = type if type
|
|
11
|
+
self.attributes = {}
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module RETS4R
|
|
2
|
+
class Client
|
|
3
|
+
# Provides a means of indexing metadata to allow faster searching of it.
|
|
4
|
+
# This is in dire need of a review and cleanup, so I advise you not to use it until that has been done.
|
|
5
|
+
class MetadataIndex
|
|
6
|
+
# Constructor that takes an array of Metadata objects as its sole argument.
|
|
7
|
+
def initialize(metadata)
|
|
8
|
+
@metadata = metadata
|
|
9
|
+
@index = index(@metadata)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# WARNING! Not working properly (does NOT pass unit test)
|
|
13
|
+
# This is more of a free form search than #lookup, but it is also slower because it must iterate
|
|
14
|
+
# through the entire metadata array. This also returns multiple matches, which #lookup doesn't do.
|
|
15
|
+
def search(type, attributes = {})
|
|
16
|
+
matches = []
|
|
17
|
+
|
|
18
|
+
@metadata.each do |meta|
|
|
19
|
+
catch :mismatch do
|
|
20
|
+
if meta.type == type
|
|
21
|
+
attributes.each do |k,v|
|
|
22
|
+
throw :mismatch unless meta.attributes[k] == v
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
matches << meta
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return matches
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# This is a streamlined and probably the preferred method for looking up metadata because it
|
|
34
|
+
# uses a index to quickly access the data. The downside is that it is not very flexible. This also
|
|
35
|
+
# only returns one (the "best") match. If you need multiple matches, then you should use #search.
|
|
36
|
+
# Tests show about a 690% speed increase by using #lookup over #search, so you've been warned.
|
|
37
|
+
def lookup(type, *attributes)
|
|
38
|
+
key = type
|
|
39
|
+
key << attributes.join('')
|
|
40
|
+
|
|
41
|
+
@index[key]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Provided an array of metadata, it indexes it for faster lookup.
|
|
47
|
+
# Takes a +Metadata+ object as its argument.
|
|
48
|
+
def index(metadata)
|
|
49
|
+
index = {}
|
|
50
|
+
|
|
51
|
+
metadata.each do |meta|
|
|
52
|
+
key = generate_key(meta)
|
|
53
|
+
|
|
54
|
+
index[key] = meta
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return index
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Used to generate the key for a specified piece of metadata. Takes a +Metadata+ object as its
|
|
61
|
+
# argument.
|
|
62
|
+
def generate_key(meta)
|
|
63
|
+
key = meta.type
|
|
64
|
+
|
|
65
|
+
case (meta.type)
|
|
66
|
+
when 'METADATA-LOOKUP'
|
|
67
|
+
key << meta.attributes['Resource']
|
|
68
|
+
when 'METADATA-LOOKUP_TYPE'
|
|
69
|
+
key << meta.attributes['Resource'] << meta.attributes['Lookup']
|
|
70
|
+
when 'METADATA-CLASS'
|
|
71
|
+
key << meta.attributes['Resource']
|
|
72
|
+
when 'METADATA-OBJECT'
|
|
73
|
+
key << meta.attributes['Resource']
|
|
74
|
+
when 'METADATA-TABLE'
|
|
75
|
+
key << meta.attributes['Resource'] << meta.attributes['Class']
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return key
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# This is the generic parser
|
|
2
|
+
#
|
|
3
|
+
# Supports responses, data, metadata, reply codes, and reply text.
|
|
4
|
+
#
|
|
5
|
+
# Supports the following tags:
|
|
6
|
+
# RETS, METADATA-.*, MAXROWS, COLUMNS, DATA, COUNT, DELIMITER
|
|
7
|
+
#
|
|
8
|
+
# Metadata is built as:
|
|
9
|
+
# (Metadata 1-> data row
|
|
10
|
+
# -> data row),
|
|
11
|
+
# (Metadata 2 -> data row),
|
|
12
|
+
# etc.
|
|
13
|
+
#
|
|
14
|
+
# Data is built as:
|
|
15
|
+
# Data 1, Data 2, Data N
|
|
16
|
+
#
|
|
17
|
+
#
|
|
18
|
+
# TODO
|
|
19
|
+
# Add comments/documentation
|
|
20
|
+
# Handle more tags (if any are unaccounted for)
|
|
21
|
+
# Handle standard (non-compact) output
|
|
22
|
+
# Case Insensitivity?
|
|
23
|
+
# There is still some holdovers from the previous organization of parsers, and they should be cleaned
|
|
24
|
+
# up at some point.
|
|
25
|
+
|
|
26
|
+
require 'cgi'
|
|
27
|
+
|
|
28
|
+
module RETS4R
|
|
29
|
+
class Client
|
|
30
|
+
module Parser
|
|
31
|
+
attr_accessor :output, :logger
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@transaction = Transaction.new
|
|
35
|
+
@current = []
|
|
36
|
+
@output = 2
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def get_transaction
|
|
40
|
+
@transaction
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#### Stream Listener Events
|
|
44
|
+
def tag_start(name, attrs)
|
|
45
|
+
@current.push(name)
|
|
46
|
+
|
|
47
|
+
case name
|
|
48
|
+
when 'RETS'
|
|
49
|
+
@transaction.reply_code = attrs['ReplyCode']
|
|
50
|
+
@transaction.reply_text = attrs['ReplyText']
|
|
51
|
+
when /METADATA.*/
|
|
52
|
+
@metadata = Metadata.new(name)
|
|
53
|
+
@metadata.attributes = attrs
|
|
54
|
+
when 'MAXROWS'
|
|
55
|
+
@transaction.maxrows = true
|
|
56
|
+
when 'COUNT'
|
|
57
|
+
@transaction.count = attrs['Records'].to_i
|
|
58
|
+
when 'DELIMITER'
|
|
59
|
+
@transaction.delimiter = attrs['value'].to_i
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def text(text)
|
|
64
|
+
case @current[-1]
|
|
65
|
+
when 'COLUMNS'
|
|
66
|
+
@transaction.header = parse_compact_line(text, @transaction.ascii_delimiter)
|
|
67
|
+
|
|
68
|
+
when 'DATA'
|
|
69
|
+
if @transaction.header.length > 0
|
|
70
|
+
data_type << parse_data(text, @transaction.header)
|
|
71
|
+
else
|
|
72
|
+
data_type << parse_compact_line(text, @transaction.ascii_delimiter)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
when 'RETS-RESPONSE'
|
|
76
|
+
@transaction.response = parse_key_value_body(text)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tag_end(name)
|
|
81
|
+
@current.pop
|
|
82
|
+
|
|
83
|
+
@transaction.data << @metadata if name =~ /METADATA.*/
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#### Helper Methods
|
|
87
|
+
def clean_xml(xml)
|
|
88
|
+
# This is a hack, and it assumes that we're using compact mode, but it's the easiest way to
|
|
89
|
+
# strip out those bad "<" and ">" characters that were not properly escaped...
|
|
90
|
+
xml.gsub!(/<DATA>(.*)<\/DATA>/i) do |match|
|
|
91
|
+
"<DATA>#{CGI::escapeHTML(CGI::unescapeHTML($1))}</DATA>"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def data_type
|
|
96
|
+
if @current[-2].index('METADATA') === 0
|
|
97
|
+
return @metadata
|
|
98
|
+
else
|
|
99
|
+
return @transaction.data
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_compact_line(data, delim = "\t")
|
|
104
|
+
begin
|
|
105
|
+
return data.to_s.strip.split(delim)
|
|
106
|
+
rescue
|
|
107
|
+
raise "Error while parsing compact line: #{$!} with data: #{data}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def parse_data(data, header)
|
|
112
|
+
results = Data.new(@current[-2])
|
|
113
|
+
|
|
114
|
+
parsed_data = parse_compact_line(data, @transaction.ascii_delimiter)
|
|
115
|
+
|
|
116
|
+
header.length.times do |pos|
|
|
117
|
+
results[header[pos]] = parsed_data[pos]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
results
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_key_value_body(data)
|
|
124
|
+
parsed = {}
|
|
125
|
+
|
|
126
|
+
data.each do |line|
|
|
127
|
+
(key, value) = line.strip.split('=')
|
|
128
|
+
parsed[key] = value
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
return parsed
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require 'rets4r/client/parser'
|
|
2
|
+
require 'rexml/parsers/baseparser'
|
|
3
|
+
require 'rexml/parsers/streamparser'
|
|
4
|
+
require 'rexml/streamlistener'
|
|
5
|
+
require 'rets4r/client/transaction'
|
|
6
|
+
require 'rets4r/client/data'
|
|
7
|
+
require 'rets4r/client/metadata'
|
|
8
|
+
|
|
9
|
+
module RETS4R
|
|
10
|
+
class Client
|
|
11
|
+
module Parser
|
|
12
|
+
class REXML
|
|
13
|
+
include Parser
|
|
14
|
+
|
|
15
|
+
SUPPORTED_PARSERS << self
|
|
16
|
+
|
|
17
|
+
attr_accessor :logger
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@transaction = Transaction.new
|
|
21
|
+
@current = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse(xml, output = false, do_retry = true)
|
|
25
|
+
output = self.output unless output # Allow per-parse output changes
|
|
26
|
+
|
|
27
|
+
return xml if output == OUTPUT_RAW
|
|
28
|
+
|
|
29
|
+
# This is an legacy option that is not currently supported by XMLParser, but it is left in
|
|
30
|
+
# here for reference or "undocumented usage."
|
|
31
|
+
if output == OUTPUT_DOM
|
|
32
|
+
return ::REXML::Document.new(xml)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# If we're here, then we need to output a RETS::Data object
|
|
36
|
+
listener = StreamListener.new
|
|
37
|
+
|
|
38
|
+
begin
|
|
39
|
+
stream = ::REXML::Parsers::StreamParser.new(xml, listener)
|
|
40
|
+
stream.parse
|
|
41
|
+
rescue ::REXML::ParseException, Exception
|
|
42
|
+
# We should get fancier here and actually check the type of error, but, err, oh well.
|
|
43
|
+
if do_retry
|
|
44
|
+
logger.info("Unable to parse XML on first try due to '#{$!}'. Now retrying.") if logger
|
|
45
|
+
|
|
46
|
+
return parse(clean_xml(xml), output, false)
|
|
47
|
+
else
|
|
48
|
+
ex = ParserException.new($!)
|
|
49
|
+
ex.file = xml
|
|
50
|
+
|
|
51
|
+
logger.error("REXML parser was unable to parse XML: #{$!}") if logger
|
|
52
|
+
logger.error("Unparsable XML was:\n#{xml}") if logger
|
|
53
|
+
|
|
54
|
+
raise ex
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
return listener.get_transaction
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class StreamListener
|
|
62
|
+
include ::REXML::StreamListener
|
|
63
|
+
include Parser
|
|
64
|
+
|
|
65
|
+
def initialize(logger = nil)
|
|
66
|
+
self.logger = logger
|
|
67
|
+
@transaction = Transaction.new
|
|
68
|
+
@current = []
|
|
69
|
+
@output = 2
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Because XMLParser may not be present on this system, we attempt to require it and if it
|
|
2
|
+
# succeeds, we create the XMLParser and add it to the supported parsers. The user of the client
|
|
3
|
+
# can still switch to REXML if desired.
|
|
4
|
+
begin
|
|
5
|
+
require 'xml/parser'
|
|
6
|
+
require 'rets4r/client/parser'
|
|
7
|
+
require 'rets4r/client/transaction'
|
|
8
|
+
require 'rets4r/client/data'
|
|
9
|
+
require 'rets4r/client/metadata'
|
|
10
|
+
|
|
11
|
+
module RETS4R
|
|
12
|
+
class Client
|
|
13
|
+
module Parser
|
|
14
|
+
class XMLParser < XML::Parser
|
|
15
|
+
include Parser
|
|
16
|
+
|
|
17
|
+
SUPPORTED_PARSERS << self
|
|
18
|
+
|
|
19
|
+
attr_accessor :logger
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@transaction = Transaction.new
|
|
23
|
+
@current = []
|
|
24
|
+
@text = ''
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse(xml, output = false, do_retry = true)
|
|
28
|
+
begin
|
|
29
|
+
super(xml)
|
|
30
|
+
rescue XMLParserError
|
|
31
|
+
line = self.line
|
|
32
|
+
|
|
33
|
+
# We should get fancier here and actually check the type of error, but, err, oh well.
|
|
34
|
+
if do_retry
|
|
35
|
+
# We probably received this error because somebody forgot to escape XML entities...
|
|
36
|
+
# so we attempt to escape them ourselves...
|
|
37
|
+
do_retry = false
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
cleaned_xml = self.clean_xml(xml)
|
|
41
|
+
|
|
42
|
+
# Because a cparser can only be used once per instantiation...
|
|
43
|
+
retry_xml = self.class.new
|
|
44
|
+
retry_xml.parse(cleaned_xml, output, do_retry)
|
|
45
|
+
|
|
46
|
+
@transaction = retry_xml.get_transaction
|
|
47
|
+
|
|
48
|
+
rescue
|
|
49
|
+
ex = ParserException.new($!)
|
|
50
|
+
ex.file = xml
|
|
51
|
+
|
|
52
|
+
raise ex
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
# We should never get here! But if we do...
|
|
56
|
+
raise "You really shouldn't be seeing this error message! This means that there was an unexpected error: #{$!} (#{$!.class})"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@transaction
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def get_transaction
|
|
64
|
+
@transaction
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
#### Stream Listener Events
|
|
70
|
+
def startElement(name, attrs)
|
|
71
|
+
tag_start(name, attrs)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def character(text)
|
|
75
|
+
@text += text
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def processText()
|
|
79
|
+
text(@text)
|
|
80
|
+
|
|
81
|
+
@text = ''
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def endElement(name)
|
|
85
|
+
processText()
|
|
86
|
+
|
|
87
|
+
tag_end(name)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
rescue LoadError
|
|
94
|
+
# CParser is not available because we could not load the XMLParser library
|
|
95
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module RETS4R
|
|
2
|
+
class Client
|
|
3
|
+
class Transaction
|
|
4
|
+
attr_accessor :reply_code, :reply_text, :response, :metadata, :header, :data, :maxrows,
|
|
5
|
+
:count, :delimiter, :secondary_response
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
self.maxrows = false
|
|
9
|
+
self.header = []
|
|
10
|
+
self.data = []
|
|
11
|
+
self.delimiter = ?\t
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def success?
|
|
15
|
+
return true if self.reply_code == '0'
|
|
16
|
+
return false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def has_data?
|
|
20
|
+
return true if self.data.length > 0
|
|
21
|
+
return false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def maxrows?
|
|
25
|
+
return true if self.maxrows
|
|
26
|
+
return false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ascii_delimiter
|
|
30
|
+
self.delimiter.chr
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|