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.
- 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
|