rets-hack 0.11

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 (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +142 -0
  3. data/Manifest.txt +58 -0
  4. data/README.md +129 -0
  5. data/Rakefile +28 -0
  6. data/bin/rets +202 -0
  7. data/example/connect.rb +19 -0
  8. data/example/get-photos.rb +20 -0
  9. data/example/get-property.rb +16 -0
  10. data/lib/rets/client.rb +373 -0
  11. data/lib/rets/client_progress_reporter.rb +48 -0
  12. data/lib/rets/http_client.rb +133 -0
  13. data/lib/rets/locking_http_client.rb +34 -0
  14. data/lib/rets/measuring_http_client.rb +27 -0
  15. data/lib/rets/metadata/caching.rb +59 -0
  16. data/lib/rets/metadata/containers.rb +89 -0
  17. data/lib/rets/metadata/file_cache.rb +29 -0
  18. data/lib/rets/metadata/json_serializer.rb +27 -0
  19. data/lib/rets/metadata/lookup_table.rb +65 -0
  20. data/lib/rets/metadata/lookup_type.rb +19 -0
  21. data/lib/rets/metadata/marshal_serializer.rb +27 -0
  22. data/lib/rets/metadata/multi_lookup_table.rb +70 -0
  23. data/lib/rets/metadata/null_cache.rb +24 -0
  24. data/lib/rets/metadata/resource.rb +103 -0
  25. data/lib/rets/metadata/rets_class.rb +57 -0
  26. data/lib/rets/metadata/rets_object.rb +41 -0
  27. data/lib/rets/metadata/root.rb +155 -0
  28. data/lib/rets/metadata/table.rb +33 -0
  29. data/lib/rets/metadata/table_factory.rb +19 -0
  30. data/lib/rets/metadata/yaml_serializer.rb +27 -0
  31. data/lib/rets/metadata.rb +18 -0
  32. data/lib/rets/parser/compact.rb +117 -0
  33. data/lib/rets/parser/error_checker.rb +56 -0
  34. data/lib/rets/parser/multipart.rb +39 -0
  35. data/lib/rets.rb +269 -0
  36. data/test/fixtures.rb +324 -0
  37. data/test/helper.rb +14 -0
  38. data/test/test_caching.rb +89 -0
  39. data/test/test_client.rb +307 -0
  40. data/test/test_error_checker.rb +87 -0
  41. data/test/test_file_cache.rb +42 -0
  42. data/test/test_http_client.rb +132 -0
  43. data/test/test_json_serializer.rb +26 -0
  44. data/test/test_locking_http_client.rb +29 -0
  45. data/test/test_marshal_serializer.rb +26 -0
  46. data/test/test_metadata.rb +71 -0
  47. data/test/test_metadata_class.rb +50 -0
  48. data/test/test_metadata_lookup_table.rb +21 -0
  49. data/test/test_metadata_lookup_type.rb +21 -0
  50. data/test/test_metadata_multi_lookup_table.rb +60 -0
  51. data/test/test_metadata_object.rb +33 -0
  52. data/test/test_metadata_resource.rb +148 -0
  53. data/test/test_metadata_root.rb +151 -0
  54. data/test/test_metadata_table.rb +21 -0
  55. data/test/test_metadata_table_factory.rb +24 -0
  56. data/test/test_parser_compact.rb +115 -0
  57. data/test/test_parser_multipart.rb +39 -0
  58. data/test/test_yaml_serializer.rb +26 -0
  59. data/test/vcr_cassettes/unauthorized_response.yml +262 -0
  60. metadata +227 -0
@@ -0,0 +1,155 @@
1
+ module Rets
2
+ module Metadata
3
+ METADATA_TYPES = %w(SYSTEM RESOURCE CLASS TABLE LOOKUP LOOKUP_TYPE OBJECT)
4
+
5
+ # It's useful when dealing with the Rets standard to represent their
6
+ # relatively flat namespace of interweived components as a Tree. With
7
+ # a collection of resources at the top, and their various, classes,
8
+ # tables, lookups, and lookup types underneath.
9
+ #
10
+ # It looks something like ...
11
+ #
12
+ # Resource
13
+ # |
14
+ # Class
15
+ # |
16
+ # `-- Table
17
+ # |
18
+ # `-- Lookups
19
+ # |
20
+ # `-- LookupType
21
+ #
22
+ # For our purposes it was helpful to denormalize some of the more deeply
23
+ # nested branches. In particular by relating Lookups to LookupTypes, and
24
+ # Tables to lookups with can simplify this diagram.
25
+ #
26
+ #
27
+ # Resource
28
+ # |
29
+ # Class
30
+ # |
31
+ # `-- Table
32
+ # |
33
+ # `-- Lookups
34
+ #
35
+ # By associating Tables and lookups when we parse this structure. It allows
36
+ # us to seemlessly map Lookup values to their Long or Short value forms.
37
+ class Root
38
+ # Metadata_types is the low level parsed representation of the raw xml
39
+ # sources. Just one level up, they contain Containers, consisting of
40
+ # SystemContainers or RowContainers
41
+ attr_writer :metadata_types
42
+
43
+ # the tree is the high level represenation of the metadata heiarchy
44
+ # it begins with root. Stored as a list of Metadata::Resources
45
+ attr_writer :tree
46
+
47
+ # Sources are the raw xml documents fetched for each metadata type
48
+ # they are stored as a hash with the type names as their keys
49
+ # and the raw xml as the values
50
+ attr_reader :sources
51
+
52
+ # Metadata can be unmarshalled from cache. @logger is not set during that process, constructor is not called.
53
+ # Client code must set it after unmarshalling.
54
+ attr_reader :logger
55
+
56
+ # fetcher is a proc that inverts control to the client to retrieve metadata
57
+ # types
58
+ def initialize(logger, sources)
59
+ @logger = logger
60
+ @tree = nil
61
+ @metadata_types = nil # TODO think up a better name ... containers?
62
+ @sources = sources
63
+ end
64
+
65
+ def marshal_dump
66
+ sources
67
+ end
68
+
69
+ def version
70
+ metadata_types[:system].first.version
71
+ end
72
+
73
+ def date
74
+ metadata_types[:system].first.date
75
+ end
76
+
77
+ # Wether there exists a more up to date version of the metadata to fetch
78
+ # is dependant on either a timestamp indicating when the most recent
79
+ # version was published, or a version number. These values may or may
80
+ # not exist on any given rets server.
81
+ def current?(current_timestamp, current_version)
82
+ if !current_version.to_s.empty? && !version.to_s.empty?
83
+ current_version == version
84
+ else
85
+ current_timestamp ? current_timestamp == date : true
86
+ end
87
+ end
88
+
89
+ def build_tree
90
+ tree = Hash.new { |h, k| h.key?(k.downcase) ? h[k.downcase] : nil }
91
+
92
+ resource_containers = metadata_types[:resource]
93
+
94
+ resource_containers.each do |resource_container|
95
+ resource_container.rows.each do |resource_fragment|
96
+ resource = Resource.build(resource_fragment, metadata_types, @logger)
97
+ #some mlses list resource types without an associated data, throw those away
98
+ tree[resource.id.downcase] = resource if resource
99
+ end
100
+ end
101
+
102
+ tree
103
+ end
104
+
105
+ def tree
106
+ @tree ||= build_tree
107
+ end
108
+
109
+ # Print the tree to a file
110
+ #
111
+ # [out] The file to print to. Defaults to $stdout.
112
+ def print_tree(out = $stdout)
113
+ tree.each do |name, value|
114
+ value.print_tree(out)
115
+ end
116
+ end
117
+
118
+ def metadata_types
119
+ return @metadata_types if @metadata_types
120
+
121
+ h = {}
122
+
123
+ sources.each do |name, source|
124
+ h[name.downcase.to_sym] = build_containers(Nokogiri.parse(source))
125
+ end
126
+
127
+ @metadata_types = h
128
+ end
129
+
130
+ # Returns an array of container classes that represents
131
+ # the metadata stored in the document provided.
132
+ def build_containers(doc)
133
+ # find all tags that match /RETS/METADATA-*
134
+ fragments = doc.xpath("/RETS/*[starts-with(name(), 'METADATA-')]")
135
+
136
+ fragments.map { |fragment| build_container(fragment) }
137
+ end
138
+
139
+ def build_container(fragment)
140
+ tag = fragment.name # METADATA-RESOURCE
141
+ type = tag.sub(/^METADATA-/, "") # RESOURCE
142
+
143
+ class_name = type.capitalize.gsub(/_(\w)/) { $1.upcase }
144
+ container_name = "#{class_name}Container"
145
+
146
+ if ::RUBY_VERSION < '1.9'
147
+ container_class = Containers.const_defined?(container_name) ? Containers.const_get(container_name) : Containers::Container
148
+ else
149
+ container_class = Containers.const_defined?(container_name, true) ? Containers.const_get(container_name, true) : Containers::Container
150
+ end
151
+ container_class.new(fragment)
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,33 @@
1
+ module Rets
2
+ module Metadata
3
+ class Table
4
+ attr_reader :table_fragment, :resource_id, :name, :long_name
5
+
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"]
12
+ end
13
+
14
+ # Print the tree to a file
15
+ #
16
+ # [out] The file to print to. Defaults to $stdout.
17
+ def print_tree(out = $stdout)
18
+ out.puts "### Table: #{name}"
19
+ out.puts " Resource: #{resource_id}"
20
+ out.puts " ShortName: #{ table_fragment["ShortName"] }"
21
+ out.puts " LongName: #{ long_name }"
22
+ out.puts " StandardName: #{ table_fragment["StandardName"] }"
23
+ out.puts " Units: #{ table_fragment["Units"] }"
24
+ out.puts " Searchable: #{ table_fragment["Searchable"] }"
25
+ out.puts " Required: #{table_fragment['Required']}"
26
+ end
27
+
28
+ def resolve(value)
29
+ value.to_s.strip
30
+ end
31
+ end
32
+ end
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
@@ -0,0 +1,18 @@
1
+ require 'rets/metadata/caching'
2
+ require 'rets/metadata/null_cache'
3
+ require 'rets/metadata/file_cache'
4
+ require 'rets/metadata/json_serializer'
5
+ require 'rets/metadata/yaml_serializer'
6
+ require 'rets/metadata/marshal_serializer'
7
+
8
+ require 'rets/metadata/containers'
9
+
10
+ require 'rets/metadata/root'
11
+ require 'rets/metadata/resource'
12
+ require 'rets/metadata/lookup_type'
13
+ require 'rets/metadata/table_factory'
14
+ require 'rets/metadata/table'
15
+ require 'rets/metadata/lookup_table'
16
+ require 'rets/metadata/multi_lookup_table'
17
+ require 'rets/metadata/rets_class'
18
+ require 'rets/metadata/rets_object'
@@ -0,0 +1,117 @@
1
+ # coding: utf-8
2
+ require 'cgi'
3
+
4
+ module Rets
5
+ module Parser
6
+ class Compact
7
+ DEFAULT_DELIMITER = "\t"
8
+
9
+ INCLUDE_NULL_FIELDS = -1
10
+
11
+ InvalidDelimiter = Class.new(ArgumentError)
12
+
13
+ def self.parse_document(xml)
14
+ doc = SaxParser.new
15
+ parser = Nokogiri::XML::SAX::Parser.new(doc)
16
+ io = StringIO.new(xml.to_s)
17
+
18
+ parser.parse(io)
19
+ doc.results.map {|r| parse(doc.columns, r, doc.delimiter) }
20
+ end
21
+
22
+ class SaxParser < Nokogiri::XML::SAX::Document
23
+ attr_reader :results, :columns, :delimiter
24
+
25
+ def initialize
26
+ @results = []
27
+ @columns = ''
28
+ @result_index = nil
29
+ @delimiter = nil
30
+ @columns_start = false
31
+ @data_start = false
32
+ end
33
+
34
+ def start_element name, attrs=[]
35
+ case name
36
+ when 'DELIMITER'
37
+ @delimiter = attrs.last.last.to_i.chr
38
+ when 'COLUMNS'
39
+ @columns_start = true
40
+ when 'DATA'
41
+ @result_index = @results.size
42
+ end
43
+ end
44
+
45
+ def end_element name
46
+ case name
47
+ when 'COLUMNS'
48
+ @columns_start = false
49
+ when 'DATA'
50
+ @result_index = nil
51
+ end
52
+ end
53
+
54
+ def characters string
55
+ if @columns_start
56
+ @columns << string
57
+ end
58
+
59
+ if @result_index
60
+ @results[@result_index] ||= ''
61
+ @results[@result_index] << string
62
+ end
63
+ end
64
+ end
65
+
66
+ # Parses a single row of RETS-COMPACT data.
67
+ #
68
+ # Delimiter must be a regexp because String#split behaves differently when
69
+ # given a string pattern. (It removes leading spaces).
70
+ #
71
+ def self.parse(columns, data, delimiter = nil)
72
+ delimiter ||= DEFAULT_DELIMITER
73
+ delimiter = Regexp.new(Regexp.escape(delimiter))
74
+
75
+ if delimiter == // || delimiter == /,/
76
+ raise Rets::Parser::Compact::InvalidDelimiter, "Empty or invalid delimiter found, unable to parse."
77
+ end
78
+
79
+ column_names = columns.split(delimiter)
80
+ data_values = data.split(delimiter, INCLUDE_NULL_FIELDS).map do |x|
81
+ safely_decode_character_references!(x)
82
+ CGI.unescape_html(x)
83
+ end
84
+
85
+ zipped_key_values = column_names.zip(data_values).map { |k, v| [k.freeze, v.to_s] }
86
+
87
+ hash = Hash[*zipped_key_values.flatten]
88
+ hash.reject { |key, value| key.empty? && value.to_s.empty? }
89
+ end
90
+
91
+ def self.safely_decode_character_references!(string)
92
+ string.gsub!(/&#(x)?([\h]+);/) do
93
+ if $2
94
+ base = $1 == "x" ? 16 : 10
95
+ int = Integer($2, base)
96
+ begin
97
+ int.chr(Encoding::UTF_8)
98
+ rescue RangeError
99
+ ""
100
+ end
101
+ end
102
+ end
103
+ string
104
+ end
105
+
106
+ def self.get_count(xml)
107
+ doc = Nokogiri.parse(xml.to_s)
108
+ if node = doc.at("//COUNT")
109
+ node.attr('Records').to_i
110
+ else
111
+ 0
112
+ end
113
+ end
114
+
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,56 @@
1
+ module Rets
2
+ module Parser
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
+
6
+ def self.check(response)
7
+ # some RETS servers returns HTTP code 412 when session cookie expired, yet the response body
8
+ # passes XML check. We need to special case for this situation.
9
+ # This method is also called from multipart.rb where there are headers and body but no status_code
10
+ if response.respond_to?(:status_code) && response.status_code == 412
11
+ raise HttpError, "HTTP status: #{response.status_code}, body: #{response.body}"
12
+ end
13
+
14
+ # some RETS servers return success code in XML body but failure code 4xx in http status
15
+ # If xml body is present we ignore http status
16
+
17
+ if !response.body.empty?
18
+ begin
19
+ xml = Nokogiri::XML.parse(response.body, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
20
+
21
+ rets_element = xml.xpath("/RETS")
22
+ unless rets_element.empty?
23
+ reply_text = (rets_element.attr("ReplyText") || rets_element.attr("replyText")).value
24
+ reply_code = (rets_element.attr("ReplyCode") || rets_element.attr("replyCode")).value.to_i
25
+
26
+ if reply_code == NoRecordsFound::ERROR_CODE
27
+ raise NoRecordsFound.new(reply_text)
28
+ elsif reply_code == NoObjectFound::ERROR_CODE
29
+ raise NoObjectFound.new(reply_text)
30
+ elsif reply_code.nonzero?
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
37
+ else
38
+ return
39
+ end
40
+ end
41
+ rescue Nokogiri::XML::SyntaxError
42
+ #Not xml
43
+ end
44
+ end
45
+
46
+ if response.respond_to?(:ok?) && ! response.ok?
47
+ if response.status_code == 401
48
+ raise AuthorizationFailure.new(response.status_code, response.body)
49
+ else
50
+ raise HttpError, "HTTP status: #{response.status_code}, body: #{response.body}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ module Rets
2
+ module Parser
3
+ # Inspired by Mail.
4
+ class Multipart
5
+ CRLF = "\r\n"
6
+ WSP = "\s"
7
+ HEADER_LINE = /^([!-9;-~]+:\s*.+)$/
8
+
9
+ Part = Struct.new(:headers, :body)
10
+
11
+ def self.parse(raw, boundary)
12
+ parts = []
13
+ boundary_regexp = /--#{Regexp.quote(boundary)}(--)?#{CRLF}/
14
+
15
+ # WTF some RETS servers declare response body including jpeg binary is encoded in utf8
16
+ raw.force_encoding 'ascii-8bit' if raw.respond_to?(:force_encoding)
17
+
18
+ raw.split(boundary_regexp).each do |chunk|
19
+ header_part, body_part = chunk.split(/#{CRLF}#{WSP}*#{CRLF}/m, 2)
20
+
21
+ if header_part =~ HEADER_LINE
22
+ headers = header_part.split(/\r\n/).map { |kv| p = kv.split(/:\s?/); [p[0].downcase, p[1..-1].join(':')] }
23
+ headers = Hash[*headers.flatten]
24
+ parts << Part.new(headers, body_part)
25
+ else
26
+ next # not a valid chunk.
27
+ end
28
+ end
29
+ check_for_invalids_parts!(parts)
30
+ parts
31
+ end
32
+
33
+ def self.check_for_invalids_parts!(parts)
34
+ return unless parts.length == 1 && parts.first.headers['content-type'] == 'text/xml'
35
+ ErrorChecker.check(parts.first)
36
+ end
37
+ end
38
+ end
39
+ end