rets-hack 0.11

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