rets-hack 0.11
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +142 -0
- data/Manifest.txt +58 -0
- data/README.md +129 -0
- data/Rakefile +28 -0
- data/bin/rets +202 -0
- data/example/connect.rb +19 -0
- data/example/get-photos.rb +20 -0
- data/example/get-property.rb +16 -0
- data/lib/rets/client.rb +373 -0
- data/lib/rets/client_progress_reporter.rb +48 -0
- data/lib/rets/http_client.rb +133 -0
- data/lib/rets/locking_http_client.rb +34 -0
- data/lib/rets/measuring_http_client.rb +27 -0
- data/lib/rets/metadata/caching.rb +59 -0
- data/lib/rets/metadata/containers.rb +89 -0
- data/lib/rets/metadata/file_cache.rb +29 -0
- data/lib/rets/metadata/json_serializer.rb +27 -0
- data/lib/rets/metadata/lookup_table.rb +65 -0
- data/lib/rets/metadata/lookup_type.rb +19 -0
- data/lib/rets/metadata/marshal_serializer.rb +27 -0
- data/lib/rets/metadata/multi_lookup_table.rb +70 -0
- data/lib/rets/metadata/null_cache.rb +24 -0
- data/lib/rets/metadata/resource.rb +103 -0
- data/lib/rets/metadata/rets_class.rb +57 -0
- data/lib/rets/metadata/rets_object.rb +41 -0
- data/lib/rets/metadata/root.rb +155 -0
- data/lib/rets/metadata/table.rb +33 -0
- data/lib/rets/metadata/table_factory.rb +19 -0
- data/lib/rets/metadata/yaml_serializer.rb +27 -0
- data/lib/rets/metadata.rb +18 -0
- data/lib/rets/parser/compact.rb +117 -0
- data/lib/rets/parser/error_checker.rb +56 -0
- data/lib/rets/parser/multipart.rb +39 -0
- data/lib/rets.rb +269 -0
- data/test/fixtures.rb +324 -0
- data/test/helper.rb +14 -0
- data/test/test_caching.rb +89 -0
- data/test/test_client.rb +307 -0
- data/test/test_error_checker.rb +87 -0
- data/test/test_file_cache.rb +42 -0
- data/test/test_http_client.rb +132 -0
- data/test/test_json_serializer.rb +26 -0
- data/test/test_locking_http_client.rb +29 -0
- data/test/test_marshal_serializer.rb +26 -0
- data/test/test_metadata.rb +71 -0
- data/test/test_metadata_class.rb +50 -0
- data/test/test_metadata_lookup_table.rb +21 -0
- data/test/test_metadata_lookup_type.rb +21 -0
- data/test/test_metadata_multi_lookup_table.rb +60 -0
- data/test/test_metadata_object.rb +33 -0
- data/test/test_metadata_resource.rb +148 -0
- data/test/test_metadata_root.rb +151 -0
- data/test/test_metadata_table.rb +21 -0
- data/test/test_metadata_table_factory.rb +24 -0
- data/test/test_parser_compact.rb +115 -0
- data/test/test_parser_multipart.rb +39 -0
- data/test/test_yaml_serializer.rb +26 -0
- data/test/vcr_cassettes/unauthorized_response.yml +262 -0
- metadata +227 -0
data/lib/rets/client.rb
ADDED
@@ -0,0 +1,373 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
module Rets
|
4
|
+
class Client
|
5
|
+
COUNT = Struct.new(:exclude, :include, :only).new(0,1,2)
|
6
|
+
CASE_INSENSITIVE_PROC = Proc.new { |h,k| h.key?(k.downcase) ? h[k.downcase] : nil }
|
7
|
+
|
8
|
+
attr_accessor :cached_metadata, :client_progress, :logger, :login_url, :options
|
9
|
+
|
10
|
+
def initialize(options)
|
11
|
+
@options = options
|
12
|
+
clean_setup
|
13
|
+
end
|
14
|
+
|
15
|
+
def clean_setup
|
16
|
+
if options.fetch(:login_after_error, true)
|
17
|
+
@capabilities = nil
|
18
|
+
end
|
19
|
+
@metadata = nil
|
20
|
+
@tries = nil
|
21
|
+
@login_url = options[:login_url]
|
22
|
+
@cached_metadata = options[:metadata]
|
23
|
+
@cached_capabilities = options[:capabilities]
|
24
|
+
@logger = options[:logger] || FakeLogger.new
|
25
|
+
@client_progress = ClientProgressReporter.new(logger, options[:stats_collector], options[:stats_prefix])
|
26
|
+
@http_client = Rets::HttpClient.from_options(options, logger)
|
27
|
+
@caching = Metadata::Caching.make(options)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Attempts to login by making an empty request to the URL provided in
|
31
|
+
# initialize. Returns the capabilities that the RETS server provides, per
|
32
|
+
# page 34 of http://www.realtor.org/retsorg.nsf/retsproto1.7d6.pdf#page=34
|
33
|
+
def login
|
34
|
+
res = http_get(login_url)
|
35
|
+
Parser::ErrorChecker.check(res)
|
36
|
+
|
37
|
+
new_capabilities = extract_capabilities(Nokogiri.parse(res.body))
|
38
|
+
unless new_capabilities
|
39
|
+
raise UnknownResponse, "Cannot read rets server capabilities."
|
40
|
+
end
|
41
|
+
@capabilities = new_capabilities
|
42
|
+
end
|
43
|
+
|
44
|
+
def logout
|
45
|
+
unless capabilities["Logout"]
|
46
|
+
raise NoLogout.new('No logout method found for rets client')
|
47
|
+
end
|
48
|
+
http_get(capability_url("Logout"))
|
49
|
+
rescue UnknownResponse => e
|
50
|
+
unless e.message.match(/expected a 200, but got 401/)
|
51
|
+
raise e
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Finds records.
|
56
|
+
#
|
57
|
+
# [quantity] Return the first record, or an array of records.
|
58
|
+
# Uses a symbol <tt>:first</tt> or <tt>:all</tt>, respectively.
|
59
|
+
#
|
60
|
+
# [opts] A hash of arguments used to construct the search query,
|
61
|
+
# using the following keys:
|
62
|
+
#
|
63
|
+
# <tt>:search_type</tt>:: Required. The resource to search for.
|
64
|
+
# <tt>:class</tt>:: Required. The class of the resource to search for.
|
65
|
+
# <tt>:query</tt>:: Required. The DMQL2 query string to execute.
|
66
|
+
# <tt>:limit</tt>:: The number of records to request from the server.
|
67
|
+
# <tt>:resolve</tt>:: Provide resolved values that use metadata instead
|
68
|
+
# of raw system values.
|
69
|
+
#
|
70
|
+
# Any other keys are converted to the RETS query format, and passed
|
71
|
+
# to the server as part of the query. For instance, the key <tt>:offset</tt>
|
72
|
+
# will be sent as +Offset+.
|
73
|
+
#
|
74
|
+
def find(quantity, opts = {})
|
75
|
+
case quantity
|
76
|
+
when :first then find_with_retries(opts.merge(:limit => 1)).first
|
77
|
+
when :all then find_with_retries(opts)
|
78
|
+
else raise ArgumentError, "First argument must be :first or :all"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
alias search find
|
83
|
+
|
84
|
+
def find_with_retries(opts = {})
|
85
|
+
retries = 0
|
86
|
+
find_with_given_retry(retries, opts)
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_with_given_retry(retries, opts)
|
90
|
+
begin
|
91
|
+
find_every(opts)
|
92
|
+
rescue NoRecordsFound => e
|
93
|
+
if opts.fetch(:no_records_not_an_error, false)
|
94
|
+
client_progress.no_records_found
|
95
|
+
opts[:count] == COUNT.only ? 0 : []
|
96
|
+
else
|
97
|
+
handle_find_failure(retries, opts, e)
|
98
|
+
end
|
99
|
+
rescue InvalidRequest, HttpError => e
|
100
|
+
handle_find_failure(retries, opts, e)
|
101
|
+
rescue AuthorizationFailure => e
|
102
|
+
login
|
103
|
+
handle_find_failure(retries, opts, e)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def handle_find_failure(retries, opts, e)
|
108
|
+
max_retries = fetch_max_retries(opts)
|
109
|
+
if retries < max_retries
|
110
|
+
retries += 1
|
111
|
+
wait_before_next_request
|
112
|
+
client_progress.find_with_retries_failed_a_retry(e, retries, max_retries)
|
113
|
+
clean_setup
|
114
|
+
find_with_given_retry(retries, opts)
|
115
|
+
else
|
116
|
+
client_progress.find_with_retries_exceeded_retry_count(e)
|
117
|
+
raise e
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def fetch_max_retries(hash)
|
122
|
+
hash[:max_retries] || options.fetch(:max_retries, 3)
|
123
|
+
end
|
124
|
+
|
125
|
+
def wait_before_next_request
|
126
|
+
sleep_time = Float(options.fetch(:recoverable_error_wait_secs, 0))
|
127
|
+
if sleep_time > 0
|
128
|
+
logger.info "Waiting #{sleep_time} seconds before next attempt"
|
129
|
+
sleep sleep_time
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def find_every(opts)
|
134
|
+
raise ArgumentError.new("missing option :search_type (provide the name of a RETS resource)") unless opts[:search_type]
|
135
|
+
raise ArgumentError.new("missing option :class (provide the name of a RETS class)") unless opts[:class]
|
136
|
+
|
137
|
+
params = {
|
138
|
+
"SearchType" => opts.fetch(:search_type),
|
139
|
+
"Class" => opts.fetch(:class),
|
140
|
+
"Count" => opts[:count],
|
141
|
+
"Format" => opts.fetch(:format, "COMPACT"),
|
142
|
+
"Limit" => opts[:limit],
|
143
|
+
"Offset" => opts[:offset],
|
144
|
+
"Select" => opts[:select],
|
145
|
+
"RestrictedIndicator" => opts[:RestrictedIndicator],
|
146
|
+
"StandardNames" => opts[:standard_name],
|
147
|
+
"Payload" => opts[:payload],
|
148
|
+
"Query" => opts[:query],
|
149
|
+
"QueryType" => opts.fetch(:query_type, "DMQL2"),
|
150
|
+
}.reject { |k,v| v.nil? }
|
151
|
+
res = clean_response(http_post(capability_url("Search"), params))
|
152
|
+
|
153
|
+
if opts[:count] == COUNT.only
|
154
|
+
Parser::Compact.get_count(res.body)
|
155
|
+
else
|
156
|
+
results = Parser::Compact.parse_document(
|
157
|
+
res.body
|
158
|
+
)
|
159
|
+
if opts[:resolve]
|
160
|
+
rets_class = find_rets_class(opts[:search_type], opts[:class])
|
161
|
+
decorate_results(results, rets_class)
|
162
|
+
else
|
163
|
+
results
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def find_rets_class(resource_name, rets_class_name)
|
169
|
+
metadata.tree[resource_name].find_rets_class(rets_class_name)
|
170
|
+
end
|
171
|
+
|
172
|
+
def decorate_results(results, rets_class)
|
173
|
+
results.map do |result|
|
174
|
+
decorate_result(result, rets_class)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def decorate_result(result, rets_class)
|
179
|
+
result.each do |key, value|
|
180
|
+
table = rets_class.find_table(key)
|
181
|
+
if table
|
182
|
+
result[key] = table.resolve(value.to_s)
|
183
|
+
else
|
184
|
+
#can't resolve just leave the value be
|
185
|
+
client_progress.could_not_resolve_find_metadata(key)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Returns an array of all objects associated with the given resource.
|
191
|
+
def all_objects(opts = {})
|
192
|
+
objects("*", opts)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Returns an array of specified objects.
|
196
|
+
def objects(object_ids, opts = {})
|
197
|
+
response = case object_ids
|
198
|
+
when String then fetch_object(object_ids, opts)
|
199
|
+
when Array then fetch_object(object_ids.join(":"), opts)
|
200
|
+
else raise ArgumentError, "Expected instance of String or Array, but got #{object_ids.inspect}."
|
201
|
+
end
|
202
|
+
|
203
|
+
create_parts_from_response(response)
|
204
|
+
end
|
205
|
+
|
206
|
+
def create_parts_from_response(response)
|
207
|
+
content_type = response.header["content-type"][0]
|
208
|
+
|
209
|
+
if content_type.nil?
|
210
|
+
raise MalformedResponse, "Unable to read content-type from response: #{response.inspect}"
|
211
|
+
end
|
212
|
+
|
213
|
+
if content_type.include?("multipart")
|
214
|
+
boundary = content_type.scan(/boundary="?([^;"]*)?/).join
|
215
|
+
|
216
|
+
parts = Parser::Multipart.parse(response.body, boundary)
|
217
|
+
|
218
|
+
logger.debug "Rets::Client: Found #{parts.size} parts"
|
219
|
+
|
220
|
+
return parts
|
221
|
+
else
|
222
|
+
logger.debug "Rets::Client: Found 1 part (the whole body)"
|
223
|
+
|
224
|
+
# fake a multipart for interface compatibility
|
225
|
+
headers = {}
|
226
|
+
response.headers.each { |k,v| headers[k.downcase] = v }
|
227
|
+
part = Parser::Multipart::Part.new(headers, response.body)
|
228
|
+
|
229
|
+
return [part]
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Returns a single object.
|
234
|
+
#
|
235
|
+
# resource RETS resource as defined in the resource metadata.
|
236
|
+
# object_type an object type defined in the object metadata.
|
237
|
+
# resource_id the KeyField value of the given resource instance.
|
238
|
+
# object_id can be "*" or a colon delimited string of integers or an array of integers.
|
239
|
+
def object(object_id, opts = {})
|
240
|
+
response = fetch_object(Array(object_id).join(':'), opts)
|
241
|
+
response.body
|
242
|
+
end
|
243
|
+
|
244
|
+
def fetch_object(object_id, opts = {})
|
245
|
+
params = {
|
246
|
+
"Resource" => opts.fetch("resource"),
|
247
|
+
"Type" => opts.fetch("object_type"),
|
248
|
+
"ID" => "#{opts.fetch("resource_id")}:#{object_id}",
|
249
|
+
"Location" => opts.fetch("location", 0)
|
250
|
+
}
|
251
|
+
|
252
|
+
extra_headers = {
|
253
|
+
"Accept" => "image/jpeg, image/png;q=0.5, image/gif;q=0.1",
|
254
|
+
}
|
255
|
+
|
256
|
+
http_post(capability_url("GetObject"), params, extra_headers)
|
257
|
+
end
|
258
|
+
|
259
|
+
def metadata(types=nil)
|
260
|
+
return @metadata if @metadata
|
261
|
+
@cached_metadata ||= @caching.load(@logger)
|
262
|
+
if cached_metadata && (options[:skip_metadata_uptodate_check] ||
|
263
|
+
cached_metadata.current?(capabilities["MetadataTimestamp"], capabilities["MetadataVersion"]))
|
264
|
+
client_progress.use_cached_metadata
|
265
|
+
@metadata = cached_metadata
|
266
|
+
else
|
267
|
+
client_progress.bad_cached_metadata(cached_metadata)
|
268
|
+
@metadata = Metadata::Root.new(logger, retrieve_metadata(types))
|
269
|
+
@caching.save(metadata)
|
270
|
+
end
|
271
|
+
@metadata
|
272
|
+
end
|
273
|
+
|
274
|
+
def retrieve_metadata(types=nil)
|
275
|
+
raw_metadata = {}
|
276
|
+
(types || Metadata::METADATA_TYPES).each {|type|
|
277
|
+
raw_metadata[type] = retrieve_metadata_type(type)
|
278
|
+
}
|
279
|
+
raw_metadata
|
280
|
+
end
|
281
|
+
|
282
|
+
def retrieve_metadata_type(type)
|
283
|
+
res = http_post(capability_url("GetMetadata"),
|
284
|
+
{ "Format" => "COMPACT",
|
285
|
+
"Type" => "METADATA-#{type}",
|
286
|
+
"ID" => "0"
|
287
|
+
})
|
288
|
+
clean_response(res).body
|
289
|
+
end
|
290
|
+
|
291
|
+
# The capabilies as provided by the RETS server during login.
|
292
|
+
#
|
293
|
+
# Currently, only the path in the endpoint URLs is used[1]. Host,
|
294
|
+
# port, other details remaining constant with those provided to
|
295
|
+
# the constructor.
|
296
|
+
#
|
297
|
+
# [1] In fact, sometimes only a path is returned from the server.
|
298
|
+
def capabilities
|
299
|
+
if @capabilities
|
300
|
+
@capabilities
|
301
|
+
elsif @cached_capabilities
|
302
|
+
@capabilities = add_case_insensitive_default_proc(@cached_capabilities)
|
303
|
+
else
|
304
|
+
login
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def capability_url(name)
|
309
|
+
val = capabilities[name] || capabilities[name.downcase]
|
310
|
+
|
311
|
+
raise UnknownCapability.new(name, capabilities.keys) unless val
|
312
|
+
|
313
|
+
begin
|
314
|
+
if val.downcase.match(/^https?:\/\//)
|
315
|
+
uri = URI.parse(val)
|
316
|
+
else
|
317
|
+
uri = URI.parse(login_url)
|
318
|
+
uri.path = val
|
319
|
+
end
|
320
|
+
rescue URI::InvalidURIError
|
321
|
+
raise MalformedResponse, "Unable to parse capability URL: #{name} => #{val.inspect}"
|
322
|
+
end
|
323
|
+
uri.to_s
|
324
|
+
end
|
325
|
+
|
326
|
+
def extract_capabilities(document)
|
327
|
+
raw_key_values = document.xpath("/RETS/RETS-RESPONSE").text.strip
|
328
|
+
|
329
|
+
# ... :(
|
330
|
+
# Feel free to make this better. It has a test.
|
331
|
+
hash = raw_key_values.split(/\n/).
|
332
|
+
map { |r| r.split(/\=/, 2) }.
|
333
|
+
each_with_object({}) { |(k,v), h| h[k.strip.downcase] = v.strip }
|
334
|
+
|
335
|
+
add_case_insensitive_default_proc(hash)
|
336
|
+
end
|
337
|
+
|
338
|
+
def add_case_insensitive_default_proc(hash)
|
339
|
+
new_hash = hash.dup
|
340
|
+
new_hash.default_proc = CASE_INSENSITIVE_PROC
|
341
|
+
new_hash
|
342
|
+
end
|
343
|
+
|
344
|
+
def save_cookie_store
|
345
|
+
@http_client.save_cookie_store
|
346
|
+
end
|
347
|
+
|
348
|
+
def http_get(url, params=nil, extra_headers={})
|
349
|
+
clean_response(@http_client.http_get(url, params, extra_headers))
|
350
|
+
end
|
351
|
+
|
352
|
+
def clean_response(res)
|
353
|
+
res.body.encode!("UTF-8", res.body.encoding, :invalid => :replace, :undef => :replace)
|
354
|
+
res
|
355
|
+
end
|
356
|
+
|
357
|
+
def http_post(url, params, extra_headers = {})
|
358
|
+
@http_client.http_post(url, params, extra_headers)
|
359
|
+
end
|
360
|
+
|
361
|
+
def tries
|
362
|
+
@tries ||= 1
|
363
|
+
|
364
|
+
(@tries += 1) - 1
|
365
|
+
end
|
366
|
+
|
367
|
+
class FakeLogger < Logger
|
368
|
+
def initialize
|
369
|
+
super(IO::NULL)
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Rets
|
2
|
+
class NullStatsReporter
|
3
|
+
def time(metric_name, &block)
|
4
|
+
block.call
|
5
|
+
end
|
6
|
+
|
7
|
+
def gauge(metric_name, measurement)
|
8
|
+
end
|
9
|
+
|
10
|
+
def count(metric_name, count=1)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class ClientProgressReporter
|
15
|
+
def initialize(logger, stats, stats_prefix)
|
16
|
+
@logger = logger
|
17
|
+
@stats = stats || NullStatsReporter.new
|
18
|
+
@stats_prefix = stats_prefix
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_with_retries_failed_a_retry(exception, retries, max_retries)
|
22
|
+
@stats.count("#{@stats_prefix}find_with_retries_failed_retry")
|
23
|
+
@logger.warn("Rets::Client: Failed with message: #{exception.message}")
|
24
|
+
@logger.info("Rets::Client: Retry #{retries}/#{max_retries}")
|
25
|
+
end
|
26
|
+
|
27
|
+
def find_with_retries_exceeded_retry_count(exception)
|
28
|
+
@stats.count("#{@stats_prefix}find_with_retries_exceeded_retry_count")
|
29
|
+
end
|
30
|
+
|
31
|
+
def no_records_found
|
32
|
+
@logger.info("Rets::Client: No Records Found")
|
33
|
+
end
|
34
|
+
|
35
|
+
def could_not_resolve_find_metadata(key)
|
36
|
+
@stats.count("#{@stats_prefix}could_not_resolve_find_metadata")
|
37
|
+
@logger.warn "Rets::Client: Can't resolve find metadata for #{key.inspect}"
|
38
|
+
end
|
39
|
+
|
40
|
+
def use_cached_metadata
|
41
|
+
@logger.info "Rets::Client: Use cached metadata"
|
42
|
+
end
|
43
|
+
|
44
|
+
def bad_cached_metadata(cached_metadata)
|
45
|
+
@logger.info cached_metadata ? "Rets::Client: Cached metadata out of date" : "Rets::Client: Cached metadata unavailable"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'http-cookie'
|
2
|
+
require 'httpclient'
|
3
|
+
|
4
|
+
module Rets
|
5
|
+
class HttpClient
|
6
|
+
attr_reader :http, :options, :logger, :login_url
|
7
|
+
|
8
|
+
def initialize(http, options, logger, login_url)
|
9
|
+
@http = http
|
10
|
+
@options = options
|
11
|
+
@logger = logger
|
12
|
+
@login_url = login_url
|
13
|
+
@options.fetch(:ca_certs, []).each {|c| @http.ssl_config.add_trust_ca(c) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.from_options(options, logger)
|
17
|
+
if options[:http_proxy]
|
18
|
+
http = HTTPClient.new(options.fetch(:http_proxy))
|
19
|
+
|
20
|
+
if options[:proxy_username]
|
21
|
+
http.set_proxy_auth(options.fetch(:proxy_username), options.fetch(:proxy_password))
|
22
|
+
end
|
23
|
+
else
|
24
|
+
http = HTTPClient.new
|
25
|
+
end
|
26
|
+
|
27
|
+
if options[:receive_timeout]
|
28
|
+
http.receive_timeout = options[:receive_timeout]
|
29
|
+
end
|
30
|
+
|
31
|
+
if options[:cookie_store]
|
32
|
+
ensure_cookie_store_exists! options[:cookie_store]
|
33
|
+
http.set_cookie_store(options[:cookie_store])
|
34
|
+
end
|
35
|
+
|
36
|
+
http_client = new(http, options, logger, options[:login_url])
|
37
|
+
|
38
|
+
if options[:http_timing_stats_collector]
|
39
|
+
http_client = Rets::MeasuringHttpClient.new(http_client, options.fetch(:http_timing_stats_collector), options.fetch(:http_timing_stats_prefix))
|
40
|
+
end
|
41
|
+
|
42
|
+
if options[:lock_around_http_requests]
|
43
|
+
http_client = Rets::LockingHttpClient.new(http_client, options.fetch(:locker), options.fetch(:lock_name), options.fetch(:lock_options))
|
44
|
+
end
|
45
|
+
|
46
|
+
http_client
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.ensure_cookie_store_exists!(cookie_store)
|
50
|
+
unless File.exist? cookie_store
|
51
|
+
FileUtils.touch(cookie_store)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def http_get(url, params=nil, extra_headers={})
|
56
|
+
http.set_auth(url, options[:username], options[:password])
|
57
|
+
headers = extra_headers.merge(rets_extra_headers)
|
58
|
+
res = nil
|
59
|
+
log_http_traffic("GET", url, params, headers) do
|
60
|
+
res = http.get(url, params, headers)
|
61
|
+
end
|
62
|
+
Parser::ErrorChecker.check(res)
|
63
|
+
res
|
64
|
+
end
|
65
|
+
|
66
|
+
def http_post(url, params, extra_headers = {})
|
67
|
+
http.set_auth(url, options[:username], options[:password])
|
68
|
+
headers = extra_headers.merge(rets_extra_headers)
|
69
|
+
res = nil
|
70
|
+
log_http_traffic("POST", url, params, headers) do
|
71
|
+
res = http.post(url, params, headers)
|
72
|
+
end
|
73
|
+
Parser::ErrorChecker.check(res)
|
74
|
+
res
|
75
|
+
end
|
76
|
+
|
77
|
+
def log_http_traffic(method, url, params, headers, &block)
|
78
|
+
# optimization, we don't want to compute log params
|
79
|
+
# if logging is off
|
80
|
+
if logger.debug?
|
81
|
+
logger.debug "Rets::Client >> #{method} #{url}"
|
82
|
+
logger.debug "Rets::Client >> params = #{params.inspect}"
|
83
|
+
logger.debug "Rets::Client >> headers = #{headers.inspect}"
|
84
|
+
end
|
85
|
+
|
86
|
+
res = block.call
|
87
|
+
|
88
|
+
# optimization, we don't want to compute log params
|
89
|
+
# if logging is off, especially when there is a loop just
|
90
|
+
# for logging
|
91
|
+
if logger.debug?
|
92
|
+
logger.debug "Rets::Client << Status #{res.status_code}"
|
93
|
+
res.headers.each { |k, v| logger.debug "Rets::Client << #{k}: #{v}" }
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def save_cookie_store
|
98
|
+
if options[:cookie_store]
|
99
|
+
#save session cookies
|
100
|
+
@http.cookie_manager.save_cookies(true)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def rets_extra_headers
|
105
|
+
user_agent = options[:agent] || "Client/1.0"
|
106
|
+
rets_version = options[:version] || "RETS/1.7.2"
|
107
|
+
|
108
|
+
headers = {
|
109
|
+
"User-Agent" => user_agent,
|
110
|
+
"RETS-Version" => rets_version
|
111
|
+
}
|
112
|
+
|
113
|
+
if options[:ua_password]
|
114
|
+
up = Digest::MD5.hexdigest "#{user_agent}:#{options[:ua_password]}"
|
115
|
+
session_id = http_cookie('RETS-Session-ID') || ''
|
116
|
+
digest = Digest::MD5.hexdigest "#{up}::#{session_id}:#{rets_version}"
|
117
|
+
headers.merge!("RETS-UA-Authorization" => "Digest #{digest}")
|
118
|
+
end
|
119
|
+
|
120
|
+
headers
|
121
|
+
end
|
122
|
+
|
123
|
+
def http_cookie(name)
|
124
|
+
@http.cookie_manager.cookies(login_url).each do |c|
|
125
|
+
if c.name.downcase == name.downcase
|
126
|
+
return c.value
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
nil
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Rets
|
2
|
+
class LockingHttpClient
|
3
|
+
def initialize(http_client, locker, lock_name, options={})
|
4
|
+
@http_client = http_client
|
5
|
+
@locker = locker
|
6
|
+
@lock_name = lock_name
|
7
|
+
@options = options
|
8
|
+
end
|
9
|
+
|
10
|
+
def http_get(url, params=nil, extra_headers={})
|
11
|
+
lock_around do
|
12
|
+
@http_client.http_get(url, params, extra_headers)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def http_post(url, params, extra_headers = {})
|
17
|
+
lock_around do
|
18
|
+
@http_client.http_post(url, params, extra_headers)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def save_cookie_store
|
23
|
+
@http_client.save_cookie_store
|
24
|
+
end
|
25
|
+
|
26
|
+
def lock_around(&block)
|
27
|
+
result = nil
|
28
|
+
@locker.lock(@lock_name, @options) do
|
29
|
+
result = block.call
|
30
|
+
end
|
31
|
+
result
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Rets
|
2
|
+
class MeasuringHttpClient
|
3
|
+
def initialize(http_client, stats, prefix)
|
4
|
+
@http_client = http_client
|
5
|
+
@stats = stats
|
6
|
+
@prefix = prefix
|
7
|
+
end
|
8
|
+
|
9
|
+
def http_get(url, params=nil, extra_headers={})
|
10
|
+
@stats.count("#{@prefix}.http_get_rate")
|
11
|
+
@stats.time("#{@prefix}.http_get") do
|
12
|
+
@http_client.http_get(url, params, extra_headers)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def http_post(url, params, extra_headers = {})
|
17
|
+
@stats.count("#{@prefix}.http_post_rate")
|
18
|
+
@stats.time("#{@prefix}.http_post") do
|
19
|
+
@http_client.http_post(url, params, extra_headers)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def save_cookie_store
|
24
|
+
@http_client.save_cookie_store
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Rets
|
2
|
+
module Metadata
|
3
|
+
|
4
|
+
# Metadata caching.
|
5
|
+
# @api internal
|
6
|
+
class Caching
|
7
|
+
|
8
|
+
# Given the options passed to Client#initialize, make an
|
9
|
+
# instance. Options:
|
10
|
+
#
|
11
|
+
# * :metadata_cache - Persistence mechanism. Defaults to
|
12
|
+
# NullCache.
|
13
|
+
#
|
14
|
+
# * "metadata_serializer - Serialization mechanism. Defaults to
|
15
|
+
# MarshalSerializer.
|
16
|
+
def self.make(options)
|
17
|
+
cache = options.fetch(:metadata_cache) { Metadata::NullCache.new }
|
18
|
+
serializer = options.fetch(:metadata_serializer) do
|
19
|
+
Metadata::MarshalSerializer.new
|
20
|
+
end
|
21
|
+
new(cache, serializer)
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :cache
|
25
|
+
attr_reader :serializer
|
26
|
+
|
27
|
+
# The cache is responsible for reading and writing the
|
28
|
+
# serialized metadata. The cache should quack like a
|
29
|
+
# Rets::Metadata::FileCache.
|
30
|
+
#
|
31
|
+
# The serializer is responsible for serializing/deserializing
|
32
|
+
# the metadata. The serializer should quack like a
|
33
|
+
# Rets::Metadata::MarshalSerializer.
|
34
|
+
def initialize(cache, serializer)
|
35
|
+
@cache = cache
|
36
|
+
@serializer = serializer
|
37
|
+
end
|
38
|
+
|
39
|
+
# Load metadata. Returns a Metadata::Root if successful, or nil
|
40
|
+
# if it could be loaded for any reason.
|
41
|
+
def load(logger)
|
42
|
+
sources = @cache.load do |file|
|
43
|
+
@serializer.load(file)
|
44
|
+
end
|
45
|
+
return nil unless sources.is_a?(Hash)
|
46
|
+
Metadata::Root.new(logger, sources)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Save metadata.
|
50
|
+
def save(metadata)
|
51
|
+
@cache.save do |file|
|
52
|
+
@serializer.save(file, metadata.sources)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|