rets 0.9.0 → 0.10.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/Manifest.txt +24 -0
- data/README.md +73 -1
- data/Rakefile +1 -1
- data/lib/rets.rb +202 -1
- data/lib/rets/client.rb +83 -94
- data/lib/rets/http_client.rb +42 -0
- data/lib/rets/metadata.rb +15 -3
- data/lib/rets/metadata/caching.rb +59 -0
- data/lib/rets/metadata/file_cache.rb +29 -0
- data/lib/rets/metadata/json_serializer.rb +27 -0
- data/lib/rets/metadata/lookup_table.rb +65 -0
- data/lib/rets/metadata/lookup_type.rb +3 -4
- data/lib/rets/metadata/marshal_serializer.rb +27 -0
- data/lib/rets/metadata/multi_lookup_table.rb +70 -0
- data/lib/rets/metadata/null_cache.rb +24 -0
- data/lib/rets/metadata/resource.rb +39 -29
- data/lib/rets/metadata/rets_class.rb +27 -23
- data/lib/rets/metadata/rets_object.rb +32 -0
- data/lib/rets/metadata/table.rb +9 -101
- data/lib/rets/metadata/table_factory.rb +19 -0
- data/lib/rets/metadata/yaml_serializer.rb +27 -0
- data/lib/rets/parser/compact.rb +61 -18
- data/lib/rets/parser/error_checker.rb +8 -1
- data/test/fixtures.rb +58 -0
- data/test/test_caching.rb +89 -0
- data/test/test_client.rb +44 -24
- data/test/test_error_checker.rb +18 -0
- data/test/test_file_cache.rb +42 -0
- data/test/test_http_client.rb +96 -60
- data/test/test_json_serializer.rb +26 -0
- data/test/test_marshal_serializer.rb +26 -0
- data/test/test_metadata.rb +62 -450
- data/test/test_metadata_class.rb +50 -0
- data/test/test_metadata_lookup_table.rb +21 -0
- data/test/test_metadata_lookup_type.rb +12 -0
- data/test/test_metadata_multi_lookup_table.rb +60 -0
- data/test/test_metadata_object.rb +20 -0
- data/test/test_metadata_resource.rb +140 -0
- data/test/test_metadata_root.rb +151 -0
- data/test/test_metadata_table.rb +21 -0
- data/test/test_metadata_table_factory.rb +24 -0
- data/test/test_parser_compact.rb +23 -28
- data/test/test_yaml_serializer.rb +26 -0
- metadata +29 -5
@@ -1,36 +1,40 @@
|
|
1
1
|
module Rets
|
2
2
|
module Metadata
|
3
3
|
class RetsClass
|
4
|
-
|
5
|
-
attr_accessor :name
|
6
|
-
attr_accessor :visible_name
|
7
|
-
attr_accessor :description
|
8
|
-
attr_accessor :resource
|
9
|
-
|
10
|
-
def initialize(rets_class_fragment, resource)
|
11
|
-
self.resource = resource
|
12
|
-
self.tables = []
|
13
|
-
self.name = rets_class_fragment["ClassName"]
|
14
|
-
self.visible_name = rets_class_fragment["VisibleName"]
|
15
|
-
self.description = rets_class_fragment["Description"]
|
16
|
-
end
|
4
|
+
attr_reader :name, :visible_name, :standard_name, :description, :tables
|
17
5
|
|
18
|
-
def
|
19
|
-
|
20
|
-
|
6
|
+
def initialize(name, visible_name, standard_name, description, tables)
|
7
|
+
@name = name
|
8
|
+
@visible_name = visible_name
|
9
|
+
@description = description
|
10
|
+
@standard_name = standard_name
|
21
11
|
|
22
|
-
|
23
|
-
|
12
|
+
@tables = tables
|
13
|
+
end
|
24
14
|
|
25
|
-
|
15
|
+
def self.find_table_container(metadata, resource_id, class_name)
|
16
|
+
metadata[:table].detect { |t| t.resource == resource_id && t.class == class_name }
|
17
|
+
end
|
26
18
|
|
19
|
+
def self.builds_tables(table_container, resource_id, lookup_types)
|
27
20
|
if table_container
|
28
|
-
table_container.tables.
|
29
|
-
|
21
|
+
table_container.tables.map do |table_fragment|
|
22
|
+
TableFactory.build(table_fragment, resource_id, lookup_types)
|
30
23
|
end
|
24
|
+
else
|
25
|
+
[]
|
31
26
|
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.build(rets_class_fragment, resource_id, lookup_types, metadata)
|
30
|
+
class_name = rets_class_fragment["ClassName"]
|
31
|
+
visible_name = rets_class_fragment["VisibleName"]
|
32
|
+
standard_name = rets_class_fragment["StandardName"]
|
33
|
+
description = rets_class_fragment["Description"]
|
32
34
|
|
33
|
-
|
35
|
+
table_container = find_table_container(metadata, resource_id, class_name)
|
36
|
+
tables = builds_tables(table_container, resource_id, lookup_types)
|
37
|
+
new(class_name, visible_name, standard_name, description, tables)
|
34
38
|
end
|
35
39
|
|
36
40
|
# Print the tree to a file
|
@@ -46,7 +50,7 @@ module Rets
|
|
46
50
|
end
|
47
51
|
|
48
52
|
def find_table(name)
|
49
|
-
tables.detect { |value| value.name == name }
|
53
|
+
tables.detect { |value| value.name.downcase == name.downcase }
|
50
54
|
end
|
51
55
|
end
|
52
56
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Rets
|
2
|
+
module Metadata
|
3
|
+
class RetsObject
|
4
|
+
attr_reader :name, :mime_type, :description
|
5
|
+
|
6
|
+
def initialize(name, mime_type, description)
|
7
|
+
@name = name
|
8
|
+
@mime_type = mime_type
|
9
|
+
@description = description
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.build(rets_object_fragment)
|
13
|
+
name = rets_object_fragment["VisibleName"]
|
14
|
+
mime_type = rets_object_fragment["MIMEType"]
|
15
|
+
description = rets_object_fragment["Description"]
|
16
|
+
new(name, mime_type, description)
|
17
|
+
end
|
18
|
+
|
19
|
+
def print_tree(out = $stdout)
|
20
|
+
out.puts " Object: #{name}"
|
21
|
+
out.puts " MimeType: #{mime_type}"
|
22
|
+
out.puts " Description: #{description}"
|
23
|
+
end
|
24
|
+
|
25
|
+
def ==(other)
|
26
|
+
name == other.name &&
|
27
|
+
mime_type == other.mime_type &&
|
28
|
+
description == other.description
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/rets/metadata/table.rb
CHANGED
@@ -1,31 +1,14 @@
|
|
1
1
|
module Rets
|
2
2
|
module Metadata
|
3
|
-
class TableFactory
|
4
|
-
def self.build(table_fragment, resource)
|
5
|
-
enum?(table_fragment) ? LookupTable.new(table_fragment, resource) : Table.new(table_fragment, resource)
|
6
|
-
end
|
7
|
-
|
8
|
-
def self.enum?(table_fragment)
|
9
|
-
lookup_value = table_fragment["LookupName"].strip
|
10
|
-
interpretation = table_fragment["Interpretation"].strip
|
11
|
-
|
12
|
-
interpretation =~ /Lookup/ && !lookup_value.empty?
|
13
|
-
end
|
14
|
-
end
|
15
|
-
|
16
3
|
class Table
|
17
|
-
|
18
|
-
attr_accessor :name
|
19
|
-
attr_accessor :long_name
|
20
|
-
attr_accessor :table_fragment
|
21
|
-
attr_accessor :resource
|
4
|
+
attr_reader :table_fragment, :resource_id, :name, :long_name
|
22
5
|
|
23
|
-
def initialize(table_fragment,
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
6
|
+
def initialize(table_fragment, resource_id)
|
7
|
+
@table_fragment = table_fragment
|
8
|
+
@resource_id = resource_id
|
9
|
+
|
10
|
+
@name = table_fragment["SystemName"]
|
11
|
+
@long_name = table_fragment["LongName"]
|
29
12
|
end
|
30
13
|
|
31
14
|
# Print the tree to a file
|
@@ -33,9 +16,9 @@ module Rets
|
|
33
16
|
# [out] The file to print to. Defaults to $stdout.
|
34
17
|
def print_tree(out = $stdout)
|
35
18
|
out.puts " Table: #{name}"
|
36
|
-
out.puts " Resource: #{
|
19
|
+
out.puts " Resource: #{resource_id}"
|
37
20
|
out.puts " ShortName: #{ table_fragment["ShortName"] }"
|
38
|
-
out.puts " LongName: #{
|
21
|
+
out.puts " LongName: #{ long_name }"
|
39
22
|
out.puts " StandardName: #{ table_fragment["StandardName"] }"
|
40
23
|
out.puts " Units: #{ table_fragment["Units"] }"
|
41
24
|
out.puts " Searchable: #{ table_fragment["Searchable"] }"
|
@@ -46,80 +29,5 @@ module Rets
|
|
46
29
|
value.to_s.strip
|
47
30
|
end
|
48
31
|
end
|
49
|
-
|
50
|
-
class LookupTable
|
51
|
-
attr_accessor :resource
|
52
|
-
attr_accessor :lookup_name
|
53
|
-
attr_accessor :name
|
54
|
-
attr_accessor :interpretation
|
55
|
-
attr_accessor :long_name
|
56
|
-
attr_accessor :table_fragment
|
57
|
-
|
58
|
-
def initialize(table_fragment, resource)
|
59
|
-
self.table_fragment = table_fragment
|
60
|
-
self.resource = resource
|
61
|
-
self.name = table_fragment["SystemName"]
|
62
|
-
self.interpretation = table_fragment["Interpretation"]
|
63
|
-
self.lookup_name = table_fragment["LookupName"]
|
64
|
-
self.long_name = table_fragment["LongName"]
|
65
|
-
end
|
66
|
-
|
67
|
-
def multi?
|
68
|
-
interpretation == "LookupMulti"
|
69
|
-
end
|
70
|
-
|
71
|
-
def lookup_types
|
72
|
-
resource.lookup_types[lookup_name]
|
73
|
-
end
|
74
|
-
|
75
|
-
# Print the tree to a file
|
76
|
-
#
|
77
|
-
# [out] The file to print to. Defaults to $stdout.
|
78
|
-
def print_tree(out = $stdout)
|
79
|
-
out.puts " LookupTable: #{name}"
|
80
|
-
out.puts " Resource: #{resource.id}"
|
81
|
-
out.puts " Required: #{table_fragment['Required']}"
|
82
|
-
out.puts " Searchable: #{ table_fragment["Searchable"] }"
|
83
|
-
out.puts " Units: #{ table_fragment["Units"] }"
|
84
|
-
out.puts " ShortName: #{ table_fragment["ShortName"] }"
|
85
|
-
out.puts " LongName: #{ table_fragment["LongName"] }"
|
86
|
-
out.puts " StandardName: #{ table_fragment["StandardName"] }"
|
87
|
-
out.puts " Types:"
|
88
|
-
|
89
|
-
lookup_types.each do |lookup_type|
|
90
|
-
lookup_type.print_tree(out)
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
def lookup_type(value)
|
95
|
-
lookup_types.detect {|lt| lt.value == value }
|
96
|
-
end
|
97
|
-
|
98
|
-
def resolve(value)
|
99
|
-
if value.empty?
|
100
|
-
return [] if multi?
|
101
|
-
return value.to_s.strip
|
102
|
-
end
|
103
|
-
|
104
|
-
values = multi? ? value.split(","): [value]
|
105
|
-
|
106
|
-
values = values.map do |v|
|
107
|
-
|
108
|
-
#Remove surrounding quotes
|
109
|
-
clean_value = v.scan(/^["']?(.*?)["']?$/).join
|
110
|
-
|
111
|
-
|
112
|
-
lookup_type = lookup_type(clean_value)
|
113
|
-
|
114
|
-
resolved_value = lookup_type ? lookup_type.long_value : nil
|
115
|
-
|
116
|
-
warn("Discarding unmappable value of #{clean_value.inspect}") if resolved_value.nil? && $VERBOSE
|
117
|
-
|
118
|
-
resolved_value
|
119
|
-
end
|
120
|
-
|
121
|
-
multi? ? values.map {|v| v.to_s.strip } : values.first.to_s.strip
|
122
|
-
end
|
123
|
-
end
|
124
32
|
end
|
125
33
|
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Rets
|
2
|
+
module Metadata
|
3
|
+
class TableFactory
|
4
|
+
|
5
|
+
def self.build(table_fragment, resource_id, lookup_types)
|
6
|
+
if table_fragment["LookupName"].empty?
|
7
|
+
Table.new(table_fragment, resource_id)
|
8
|
+
else
|
9
|
+
if table_fragment["Interpretation"] == "LookupMulti"
|
10
|
+
MultiLookupTable.build(table_fragment, resource_id, lookup_types)
|
11
|
+
else
|
12
|
+
LookupTable.build(table_fragment, resource_id, lookup_types)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module Rets
|
4
|
+
module Metadata
|
5
|
+
|
6
|
+
# Serialize/Deserialize metadata using Marshal.
|
7
|
+
class YamlSerializer
|
8
|
+
|
9
|
+
# Serialize to a file. The library reserves the right to change
|
10
|
+
# the type or contents of o, so don't depend on it being
|
11
|
+
# anything in particular.
|
12
|
+
def save(file, o)
|
13
|
+
YAML.dump(o, file)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Deserialize from a file. If the metadata cannot be
|
17
|
+
# deserialized, return nil.
|
18
|
+
def load(file)
|
19
|
+
YAML.load(file)
|
20
|
+
rescue Psych::SyntaxError
|
21
|
+
nil
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
data/lib/rets/parser/compact.rb
CHANGED
@@ -1,26 +1,64 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
require 'cgi'
|
1
3
|
module Rets
|
2
4
|
module Parser
|
3
5
|
class Compact
|
4
|
-
|
6
|
+
DEFAULT_DELIMITER = "\t"
|
7
|
+
|
8
|
+
INCLUDE_NULL_FIELDS = -1
|
5
9
|
|
6
10
|
InvalidDelimiter = Class.new(ArgumentError)
|
7
11
|
|
8
12
|
def self.parse_document(xml)
|
9
|
-
doc =
|
13
|
+
doc = SaxParser.new
|
14
|
+
parser = Nokogiri::XML::SAX::Parser.new(doc)
|
15
|
+
io = StringIO.new(xml.to_s)
|
16
|
+
|
17
|
+
parser.parse(io)
|
18
|
+
doc.results.map {|r| parse(doc.columns, r, doc.delimiter) }
|
19
|
+
end
|
10
20
|
|
11
|
-
|
12
|
-
|
21
|
+
class SaxParser < Nokogiri::XML::SAX::Document
|
22
|
+
attr_reader :results, :columns, :delimiter
|
13
23
|
|
14
|
-
|
15
|
-
|
24
|
+
def initialize
|
25
|
+
@results = []
|
26
|
+
@columns = ''
|
27
|
+
@result_index = nil
|
28
|
+
@delimiter = nil
|
29
|
+
@columns_start = false
|
30
|
+
@data_start = false
|
31
|
+
end
|
32
|
+
|
33
|
+
def start_element name, attrs=[]
|
34
|
+
case name
|
35
|
+
when 'DELIMITER'
|
36
|
+
@delimiter = attrs.last.last.to_i.chr
|
37
|
+
when 'COLUMNS'
|
38
|
+
@columns_start = true
|
39
|
+
when 'DATA'
|
40
|
+
@result_index = @results.size
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def end_element name
|
45
|
+
case name
|
46
|
+
when 'COLUMNS'
|
47
|
+
@columns_start = false
|
48
|
+
when 'DATA'
|
49
|
+
@result_index = nil
|
50
|
+
end
|
16
51
|
end
|
17
52
|
|
18
|
-
|
19
|
-
|
53
|
+
def characters string
|
54
|
+
if @columns_start
|
55
|
+
@columns << string
|
56
|
+
end
|
20
57
|
|
21
|
-
|
22
|
-
|
23
|
-
|
58
|
+
if @result_index
|
59
|
+
@results[@result_index] ||= ''
|
60
|
+
@results[@result_index] << string
|
61
|
+
end
|
24
62
|
end
|
25
63
|
end
|
26
64
|
|
@@ -29,10 +67,16 @@ module Rets
|
|
29
67
|
# Delimiter must be a regexp because String#split behaves differently when
|
30
68
|
# given a string pattern. (It removes leading spaces).
|
31
69
|
#
|
32
|
-
def self.
|
33
|
-
|
70
|
+
def self.parse(columns, data, delimiter = nil)
|
71
|
+
delimiter ||= DEFAULT_DELIMITER
|
72
|
+
delimiter = Regexp.new(Regexp.escape(delimiter))
|
73
|
+
|
74
|
+
if delimiter == // || delimiter == /,/
|
75
|
+
raise Rets::Parser::Compact::InvalidDelimiter, "Empty or invalid delimiter found, unable to parse."
|
76
|
+
end
|
34
77
|
|
35
|
-
|
78
|
+
column_names = columns.split(delimiter)
|
79
|
+
data_values = data.split(delimiter, INCLUDE_NULL_FIELDS).map { |x| CGI.unescapeHTML(x) }
|
36
80
|
|
37
81
|
zipped_key_values = column_names.zip(data_values).map { |k, v| [k.freeze, v.to_s] }
|
38
82
|
|
@@ -43,10 +87,9 @@ module Rets
|
|
43
87
|
def self.get_count(xml)
|
44
88
|
doc = Nokogiri.parse(xml.to_s)
|
45
89
|
if node = doc.at("//COUNT")
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
return 0 if node.attr('ReplyCode') == '20201'
|
90
|
+
node.attr('Records').to_i
|
91
|
+
else
|
92
|
+
0
|
50
93
|
end
|
51
94
|
end
|
52
95
|
|
@@ -1,6 +1,8 @@
|
|
1
1
|
module Rets
|
2
2
|
module Parser
|
3
3
|
class ErrorChecker
|
4
|
+
INVALID_REQUEST_ERROR_MAPPING = Hash[Rets.constants.map {|c| Rets.const_get(c) }.select { |klass| klass.is_a?(Class) && klass < Rets::InvalidRequest }.map {|klass| [klass.const_get('ERROR_CODE'), klass] }]
|
5
|
+
|
4
6
|
def self.check(response)
|
5
7
|
# some RETS servers returns HTTP code 412 when session cookie expired, yet the response body
|
6
8
|
# passes XML check. We need to special case for this situation.
|
@@ -26,7 +28,12 @@ module Rets
|
|
26
28
|
elsif reply_code == NoObjectFound::ERROR_CODE
|
27
29
|
raise NoObjectFound.new(reply_text)
|
28
30
|
elsif reply_code.nonzero?
|
29
|
-
|
31
|
+
error_class = INVALID_REQUEST_ERROR_MAPPING[reply_code]
|
32
|
+
if error_class
|
33
|
+
raise error_class.new(reply_code, reply_text)
|
34
|
+
else
|
35
|
+
raise InvalidRequest.new(reply_code, reply_text)
|
36
|
+
end
|
30
37
|
else
|
31
38
|
return
|
32
39
|
end
|
data/test/fixtures.rb
CHANGED
@@ -65,6 +65,16 @@ INVALID_DELIMETER = <<-XML
|
|
65
65
|
</METADATA>
|
66
66
|
XML
|
67
67
|
|
68
|
+
CHANGED_DELIMITER = <<-XML
|
69
|
+
<?xml version="1.0"?>
|
70
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
71
|
+
<DELIMITER value="45" />
|
72
|
+
<COLUMNS>A-B</COLUMNS>
|
73
|
+
<DATA>1-2</DATA>
|
74
|
+
<DATA>4-5</DATA>
|
75
|
+
</METADATA>
|
76
|
+
XML
|
77
|
+
|
68
78
|
COMPACT = <<-XML
|
69
79
|
<?xml version="1.0"?>
|
70
80
|
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
@@ -255,3 +265,51 @@ SAMPLE_COMPACT_WITH_SPECIAL_CHARS_2 = <<EOF
|
|
255
265
|
<DATA> text with <tag> 1999 00 </DATA>
|
256
266
|
</RETS>
|
257
267
|
EOF
|
268
|
+
|
269
|
+
SAMPLE_PROPERTY_WITH_LOTS_OF_COLUMNS = <<EOF
|
270
|
+
<RETS ReplyCode=\"0\" ReplyText=\"Operation Success.\">
|
271
|
+
<DELIMITER value=\"09\" />
|
272
|
+
<COLUMNS>\t#{800.times.map { |x| "K%03d" % x }.join("\t") }\t</COLUMNS>
|
273
|
+
<DATA>\t\t</DATA>
|
274
|
+
</RETS>
|
275
|
+
EOF
|
276
|
+
|
277
|
+
EXAMPLE_METADATA_TREE = <<EOF
|
278
|
+
Resource: Properties (Key Field: matrix_unique_key)
|
279
|
+
Class: T100
|
280
|
+
Visible Name: Prop
|
281
|
+
Description : some description
|
282
|
+
Table: L_1
|
283
|
+
Resource: Properties
|
284
|
+
ShortName: Sq
|
285
|
+
LongName: Square Footage
|
286
|
+
StandardName: Sqft
|
287
|
+
Units: Meters
|
288
|
+
Searchable: Y
|
289
|
+
Required: N
|
290
|
+
LookupTable: L_10
|
291
|
+
Resource: Properties
|
292
|
+
Required: N
|
293
|
+
Searchable: Y
|
294
|
+
Units:
|
295
|
+
ShortName: HF
|
296
|
+
LongName: HOA Frequency
|
297
|
+
StandardName: HOA F
|
298
|
+
Types:
|
299
|
+
Quarterly -> Q
|
300
|
+
Annually -> A
|
301
|
+
MultiLookupTable: L_11
|
302
|
+
Resource: Properties
|
303
|
+
Required: N
|
304
|
+
Searchable: Y
|
305
|
+
Units:
|
306
|
+
ShortName: HFs
|
307
|
+
LongName: HOA Frequencies
|
308
|
+
StandardName: HOA Fs
|
309
|
+
Types:
|
310
|
+
Quarterly -> Q
|
311
|
+
Annually -> A
|
312
|
+
Object: Photo
|
313
|
+
MimeType: photo/jpg
|
314
|
+
Description: photo description
|
315
|
+
EOF
|