rets 0.9.0 → 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
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