rets 0.9.0 → 0.10.0

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/Manifest.txt +24 -0
  4. data/README.md +73 -1
  5. data/Rakefile +1 -1
  6. data/lib/rets.rb +202 -1
  7. data/lib/rets/client.rb +83 -94
  8. data/lib/rets/http_client.rb +42 -0
  9. data/lib/rets/metadata.rb +15 -3
  10. data/lib/rets/metadata/caching.rb +59 -0
  11. data/lib/rets/metadata/file_cache.rb +29 -0
  12. data/lib/rets/metadata/json_serializer.rb +27 -0
  13. data/lib/rets/metadata/lookup_table.rb +65 -0
  14. data/lib/rets/metadata/lookup_type.rb +3 -4
  15. data/lib/rets/metadata/marshal_serializer.rb +27 -0
  16. data/lib/rets/metadata/multi_lookup_table.rb +70 -0
  17. data/lib/rets/metadata/null_cache.rb +24 -0
  18. data/lib/rets/metadata/resource.rb +39 -29
  19. data/lib/rets/metadata/rets_class.rb +27 -23
  20. data/lib/rets/metadata/rets_object.rb +32 -0
  21. data/lib/rets/metadata/table.rb +9 -101
  22. data/lib/rets/metadata/table_factory.rb +19 -0
  23. data/lib/rets/metadata/yaml_serializer.rb +27 -0
  24. data/lib/rets/parser/compact.rb +61 -18
  25. data/lib/rets/parser/error_checker.rb +8 -1
  26. data/test/fixtures.rb +58 -0
  27. data/test/test_caching.rb +89 -0
  28. data/test/test_client.rb +44 -24
  29. data/test/test_error_checker.rb +18 -0
  30. data/test/test_file_cache.rb +42 -0
  31. data/test/test_http_client.rb +96 -60
  32. data/test/test_json_serializer.rb +26 -0
  33. data/test/test_marshal_serializer.rb +26 -0
  34. data/test/test_metadata.rb +62 -450
  35. data/test/test_metadata_class.rb +50 -0
  36. data/test/test_metadata_lookup_table.rb +21 -0
  37. data/test/test_metadata_lookup_type.rb +12 -0
  38. data/test/test_metadata_multi_lookup_table.rb +60 -0
  39. data/test/test_metadata_object.rb +20 -0
  40. data/test/test_metadata_resource.rb +140 -0
  41. data/test/test_metadata_root.rb +151 -0
  42. data/test/test_metadata_table.rb +21 -0
  43. data/test/test_metadata_table_factory.rb +24 -0
  44. data/test/test_parser_compact.rb +23 -28
  45. data/test/test_yaml_serializer.rb +26 -0
  46. metadata +29 -5
@@ -1,36 +1,40 @@
1
1
  module Rets
2
2
  module Metadata
3
3
  class RetsClass
4
- attr_accessor :tables
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 self.find_table_container(metadata, resource, rets_class)
19
- metadata[:table].detect { |t| t.resource == resource.id && t.class == rets_class.name }
20
- end
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
- def self.build(rets_class_fragment, resource, metadata)
23
- rets_class = new(rets_class_fragment, resource)
12
+ @tables = tables
13
+ end
24
14
 
25
- table_container = find_table_container(metadata, resource, rets_class)
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.each do |table_fragment|
29
- rets_class.tables << TableFactory.build(table_fragment, resource)
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
- rets_class
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
@@ -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
- attr_accessor :type
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, resource)
24
- self.table_fragment = table_fragment
25
- self.resource = resource
26
- self.type = table_fragment["DataType"]
27
- self.name = table_fragment["SystemName"]
28
- self.long_name = table_fragment["LongName"]
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: #{resource.id}"
19
+ out.puts " Resource: #{resource_id}"
37
20
  out.puts " ShortName: #{ table_fragment["ShortName"] }"
38
- out.puts " LongName: #{ table_fragment["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
@@ -1,26 +1,64 @@
1
+ # coding: utf-8
2
+ require 'cgi'
1
3
  module Rets
2
4
  module Parser
3
5
  class Compact
4
- TAB = /\t/
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 = Nokogiri.parse(xml.to_s)
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
- delimiter = doc.at("//DELIMITER")
12
- delimiter = delimiter ? Regexp.new(Regexp.escape(delimiter.attr(:value).to_i.chr)) : TAB
21
+ class SaxParser < Nokogiri::XML::SAX::Document
22
+ attr_reader :results, :columns, :delimiter
13
23
 
14
- if delimiter == // || delimiter == /,/
15
- raise InvalidDelimiter, "Empty or invalid delimiter found, unable to parse."
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
- column_node = doc.at("//COLUMNS")
19
- column_names = column_node.nil? ? [] : column_node.text.split(delimiter)
53
+ def characters string
54
+ if @columns_start
55
+ @columns << string
56
+ end
20
57
 
21
- rows = doc.xpath("//DATA")
22
- rows.map do |data|
23
- self.parse_row(column_names, data.text, delimiter)
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.parse_row(column_names, data, delimiter = TAB)
33
- raise ArgumentError, "Delimiter must be a regular expression" unless Regexp === delimiter
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
- data_values = data.split(delimiter).map { |x| CGI.unescapeHTML(x) }
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
- return node.attr('Records').to_i
47
- elsif node = doc.at("//RETS-STATUS")
48
- # Handle <RETS-STATUS ReplyCode="20201" ReplyText="No matching records were found" />
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
- raise InvalidRequest.new(reply_code, reply_text)
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
@@ -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 &lt;tag&gt; 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