rets 0.1.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.
- data/.gemtest +0 -0
- data/CHANGELOG.md +9 -0
- data/Manifest.txt +23 -0
- data/README.md +42 -0
- data/Rakefile +18 -0
- data/bin/rets +194 -0
- data/lib/rets.rb +24 -0
- data/lib/rets/authentication.rb +59 -0
- data/lib/rets/client.rb +473 -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 +73 -0
- data/lib/rets/metadata/rets_class.rb +42 -0
- data/lib/rets/metadata/root.rb +155 -0
- data/lib/rets/metadata/table.rb +98 -0
- data/lib/rets/parser/compact.rb +46 -0
- data/lib/rets/parser/multipart.rb +36 -0
- data/test/fixtures.rb +142 -0
- data/test/helper.rb +6 -0
- data/test/test_client.rb +571 -0
- data/test/test_metadata.rb +452 -0
- data/test/test_parser_compact.rb +71 -0
- data/test/test_parser_multipart.rb +21 -0
- metadata +162 -0
@@ -0,0 +1,46 @@
|
|
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
|
+
columns = doc.at("//COLUMNS").text
|
21
|
+
rows = doc.xpath("//DATA")
|
22
|
+
|
23
|
+
rows.map do |data|
|
24
|
+
self.parse(columns, data.text, delimiter)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# Parses a single row of RETS-COMPACT data.
|
29
|
+
#
|
30
|
+
# Delimiter must be a regexp because String#split behaves differently when
|
31
|
+
# given a string pattern. (It removes leading spaces).
|
32
|
+
#
|
33
|
+
def self.parse(columns, data, delimiter = TAB)
|
34
|
+
raise ArgumentError, "Delimiter must be a regular expression" unless Regexp === delimiter
|
35
|
+
|
36
|
+
column_names = columns.split(delimiter)
|
37
|
+
data_values = data.split(delimiter, INCLUDE_NULL_FIELDS)
|
38
|
+
|
39
|
+
zipped_key_values = column_names.zip(data_values).map { |k, v| [k, v.to_s] }
|
40
|
+
|
41
|
+
hash = Hash[*zipped_key_values.flatten]
|
42
|
+
hash.reject { |key, value| key.empty? && value.to_s.empty? }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Rets
|
2
|
+
module Parser
|
3
|
+
|
4
|
+
# Inspired by Mail.
|
5
|
+
class Multipart
|
6
|
+
CRLF = "\r\n"
|
7
|
+
WSP = "\s"
|
8
|
+
|
9
|
+
HEADER_LINE = /^([!-9;-~]+:\s*.+)$/
|
10
|
+
|
11
|
+
Part = Struct.new(:headers, :body)
|
12
|
+
|
13
|
+
def self.parse(raw, boundary)
|
14
|
+
parts = []
|
15
|
+
|
16
|
+
boundary_regexp = /--#{Regexp.quote(boundary)}(--)?#{CRLF}/
|
17
|
+
|
18
|
+
raw.split(boundary_regexp).each do |chunk|
|
19
|
+
|
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| k,v = kv.split(/:\s?/); [k.downcase, v] }
|
24
|
+
headers = Hash[*headers.flatten]
|
25
|
+
|
26
|
+
parts << Part.new(headers, body_part)
|
27
|
+
else
|
28
|
+
next # not a valid chunk.
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
parts
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/test/fixtures.rb
ADDED
@@ -0,0 +1,142 @@
|
|
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
|
+
# 44 is the ASCII code for comma; an invalid delimiter.
|
25
|
+
INVALID_DELIMETER = <<-XML
|
26
|
+
<?xml version="1.0"?>
|
27
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
28
|
+
<DELIMITER value="44" />
|
29
|
+
<COLUMNS>A\tB</COLUMNS>
|
30
|
+
<DATA>1\t2</DATA>
|
31
|
+
<DATA>4\t5</DATA>
|
32
|
+
</METADATA>
|
33
|
+
XML
|
34
|
+
|
35
|
+
COMPACT = <<-XML
|
36
|
+
<?xml version="1.0"?>
|
37
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
38
|
+
<COLUMNS>A\tB</COLUMNS>
|
39
|
+
<DATA>1\t2</DATA>
|
40
|
+
<DATA>4\t5</DATA>
|
41
|
+
</METADATA>
|
42
|
+
XML
|
43
|
+
|
44
|
+
METADATA_UNKNOWN = <<-XML
|
45
|
+
<?xml version="1.0"?>
|
46
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
47
|
+
<METADATA-FOO Version="01.72.10306" Date="2011-03-15T19:51:22">
|
48
|
+
<UNKNOWN />
|
49
|
+
</METADATA-FOO>
|
50
|
+
XML
|
51
|
+
|
52
|
+
METADATA_SYSTEM = <<-XML
|
53
|
+
<?xml version="1.0"?>
|
54
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
55
|
+
<METADATA-SYSTEM Version="01.72.10306" Date="2011-03-15T19:51:22">
|
56
|
+
<SYSTEM />
|
57
|
+
<COMMENTS />
|
58
|
+
</METADATA-SYSTEM>
|
59
|
+
XML
|
60
|
+
|
61
|
+
METADATA_RESOURCE = <<-XML
|
62
|
+
<?xml version="1.0"?>
|
63
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
64
|
+
<METADATA-RESOURCE Version="01.72.10306" Date="2011-03-15T19:51:22">
|
65
|
+
<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>
|
66
|
+
<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>
|
67
|
+
<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>
|
68
|
+
<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>
|
69
|
+
<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>
|
70
|
+
<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>
|
71
|
+
<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>
|
72
|
+
<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>
|
73
|
+
<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>
|
74
|
+
<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>
|
75
|
+
<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>
|
76
|
+
<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>
|
77
|
+
<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>
|
78
|
+
<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>
|
79
|
+
</METADATA-RESOURCE>
|
80
|
+
</RETS>
|
81
|
+
XML
|
82
|
+
|
83
|
+
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"
|
84
|
+
|
85
|
+
MULITPART_RESPONSE = [
|
86
|
+
"--simple boundary",
|
87
|
+
"Content-Type: image/jpeg",
|
88
|
+
"Content-Length: 10",
|
89
|
+
"Content-ID: 90020062739",
|
90
|
+
"Object-ID: 1",
|
91
|
+
"",
|
92
|
+
"xxxxxxxx",
|
93
|
+
"--simple boundary",
|
94
|
+
"Content-Type: image/jpeg",
|
95
|
+
"Content-Length: 10",
|
96
|
+
"Content-ID: 90020062739",
|
97
|
+
"Object-ID: 2",
|
98
|
+
"",
|
99
|
+
"yyyyyyyy",
|
100
|
+
"--simple boundary",
|
101
|
+
""
|
102
|
+
].join("\r\n")
|
103
|
+
|
104
|
+
|
105
|
+
SAMPLE_COMPACT = <<XML
|
106
|
+
<RETS ReplyCode="0" ReplyText="Operation successful.">
|
107
|
+
<METADATA-TABLE Resource="ActiveAgent" Class="MEMB" Version="01.72.10236" Date="2011-03-03T00:29:23">
|
108
|
+
<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>
|
109
|
+
<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>
|
110
|
+
<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>
|
111
|
+
<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>
|
112
|
+
<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>
|
113
|
+
<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>
|
114
|
+
<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>
|
115
|
+
<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>
|
116
|
+
</METADATA-TABLE>
|
117
|
+
</RETS>
|
118
|
+
XML
|
119
|
+
|
120
|
+
SAMPLE_COMPACT_2 = <<XML
|
121
|
+
<?xml version="1.0" encoding="utf-8"?>
|
122
|
+
<RETS ReplyCode="0" ReplyText="Success">
|
123
|
+
<METADATA-TABLE Class="15" Date="2010-10-28T05:41:31Z" Resource="Office" Version="26.27.62891">
|
124
|
+
<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>
|
125
|
+
<DATA> sysid15 sysid sysid sysid sysid 10 Int 0 1 0 0 1 0 0 1 1 </DATA>
|
126
|
+
<DATA> 15n1155 OfficePhone_f1155 OfficePhone Phone Offic_1155 Phone 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
127
|
+
<DATA> 15n1158 AccessFlag_f1158 Office Status Acces_1158 Status 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
128
|
+
<DATA> 15n1163 MODIFIED_f1163 Modified MODIF_1163 Modified 20 DateTime 0 1 0 0 0 0 0 0 0 </DATA>
|
129
|
+
<DATA> 15n1165 DESREALTOR_f1165 DesRealtor DESRE_1165 DesRealtor 75 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
130
|
+
<DATA> 15n1166 DESREALTORUID_f1166 Designated Realtor Uid DESRE_1166 RealtorUid 20 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
131
|
+
<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>
|
132
|
+
<DATA> 15n1168 STATE_f1168 State STATE_1168 State 50 Character 0 1 Lookup 0 1_523 1 0 0 0 0 0 </DATA>
|
133
|
+
<DATA> 15n1169 CITY_f1169 City CITY_1169 City 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
134
|
+
<DATA> 15n1170 IDX_NO_f1170 IDX (Y/N) IDX_N_1170 IDX 1 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
135
|
+
<DATA> 15n1172 ZipCode_f1172 Zip ZipCo_1172 Zip 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
136
|
+
<DATA> 15n1177 ADDRESS1_f1177 Address Line 1 ADDRE_1177 Address1 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
137
|
+
<DATA> 15n1182 MLSYN_f1182 MLS Y/N MLSYN_1182 MLSYN 1 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
138
|
+
<DATA> 15n1184 OFFICENAME_f1184 Name Office Name OFFIC_1184 Office Name 50 Character 0 1 0 0 0 0 0 0 0 </DATA>
|
139
|
+
<DATA> 15n1193 OfficeCode_f1193 OfficeID Office Code Offic_1193 Office Code 12 Character 0 1 0 0 0 0 0 0 1 </DATA>
|
140
|
+
</METADATA-TABLE>
|
141
|
+
</RETS>
|
142
|
+
XML
|
data/test/helper.rb
ADDED
data/test/test_client.rb
ADDED
@@ -0,0 +1,571 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
class TestClient < Test::Unit::TestCase
|
4
|
+
|
5
|
+
def setup
|
6
|
+
@client = Rets::Client.new(:login_url => "http://example.com/login")
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_initialize_adds_escaped_username_to_uri
|
10
|
+
client = Rets::Client.new(
|
11
|
+
:login_url => "http://example.com",
|
12
|
+
:username => "bob@example.com")
|
13
|
+
|
14
|
+
assert_equal CGI.escape("bob@example.com"), client.uri.user
|
15
|
+
assert_nil client.uri.password
|
16
|
+
end
|
17
|
+
|
18
|
+
def test_initialize_adds_escaped_password_to_uri
|
19
|
+
client = Rets::Client.new(
|
20
|
+
:login_url => "http://example.com",
|
21
|
+
:username => "bob",
|
22
|
+
:password => "secret@2!")
|
23
|
+
|
24
|
+
assert_equal CGI.escape("secret@2!"), client.uri.password
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_initialize_merges_default_options
|
28
|
+
client = Rets::Client.new(:login_url => "http://example.com", :foo => true)
|
29
|
+
|
30
|
+
assert client.options.include?(:foo)
|
31
|
+
end
|
32
|
+
|
33
|
+
def test_initialize_allows_default_options_to_be_overridden
|
34
|
+
assert Rets::Client::DEFAULT_OPTIONS.include?(:persistent)
|
35
|
+
|
36
|
+
client = Rets::Client.new(:login_url => "http://example.com")
|
37
|
+
assert_equal true, client.options[:persistent]
|
38
|
+
|
39
|
+
client = Rets::Client.new(:login_url => "http://example.com", :persistent => false)
|
40
|
+
assert_equal false, client.options[:persistent]
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
def test_connection_uses_persistent
|
45
|
+
assert_kind_of Net::HTTP::Persistent, @client.connection
|
46
|
+
end
|
47
|
+
|
48
|
+
def test_connection_uses_net_http
|
49
|
+
client = Rets::Client.new(:login_url => "http://example.com", :persistent => false)
|
50
|
+
|
51
|
+
assert_kind_of Net::HTTP, client.connection
|
52
|
+
assert_equal "example.com", client.connection.address
|
53
|
+
assert_equal 80, client.connection.port
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
def test_request
|
58
|
+
post = mock()
|
59
|
+
post.expects(:body=).with("fake body")
|
60
|
+
|
61
|
+
headers = @client.build_headers
|
62
|
+
|
63
|
+
Net::HTTP::Post.expects(:new).with("/foo", headers).returns(post)
|
64
|
+
|
65
|
+
@client.connection.expects(:request).with(@client.uri, post).returns(stub_everything)
|
66
|
+
|
67
|
+
@client.expects(:handle_cookies)
|
68
|
+
@client.expects(:handle_response)
|
69
|
+
|
70
|
+
@client.stubs(:format_headers)
|
71
|
+
|
72
|
+
@client.request("/foo", "fake body")
|
73
|
+
end
|
74
|
+
|
75
|
+
def test_request_with_block
|
76
|
+
# TODO
|
77
|
+
end
|
78
|
+
|
79
|
+
def test_request_passes_correct_arguments_to_persistent_connection
|
80
|
+
@client.connection.expects(:request).with(@client.uri, instance_of(Net::HTTP::Post)).returns(stub_everything)
|
81
|
+
|
82
|
+
@client.stubs(:handle_cookies)
|
83
|
+
@client.stubs(:handle_response)
|
84
|
+
@client.stubs(:format_headers)
|
85
|
+
|
86
|
+
@client.request("/foo")
|
87
|
+
end
|
88
|
+
|
89
|
+
def test_request_passes_correct_arguments_to_net_http_connection
|
90
|
+
client = Rets::Client.new(:login_url => "http://example.com", :persistent => false)
|
91
|
+
|
92
|
+
client.connection.expects(:request).with(instance_of(Net::HTTP::Post)).returns(stub_everything)
|
93
|
+
|
94
|
+
client.stubs(:handle_cookies)
|
95
|
+
client.stubs(:handle_response)
|
96
|
+
client.stubs(:format_headers)
|
97
|
+
|
98
|
+
client.request("/foo")
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
def test_handle_response_instigates_login_process
|
103
|
+
response = Net::HTTPUnauthorized.new("","","")
|
104
|
+
|
105
|
+
@client.expects(:handle_unauthorized_response)
|
106
|
+
|
107
|
+
assert_equal response, @client.handle_response(response)
|
108
|
+
end
|
109
|
+
|
110
|
+
def test_handle_response_handles_rets_errors
|
111
|
+
response = Net::HTTPSuccess.new("", "", "")
|
112
|
+
response.stubs(:body => RETS_ERROR)
|
113
|
+
|
114
|
+
assert_raise Rets::InvalidRequest do
|
115
|
+
@client.handle_response(response)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def test_handle_response_handles_rets_valid_response
|
120
|
+
response = Net::HTTPSuccess.new("", "", "")
|
121
|
+
response.stubs(:body => RETS_REPLY)
|
122
|
+
|
123
|
+
assert_equal response, @client.handle_response(response)
|
124
|
+
end
|
125
|
+
|
126
|
+
def test_handle_response_handles_empty_responses
|
127
|
+
response = Net::HTTPSuccess.new("", "", "")
|
128
|
+
response.stubs(:body => "")
|
129
|
+
|
130
|
+
assert_equal response, @client.handle_response(response)
|
131
|
+
end
|
132
|
+
|
133
|
+
def test_handle_response_handles_non_xml_responses
|
134
|
+
response = Net::HTTPSuccess.new("", "", "")
|
135
|
+
response.stubs(:body => "<notxml")
|
136
|
+
|
137
|
+
assert_equal response, @client.handle_response(response)
|
138
|
+
end
|
139
|
+
|
140
|
+
def test_handle_response_raises_on_unknown_response_code
|
141
|
+
response = Net::HTTPServerError.new("", "", "")
|
142
|
+
|
143
|
+
assert_raise Rets::UnknownResponse do
|
144
|
+
assert_equal response, @client.handle_response(response)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
def test_handle_unauthorized_response_sets_capabilities_on_success
|
150
|
+
response = Net::HTTPSuccess.new("","","")
|
151
|
+
response.stubs(:body => CAPABILITIES, :get_fields => ["xxx"])
|
152
|
+
|
153
|
+
@client.stubs(:build_auth)
|
154
|
+
@client.expects(:raw_request).with("/login").returns(response)
|
155
|
+
|
156
|
+
@client.handle_unauthorized_response(response)
|
157
|
+
|
158
|
+
capabilities = {"abc" => "123", "def" => "ghi=jk"}
|
159
|
+
|
160
|
+
assert_equal capabilities, @client.capabilities
|
161
|
+
end
|
162
|
+
|
163
|
+
def test_handle_unauthorized_response_raises_on_auth_failure
|
164
|
+
response = Net::HTTPUnauthorized.new("","","")
|
165
|
+
response.stubs(:body => "", :get_fields => ["xxx"])
|
166
|
+
|
167
|
+
@client.stubs(:build_auth)
|
168
|
+
@client.expects(:raw_request).with("/login").returns(response)
|
169
|
+
|
170
|
+
assert_raise Rets::AuthorizationFailure do
|
171
|
+
@client.handle_unauthorized_response(response)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
|
176
|
+
|
177
|
+
def test_extract_capabilities
|
178
|
+
assert_equal(
|
179
|
+
{"abc" => "123", "def" => "ghi=jk"},
|
180
|
+
@client.extract_capabilities(Nokogiri.parse(CAPABILITIES))
|
181
|
+
)
|
182
|
+
end
|
183
|
+
|
184
|
+
def test_capability_url_returns_parsed_url
|
185
|
+
@client.capabilities = { "foo" => "http://example.com" }
|
186
|
+
|
187
|
+
assert_equal URI.parse("http://example.com"), @client.capability_url("foo")
|
188
|
+
end
|
189
|
+
|
190
|
+
def test_capability_url_raises_on_malformed_url
|
191
|
+
@client.capabilities = { "foo" => "http://e$^&#$&xample.com" }
|
192
|
+
|
193
|
+
assert_raise Rets::MalformedResponse do
|
194
|
+
@client.capability_url("foo")
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def test_capabilities_calls_login_when_nil
|
199
|
+
@client.expects(:login)
|
200
|
+
@client.capabilities
|
201
|
+
end
|
202
|
+
|
203
|
+
|
204
|
+
def test_cookies?
|
205
|
+
assert @client.cookies?({"set-cookie" => "FavoriteFruit=Plum;"})
|
206
|
+
assert !@client.cookies?({})
|
207
|
+
end
|
208
|
+
|
209
|
+
def test_cookies=
|
210
|
+
@client.cookies = ["abc=123; path=/; HttpOnly", "def=456;", "ghi=789"]
|
211
|
+
|
212
|
+
assert_equal(
|
213
|
+
{"abc" => "123", "def" => "456", "ghi" => "789"},
|
214
|
+
@client.instance_variable_get("@cookies")
|
215
|
+
)
|
216
|
+
|
217
|
+
@client.cookies = ["abc=111; blah", "zzz=123"]
|
218
|
+
|
219
|
+
assert_equal(
|
220
|
+
{"abc" => "111", "def" => "456", "ghi" => "789", "zzz" => "123"},
|
221
|
+
@client.instance_variable_get("@cookies")
|
222
|
+
)
|
223
|
+
end
|
224
|
+
|
225
|
+
def test_cookies
|
226
|
+
# Set an array instead of hash for predictable iteration and string construction
|
227
|
+
@client.instance_variable_set("@cookies", [%w(abc 123), %w(def 456)])
|
228
|
+
|
229
|
+
assert_equal "abc=123; def=456", @client.cookies
|
230
|
+
end
|
231
|
+
|
232
|
+
|
233
|
+
def test_build_headers_provides_basic_headers
|
234
|
+
assert_equal({
|
235
|
+
"User-Agent" => "Client/1.0",
|
236
|
+
"Host" => "example.com:80",
|
237
|
+
"RETS-Version" => "RETS/1.7.2"},
|
238
|
+
@client.build_headers)
|
239
|
+
end
|
240
|
+
|
241
|
+
def test_build_headers_provides_authorization
|
242
|
+
@client.authorization = "Just trust me"
|
243
|
+
|
244
|
+
assert_equal({
|
245
|
+
"Authorization" => "Just trust me",
|
246
|
+
"User-Agent" => "Client/1.0",
|
247
|
+
"Host" => "example.com:80",
|
248
|
+
"RETS-Version" => "RETS/1.7.2"},
|
249
|
+
@client.build_headers)
|
250
|
+
end
|
251
|
+
|
252
|
+
def test_build_headers_provides_cookies
|
253
|
+
@client.cookies = ["Allowed=totally"]
|
254
|
+
|
255
|
+
assert_equal({
|
256
|
+
"Cookie" => "Allowed=totally",
|
257
|
+
"User-Agent" => "Client/1.0",
|
258
|
+
"Host" => "example.com:80",
|
259
|
+
"RETS-Version" => "RETS/1.7.2"},
|
260
|
+
@client.build_headers)
|
261
|
+
end
|
262
|
+
|
263
|
+
|
264
|
+
def test_tries_increments_with_each_call
|
265
|
+
assert_equal 1, @client.tries
|
266
|
+
assert_equal 2, @client.tries
|
267
|
+
end
|
268
|
+
|
269
|
+
def test_build_auth
|
270
|
+
www_authenticate =
|
271
|
+
%q(Digest realm="EXAMPLE", nonce="aec306b318feef4c360bc986e06d0a71", opaque="4211001cd29d5a65b3ed99f766a896b0", qop="auth")
|
272
|
+
|
273
|
+
uri = URI.parse("http://bob:secret@example.com/login")
|
274
|
+
|
275
|
+
Digest::MD5.stubs(:hexdigest => "heeheehee")
|
276
|
+
|
277
|
+
expected = <<-DIGEST.gsub(/\n/, "")
|
278
|
+
Digest username="bob", realm="EXAMPLE", qop="auth", uri="/login", nonce="aec306b318feef4c360bc986e06d0a71",
|
279
|
+
nc=00000000, cnonce="heeheehee", response="heeheehee", opaque="4211001cd29d5a65b3ed99f766a896b0"
|
280
|
+
DIGEST
|
281
|
+
|
282
|
+
assert_equal expected, @client.build_auth(www_authenticate, uri)
|
283
|
+
end
|
284
|
+
|
285
|
+
def test_calculate_digest_with_qop
|
286
|
+
Digest::MD5.expects(:hexdigest).with("bob:example:secret").returns("a1")
|
287
|
+
Digest::MD5.expects(:hexdigest).with("POST:/login").returns("a2")
|
288
|
+
|
289
|
+
Digest::MD5.expects(:hexdigest).with("a1:nonce:00000001:cnonce:qop:a2")
|
290
|
+
|
291
|
+
@client.calculate_digest("bob", "secret", "example", "nonce", "POST", URI.parse("/login"), "qop", "cnonce", 1)
|
292
|
+
end
|
293
|
+
|
294
|
+
def test_calculate_digest_without_qop
|
295
|
+
Digest::MD5.expects(:hexdigest).with("bob:example:secret").returns("a1")
|
296
|
+
Digest::MD5.expects(:hexdigest).with("POST:/login").returns("a2")
|
297
|
+
|
298
|
+
Digest::MD5.expects(:hexdigest).with("a1:nonce:a2").returns("hash")
|
299
|
+
|
300
|
+
assert_equal "hash",
|
301
|
+
@client.calculate_digest("bob", "secret", "example", "nonce", "POST", URI.parse("/login"), nil, "cnonce", 1)
|
302
|
+
end
|
303
|
+
|
304
|
+
def test_calculate_user_agent_digest
|
305
|
+
Digest::MD5.expects(:hexdigest).with("agent:secret").returns("a1")
|
306
|
+
Digest::MD5.expects(:hexdigest).with("a1::session:version").returns("hash")
|
307
|
+
|
308
|
+
assert_equal "hash",
|
309
|
+
@client.calculate_user_agent_digest("agent", "secret", "session", "version")
|
310
|
+
end
|
311
|
+
|
312
|
+
|
313
|
+
def test_session_restores_state
|
314
|
+
session = Rets::Session.new("Digest auth", {"Foo" => "/foo"}, "sessionid=123")
|
315
|
+
|
316
|
+
@client.session = session
|
317
|
+
|
318
|
+
assert_equal("Digest auth", @client.authorization)
|
319
|
+
assert_equal({"Foo" => "/foo"}, @client.capabilities)
|
320
|
+
assert_equal("sessionid=123", @client.cookies)
|
321
|
+
end
|
322
|
+
|
323
|
+
def test_session_dumps_state
|
324
|
+
@client.authorization = "Digest auth"
|
325
|
+
@client.capabilities = {"Foo" => "/foo"}
|
326
|
+
@client.cookies = "session-id=123"
|
327
|
+
|
328
|
+
session = @client.session
|
329
|
+
|
330
|
+
assert_equal("Digest auth", session.authorization)
|
331
|
+
assert_equal({"Foo" => "/foo"}, session.capabilities)
|
332
|
+
assert_equal("session-id=123", session.cookies)
|
333
|
+
end
|
334
|
+
|
335
|
+
def test_initialize_with_session_restores_state
|
336
|
+
session = Rets::Session.new("Digest auth", {"Foo" => "/foo"}, "sessionid=123")
|
337
|
+
|
338
|
+
client = Rets::Client.new(:login_url => "http://example.com", :session => session)
|
339
|
+
|
340
|
+
assert_equal("Digest auth", client.authorization)
|
341
|
+
assert_equal({"Foo" => "/foo"}, client.capabilities)
|
342
|
+
assert_equal("sessionid=123", client.cookies)
|
343
|
+
end
|
344
|
+
|
345
|
+
def test_metadata_when_not_initialized_with_metadata
|
346
|
+
client = Rets::Client.new(:login_url => "http://example.com")
|
347
|
+
Rets::Metadata::Root.expects(:new)
|
348
|
+
client.metadata
|
349
|
+
end
|
350
|
+
|
351
|
+
def test_initialize_with_old_metadata_cached_gets_new_metadata
|
352
|
+
metadata = stub(:current? => false)
|
353
|
+
new_metadata = stub(:current? => false)
|
354
|
+
client = Rets::Client.new(:login_url => "http://example.com", :metadata => metadata)
|
355
|
+
client.stubs(:capabilities => {})
|
356
|
+
Rets::Metadata::Root.expects(:new => new_metadata).once
|
357
|
+
|
358
|
+
assert_same new_metadata, client.metadata
|
359
|
+
# This second call ensures the expectations on Root are met
|
360
|
+
client.metadata
|
361
|
+
end
|
362
|
+
|
363
|
+
def test_initialize_with_current_metadata_cached_return_cached_metadata
|
364
|
+
metadata = stub(:current? => true)
|
365
|
+
client = Rets::Client.new(:login_url => "http://example.com", :metadata => metadata)
|
366
|
+
client.stubs(:capabilities => {})
|
367
|
+
|
368
|
+
assert_same metadata, client.metadata
|
369
|
+
end
|
370
|
+
|
371
|
+
def test_initialize_takes_logger
|
372
|
+
logger = Object.new
|
373
|
+
|
374
|
+
client = Rets::Client.new(:login_url => "http://example.com", :logger => logger)
|
375
|
+
|
376
|
+
assert_equal logger, client.logger
|
377
|
+
end
|
378
|
+
|
379
|
+
def test_default_logger_returns_api_compatible_silent_logger
|
380
|
+
logger = @client.logger
|
381
|
+
|
382
|
+
assert_nothing_raised do
|
383
|
+
logger.fatal "foo"
|
384
|
+
logger.error "foo"
|
385
|
+
logger.warn "foo"
|
386
|
+
logger.info "foo"
|
387
|
+
logger.debug "foo"
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
|
392
|
+
def test_find_first_calls_find_every_with_limit_one
|
393
|
+
@client.expects(:find_every).with(:limit => 1, :foo => :bar).returns([1,2,3])
|
394
|
+
|
395
|
+
assert_equal 1, @client.find(:first, :foo => :bar, :limit => 5), "User-specified limit should be ignored"
|
396
|
+
end
|
397
|
+
|
398
|
+
def test_find_all_calls_find_every
|
399
|
+
@client.expects(:find_every).with(:limit => 5, :foo => :bar).returns([1,2,3])
|
400
|
+
|
401
|
+
assert_equal [1,2,3], @client.find(:all, :limit => 5, :foo => :bar)
|
402
|
+
end
|
403
|
+
|
404
|
+
def test_find_raises_on_unknown_quantity
|
405
|
+
assert_raise ArgumentError do
|
406
|
+
@client.find(:incorrect, :foo => :bar)
|
407
|
+
end
|
408
|
+
end
|
409
|
+
|
410
|
+
def test_find_provides_default_values
|
411
|
+
@client.expects(:build_key_values).
|
412
|
+
with("QueryType" => "DMQL2", "Format" => "COMPACT", "Query" => "x", "Foo" => "bar").
|
413
|
+
returns("xxx")
|
414
|
+
|
415
|
+
@client.stubs(:capability_url => URI.parse("/example"))
|
416
|
+
@client.stubs(:request_with_compact_response)
|
417
|
+
|
418
|
+
@client.find(:all, :query => "x", :foo => "bar")
|
419
|
+
end
|
420
|
+
|
421
|
+
def test_find_allows_defaults_to_be_overridden
|
422
|
+
@client.expects(:build_key_values).
|
423
|
+
with("QueryType" => "DMQL3000", "Format" => "COMPACT", "Query" => "x", "Foo" => "bar").
|
424
|
+
returns("xxx")
|
425
|
+
|
426
|
+
@client.stubs(:capability_url => URI.parse("/example"))
|
427
|
+
@client.stubs(:request_with_compact_response)
|
428
|
+
|
429
|
+
@client.find(:all, :query => "x", :foo => "bar", :query_type => "DMQL3000")
|
430
|
+
end
|
431
|
+
|
432
|
+
def test_find_returns_undecorated_results
|
433
|
+
@client.stubs(:capability_url => URI.parse("/example"))
|
434
|
+
|
435
|
+
@client.expects(:request_with_compact_response).
|
436
|
+
with("/example", instance_of(String), instance_of(Hash)).
|
437
|
+
returns([["foo", "bar"]])
|
438
|
+
|
439
|
+
results = @client.find(:all, :search_type => "Property", :class => "Res", :query => "x", :foo => "bar")
|
440
|
+
|
441
|
+
assert_equal [["foo", "bar"]], results
|
442
|
+
end
|
443
|
+
|
444
|
+
def test_find_returns_decorated_results
|
445
|
+
@client.stubs(:capability_url => URI.parse("/example"))
|
446
|
+
|
447
|
+
@client.expects(:request_with_compact_response).
|
448
|
+
with("/example", instance_of(String), instance_of(Hash)).
|
449
|
+
returns([["foo", "bar"]])
|
450
|
+
|
451
|
+
fake_rets_class = stub(:rets_class)
|
452
|
+
fake_result = stub(:result)
|
453
|
+
|
454
|
+
@client.expects(:find_rets_class).with("Property", "Res").returns(fake_rets_class)
|
455
|
+
@client.expects(:decorate_results).with([["foo", "bar"]], fake_rets_class).returns(fake_result)
|
456
|
+
|
457
|
+
results = @client.find(:all, :search_type => "Property", :class => "Res", :query => "x", :foo => "bar", :resolve => true)
|
458
|
+
|
459
|
+
assert_equal fake_result, results
|
460
|
+
end
|
461
|
+
|
462
|
+
def test_fixup_keys
|
463
|
+
assert_equal({ "Foo" => "bar" }, @client.fixup_keys(:foo => "bar"))
|
464
|
+
assert_equal({ "FooFoo" => "bar" }, @client.fixup_keys(:foo_foo => "bar"))
|
465
|
+
end
|
466
|
+
|
467
|
+
def test_all_objects_calls_objects
|
468
|
+
@client.expects(:objects).with("*", :foo => :bar)
|
469
|
+
|
470
|
+
@client.all_objects(:foo => :bar)
|
471
|
+
end
|
472
|
+
|
473
|
+
def test_objects_handles_string_argument
|
474
|
+
@client.expects(:fetch_object).with("*", :foo => :bar)
|
475
|
+
@client.stubs(:create_parts_from_response)
|
476
|
+
|
477
|
+
@client.objects("*", :foo => :bar)
|
478
|
+
end
|
479
|
+
|
480
|
+
def test_objects_handle_array_argument
|
481
|
+
@client.expects(:fetch_object).with("1,2", :foo => :bar)
|
482
|
+
@client.stubs(:create_parts_from_response)
|
483
|
+
|
484
|
+
@client.objects([1,2], :foo => :bar)
|
485
|
+
end
|
486
|
+
|
487
|
+
def test_objects_raises_on_other_arguments
|
488
|
+
assert_raise ArgumentError do
|
489
|
+
@client.objects(Object.new, :foo => :bar)
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
def test_create_parts_from_response_returns_multiple_parts_when_multipart_response
|
494
|
+
response = {"content-type" => 'multipart; boundary="simple boundary"'}
|
495
|
+
response.stubs(:body => MULITPART_RESPONSE)
|
496
|
+
|
497
|
+
Rets::Parser::Multipart.expects(:parse).
|
498
|
+
with(MULITPART_RESPONSE, "simple boundary").
|
499
|
+
returns([])
|
500
|
+
|
501
|
+
@client.create_parts_from_response(response)
|
502
|
+
end
|
503
|
+
|
504
|
+
def test_create_parts_from_response_returns_a_single_part_when_not_multipart_response
|
505
|
+
response = {"content-type" => "text/plain"}
|
506
|
+
response.stubs(:body => "fakebody")
|
507
|
+
|
508
|
+
parts = @client.create_parts_from_response(response)
|
509
|
+
|
510
|
+
assert_equal 1, parts.size
|
511
|
+
|
512
|
+
part = parts.first
|
513
|
+
|
514
|
+
assert_equal response, part.headers
|
515
|
+
assert_equal "fakebody", part.body
|
516
|
+
end
|
517
|
+
|
518
|
+
def test_object_calls_fetch_object
|
519
|
+
response = stub(:body => "foo")
|
520
|
+
|
521
|
+
@client.expects(:fetch_object).with("1", :foo => :bar).returns(response)
|
522
|
+
|
523
|
+
assert_equal "foo", @client.object("1", :foo => :bar)
|
524
|
+
end
|
525
|
+
|
526
|
+
def test_fetch_object
|
527
|
+
@client.expects(:capability_url).with("GetObject").returns(URI.parse("/obj"))
|
528
|
+
|
529
|
+
@client.expects(:build_key_values => "fakebody").with(
|
530
|
+
"Resource" => "Property",
|
531
|
+
"Type" => "Image",
|
532
|
+
"ID" => "123:*",
|
533
|
+
"Location" => 0
|
534
|
+
)
|
535
|
+
|
536
|
+
@client.expects(:request).with("/obj", "fakebody",
|
537
|
+
has_entries(
|
538
|
+
"Accept" => "image/jpeg, image/png;q=0.5, image/gif;q=0.1",
|
539
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
540
|
+
"Content-Length" => "8")
|
541
|
+
)
|
542
|
+
|
543
|
+
@client.fetch_object("*", :resource => "Property", :object_type => "Image", :resource_id => "123")
|
544
|
+
end
|
545
|
+
|
546
|
+
def test_metadata_caches
|
547
|
+
metadata = stub(:current? => true)
|
548
|
+
@client.metadata = metadata
|
549
|
+
@client.stubs(:capabilities => {})
|
550
|
+
|
551
|
+
assert_same metadata, @client.metadata, "Should be memoized"
|
552
|
+
end
|
553
|
+
|
554
|
+
def test_retrieve_metadata_type
|
555
|
+
@client.expects(:capability_url).with("GetMetadata").returns(URI.parse("/meta"))
|
556
|
+
|
557
|
+
@client.expects(:build_key_values => "fakebody").with(
|
558
|
+
"Format" => "COMPACT",
|
559
|
+
"Type" => "METADATA-FOO",
|
560
|
+
"ID" => "0"
|
561
|
+
)
|
562
|
+
|
563
|
+
@client.expects(:request => stub(:body => "response")).with("/meta", "fakebody", has_entries(
|
564
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
565
|
+
"Content-Length" => "8"
|
566
|
+
))
|
567
|
+
|
568
|
+
assert_equal "response", @client.retrieve_metadata_type("FOO")
|
569
|
+
end
|
570
|
+
|
571
|
+
end
|