rets 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
data/.gemtest
ADDED
File without changes
|
data/CHANGELOG.md
ADDED
data/Manifest.txt
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
CHANGELOG.md
|
2
|
+
Manifest.txt
|
3
|
+
README.md
|
4
|
+
Rakefile
|
5
|
+
bin/rets
|
6
|
+
lib/rets.rb
|
7
|
+
lib/rets/authentication.rb
|
8
|
+
lib/rets/client.rb
|
9
|
+
lib/rets/metadata.rb
|
10
|
+
lib/rets/metadata/containers.rb
|
11
|
+
lib/rets/metadata/lookup_type.rb
|
12
|
+
lib/rets/metadata/resource.rb
|
13
|
+
lib/rets/metadata/rets_class.rb
|
14
|
+
lib/rets/metadata/root.rb
|
15
|
+
lib/rets/metadata/table.rb
|
16
|
+
lib/rets/parser/compact.rb
|
17
|
+
lib/rets/parser/multipart.rb
|
18
|
+
test/fixtures.rb
|
19
|
+
test/helper.rb
|
20
|
+
test/test_client.rb
|
21
|
+
test/test_metadata.rb
|
22
|
+
test/test_parser_compact.rb
|
23
|
+
test/test_parser_multipart.rb
|
data/README.md
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
# rets
|
2
|
+
|
3
|
+
* http://github.com/estately/rets
|
4
|
+
|
5
|
+
## DESCRIPTION:
|
6
|
+
|
7
|
+
A pure-ruby library for fetching data from [RETS] servers.
|
8
|
+
|
9
|
+
[RETS]: http://www.rets.org
|
10
|
+
|
11
|
+
## REQUIREMENTS:
|
12
|
+
|
13
|
+
* [net-http-persistent]
|
14
|
+
* [nokogiri]
|
15
|
+
|
16
|
+
[net-http-persistent]: http://seattlerb.rubyforge.org/net-http-persistent/
|
17
|
+
[nokogiri]: http://nokogiri.org
|
18
|
+
|
19
|
+
## LICENSE:
|
20
|
+
|
21
|
+
(The MIT License)
|
22
|
+
|
23
|
+
Copyright (c) 2011 Estately, Inc. <opensource@estately.com>
|
24
|
+
|
25
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
26
|
+
a copy of this software and associated documentation files (the
|
27
|
+
'Software'), to deal in the Software without restriction, including
|
28
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
29
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
30
|
+
permit persons to whom the Software is furnished to do so, subject to
|
31
|
+
the following conditions:
|
32
|
+
|
33
|
+
The above copyright notice and this permission notice shall be included
|
34
|
+
in all copies or substantial portions of the Software.
|
35
|
+
|
36
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
37
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
38
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
39
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
40
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
41
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
42
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'hoe'
|
3
|
+
|
4
|
+
Hoe.plugin :git, :doofus
|
5
|
+
|
6
|
+
Hoe.spec 'rets' do
|
7
|
+
developer 'Estately, Inc. Open Source', 'opensource@estately.com'
|
8
|
+
developer 'Ben Bleything', 'ben@bleything.net'
|
9
|
+
|
10
|
+
extra_deps << [ "net-http-persistent", "~> 1.7" ]
|
11
|
+
extra_deps << [ "nokogiri", "~> 1.4.4" ]
|
12
|
+
|
13
|
+
extra_dev_deps << [ "mocha", "~> 0.9.12" ]
|
14
|
+
|
15
|
+
### Use markdown for changelog and readme
|
16
|
+
self.history_file = 'CHANGELOG.md'
|
17
|
+
self.readme_file = 'README.md'
|
18
|
+
end
|
data/bin/rets
ADDED
@@ -0,0 +1,194 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "optparse"
|
4
|
+
require "pp"
|
5
|
+
|
6
|
+
require "rubygems"
|
7
|
+
require "rets"
|
8
|
+
|
9
|
+
class RetsCli
|
10
|
+
def self.parse(args)
|
11
|
+
|
12
|
+
actions = %w(metadata search object)
|
13
|
+
options = {:count => 5}
|
14
|
+
|
15
|
+
opts = OptionParser.new do |opts|
|
16
|
+
opts.banner = "Usage: #{File.basename($0)} URL [options] [query]"
|
17
|
+
|
18
|
+
opts.separator ""
|
19
|
+
opts.separator "Authentication options:"
|
20
|
+
|
21
|
+
opts.on("-U", "--username USERNAME", "The username to authenticate with.") do |username|
|
22
|
+
options[:username] = username
|
23
|
+
end
|
24
|
+
|
25
|
+
opts.on("-P", "--password [PASSWORD]", "The password to authenticate with.","Prompts if no argument is provided.") do |password|
|
26
|
+
options[:password] = password #or prompt # TODO
|
27
|
+
end
|
28
|
+
|
29
|
+
opts.on("-A", "--agent AGENT", "User-Agent header to provide.") do |agent|
|
30
|
+
options[:agent] = agent
|
31
|
+
end
|
32
|
+
|
33
|
+
opts.on("-B", "--agent-password [PASSWORD]", "User-Agent password to provide.") do |ua_password|
|
34
|
+
options[:ua_password] = ua_password
|
35
|
+
end
|
36
|
+
|
37
|
+
opts.separator ""
|
38
|
+
opts.separator "Actions:"
|
39
|
+
|
40
|
+
opts.on("-p", "--capabilities", "Print capabilities of the RETS server.") do |capabilities|
|
41
|
+
options[:capabilities] = capabilities
|
42
|
+
end
|
43
|
+
|
44
|
+
opts.on("-a", "--action ACTION", actions, "Action to perform (#{actions.join(",")}).") do |action|
|
45
|
+
options[:action] = action
|
46
|
+
end
|
47
|
+
|
48
|
+
opts.on("-m", "--metadata [FORMAT]", %w(tree long short), "Print metadata.", "Format is short, long or tree.", "Defaults to short.") do |format|
|
49
|
+
options[:action] = "metadata"
|
50
|
+
options[:metadata_format] = format || "short"
|
51
|
+
end
|
52
|
+
|
53
|
+
opts.separator ""
|
54
|
+
opts.separator "Search action options:"
|
55
|
+
|
56
|
+
opts.on("-r", "--resource NAME", "Name of resource to search for.") do |name|
|
57
|
+
options[:resource] = name
|
58
|
+
end
|
59
|
+
|
60
|
+
opts.on("-c", "--class NAME", "Name of class to search for.") do |name|
|
61
|
+
options[:class] = name
|
62
|
+
end
|
63
|
+
|
64
|
+
opts.on("-n", "--number LIMIT", Integer, "Return LIMIT results. Defaults to 5.") do |limit|
|
65
|
+
options[:limit] = limit
|
66
|
+
end
|
67
|
+
|
68
|
+
opts.separator ""
|
69
|
+
opts.separator "Misc options:"
|
70
|
+
|
71
|
+
opts.on_tail("-v", "--verbose", "Be verbose.") do |verbose|
|
72
|
+
logger = Class.new do
|
73
|
+
def method_missing(method, *a, &b)
|
74
|
+
puts a
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
options[:logger] = logger.new
|
79
|
+
end
|
80
|
+
|
81
|
+
opts.on_tail("-h", "--help", "Show this message") do
|
82
|
+
puts opts
|
83
|
+
exit
|
84
|
+
end
|
85
|
+
|
86
|
+
opts.on_tail("--version", "Show version") do
|
87
|
+
puts Rets::VERSION
|
88
|
+
exit
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
begin
|
94
|
+
opts.parse!(args.empty? ? ["-h"] : args)
|
95
|
+
rescue OptionParser::InvalidArgument => e
|
96
|
+
abort e.message
|
97
|
+
end
|
98
|
+
|
99
|
+
options
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
options = RetsCli.parse(ARGV)
|
105
|
+
url = ARGV[0] or abort "Need login URL"
|
106
|
+
query = ARGV[1]
|
107
|
+
|
108
|
+
client = Rets::Client.new(options.merge(:login_url => url))
|
109
|
+
|
110
|
+
COUNT = Struct.new(:exclude, :include, :only).new(0,1,2)
|
111
|
+
|
112
|
+
if options[:capabilities]
|
113
|
+
pp client.capabilities
|
114
|
+
end
|
115
|
+
|
116
|
+
case options[:action]
|
117
|
+
when "metadata" then
|
118
|
+
metadata = client.metadata
|
119
|
+
|
120
|
+
if options[:metadata_format] != "tree"
|
121
|
+
preferred_fields = %w(ClassName SystemName ResourceID StandardName VisibleName MetadataEntryID KeyField)
|
122
|
+
|
123
|
+
|
124
|
+
# All types except system
|
125
|
+
types = Rets::METADATA_TYPES.map { |t| t.downcase.to_sym } - [:system]
|
126
|
+
|
127
|
+
types.each do |type|
|
128
|
+
# if RowContainer ...
|
129
|
+
rows = metadata[type]
|
130
|
+
|
131
|
+
puts type.to_s.capitalize
|
132
|
+
puts "="*40
|
133
|
+
|
134
|
+
print_key_value = lambda do |k,v|
|
135
|
+
key = "#{k}:".ljust(35)
|
136
|
+
value = "#{v}".ljust(35)
|
137
|
+
|
138
|
+
puts [key, value].join
|
139
|
+
end
|
140
|
+
|
141
|
+
rows.each do |row|
|
142
|
+
top, rest = row.partition { |k,v| preferred_fields.include?(k) }
|
143
|
+
|
144
|
+
top.each(&print_key_value)
|
145
|
+
|
146
|
+
rest.sort_by{|k,v|k}.each(&print_key_value) if options[:metadata_format] == "long"
|
147
|
+
|
148
|
+
puts
|
149
|
+
end
|
150
|
+
|
151
|
+
puts
|
152
|
+
end
|
153
|
+
|
154
|
+
# Tree format
|
155
|
+
else
|
156
|
+
metadata.print_tree
|
157
|
+
end
|
158
|
+
|
159
|
+
when "search" then
|
160
|
+
pp client.find(:all,
|
161
|
+
:search_type => options[:resource],
|
162
|
+
:class => options[:class],
|
163
|
+
:query => query,
|
164
|
+
:count => COUNT.exclude,
|
165
|
+
:limit => options[:limit])
|
166
|
+
|
167
|
+
when "object" then
|
168
|
+
|
169
|
+
def write_objects(parts)
|
170
|
+
parts.each do |part|
|
171
|
+
cid = part.headers["content-id"].to_i
|
172
|
+
oid = part.headers["object-id"].to_i
|
173
|
+
|
174
|
+
File.open("tmp/#{cid}-#{oid}", "wb") do |f|
|
175
|
+
puts f.path
|
176
|
+
|
177
|
+
f.write part.body
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
parts = client.all_objects(
|
183
|
+
:resource => "Property",
|
184
|
+
:resource_id => 90020062739, # id from KeyField for a given property
|
185
|
+
:object_type => "Photo"
|
186
|
+
)
|
187
|
+
|
188
|
+
parts.each { |pt| p pt.headers }
|
189
|
+
|
190
|
+
write_objects(parts)
|
191
|
+
|
192
|
+
end
|
193
|
+
|
194
|
+
|
data/lib/rets.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'uri'
|
3
|
+
require 'cgi'
|
4
|
+
require 'digest/md5'
|
5
|
+
|
6
|
+
require 'rubygems'
|
7
|
+
require 'net/http/persistent'
|
8
|
+
require 'nokogiri'
|
9
|
+
|
10
|
+
module Rets
|
11
|
+
VERSION = '0.1.0'
|
12
|
+
|
13
|
+
AuthorizationFailure = Class.new(ArgumentError)
|
14
|
+
InvalidRequest = Class.new(ArgumentError)
|
15
|
+
MalformedResponse = Class.new(ArgumentError)
|
16
|
+
UnknownResponse = Class.new(ArgumentError)
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'rets/authentication'
|
20
|
+
require 'rets/metadata'
|
21
|
+
require 'rets/parser/compact'
|
22
|
+
require 'rets/parser/multipart'
|
23
|
+
|
24
|
+
require 'rets/client'
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Rets
|
2
|
+
# Adapted from dbrain's Net::HTTP::DigestAuth gem, and RETS4R auth
|
3
|
+
# in order to support RETS' usage of digest authentication.
|
4
|
+
module Authentication
|
5
|
+
def build_auth(digest_authenticate, uri, nc = 0, method = "POST")
|
6
|
+
user = CGI.unescape uri.user
|
7
|
+
password = CGI.unescape uri.password
|
8
|
+
|
9
|
+
digest_authenticate =~ /^(\w+) (.*)/
|
10
|
+
|
11
|
+
params = {}
|
12
|
+
$2.gsub(/(\w+)="(.*?)"/) { params[$1] = $2 }
|
13
|
+
|
14
|
+
cnonce = Digest::MD5.hexdigest "%x" % (Time.now.to_i + rand(65535))
|
15
|
+
|
16
|
+
digest = calculate_digest(
|
17
|
+
user, password, params['realm'], params['nonce'], method, uri.request_uri, params['qop'], cnonce, nc
|
18
|
+
)
|
19
|
+
|
20
|
+
header = [
|
21
|
+
%Q(Digest username="#{user}"),
|
22
|
+
%Q(realm="#{params['realm']}"),
|
23
|
+
%Q(qop="#{params['qop']}"),
|
24
|
+
%Q(uri="#{uri.request_uri}"),
|
25
|
+
%Q(nonce="#{params['nonce']}"),
|
26
|
+
%Q(nc=#{('%08x' % nc)}),
|
27
|
+
%Q(cnonce="#{cnonce}"),
|
28
|
+
%Q(response="#{digest}"),
|
29
|
+
%Q(opaque="#{params['opaque']}"),
|
30
|
+
]
|
31
|
+
|
32
|
+
header.join(", ")
|
33
|
+
end
|
34
|
+
|
35
|
+
def calculate_digest(user, password, realm, nonce, method, uri, qop, cnonce, nc)
|
36
|
+
a1 = Digest::MD5.hexdigest "#{user}:#{realm}:#{password}"
|
37
|
+
a2 = Digest::MD5.hexdigest "#{method}:#{uri}"
|
38
|
+
|
39
|
+
if qop
|
40
|
+
Digest::MD5.hexdigest("#{a1}:#{nonce}:#{'%08x' % nc}:#{cnonce}:#{qop}:#{a2}")
|
41
|
+
else
|
42
|
+
Digest::MD5.hexdigest("#{a1}:#{nonce}:#{a2}")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def calculate_user_agent_digest(user_agent, user_agent_password, session_id, version)
|
47
|
+
product, _ = user_agent.split("/")
|
48
|
+
|
49
|
+
a1 = Digest::MD5.hexdigest "#{product}:#{user_agent_password}"
|
50
|
+
|
51
|
+
Digest::MD5.hexdigest "#{a1}::#{session_id}:#{version}"
|
52
|
+
end
|
53
|
+
|
54
|
+
def build_user_agent_auth(*args)
|
55
|
+
%Q(Digest "#{calculate_user_agent_digest(*args)}")
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|
data/lib/rets/client.rb
ADDED
@@ -0,0 +1,473 @@
|
|
1
|
+
module Rets
|
2
|
+
Session = Struct.new(:authorization, :capabilities, :cookies)
|
3
|
+
|
4
|
+
class Client
|
5
|
+
DEFAULT_OPTIONS = { :persistent => true }
|
6
|
+
|
7
|
+
include Authentication
|
8
|
+
|
9
|
+
attr_accessor :uri, :options, :authorization, :logger
|
10
|
+
attr_writer :capabilities, :metadata
|
11
|
+
|
12
|
+
def initialize(options)
|
13
|
+
@capabilities = nil
|
14
|
+
@cookies = nil
|
15
|
+
@metadata = nil
|
16
|
+
|
17
|
+
uri = URI.parse(options[:login_url])
|
18
|
+
|
19
|
+
uri.user = options.key?(:username) ? CGI.escape(options[:username]) : nil
|
20
|
+
uri.password = options.key?(:password) ? CGI.escape(options[:password]) : nil
|
21
|
+
|
22
|
+
self.options = DEFAULT_OPTIONS.merge(options)
|
23
|
+
self.uri = uri
|
24
|
+
|
25
|
+
self.logger = options[:logger] || FakeLogger.new
|
26
|
+
|
27
|
+
self.session = options[:session] if options[:session]
|
28
|
+
@cached_metadata = options[:metadata] || nil
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
# Attempts to login by making an empty request to the URL
|
33
|
+
# provided in initialize. Returns the capabilities that the
|
34
|
+
# RETS server provides, per http://retsdoc.onconfluence.com/display/rets172/4.10+Capability+URL+List.
|
35
|
+
def login
|
36
|
+
request(uri.path)
|
37
|
+
capabilities
|
38
|
+
end
|
39
|
+
|
40
|
+
# Finds records.
|
41
|
+
#
|
42
|
+
# [quantity] Return the first record, or an array of records.
|
43
|
+
# Uses a symbol <tt>:first</tt> or <tt>:all</tt>, respectively.
|
44
|
+
#
|
45
|
+
# [opts] A hash of arguments used to construct the search query,
|
46
|
+
# using the following keys:
|
47
|
+
#
|
48
|
+
# <tt>:search_type</tt>:: Required. The resource to search for.
|
49
|
+
# <tt>:class</tt>:: Required. The class of the resource to search for.
|
50
|
+
# <tt>:query</tt>:: Required. The DMQL2 query string to execute.
|
51
|
+
# <tt>:limit</tt>:: The number of records to request from the server.
|
52
|
+
# <tt>:resolve</tt>:: Provide resolved values that use metadata instead
|
53
|
+
# of raw system values.
|
54
|
+
#
|
55
|
+
# Any other keys are converted to the RETS query format, and passed
|
56
|
+
# to the server as part of the query. For instance, the key <tt>:offset</tt>
|
57
|
+
# will be sent as +Offset+.
|
58
|
+
#
|
59
|
+
def find(quantity, opts = {})
|
60
|
+
case quantity
|
61
|
+
when :first then find_every(opts.merge(:limit => 1)).first
|
62
|
+
when :all then find_every(opts)
|
63
|
+
else raise ArgumentError, "First argument must be :first or :all"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
alias search find
|
68
|
+
|
69
|
+
def find_every(opts = {})
|
70
|
+
search_uri = capability_url("Search")
|
71
|
+
|
72
|
+
resolve = opts.delete(:resolve)
|
73
|
+
|
74
|
+
extras = fixup_keys(opts)
|
75
|
+
|
76
|
+
defaults = {"QueryType" => "DMQL2", "Format" => "COMPACT"}
|
77
|
+
|
78
|
+
query = defaults.merge(extras)
|
79
|
+
|
80
|
+
body = build_key_values(query)
|
81
|
+
|
82
|
+
headers = build_headers.merge(
|
83
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
84
|
+
"Content-Length" => body.size.to_s
|
85
|
+
)
|
86
|
+
|
87
|
+
results = request_with_compact_response(search_uri.path, body, headers)
|
88
|
+
|
89
|
+
if resolve
|
90
|
+
rets_class = find_rets_class(opts[:search_type], opts[:class])
|
91
|
+
decorate_results(results, rets_class)
|
92
|
+
else
|
93
|
+
results
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def find_rets_class(resource_name, rets_class_name)
|
98
|
+
metadata.build_tree[resource_name].find_rets_class(rets_class_name)
|
99
|
+
end
|
100
|
+
|
101
|
+
def decorate_results(results, rets_class)
|
102
|
+
results.map do |result|
|
103
|
+
decorate_result(result, rets_class)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def decorate_result(result, rets_class)
|
108
|
+
result.each do |key, value|
|
109
|
+
result[key] = rets_class.find_table(key).resolve(value.to_s)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
# Returns an array of all objects associated with the given resource.
|
115
|
+
def all_objects(opts = {})
|
116
|
+
objects("*", opts)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Returns an array of specified objects.
|
120
|
+
def objects(object_ids, opts = {})
|
121
|
+
response = case object_ids
|
122
|
+
when String then fetch_object(object_ids, opts)
|
123
|
+
when Array then fetch_object(object_ids.join(","), opts)
|
124
|
+
else raise ArgumentError, "Expected instance of String or Array, but got #{object_ids.inspect}."
|
125
|
+
end
|
126
|
+
|
127
|
+
create_parts_from_response(response)
|
128
|
+
end
|
129
|
+
|
130
|
+
def create_parts_from_response(response)
|
131
|
+
content_type = response["content-type"]
|
132
|
+
|
133
|
+
if content_type.include?("multipart")
|
134
|
+
boundary = content_type.scan(/boundary="(.*?)"/).to_s
|
135
|
+
|
136
|
+
parts = Parser::Multipart.parse(response.body, boundary)
|
137
|
+
|
138
|
+
logger.debug "Found #{parts.size} parts"
|
139
|
+
|
140
|
+
return parts
|
141
|
+
else
|
142
|
+
# fake a multipart for interface compatibility
|
143
|
+
headers = {}
|
144
|
+
response.each { |k,v| headers[k] = v }
|
145
|
+
|
146
|
+
part = Parser::Multipart::Part.new(headers, response.body)
|
147
|
+
|
148
|
+
return [part]
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Returns a single object.
|
153
|
+
#
|
154
|
+
# resource RETS resource as defined in the resource metadata.
|
155
|
+
# object_type an object type defined in the object metadata.
|
156
|
+
# resource_id the KeyField value of the given resource instance.
|
157
|
+
# object_id can be "*", or a comma delimited string of one or more integers.
|
158
|
+
def object(object_id, opts = {})
|
159
|
+
response = fetch_object(object_id, opts)
|
160
|
+
|
161
|
+
response.body
|
162
|
+
end
|
163
|
+
|
164
|
+
def fetch_object(object_id, opts = {})
|
165
|
+
object_uri = capability_url("GetObject")
|
166
|
+
|
167
|
+
body = build_key_values(
|
168
|
+
"Resource" => opts[:resource],
|
169
|
+
"Type" => opts[:object_type],
|
170
|
+
"ID" => "#{opts[:resource_id]}:#{object_id}",
|
171
|
+
"Location" => 0
|
172
|
+
)
|
173
|
+
|
174
|
+
headers = build_headers.merge(
|
175
|
+
"Accept" => "image/jpeg, image/png;q=0.5, image/gif;q=0.1",
|
176
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
177
|
+
"Content-Length" => body.size.to_s
|
178
|
+
)
|
179
|
+
|
180
|
+
request(object_uri.path, body, headers)
|
181
|
+
end
|
182
|
+
|
183
|
+
# Changes keys to be camel cased, per the RETS standard for queries.
|
184
|
+
def fixup_keys(hash)
|
185
|
+
fixed_hash = {}
|
186
|
+
|
187
|
+
hash.each do |key, value|
|
188
|
+
camel_cased_key = key.to_s.capitalize.gsub(/_(\w)/) { $1.upcase }
|
189
|
+
|
190
|
+
fixed_hash[camel_cased_key] = value
|
191
|
+
end
|
192
|
+
|
193
|
+
fixed_hash
|
194
|
+
end
|
195
|
+
|
196
|
+
def metadata
|
197
|
+
return @metadata if @metadata
|
198
|
+
|
199
|
+
if @cached_metadata && @cached_metadata.current?(capabilities["MetadataTimestamp"], capabilities["MetadataVersion"])
|
200
|
+
self.metadata = @cached_metadata
|
201
|
+
else
|
202
|
+
metadata_fetcher = lambda { |type| retrieve_metadata_type(type) }
|
203
|
+
self.metadata = Metadata::Root.new(&metadata_fetcher)
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
def retrieve_metadata_type(type)
|
208
|
+
metadata_uri = capability_url("GetMetadata")
|
209
|
+
|
210
|
+
body = build_key_values(
|
211
|
+
"Format" => "COMPACT",
|
212
|
+
"Type" => "METADATA-#{type}",
|
213
|
+
"ID" => "0"
|
214
|
+
)
|
215
|
+
|
216
|
+
headers = build_headers.merge(
|
217
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
218
|
+
"Content-Length" => body.size.to_s
|
219
|
+
)
|
220
|
+
|
221
|
+
response = request(metadata_uri.path, body, headers)
|
222
|
+
|
223
|
+
response.body
|
224
|
+
end
|
225
|
+
|
226
|
+
def raw_request(path, body = nil, headers = build_headers, &reader)
|
227
|
+
logger.info "posting to #{path}"
|
228
|
+
|
229
|
+
post = Net::HTTP::Post.new(path, headers)
|
230
|
+
post.body = body.to_s
|
231
|
+
|
232
|
+
logger.debug ""
|
233
|
+
logger.debug format_headers(headers)
|
234
|
+
logger.debug body.to_s
|
235
|
+
|
236
|
+
connection_args = [Net::HTTP::Persistent === connection ? uri : nil, post].compact
|
237
|
+
|
238
|
+
response = connection.request(*connection_args) do |res|
|
239
|
+
res.read_body(&reader)
|
240
|
+
end
|
241
|
+
|
242
|
+
handle_cookies(response)
|
243
|
+
|
244
|
+
logger.debug "Response: (#{response.class})"
|
245
|
+
logger.debug ""
|
246
|
+
logger.debug format_headers(response.to_hash)
|
247
|
+
logger.debug ""
|
248
|
+
logger.debug "Body:"
|
249
|
+
logger.debug response.body
|
250
|
+
|
251
|
+
return response
|
252
|
+
end
|
253
|
+
|
254
|
+
def request(*args, &block)
|
255
|
+
handle_response(raw_request(*args, &block))
|
256
|
+
end
|
257
|
+
|
258
|
+
def request_with_compact_response(path, body, headers)
|
259
|
+
response = request(path, body, headers)
|
260
|
+
|
261
|
+
Parser::Compact.parse_document response.body
|
262
|
+
end
|
263
|
+
|
264
|
+
def extract_digest_header(response)
|
265
|
+
authenticate_headers = response.get_fields("www-authenticate")
|
266
|
+
authenticate_headers.detect {|h| h =~ /Digest/}
|
267
|
+
end
|
268
|
+
|
269
|
+
def handle_unauthorized_response(response)
|
270
|
+
self.authorization = build_auth(extract_digest_header(response), uri, tries)
|
271
|
+
|
272
|
+
response = raw_request(uri.path)
|
273
|
+
|
274
|
+
if Net::HTTPUnauthorized === response
|
275
|
+
raise AuthorizationFailure, "Authorization failed, check credentials?"
|
276
|
+
else
|
277
|
+
self.capabilities = extract_capabilities(Nokogiri.parse(response.body))
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
def handle_response(response)
|
282
|
+
|
283
|
+
if Net::HTTPUnauthorized === response # 401
|
284
|
+
handle_unauthorized_response(response)
|
285
|
+
|
286
|
+
elsif Net::HTTPSuccess === response # 2xx
|
287
|
+
begin
|
288
|
+
if !response.body.empty?
|
289
|
+
xml = Nokogiri::XML.parse(response.body, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
|
290
|
+
|
291
|
+
reply_text = xml.xpath("/RETS").attr("ReplyText").value
|
292
|
+
reply_code = xml.xpath("/RETS").attr("ReplyCode").value.to_i
|
293
|
+
|
294
|
+
if reply_code.nonzero?
|
295
|
+
raise InvalidRequest, "Got error code #{reply_code} (#{reply_text})."
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
rescue Nokogiri::XML::SyntaxError => e
|
300
|
+
logger.debug "Not xml"
|
301
|
+
|
302
|
+
end
|
303
|
+
|
304
|
+
else
|
305
|
+
raise UnknownResponse, "Unable to handle response #{response.class}"
|
306
|
+
end
|
307
|
+
|
308
|
+
return response
|
309
|
+
end
|
310
|
+
|
311
|
+
|
312
|
+
def handle_cookies(response)
|
313
|
+
if cookies?(response)
|
314
|
+
self.cookies = response.get_fields('set-cookie')
|
315
|
+
logger.info "Cookies set to #{cookies.inspect}"
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def cookies?(response)
|
320
|
+
response['set-cookie']
|
321
|
+
end
|
322
|
+
|
323
|
+
def cookies=(cookies)
|
324
|
+
@cookies ||= {}
|
325
|
+
|
326
|
+
cookies.each do |cookie|
|
327
|
+
cookie.match(/(\S+)=([^;]+);?/)
|
328
|
+
|
329
|
+
@cookies[$1] = $2
|
330
|
+
end
|
331
|
+
|
332
|
+
nil
|
333
|
+
end
|
334
|
+
|
335
|
+
def cookies
|
336
|
+
return if @cookies.nil? or @cookies.empty?
|
337
|
+
|
338
|
+
@cookies.map{ |k,v| "#{k}=#{v}" }.join("; ")
|
339
|
+
end
|
340
|
+
|
341
|
+
|
342
|
+
def session=(session)
|
343
|
+
self.authorization = session.authorization
|
344
|
+
self.capabilities = session.capabilities
|
345
|
+
self.cookies = session.cookies
|
346
|
+
end
|
347
|
+
|
348
|
+
def session
|
349
|
+
Session.new(authorization, capabilities, cookies)
|
350
|
+
end
|
351
|
+
|
352
|
+
|
353
|
+
# The capabilies as provided by the RETS server during login.
|
354
|
+
#
|
355
|
+
# Currently, only the path in the endpoint URLs is used[1]. Host,
|
356
|
+
# port, other details remaining constant with those provided to
|
357
|
+
# the constructor.
|
358
|
+
#
|
359
|
+
# [1] In fact, sometimes only a path is returned from the server.
|
360
|
+
def capabilities
|
361
|
+
@capabilities || login
|
362
|
+
end
|
363
|
+
|
364
|
+
def capability_url(name)
|
365
|
+
url = capabilities[name]
|
366
|
+
|
367
|
+
begin
|
368
|
+
capability_uri = URI.parse(url)
|
369
|
+
rescue URI::InvalidURIError => e
|
370
|
+
raise MalformedResponse, "Unable to parse capability URL: #{url.inspect}"
|
371
|
+
end
|
372
|
+
|
373
|
+
capability_uri
|
374
|
+
end
|
375
|
+
|
376
|
+
def extract_capabilities(document)
|
377
|
+
raw_key_values = document.xpath("/RETS/RETS-RESPONSE").text.strip
|
378
|
+
|
379
|
+
h = Hash.new{|h,k| h.key?(k.downcase) ? h[k.downcase] : nil }
|
380
|
+
|
381
|
+
# ... :(
|
382
|
+
# Feel free to make this better. It has a test.
|
383
|
+
raw_key_values.split(/\n/).
|
384
|
+
map { |r| r.split(/=/, 2) }.
|
385
|
+
each { |k,v| h[k.strip.downcase] = v }
|
386
|
+
|
387
|
+
h
|
388
|
+
end
|
389
|
+
|
390
|
+
|
391
|
+
|
392
|
+
def connection
|
393
|
+
@connection ||= options[:persistent] ?
|
394
|
+
persistent_connection :
|
395
|
+
Net::HTTP.new(uri.host, uri.port)
|
396
|
+
end
|
397
|
+
|
398
|
+
def persistent_connection
|
399
|
+
conn = Net::HTTP::Persistent.new
|
400
|
+
|
401
|
+
def conn.idempotent?(*)
|
402
|
+
true
|
403
|
+
end
|
404
|
+
|
405
|
+
conn
|
406
|
+
end
|
407
|
+
|
408
|
+
|
409
|
+
def user_agent
|
410
|
+
options[:agent] || "Client/1.0"
|
411
|
+
end
|
412
|
+
|
413
|
+
def rets_version
|
414
|
+
options[:version] || "RETS/1.7.2"
|
415
|
+
end
|
416
|
+
|
417
|
+
def build_headers
|
418
|
+
headers = {
|
419
|
+
"User-Agent" => user_agent,
|
420
|
+
"Host" => "#{uri.host}:#{uri.port}",
|
421
|
+
"RETS-Version" => rets_version
|
422
|
+
}
|
423
|
+
|
424
|
+
headers.merge!("Authorization" => authorization) if authorization
|
425
|
+
headers.merge!("Cookie" => cookies) if cookies
|
426
|
+
|
427
|
+
if options[:ua_password]
|
428
|
+
headers.merge!(
|
429
|
+
"RETS-UA-Authorization" => build_user_agent_auth(
|
430
|
+
user_agent, options[:ua_password], "", rets_version))
|
431
|
+
end
|
432
|
+
|
433
|
+
headers
|
434
|
+
end
|
435
|
+
|
436
|
+
def build_key_values(data)
|
437
|
+
data.map{|k,v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
|
438
|
+
end
|
439
|
+
|
440
|
+
|
441
|
+
|
442
|
+
def tries
|
443
|
+
@tries ||= 1
|
444
|
+
|
445
|
+
(@tries += 1) - 1
|
446
|
+
end
|
447
|
+
|
448
|
+
class FakeLogger
|
449
|
+
def fatal(*); end
|
450
|
+
def error(*); end
|
451
|
+
def warn(*); end
|
452
|
+
def info(*); end
|
453
|
+
def debug(*); end
|
454
|
+
end
|
455
|
+
|
456
|
+
def format_headers(headers)
|
457
|
+
out = []
|
458
|
+
|
459
|
+
headers.each do |name, value|
|
460
|
+
if Array === value
|
461
|
+
value.each do |v|
|
462
|
+
out << "#{name}: #{v}"
|
463
|
+
end
|
464
|
+
else
|
465
|
+
out << "#{name}: #{value}"
|
466
|
+
end
|
467
|
+
end
|
468
|
+
|
469
|
+
out.join("\n")
|
470
|
+
end
|
471
|
+
|
472
|
+
end
|
473
|
+
end
|