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