rets-hack 0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +142 -0
- data/Manifest.txt +58 -0
- data/README.md +129 -0
- data/Rakefile +28 -0
- data/bin/rets +202 -0
- data/example/connect.rb +19 -0
- data/example/get-photos.rb +20 -0
- data/example/get-property.rb +16 -0
- data/lib/rets/client.rb +373 -0
- data/lib/rets/client_progress_reporter.rb +48 -0
- data/lib/rets/http_client.rb +133 -0
- data/lib/rets/locking_http_client.rb +34 -0
- data/lib/rets/measuring_http_client.rb +27 -0
- data/lib/rets/metadata/caching.rb +59 -0
- data/lib/rets/metadata/containers.rb +89 -0
- data/lib/rets/metadata/file_cache.rb +29 -0
- data/lib/rets/metadata/json_serializer.rb +27 -0
- data/lib/rets/metadata/lookup_table.rb +65 -0
- data/lib/rets/metadata/lookup_type.rb +19 -0
- data/lib/rets/metadata/marshal_serializer.rb +27 -0
- data/lib/rets/metadata/multi_lookup_table.rb +70 -0
- data/lib/rets/metadata/null_cache.rb +24 -0
- data/lib/rets/metadata/resource.rb +103 -0
- data/lib/rets/metadata/rets_class.rb +57 -0
- data/lib/rets/metadata/rets_object.rb +41 -0
- data/lib/rets/metadata/root.rb +155 -0
- data/lib/rets/metadata/table.rb +33 -0
- data/lib/rets/metadata/table_factory.rb +19 -0
- data/lib/rets/metadata/yaml_serializer.rb +27 -0
- data/lib/rets/metadata.rb +18 -0
- data/lib/rets/parser/compact.rb +117 -0
- data/lib/rets/parser/error_checker.rb +56 -0
- data/lib/rets/parser/multipart.rb +39 -0
- data/lib/rets.rb +269 -0
- data/test/fixtures.rb +324 -0
- data/test/helper.rb +14 -0
- data/test/test_caching.rb +89 -0
- data/test/test_client.rb +307 -0
- data/test/test_error_checker.rb +87 -0
- data/test/test_file_cache.rb +42 -0
- data/test/test_http_client.rb +132 -0
- data/test/test_json_serializer.rb +26 -0
- data/test/test_locking_http_client.rb +29 -0
- data/test/test_marshal_serializer.rb +26 -0
- data/test/test_metadata.rb +71 -0
- data/test/test_metadata_class.rb +50 -0
- data/test/test_metadata_lookup_table.rb +21 -0
- data/test/test_metadata_lookup_type.rb +21 -0
- data/test/test_metadata_multi_lookup_table.rb +60 -0
- data/test/test_metadata_object.rb +33 -0
- data/test/test_metadata_resource.rb +148 -0
- data/test/test_metadata_root.rb +151 -0
- data/test/test_metadata_table.rb +21 -0
- data/test/test_metadata_table_factory.rb +24 -0
- data/test/test_parser_compact.rb +115 -0
- data/test/test_parser_multipart.rb +39 -0
- data/test/test_yaml_serializer.rb +26 -0
- data/test/vcr_cassettes/unauthorized_response.yml +262 -0
- metadata +227 -0
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
|