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
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
|