rets-hack 0.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +142 -0
- data/Manifest.txt +58 -0
- data/README.md +129 -0
- data/Rakefile +28 -0
- data/bin/rets +202 -0
- data/example/connect.rb +19 -0
- data/example/get-photos.rb +20 -0
- data/example/get-property.rb +16 -0
- data/lib/rets/client.rb +373 -0
- data/lib/rets/client_progress_reporter.rb +48 -0
- data/lib/rets/http_client.rb +133 -0
- data/lib/rets/locking_http_client.rb +34 -0
- data/lib/rets/measuring_http_client.rb +27 -0
- data/lib/rets/metadata/caching.rb +59 -0
- data/lib/rets/metadata/containers.rb +89 -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 +19 -0
- 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 +103 -0
- data/lib/rets/metadata/rets_class.rb +57 -0
- data/lib/rets/metadata/rets_object.rb +41 -0
- data/lib/rets/metadata/root.rb +155 -0
- data/lib/rets/metadata/table.rb +33 -0
- data/lib/rets/metadata/table_factory.rb +19 -0
- data/lib/rets/metadata/yaml_serializer.rb +27 -0
- data/lib/rets/metadata.rb +18 -0
- data/lib/rets/parser/compact.rb +117 -0
- data/lib/rets/parser/error_checker.rb +56 -0
- data/lib/rets/parser/multipart.rb +39 -0
- data/lib/rets.rb +269 -0
- data/test/fixtures.rb +324 -0
- data/test/helper.rb +14 -0
- data/test/test_caching.rb +89 -0
- data/test/test_client.rb +307 -0
- data/test/test_error_checker.rb +87 -0
- data/test/test_file_cache.rb +42 -0
- data/test/test_http_client.rb +132 -0
- data/test/test_json_serializer.rb +26 -0
- data/test/test_locking_http_client.rb +29 -0
- data/test/test_marshal_serializer.rb +26 -0
- data/test/test_metadata.rb +71 -0
- data/test/test_metadata_class.rb +50 -0
- data/test/test_metadata_lookup_table.rb +21 -0
- data/test/test_metadata_lookup_type.rb +21 -0
- data/test/test_metadata_multi_lookup_table.rb +60 -0
- data/test/test_metadata_object.rb +33 -0
- data/test/test_metadata_resource.rb +148 -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 +115 -0
- data/test/test_parser_multipart.rb +39 -0
- data/test/test_yaml_serializer.rb +26 -0
- data/test/vcr_cassettes/unauthorized_response.yml +262 -0
- 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
|