sean-rets 0.6.0
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/.gemtest +0 -0
- data/CHANGELOG.md +89 -0
- data/Manifest.txt +28 -0
- data/README.md +47 -0
- data/Rakefile +27 -0
- data/bin/rets +202 -0
- data/lib/rets.rb +45 -0
- data/lib/rets/client.rb +391 -0
- data/lib/rets/client_progress_reporter.rb +44 -0
- data/lib/rets/http_client.rb +91 -0
- data/lib/rets/locking_http_client.rb +34 -0
- data/lib/rets/measuring_http_client.rb +27 -0
- data/lib/rets/metadata.rb +6 -0
- data/lib/rets/metadata/containers.rb +84 -0
- data/lib/rets/metadata/lookup_type.rb +17 -0
- data/lib/rets/metadata/resource.rb +84 -0
- data/lib/rets/metadata/rets_class.rb +48 -0
- data/lib/rets/metadata/root.rb +152 -0
- data/lib/rets/metadata/table.rb +113 -0
- data/lib/rets/parser/compact.rb +62 -0
- data/lib/rets/parser/multipart.rb +40 -0
- data/test/fixtures.rb +212 -0
- data/test/helper.rb +14 -0
- data/test/test_client.rb +238 -0
- data/test/test_locking_http_client.rb +29 -0
- data/test/test_metadata.rb +459 -0
- data/test/test_parser_compact.rb +86 -0
- data/test/test_parser_multipart.rb +39 -0
- data/test/vcr_cassettes/unauthorized_response.yml +262 -0
- metadata +186 -0
@@ -0,0 +1,113 @@
|
|
1
|
+
module Rets
|
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)
|
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
|
+
class Table
|
17
|
+
attr_accessor :type
|
18
|
+
attr_accessor :name
|
19
|
+
attr_accessor :long_name
|
20
|
+
attr_accessor :table_fragment
|
21
|
+
|
22
|
+
def initialize(table_fragment)
|
23
|
+
self.table_fragment = table_fragment
|
24
|
+
self.type = table_fragment["DataType"]
|
25
|
+
self.name = table_fragment["SystemName"]
|
26
|
+
self.long_name = table_fragment["LongName"]
|
27
|
+
end
|
28
|
+
|
29
|
+
def print_tree
|
30
|
+
puts " Table: #{name}"
|
31
|
+
puts " ShortName: #{ table_fragment["ShortName"] }"
|
32
|
+
puts " LongName: #{ table_fragment["LongName"] }"
|
33
|
+
puts " StandardName: #{ table_fragment["StandardName"] }"
|
34
|
+
puts " Units: #{ table_fragment["Units"] }"
|
35
|
+
puts " Searchable: #{ table_fragment["Searchable"] }"
|
36
|
+
puts " Required: #{table_fragment['Required']}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def resolve(value)
|
40
|
+
value.to_s.strip
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class LookupTable
|
45
|
+
attr_accessor :resource
|
46
|
+
attr_accessor :lookup_name
|
47
|
+
attr_accessor :name
|
48
|
+
attr_accessor :interpretation
|
49
|
+
attr_accessor :long_name
|
50
|
+
attr_accessor :table_fragment
|
51
|
+
|
52
|
+
def initialize(table_fragment, resource)
|
53
|
+
self.table_fragment = table_fragment
|
54
|
+
self.resource = resource
|
55
|
+
self.name = table_fragment["SystemName"]
|
56
|
+
self.interpretation = table_fragment["Interpretation"]
|
57
|
+
self.lookup_name = table_fragment["LookupName"]
|
58
|
+
self.long_name = table_fragment["LongName"]
|
59
|
+
end
|
60
|
+
|
61
|
+
def multi?
|
62
|
+
interpretation == "LookupMulti"
|
63
|
+
end
|
64
|
+
|
65
|
+
def lookup_types
|
66
|
+
resource.lookup_types[lookup_name]
|
67
|
+
end
|
68
|
+
|
69
|
+
def print_tree
|
70
|
+
puts " LookupTable: #{name}"
|
71
|
+
puts " Required: #{table_fragment['Required']}"
|
72
|
+
puts " Searchable: #{ table_fragment["Searchable"] }"
|
73
|
+
puts " Units: #{ table_fragment["Units"] }"
|
74
|
+
puts " ShortName: #{ table_fragment["ShortName"] }"
|
75
|
+
puts " LongName: #{ table_fragment["LongName"] }"
|
76
|
+
puts " StandardName: #{ table_fragment["StandardName"] }"
|
77
|
+
puts " Types:"
|
78
|
+
|
79
|
+
lookup_types.each(&:print_tree)
|
80
|
+
end
|
81
|
+
|
82
|
+
def lookup_type(value)
|
83
|
+
lookup_types.detect {|lt| lt.value == value }
|
84
|
+
end
|
85
|
+
|
86
|
+
def resolve(value)
|
87
|
+
if value.empty?
|
88
|
+
return [] if multi?
|
89
|
+
return value.to_s.strip
|
90
|
+
end
|
91
|
+
|
92
|
+
values = multi? ? value.split(","): [value]
|
93
|
+
|
94
|
+
values = values.map do |v|
|
95
|
+
|
96
|
+
#Remove surrounding quotes
|
97
|
+
clean_value = v.scan(/^["']?(.*?)["']?$/).join
|
98
|
+
|
99
|
+
|
100
|
+
lookup_type = lookup_type(clean_value)
|
101
|
+
|
102
|
+
resolved_value = lookup_type ? lookup_type.long_value : nil
|
103
|
+
|
104
|
+
warn("Discarding unmappable value of #{clean_value.inspect}") if resolved_value.nil? && $VERBOSE
|
105
|
+
|
106
|
+
resolved_value
|
107
|
+
end
|
108
|
+
|
109
|
+
multi? ? values.map {|v| v.to_s.strip } : values.first.to_s.strip
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Rets
|
2
|
+
module Parser
|
3
|
+
class Compact
|
4
|
+
TAB = /\t/
|
5
|
+
|
6
|
+
INCLUDE_NULL_FIELDS = -1
|
7
|
+
|
8
|
+
InvalidDelimiter = Class.new(ArgumentError)
|
9
|
+
|
10
|
+
def self.parse_document(xml)
|
11
|
+
doc = Nokogiri.parse(xml.to_s)
|
12
|
+
|
13
|
+
delimiter = doc.at("//DELIMITER")
|
14
|
+
delimiter = delimiter ? Regexp.new(Regexp.escape(delimiter.attr(:value).to_i.chr)) : TAB
|
15
|
+
|
16
|
+
if delimiter == // || delimiter == /,/
|
17
|
+
raise InvalidDelimiter, "Empty or invalid delimiter found, unable to parse."
|
18
|
+
end
|
19
|
+
|
20
|
+
column_node = doc.at("//COLUMNS")
|
21
|
+
if column_node.nil?
|
22
|
+
columns = ''
|
23
|
+
else
|
24
|
+
columns = column_node.text
|
25
|
+
end
|
26
|
+
rows = doc.xpath("//DATA")
|
27
|
+
|
28
|
+
rows.map do |data|
|
29
|
+
self.parse(columns, data.text, delimiter)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Parses a single row of RETS-COMPACT data.
|
34
|
+
#
|
35
|
+
# Delimiter must be a regexp because String#split behaves differently when
|
36
|
+
# given a string pattern. (It removes leading spaces).
|
37
|
+
#
|
38
|
+
def self.parse(columns, data, delimiter = TAB)
|
39
|
+
raise ArgumentError, "Delimiter must be a regular expression" unless Regexp === delimiter
|
40
|
+
|
41
|
+
column_names = columns.split(delimiter)
|
42
|
+
data_values = data.split(delimiter, INCLUDE_NULL_FIELDS)
|
43
|
+
|
44
|
+
zipped_key_values = column_names.zip(data_values).map { |k, v| [k, v.to_s] }
|
45
|
+
|
46
|
+
hash = Hash[*zipped_key_values.flatten]
|
47
|
+
hash.reject { |key, value| key.empty? && value.to_s.empty? }
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.get_count(xml)
|
51
|
+
doc = Nokogiri.parse(xml.to_s)
|
52
|
+
if node = doc.at("//COUNT")
|
53
|
+
return node.attr('Records').to_i
|
54
|
+
elsif node = doc.at("//RETS-STATUS")
|
55
|
+
# Handle <RETS-STATUS ReplyCode="20201" ReplyText="No matching records were found" />
|
56
|
+
return 0 if node.attr('ReplyCode') == '20201'
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Rets
|
2
|
+
module Parser
|
3
|
+
|
4
|
+
# Inspired by Mail.
|
5
|
+
class Multipart
|
6
|
+
CRLF = "\r\n"
|
7
|
+
WSP = "\s"
|
8
|
+
HEADER_LINE = /^([!-9;-~]+:\s*.+)$/
|
9
|
+
|
10
|
+
Part = Struct.new(:headers, :body)
|
11
|
+
|
12
|
+
def self.parse(raw, boundary)
|
13
|
+
parts = []
|
14
|
+
boundary_regexp = /--#{Regexp.quote(boundary)}(--)?#{CRLF}/
|
15
|
+
|
16
|
+
# WTF some RETS servers declare response body including jpeg binary is encoded in utf8
|
17
|
+
raw.force_encoding 'ascii-8bit' if raw.respond_to?(:force_encoding)
|
18
|
+
|
19
|
+
raw.split(boundary_regexp).each do |chunk|
|
20
|
+
header_part, body_part = chunk.split(/#{CRLF}#{WSP}*#{CRLF}/m, 2)
|
21
|
+
|
22
|
+
if header_part =~ HEADER_LINE
|
23
|
+
headers = header_part.split(/\r\n/).map { |kv| p = kv.split(/:\s?/); [p[0].downcase, p[1..-1].join(':')] }
|
24
|
+
headers = Hash[*headers.flatten]
|
25
|
+
parts << Part.new(headers, body_part)
|
26
|
+
else
|
27
|
+
next # not a valid chunk.
|
28
|
+
end
|
29
|
+
end
|
30
|
+
check_for_invalids_parts!(parts)
|
31
|
+
parts
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.check_for_invalids_parts!(parts)
|
35
|
+
return unless parts.length == 1 && parts.first.headers['content-type'] == 'text/xml'
|
36
|
+
Client::ErrorChecker.check(parts.first)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/test/fixtures.rb
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
RETS_ERROR = <<-XML
|
2
|
+
<?xml version="1.0"?>
|
3
|
+
<RETS ReplyCode="123" ReplyText="Error message">
|
4
|
+
</RETS>
|
5
|
+
XML
|
6
|
+
|
7
|
+
RETS_REPLY = <<-XML
|
8
|
+
<?xml version="1.0"?>
|
9
|
+
<RETS ReplyCode="0" ReplyText="OK">
|
10
|
+
</RETS>
|
11
|
+
XML
|
12
|
+
|
13
|
+
CAPABILITIES = <<-XML
|
14
|
+
<RETS ReplyCode="0" ReplyText="OK">
|
15
|
+
<RETS-RESPONSE>
|
16
|
+
|
17
|
+
Abc=123
|
18
|
+
Def=ghi=jk
|
19
|
+
|
20
|
+
</RETS-RESPONSE>
|
21
|
+
</RETS>
|
22
|
+
XML
|
23
|
+
|
24
|
+
COUNT_ONLY = <<XML
|
25
|
+
<RETS ReplyCode="0" ReplyText="Success">
|
26
|
+
<COUNT Records="1234" />
|
27
|
+
</RETS>
|
28
|
+
XML
|
29
|
+
|
30
|
+
RETS_STATUS_NO_MATCHING_RECORDS = <<XML
|
31
|
+
<?xml version="1.0"?>
|
32
|
+
<RETS ReplyCode="0" ReplyText="Operation Successful">
|
33
|
+
<RETS-STATUS ReplyCode="20201" ReplyText="No matching records were found" />
|
34
|
+
</RETS>
|
35
|
+
XML
|
36
|
+
|
37
|
+
CAPABILITIES_WITH_WHITESPACE = <<XML
|
38
|
+
<RETS ReplyCode="0" ReplyText="Operation Successful">
|
39
|
+
<RETS-RESPONSE>
|
40
|
+
Action = /RETS/Action
|
41
|
+
</RETS-RESPONSE>
|
42
|
+
</RETS>
|
43
|
+
XML
|
44
|
+
|
45
|
+
# 44 is the ASCII code for comma; an invalid delimiter.
|
46
|
+
INVALID_DELIMETER = <<-XML
|
47
|
+
<?xml version="1.0"?>
|
48
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
49
|
+
<DELIMITER value="44" />
|
50
|
+
<COLUMNS>A\tB</COLUMNS>
|
51
|
+
<DATA>1\t2</DATA>
|
52
|
+
<DATA>4\t5</DATA>
|
53
|
+
</METADATA>
|
54
|
+
XML
|
55
|
+
|
56
|
+
COMPACT = <<-XML
|
57
|
+
<?xml version="1.0"?>
|
58
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
59
|
+
<COLUMNS>A\tB</COLUMNS>
|
60
|
+
<DATA>1\t2</DATA>
|
61
|
+
<DATA>4\t5</DATA>
|
62
|
+
</METADATA>
|
63
|
+
XML
|
64
|
+
|
65
|
+
|
66
|
+
EMPTY_COMPACT = <<-XML
|
67
|
+
<METADATA-TABLE Resource="OpenHouse" Class="OpenHouse" Version="01.01.00000" Date="2011-07-29T12:09:16">
|
68
|
+
</METADATA-TABLE>
|
69
|
+
XML
|
70
|
+
|
71
|
+
METADATA_UNKNOWN = <<-XML
|
72
|
+
<?xml version="1.0"?>
|
73
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
74
|
+
<METADATA-FOO Version="01.72.10306" Date="2011-03-15T19:51:22">
|
75
|
+
<UNKNOWN />
|
76
|
+
</METADATA-FOO>
|
77
|
+
XML
|
78
|
+
|
79
|
+
METADATA_SYSTEM = <<-XML
|
80
|
+
<?xml version="1.0"?>
|
81
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
82
|
+
<METADATA-SYSTEM Version="01.72.10306" Date="2011-03-15T19:51:22">
|
83
|
+
<SYSTEM />
|
84
|
+
<COMMENTS />
|
85
|
+
</METADATA-SYSTEM>
|
86
|
+
XML
|
87
|
+
|
88
|
+
METADATA_RESOURCE = <<-XML
|
89
|
+
<?xml version="1.0"?>
|
90
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
91
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
92
|
+
<COLUMNS> ResourceID StandardName VisibleName Description KeyField ClassCount ClassVersion ClassDate ObjectVersion ObjectDate SearchHelpVersion SearchHelpDate EditMaskVersion EditMaskDate LookupVersion LookupDate UpdateHelpVersion UpdateHelpDate ValidationExpressionVersion ValidationExpressionDate ValidationLookupVersionValidationLookupDate ValidationExternalVersion ValidationExternalDate </COLUMNS>
|
93
|
+
<DATA> ActiveAgent ActiveAgent Active Agent Search Contains information about active agents. MemberNumber 1 01.72.10304 2011-03-03T00:29:23 01.72.10000 2010-08-16T15:08:20 01.72.10305 2011-03-09T21:33:41 01.72.10284 2011-02-24T06:56:43 </DATA>
|
94
|
+
<DATA> Agent Agent Agent Search Contains information about all agents. MemberNumber 1 01.72.10303 2011-03-03T00:29:23 01.72.10000 2010-08-16T15:08:20 01.72.10305 2011-03-09T21:33:41 01.72.10284 2011-02-24T06:56:43 </DATA>
|
95
|
+
<DATA> History History History Search Contains information about accumulated changes to each listing. TransactionRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T22:08:30 01.72.10000 2010-08-16T15:08:20 </DATA>
|
96
|
+
<DATA> MemberAssociation Member Association Contains MLS member Association information. MemberAssociationKey 1 01.72.10277 2011-02-23T19:11:10 01.72.10214 2011-01-06T16:41:05 01.72.10220 2011-01-06T16:41:06 </DATA>
|
97
|
+
<DATA> Office Office Office Search Contains information about broker offices. OfficeNumber 1 01.72.10302 2011-03-03T00:29:23 01.72.10000 2010-08-16T15:08:20 01.72.10305 2011-03-09T21:33:41 01.72.10284 2011-02-24T06:56:43 </DATA>
|
98
|
+
<DATA> OfficeAssociation Office Association Contains MLS office Association information. OfficeAssociationKey 1 01.72.10306 2011-03-15T19:51:22 01.72.10245 2011-01-06T16:41:08 01.72.10251 2011-01-06T16:41:08 </DATA>
|
99
|
+
<DATA> OpenHouse OpenHouse Open House Search Contains information about public open house activities. OpenHouseRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10134 2010-11-12T13:57:32 01.72.10000 2010-08-16T15:08:20 </DATA>
|
100
|
+
<DATA> Property Property Property Search Contains information about listed properties. ListingRid 8 01.72.10288 2011-02-24T06:59:11 01.72.10000 2010-08-16T15:08:20 01.72.10289 2011-02-24T06:59:19 01.72.10290 2011-03-01T11:06:31 </DATA>
|
101
|
+
<DATA> PropertyDeleted Deleted Property Search Contains information about deleted properties. ListingRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T22:08:30 01.72.10000 2010-08-16T22:08:34 </DATA>
|
102
|
+
<DATA> PropertyWithheld Withheld Property Search Contains information about withheld properties. ListingRid 8 01.72.10201 2011-01-05T19:34:36 01.72.10000 2010-08-16T15:08:20 01.72.10200 2011-01-05T19:34:34 01.72.10000 2010-08-16T22:08:34 </DATA>
|
103
|
+
<DATA> Prospect Prospect Prospect Search Contains information about sales or listing propects. ProspectRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T15:08:20 </DATA>
|
104
|
+
<DATA> Tour Tour Tour Search Contains information about private tour activities. TourRid 1 01.72.10185 2010-12-02T02:02:58 01.72.10000 2010-08-16T15:08:20 01.72.10000 2010-08-16T22:08:30 01.72.10000 2010-08-16T15:08:20 </DATA>
|
105
|
+
<DATA> VirtualMedia Virtual Media Contains information about virtual media for MLS listings. VirtualMediaRid 1 01.72.10126 2010-11-12T13:47:41 01.72.10127 2010-11-12T13:47:41 01.72.10086 2010-11-10T09:59:11 </DATA>
|
106
|
+
</METADATA-RESOURCE>
|
107
|
+
</RETS>
|
108
|
+
XML
|
109
|
+
|
110
|
+
METADATA_OBJECT = "<RETS ReplyCode=\"0\" ReplyText=\"V2.6.0 728: Success\">\r\n<METADATA-OBJECT Resource=\"Property\" Version=\"1.12.24\" Date=\"Wed, 1 Dec 2010 00:00:00 GMT\">\r\n<COLUMNS>\tMetadataEntryID\tObjectType\tStandardName\tMimeType\tVisibleName\tDescription\tObjectTimeStamp\tObjectCount\t</COLUMNS>\r\n<DATA>\t50045650619\tMedium\tMedium\timage/jpeg\tMedium\tA 320 x 240 Size Photo\tLastPhotoDate\tTotalPhotoCount\t</DATA>\r\n<DATA>\t20101753230\tDocumentPDF\tDocumentPDF\tapplication/pdf\tDocumentPDF\tDocumentPDF\t\t\t</DATA>\r\n<DATA>\t50045650620\tPhoto\tPhoto\timage/jpeg\tPhoto\tA 640 x 480 Size Photo\tLastPhotoDate\tTotalPhotoCount\t</DATA>\r\n<DATA>\t50045650621\tThumbnail\tThumbnail\timage/jpeg\tThumbnail\tA 128 x 96 Size Photo\tLastPhotoDate\tTotalPhotoCount\t</DATA>\r\n</METADATA-OBJECT>\r\n</RETS>\r\n"
|
111
|
+
|
112
|
+
MULITPART_RESPONSE = [
|
113
|
+
"--simple boundary",
|
114
|
+
"Content-Type: image/jpeg",
|
115
|
+
"Content-Length: 10",
|
116
|
+
"Content-ID: 90020062739",
|
117
|
+
"Object-ID: 1",
|
118
|
+
"",
|
119
|
+
"xxxxxxxx",
|
120
|
+
"--simple boundary",
|
121
|
+
"Content-Type: image/jpeg",
|
122
|
+
"Content-Length: 10",
|
123
|
+
"Content-ID: 90020062739",
|
124
|
+
"Object-ID: 2",
|
125
|
+
"",
|
126
|
+
"yyyyyyyy",
|
127
|
+
"--simple boundary",
|
128
|
+
""
|
129
|
+
].join("\r\n")
|
130
|
+
|
131
|
+
MULTIPART_RESPONSE_URLS = [
|
132
|
+
'--rets.object.content.boundary.1330546052739',
|
133
|
+
'Content-ID: 38845440',
|
134
|
+
'Object-ID: 1',
|
135
|
+
'Content-Type: text/xml',
|
136
|
+
'Location: http://foobarmls.com/RETS//MediaDisplay/98/hr2890998-1.jpg',
|
137
|
+
'',
|
138
|
+
'<RETS ReplyCode="0" ReplyText="Operation Successful" />',
|
139
|
+
'',
|
140
|
+
'--rets.object.content.boundary.1330546052739',
|
141
|
+
'Content-ID: 38845440',
|
142
|
+
'Object-ID: 2',
|
143
|
+
'Content-Type: text/xml',
|
144
|
+
'Location: http://foobarmls.com/RETS//MediaDisplay/98/hr2890998-2.jpg',
|
145
|
+
'',
|
146
|
+
'<RETS ReplyCode="0" ReplyText="Operation Successful" />',
|
147
|
+
'',
|
148
|
+
'--rets.object.content.boundary.1330546052739',
|
149
|
+
'Content-ID: 38845440',
|
150
|
+
'Object-ID: 3',
|
151
|
+
'Content-Type: text/xml',
|
152
|
+
'Location: http://foobarmls.com/RETS//MediaDisplay/98/hr2890998-3.jpg',
|
153
|
+
'',
|
154
|
+
'<RETS ReplyCode="0" ReplyText="Operation Successful" />',
|
155
|
+
'',
|
156
|
+
'--rets.object.content.boundary.1330546052739',
|
157
|
+
'Content-ID: 38845440',
|
158
|
+
'Object-ID: 4',
|
159
|
+
'Content-Type: text/xml',
|
160
|
+
'Location: http://foobarmls.com/RETS//MediaDisplay/98/hr2890998-4.jpg',
|
161
|
+
'',
|
162
|
+
'<RETS ReplyCode="0" ReplyText="Operation Successful" />',
|
163
|
+
'',
|
164
|
+
'--rets.object.content.boundary.1330546052739',
|
165
|
+
'Content-ID: 38845440',
|
166
|
+
'Object-ID: 5',
|
167
|
+
'Content-Type: text/xml',
|
168
|
+
'Location: http://foobarmls.com/RETS//MediaDisplay/98/hr2890998-5.jpg',
|
169
|
+
'',
|
170
|
+
'<RETS ReplyCode="0" ReplyText="Operation Successful" />',
|
171
|
+
'',
|
172
|
+
'--rets.object.content.boundary.1330546052739--'
|
173
|
+
].join("\r\n")
|
174
|
+
|
175
|
+
SAMPLE_COMPACT = <<XML
|
176
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
177
|
+
<METADATA-TABLE Resource="ActiveAgent" Class="MEMB" Version="01.72.10236" Date="2011-03-03T00:29:23">
|
178
|
+
<COLUMNS> MetadataEntryID SystemName StandardName LongName DBName ShortName MaximumLength DataType Precision Searchable Interpretation Alignment UseSeparator EditMaskID LookupName MaxSelect Units Index Minimum Maximum Default Required SearchHelpID Unique ModTimeStamp ForeignKeyName ForeignField InKeyindex </COLUMNS>
|
179
|
+
<DATA> 7 City City City City 11 Character 0 1 Left 0 0 0 0 0 0 0 City 0 0 1 MemberNumber 0 </DATA>
|
180
|
+
<DATA> 9 ContactAddlPhoneType1 Contact Additional Phone Type 1 AddlPhTyp1 Contact Addl Ph Type 1 1 Character 0 1 Lookup Left 0 ContactAddlPhoneType 0 0 0 0 0 0 ContactAddlPhoneType 0 0 1 MemberNumber 0 </DATA>
|
181
|
+
<DATA> 11 ContactAddlPhoneType2 Contact Additional Phone Type 2 AddlPhTyp2 Contact Addl Ph Type 2 1 Character 0 1 Lookup Left 0 ContactAddlPhoneType 0 0 0 0 0 0 ContactAddlPhoneType 0 0 1 MemberNumber 0 </DATA>
|
182
|
+
<DATA> 13 ContactAddlPhoneType3 Contact Additional Phone Type 3 AddlPhTyp3 Contact Addl Ph Type 3 1 Character 0 1 Lookup Left 0 ContactAddlPhoneType 0 0 0 0 0 0 ContactAddlPhoneType 0 0 1 MemberNumber 0 </DATA>
|
183
|
+
<DATA> 15 ContactPhoneAreaCode1 Contact Phone Area Code 1 ContPhAC1 Contact Phone AC 1 3 Character 0 1 Left 0 0 0 0 0 0 0 ContactPhoneAreaCode 0 0 1 MemberNumber 0 </DATA>
|
184
|
+
<DATA> 17 ContactPhoneAreaCode2 Contact Phone Area Code 2 ContPhAC2 Contact Phone AC 2 3 Character 0 1 Left 0 0 0 0 0 0 0 ContactPhoneAreaCode 0 0 1 MemberNumber 0 </DATA>
|
185
|
+
<DATA> 19 ContactPhoneAreaCode3 Contact Phone Area Code 3 ContPhAC3 Contact Phone AC 3 3 Character 0 1 Left 0 0 0 0 0 0 0 ContactPhoneAreaCode 0 0 1 MemberNumber 0 </DATA>
|
186
|
+
</METADATA-TABLE>
|
187
|
+
</RETS>
|
188
|
+
XML
|
189
|
+
|
190
|
+
SAMPLE_COMPACT_2 = <<XML
|
191
|
+
<?xml version="1.0" encoding="utf-8"?>
|
192
|
+
<RETS ReplyCode="0" ReplyText="Success">
|
193
|
+
<METADATA-TABLE Class="15" Date="2010-10-28T05:41:31Z" Resource="Office" Version="26.27.62891">
|
194
|
+
<COLUMNS> ModTimeStamp MetadataEntryID SystemName StandardName LongName DBName ShortName MaximumLength DataType Precision Searchable Interpretation Alignment UseSeparator EditMaskID LookupName MaxSelect Units Index Minimum Maximum Default Required SearchHelpID Unique ForeignKeyName ForeignField InKeyIndex </COLUMNS>
|
195
|
+
<DATA> sysid15 sysid sysid sysid sysid 10 Int 0 1 0 0 1 0 0 1 1 </DATA>
|
196
|
+
<DATA> 15n1155 OfficePhone_f1155 OfficePhone Phone Offic_1155 Phone 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
197
|
+
<DATA> 15n1158 AccessFlag_f1158 Office Status Acces_1158 Status 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
198
|
+
<DATA> 15n1163 MODIFIED_f1163 Modified MODIF_1163 Modified 20 DateTime 0 1 0 0 0 0 0 0 0 </DATA>
|
199
|
+
<DATA> 15n1165 DESREALTOR_f1165 DesRealtor DESRE_1165 DesRealtor 75 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
200
|
+
<DATA> 15n1166 DESREALTORUID_f1166 Designated Realtor Uid DESRE_1166 RealtorUid 20 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
201
|
+
<DATA> 15n1167 INT_NO_f1167 Internet Syndication (Y/N) INT_N_1167 Int.Syn. 1 Character 0 1 Lookup 0 YESNO 1 0 0 0 0 0 </DATA>
|
202
|
+
<DATA> 15n1168 STATE_f1168 State STATE_1168 State 50 Character 0 1 Lookup 0 1_523 1 0 0 0 0 0 </DATA>
|
203
|
+
<DATA> 15n1169 CITY_f1169 City CITY_1169 City 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
204
|
+
<DATA> 15n1170 IDX_NO_f1170 IDX (Y/N) IDX_N_1170 IDX 1 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
205
|
+
<DATA> 15n1172 ZipCode_f1172 Zip ZipCo_1172 Zip 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
206
|
+
<DATA> 15n1177 ADDRESS1_f1177 Address Line 1 ADDRE_1177 Address1 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
207
|
+
<DATA> 15n1182 MLSYN_f1182 MLS Y/N MLSYN_1182 MLSYN 1 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
208
|
+
<DATA> 15n1184 OFFICENAME_f1184 Name Office Name OFFIC_1184 Office Name 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
209
|
+
<DATA> 15n1193 OfficeCode_f1193 OfficeID Office Code Offic_1193 Office Code 12 Character 0 1 0 0 0 0 0 0 1 </DATA>
|
210
|
+
</METADATA-TABLE>
|
211
|
+
</RETS>
|
212
|
+
XML
|
data/test/helper.rb
ADDED
data/test/test_client.rb
ADDED
@@ -0,0 +1,238 @@
|
|
1
|
+
require_relative "helper"
|
2
|
+
|
3
|
+
class TestClient < MiniTest::Test
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@client = Rets::Client.new(:login_url => "http://example.com/login")
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_extract_capabilities
|
10
|
+
assert_equal(
|
11
|
+
{"abc" => "123", "def" => "ghi=jk"},
|
12
|
+
@client.extract_capabilities(Nokogiri.parse(CAPABILITIES))
|
13
|
+
)
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_extract_capabilities_with_whitespace
|
17
|
+
assert_equal(
|
18
|
+
{"action" => "/RETS/Action"},
|
19
|
+
@client.extract_capabilities(Nokogiri.parse(CAPABILITIES_WITH_WHITESPACE))
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_capability_url_returns_parsed_url
|
24
|
+
@client.capabilities = { "foo" => "/foo" }
|
25
|
+
|
26
|
+
assert_equal "http://example.com/foo", @client.capability_url("foo")
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_capabilities_calls_login_when_nil
|
30
|
+
@client.expects(:login)
|
31
|
+
@client.capabilities
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_tries_increments_with_each_call
|
35
|
+
assert_equal 1, @client.tries
|
36
|
+
assert_equal 2, @client.tries
|
37
|
+
end
|
38
|
+
|
39
|
+
def test_metadata_when_not_initialized_with_metadata
|
40
|
+
new_raw_metadata = stub(:new_raw_metadata)
|
41
|
+
|
42
|
+
client = Rets::Client.new(:login_url => "http://example.com")
|
43
|
+
client.stubs(:retrieve_metadata).returns(new_raw_metadata)
|
44
|
+
|
45
|
+
assert_same new_raw_metadata, client.metadata.marshal_dump
|
46
|
+
end
|
47
|
+
|
48
|
+
def test_initialize_with_old_metadata_cached_contstructs_new_metadata_from_request
|
49
|
+
metadata = stub(:current? => false)
|
50
|
+
new_raw_metadata = stub(:new_raw_metadata)
|
51
|
+
|
52
|
+
client = Rets::Client.new(:login_url => "http://example.com", :metadata => metadata)
|
53
|
+
client.stubs(:capabilities).returns({})
|
54
|
+
client.stubs(:retrieve_metadata).returns(new_raw_metadata)
|
55
|
+
|
56
|
+
assert_same new_raw_metadata, client.metadata.marshal_dump
|
57
|
+
end
|
58
|
+
|
59
|
+
def test_initialize_with_current_metadata_cached_return_cached_metadata
|
60
|
+
metadata = stub(:current? => true)
|
61
|
+
client = Rets::Client.new(:login_url => "http://example.com", :metadata => metadata)
|
62
|
+
client.stubs(:capabilities => {})
|
63
|
+
|
64
|
+
assert_same metadata, client.metadata
|
65
|
+
end
|
66
|
+
|
67
|
+
def test_initialize_takes_logger
|
68
|
+
logger = Object.new
|
69
|
+
|
70
|
+
client = Rets::Client.new(:login_url => "http://example.com", :logger => logger)
|
71
|
+
|
72
|
+
assert_equal logger, client.logger
|
73
|
+
end
|
74
|
+
|
75
|
+
def test_default_logger_returns_api_compatible_silent_logger
|
76
|
+
logger = @client.logger
|
77
|
+
logger.fatal "foo"
|
78
|
+
logger.error "foo"
|
79
|
+
logger.warn "foo"
|
80
|
+
logger.info "foo"
|
81
|
+
logger.debug "foo"
|
82
|
+
end
|
83
|
+
|
84
|
+
|
85
|
+
def test_find_first_calls_find_every_with_limit_one
|
86
|
+
@client.expects(:find_every).with({:limit => 1, :foo => :bar}, nil).returns([1,2,3])
|
87
|
+
|
88
|
+
assert_equal 1, @client.find(:first, :foo => :bar, :limit => 5), "User-specified limit should be ignored"
|
89
|
+
end
|
90
|
+
|
91
|
+
def test_find_all_calls_find_every
|
92
|
+
@client.expects(:find_every).with({:limit => 5, :foo => :bar}, nil).returns([1,2,3])
|
93
|
+
|
94
|
+
assert_equal [1,2,3], @client.find(:all, :limit => 5, :foo => :bar)
|
95
|
+
end
|
96
|
+
|
97
|
+
def test_find_raises_on_unknown_quantity
|
98
|
+
assert_raises ArgumentError do
|
99
|
+
@client.find(:incorrect, :foo => :bar)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def test_find_retries_on_errors
|
104
|
+
@client.stubs(:find_every).raises(Rets::AuthorizationFailure.new(401, 'Not Authorized')).then.raises(Rets::InvalidRequest.new(20134, 'Not Found')).then.returns([])
|
105
|
+
@client.find(:all, :foo => :bar)
|
106
|
+
end
|
107
|
+
|
108
|
+
def test_find_retries_on_errors_preserves_resolve
|
109
|
+
@client.stubs(:find_every).raises(Rets::AuthorizationFailure.new(401, 'Not Authorized')).then.raises(Rets::InvalidRequest.new(20134, 'Not Found')).then.with({:foo => :bar}, true).returns([])
|
110
|
+
@client.find(:all, {:foo => :bar, :resolve => true})
|
111
|
+
end
|
112
|
+
|
113
|
+
def test_find_eventually_reraises_errors
|
114
|
+
@client.stubs(:find_every).raises(Rets::AuthorizationFailure.new(401, 'Not Authorized'))
|
115
|
+
assert_raises Rets::AuthorizationFailure do
|
116
|
+
@client.find(:all, :foo => :bar)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def test_fixup_keys
|
121
|
+
assert_equal({ "Foo" => "bar" }, @client.fixup_keys(:foo => "bar"))
|
122
|
+
assert_equal({ "FooFoo" => "bar" }, @client.fixup_keys(:foo_foo => "bar"))
|
123
|
+
end
|
124
|
+
|
125
|
+
def test_all_objects_calls_objects
|
126
|
+
@client.expects(:objects).with("*", :foo => :bar)
|
127
|
+
|
128
|
+
@client.all_objects(:foo => :bar)
|
129
|
+
end
|
130
|
+
|
131
|
+
def test_objects_handles_string_argument
|
132
|
+
@client.expects(:fetch_object).with("*", :foo => :bar)
|
133
|
+
@client.stubs(:create_parts_from_response)
|
134
|
+
|
135
|
+
@client.objects("*", :foo => :bar)
|
136
|
+
end
|
137
|
+
|
138
|
+
def test_objects_handle_array_argument
|
139
|
+
@client.expects(:fetch_object).with("1,2", :foo => :bar)
|
140
|
+
@client.stubs(:create_parts_from_response)
|
141
|
+
|
142
|
+
@client.objects([1,2], :foo => :bar)
|
143
|
+
end
|
144
|
+
|
145
|
+
def test_objects_raises_on_other_arguments
|
146
|
+
assert_raises ArgumentError do
|
147
|
+
@client.objects(Object.new, :foo => :bar)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def test_create_parts_from_response_returns_multiple_parts_when_multipart_response
|
152
|
+
response = {}
|
153
|
+
response.stubs(:header => { "content-type" => ['multipart; boundary="simple boundary"']})
|
154
|
+
response.stubs(:body => MULITPART_RESPONSE)
|
155
|
+
|
156
|
+
Rets::Parser::Multipart.expects(:parse).
|
157
|
+
with(MULITPART_RESPONSE, "simple boundary").
|
158
|
+
returns([])
|
159
|
+
|
160
|
+
@client.create_parts_from_response(response)
|
161
|
+
end
|
162
|
+
|
163
|
+
def test_parse_boundary_wo_quotes
|
164
|
+
response = {}
|
165
|
+
response.stubs(:header => { "content-type" => ['multipart; boundary=simple boundary; foo;']})
|
166
|
+
response.stubs(:body => MULITPART_RESPONSE)
|
167
|
+
|
168
|
+
Rets::Parser::Multipart.expects(:parse).
|
169
|
+
with(MULITPART_RESPONSE, "simple boundary").
|
170
|
+
returns([])
|
171
|
+
|
172
|
+
@client.create_parts_from_response(response)
|
173
|
+
end
|
174
|
+
|
175
|
+
def test_create_parts_from_response_returns_a_single_part_when_not_multipart_response
|
176
|
+
response = {}
|
177
|
+
response.stubs(:header => { "content-type" => ['text/plain']})
|
178
|
+
response.stubs(:headers => { "content-type" => ['text/plain']})
|
179
|
+
response.stubs(:body => "fakebody")
|
180
|
+
|
181
|
+
parts = @client.create_parts_from_response(response)
|
182
|
+
|
183
|
+
assert_equal 1, parts.size
|
184
|
+
|
185
|
+
part = parts.first
|
186
|
+
|
187
|
+
assert_equal "text/plain", part.headers["content-type"]
|
188
|
+
assert_equal "fakebody", part.body
|
189
|
+
end
|
190
|
+
|
191
|
+
def test_object_calls_fetch_object
|
192
|
+
response = stub(:body => "foo")
|
193
|
+
|
194
|
+
@client.expects(:fetch_object).with("1", :foo => :bar).returns(response)
|
195
|
+
|
196
|
+
assert_equal "foo", @client.object("1", :foo => :bar)
|
197
|
+
end
|
198
|
+
|
199
|
+
def test_metadata_caches
|
200
|
+
metadata = stub(:current? => true)
|
201
|
+
@client.metadata = metadata
|
202
|
+
@client.stubs(:capabilities => {})
|
203
|
+
|
204
|
+
assert_same metadata, @client.metadata, "Should be memoized"
|
205
|
+
end
|
206
|
+
|
207
|
+
def test_decorate_result_handles_bad_metadata
|
208
|
+
result = {'foo' => 'bar'}
|
209
|
+
rets_class = stub
|
210
|
+
rets_class.expects(:find_table).with('foo').returns(nil)
|
211
|
+
response = @client.decorate_result(result, rets_class)
|
212
|
+
assert_equal response, result
|
213
|
+
end
|
214
|
+
|
215
|
+
def test_clean_setup_with_receive_timeout
|
216
|
+
HTTPClient.any_instance.expects(:receive_timeout=).with(1234)
|
217
|
+
@client = Rets::Client.new(
|
218
|
+
login_url: 'http://example.com/login',
|
219
|
+
receive_timeout: 1234
|
220
|
+
)
|
221
|
+
end
|
222
|
+
|
223
|
+
def test_clean_setup_with_proxy_auth
|
224
|
+
@login_url = 'http://example.com/login'
|
225
|
+
@proxy_url = 'http://example.com/proxy'
|
226
|
+
@proxy_username = 'username'
|
227
|
+
@proxy_password = 'password'
|
228
|
+
HTTPClient.any_instance.expects(:set_proxy_auth).with(@proxy_username, @proxy_password)
|
229
|
+
|
230
|
+
@client = Rets::Client.new(
|
231
|
+
login_url: @login_url,
|
232
|
+
http_proxy: @proxy_url,
|
233
|
+
proxy_username: @proxy_username,
|
234
|
+
proxy_password: @proxy_password
|
235
|
+
)
|
236
|
+
end
|
237
|
+
|
238
|
+
end
|