innards 0.1.0.pre → 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.
@@ -0,0 +1,24 @@
1
+ require 'uri'
2
+
3
+ module Innards
4
+
5
+ # Handles Digest Authentication
6
+ class CookieHandler
7
+
8
+ def initialize(params)
9
+ @params = params
10
+ end
11
+
12
+ def parse cookie_header
13
+ cookie_header.scan(/(\w+)=(.+?);/) {
14
+ @params[:cookies].push([$1, $2])
15
+ }
16
+ end
17
+
18
+ def build
19
+ cookies = @params[:cookies]
20
+ URI.encode_www_form(cookies) unless cookies.empty?
21
+ end
22
+
23
+ end
24
+ end
@@ -0,0 +1,93 @@
1
+ module Innards
2
+
3
+ # Handles Digest Authentication
4
+ class DigestHandler
5
+
6
+ include Innards::Exceptions
7
+
8
+ def initialize(params)
9
+ @params = params
10
+ end
11
+
12
+ def parse www_authenticate_header
13
+ digest = {}
14
+ www_authenticate_header.scan(/(\w+)="(.*?)"/) {
15
+ digest[$1] = $2
16
+ }
17
+ if www_authenticate_header =~ /stale=true$/
18
+ digest[:stale] = true
19
+ raise StaleDigestException.new
20
+ end
21
+ @params[:digest] = digest
22
+ end
23
+
24
+ def build_for path
25
+ digest = @params[:digest]
26
+ return nil if digest.empty?
27
+
28
+ cnonce = generate_md5_hash(random)
29
+ header = [
30
+ %Q(Digest username="#{@params[:login_uri].user}"),
31
+ %Q(realm="#{digest['realm']}"),
32
+ %Q(nonce="#{digest['nonce']}"),
33
+ %Q(uri="#{path}"),
34
+ %Q(response="#{build_digest(path, cnonce)}"),
35
+ ]
36
+
37
+ if has_qop?
38
+ fields = [
39
+ %Q(cnonce="#{cnonce}"),
40
+ %Q(qop="#{digest['qop']}"),
41
+ %Q(nc=#{request_nc})
42
+ ]
43
+ fields.each { |field| header << field }
44
+ end
45
+
46
+ header << %Q(opaque="#{digest['opaque']}") if has_opaque?
47
+ header.join(", ")
48
+ end
49
+
50
+ def build_digest path, cnonce
51
+ digest = @params[:digest]
52
+ digest_parts = [
53
+ generate_md5_hash(generate_ha1_hash),
54
+ digest['nonce'],
55
+ generate_md5_hash(generate_ha2_hash(path))]
56
+ digest_parts.insert(2, request_nc, cnonce, digest['qop']) if has_qop?
57
+ generate_md5_hash(digest_parts.join(":"))
58
+ end
59
+
60
+ def has_opaque?
61
+ digest = @params[:digest]
62
+ digest.has_key?('opaque') and !digest['opaque'].empty?
63
+ end
64
+
65
+ def has_qop?
66
+ digest = @params[:digest]
67
+ digest.has_key?('qop') and !digest['qop'].empty?
68
+ end
69
+
70
+ def generate_ha1_hash
71
+ login_uri = @params[:login_uri]
72
+ digest = @params[:digest]
73
+ [login_uri.user, digest['realm'], login_uri.password].join(":")
74
+ end
75
+
76
+ def generate_ha2_hash(path)
77
+ ["GET", path].join(":")
78
+ end
79
+
80
+ def request_nc
81
+ "%08d" % @params[:request_count]
82
+ end
83
+
84
+ def random
85
+ "%x" % (Time.now.to_i + rand(65535))
86
+ end
87
+
88
+ def generate_md5_hash(str)
89
+ Digest::MD5.hexdigest(str)
90
+ end
91
+
92
+ end
93
+ end
@@ -1,8 +1,35 @@
1
1
  module Innards
2
2
  module Exceptions
3
-
4
- class AuthenticationRequiredException < Exception
5
- end
6
-
3
+
4
+ # Thrown when a invalid URL is passed
5
+ class InvalidUrlException < StandardError; end
6
+
7
+ # Thrown when a invalid/unsupported RETS Version is passed
8
+ class InvalidRetsVersionException < StandardError; end
9
+
10
+ # Thrown when a invalid Auth Mode is passed
11
+ class InvalidAuthModeException < StandardError; end
12
+
13
+ # Thrown when a invalid User Agent is passed
14
+ class InvalidUserAgentException < StandardError; end
15
+
16
+ # Thrown when the rets conection requires authentication
17
+ class AuthenticationRequiredException < StandardError; end
18
+
19
+ # Thrown when digest data is stale and the request needs to be retried
20
+ class StaleDigestException < StandardError; end
21
+
22
+ # Thrown when a function is called which requres a logged in session
23
+ class LoginRequiredException < StandardError; end
24
+
25
+ # Invalid Search Type
26
+ class InvalidSearchTypeException < StandardError; end
27
+
28
+ # Invalid Search Type
29
+ class InvalidClassException < StandardError; end
30
+
31
+ # Invalid Search Type
32
+ class InvalidQueryException < StandardError; end
33
+
7
34
  end
8
35
  end
@@ -0,0 +1,31 @@
1
+ require 'multipart_parser/reader'
2
+
3
+ module Innards
4
+
5
+ # Handles Digest Authentication
6
+ class MultipartHandler
7
+
8
+ def initialize(content_type)
9
+ @boundary = MultipartParser::Reader.extract_boundary_value(content_type)
10
+ rescue MultipartParser::NotMultipartError
11
+ @boundary = ""
12
+ end
13
+
14
+ def parse body
15
+ parts = []
16
+ multipart_reader = MultipartParser::Reader.new @boundary
17
+ multipart_reader.on_part do |part|
18
+ part.on_data do |data|
19
+ parts << part.headers.merge({:data => data})
20
+ end
21
+ end
22
+ multipart_reader.write body.strip
23
+ parts
24
+ end
25
+
26
+ def is_multipart_response?
27
+ !@boundary.empty?
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,64 @@
1
+
2
+ module Innards
3
+ module Parsers
4
+
5
+ # SAX Parser for RETS Metadata
6
+ class GetMetadataParser < ParserBase
7
+
8
+ METADATA_SECTIONS = {
9
+ "METADATA-RESOURCE" => :resources,
10
+ "METADATA-CLASS" => :classes,
11
+ "METADATA-TABLE" => :tables,
12
+ "METADATA-OBJECT" => :objects,
13
+ "METADATA-SEARCH_HELP" => :search_help,
14
+ "METADATA-EDITMASK" => :edit_masks,
15
+ "METADATA-LOOKUP" => :lookups,
16
+ "METADATA-LOOKUP_TYPE" => :lookup_types
17
+ }
18
+
19
+ def initialize
20
+ super()
21
+ @current_builder = {}
22
+ end
23
+
24
+ def start_element(name)
25
+ super
26
+ end
27
+
28
+ def end_element(name)
29
+ super
30
+ if METADATA_SECTIONS.has_key?(name.to_s)
31
+ @current_builder = {}
32
+ end
33
+ end
34
+
35
+ def attr(name, value)
36
+ super
37
+ METADATA_SECTIONS.each_key do |section|
38
+ if switch_active?(:"#{section}")
39
+ @current_builder[name.to_sym] = value
40
+ end
41
+ end
42
+ end
43
+
44
+ def text(value)
45
+ super
46
+
47
+ if switch_active?(:COLUMNS)
48
+ @current_builder[:columns] = data_splitter(value)
49
+ end
50
+
51
+ METADATA_SECTIONS.each_pair do |section, symbol|
52
+ if switch_active?(:"#{section}") and switch_active?(:DATA)
53
+ parsed_data = data_merger(@current_builder[:columns], data_splitter(value))
54
+ filtered = @current_builder.reject{ |key| key == :columns }
55
+
56
+ @metadata[symbol] = [] unless @metadata.has_key?(symbol)
57
+ @metadata[symbol].push parsed_data.merge(filtered)
58
+ end
59
+ end
60
+ end
61
+
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,29 @@
1
+
2
+ module Innards
3
+ module Parsers
4
+
5
+ # SAX Parser for RETS Login Response
6
+ class LoginParser < ParserBase
7
+
8
+ def start_element(name)
9
+ super
10
+ end
11
+
12
+ def end_element(name)
13
+ super
14
+ end
15
+
16
+ def attr(name, value)
17
+ super
18
+ end
19
+
20
+ def text(value)
21
+ super
22
+ if switch_active?(:RETS)
23
+ @metadata.merge! split_multiline_key_value_pairs(value)
24
+ end
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+
2
+ module Innards
3
+ module Parsers
4
+
5
+ # SAX Parser for RETS Logout Response
6
+ class LogoutParser < ParserBase
7
+
8
+ def start_element(name)
9
+ super
10
+ end
11
+
12
+ def end_element(name)
13
+ super
14
+ end
15
+
16
+ def attr(name, value)
17
+ super
18
+ end
19
+
20
+ def text(value)
21
+ super
22
+ if switch_active?(:"RETS-RESPONSE")
23
+ @metadata.merge! split_multiline_key_value_pairs(value)
24
+ end
25
+ end
26
+
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,73 @@
1
+ require 'uri'
2
+ require 'ox'
3
+
4
+ module Innards
5
+ module Parsers
6
+
7
+ # Base Parser from which all RETS Response Parsers are based
8
+ class ParserBase < ::Ox::Sax
9
+
10
+ attr_reader :rets_response, :metadata
11
+
12
+ def initialize
13
+ @switches = {}
14
+ @metadata = {}
15
+ end
16
+
17
+ def start_element(name)
18
+ element_tracker_switch name, true
19
+ end
20
+
21
+ def end_element(name)
22
+ element_tracker_switch name, false
23
+ end
24
+
25
+ def attr(name, value)
26
+ if switch_active?(:RETS)
27
+ @rets_response = {} unless @rets_response.kind_of?(Hash)
28
+ @rets_response[name] = value
29
+ end
30
+ end
31
+
32
+ def text(value)
33
+ end
34
+
35
+ def split_multiline_key_value_pairs value
36
+ result = {}
37
+ value.scan(/(\w+) = (.*?)$/) do |match|
38
+ result[match[0].to_sym] = match[1].gsub(/http:\/\/.+?\//, "/")
39
+ end
40
+ result
41
+ end
42
+
43
+ def valid_rets_response_received?
44
+ response_code == 0
45
+ end
46
+
47
+ def response_code
48
+ @rets_response[:ReplyCode].to_i if @rets_response.has_key?(:ReplyCode)
49
+ end
50
+
51
+ def element_tracker_switch element, currently_in
52
+ @switches[element] = currently_in
53
+ end
54
+
55
+ def switch_active? element
56
+ (@switches[element] == true)
57
+ end
58
+
59
+ def data_splitter data
60
+ data.split(/\t/)
61
+ end
62
+
63
+ def data_merger columns, data
64
+ merged_data = {}
65
+ columns.each_with_index do |object, index|
66
+ merged_data[object] = data[index] unless index == 0
67
+ end
68
+ merged_data
69
+ end
70
+
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,41 @@
1
+
2
+ module Innards
3
+ module Parsers
4
+
5
+ # SAX Parser for RETS Search Response
6
+ class SearchParser < ParserBase
7
+
8
+ attr_reader :columns, :results
9
+
10
+ def initialize
11
+ super
12
+ @results = []
13
+ end
14
+
15
+ def start_element(name)
16
+ super
17
+ end
18
+
19
+ def end_element(name)
20
+ super
21
+ end
22
+
23
+ def attr(name, value)
24
+ super
25
+ end
26
+
27
+ def text(value)
28
+ super
29
+
30
+ if switch_active?(:COLUMNS)
31
+ @columns = data_splitter(value)
32
+ end
33
+
34
+ if switch_active?(:DATA)
35
+ @results.push data_merger(@columns, data_splitter(value))
36
+ end
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -3,8 +3,8 @@ module Innards
3
3
  MAJOR = 0
4
4
  MINOR = 1
5
5
  PATCH = 0
6
- BUILD = 'pre'
6
+ BUILD = nil
7
7
 
8
8
  STRING = [MAJOR, MINOR, PATCH, BUILD].compact.join('.')
9
9
  end
10
- end
10
+ end
@@ -0,0 +1,232 @@
1
+ require "spec_helper"
2
+
3
+ describe Innards::Connection do
4
+
5
+ context "during intialization" do
6
+ before do
7
+ @valid_hash = {:login_url => "http://www.dis.com:6103/rets/login"}
8
+ @invalid_hash = {:login_url => ""}
9
+ end
10
+
11
+ it "should validate the URL" do
12
+ expect { Innards::Connection.new(@valid_hash) }.not_to raise_error()
13
+ expect { Innards::Connection.new(@invalid_hash) }.to raise_error()
14
+ end
15
+
16
+ it "should valididate RETS Version" do
17
+ expect { Innards::Connection.new(@valid_hash.merge({:rets_version => "1.5"})) }.not_to raise_error()
18
+ expect { Innards::Connection.new(@valid_hash.merge({:rets_version => "1.7"})) }.not_to raise_error()
19
+ expect { Innards::Connection.new(@valid_hash.merge({:rets_version => "1.7.2"})) }.not_to raise_error()
20
+ expect { Innards::Connection.new(@valid_hash.merge({:rets_version => "1.8"})) }.not_to raise_error()
21
+ expect { Innards::Connection.new(@valid_hash.merge({:rets_version => "0.9"})) }.to raise_error()
22
+ end
23
+
24
+ it "should validate auth type" do
25
+ expect { Innards::Connection.new(@valid_hash.merge({:auth_mode => "basic"})) }.not_to raise_error()
26
+ expect { Innards::Connection.new(@valid_hash.merge({:auth_mode => "digest"})) }.not_to raise_error()
27
+ expect { Innards::Connection.new(@valid_hash.merge({:auth_mode => "invalid"})) }.to raise_error()
28
+ end
29
+
30
+ it "doesn't accept a blank user agent" do
31
+ expect { Innards::Connection.new(@valid_hash.merge({:user_agent => ""})) }.to raise_error()
32
+ end
33
+ end
34
+
35
+ context "during connect" do
36
+ before do
37
+ Excon.defaults[:mock] = true
38
+ stub_path = File.expand_path("../../stubs", __FILE__)
39
+ Excon.stub({:method => :get}) do |params|
40
+ rets_version = "RETS/1.7.2"
41
+ rets_server = "StubRETS/0.0.1"
42
+ case params[:path]
43
+ when "/rets/login"
44
+ {:body => File.read("#{stub_path}/login_success.xml"), :headers => {
45
+ "RETS-Version" => rets_version,
46
+ "RETS-Server" => rets_server,
47
+ "WWW-Authenticate" => "Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\"",
48
+ "Set-Cookie" => "name2=value2; Expires=Wed, 09 Jun 2021 10:18:14 GMT"
49
+ }, :status => 200}
50
+ when "/rets/getMetadata"
51
+ {:body => File.read("#{stub_path}/metadata_success.xml"), :headers => {
52
+ "RETS-Version" => rets_version,
53
+ "RETS-Server" => rets_server,
54
+ }, :status => 200}
55
+ when "/rets/staleDigest"
56
+ {:body => "", :headers => {
57
+ "RETS-Version" => rets_version,
58
+ "RETS-Server" => rets_server,
59
+ "WWW-Authenticate" => "Digest realm=\"testrealm@host.com\", qop=\"auth,auth-int\", nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\", opaque=\"5ccc069c403ebaf9f0171e9517f40e41\" stale=true",
60
+ }, :status => 200}
61
+ when "/rets/retsError"
62
+ {:body => File.read("#{stub_path}/rets_error.xml"), :headers => {
63
+ "RETS-Version" => rets_version,
64
+ "RETS-Server" => rets_server,
65
+ }, :status => 200}
66
+ when "/rets/search"
67
+ {:body => File.read("#{stub_path}/search_success.xml"), :headers => {
68
+ "RETS-Version" => rets_version,
69
+ "RETS-Server" => rets_server,
70
+ }, :status => 200}
71
+ when "/rets/logout"
72
+ {:body => File.read("#{stub_path}/logout_success.xml"), :headers => {
73
+ "RETS-Version" => rets_version,
74
+ "RETS-Server" => rets_server,
75
+ }, :status => 200}
76
+ when "/rets/getObject"
77
+ {:body => File.read("#{stub_path}/getobject_success.txt"), :headers => {
78
+ "RETS-Version" => rets_version,
79
+ "RETS-Server" => rets_server,
80
+ "Cache-Control" => "private",
81
+ "MIME-Version" => "1.0",
82
+ "Content-Type" => "multipart/parallel; boundary=\"aa58ae639983623715598443ccfe159ccf009de8\"",
83
+ "Transfer-Encoding" => "chunked"
84
+ }, :status => 200}
85
+ end
86
+ end
87
+ @demo_rets = Innards::Connection.new(:login_url => "http://Joe:Schmoe@www.dis.com:6103/rets/login")
88
+ end
89
+
90
+ after do
91
+ Excon.stubs.clear
92
+ end
93
+
94
+ it "should throw an exception if get metadata called before login" do
95
+ expect { @demo_rets.get_metadata }.to raise_error
96
+ @demo_rets.login!.should be_true
97
+ expect { @demo_rets.get_metadata }.not_to raise_error
98
+ end
99
+
100
+ it "should login and logout of the demo RETS Server returning associated metadata" do
101
+ @demo_rets.login!.should be_true
102
+ @demo_rets.params[:metadata].should be_kind_of(Hash)
103
+ @demo_rets.params[:metadata].should_not be_empty
104
+ @demo_rets.params[:metadata][:MemberName].should match "Joe Schmoe"
105
+ @demo_rets.params[:metadata][:MetadataVersion].should match "1.00.00001"
106
+ @demo_rets.params[:metadata][:MinMetadataVersion].should match "1.00.00001"
107
+ @demo_rets.params[:metadata][:User].should match "Joe,NULL,NULL,NULL"
108
+ @demo_rets.params[:metadata][:Login].should match "/rets/login"
109
+ @demo_rets.params[:metadata][:Logout].should match "/rets/logout"
110
+ @demo_rets.params[:metadata][:Search].should match "/rets/search"
111
+ @demo_rets.params[:metadata][:GetMetadata].should match "/rets/getMetadata"
112
+ @demo_rets.params[:metadata][:GetObject].should match "/rets/getObject"
113
+ @demo_rets.params[:metadata][:Balance].should match "116,805.54"
114
+ @demo_rets.params[:metadata][:TimeoutSeconds].should match "1800"
115
+ end
116
+
117
+ it "should throw an error on a stale digest header" do
118
+ @demo_rets.login!.should be_true
119
+ @demo_rets.params[:metadata][:GetMetadata] = "/rets/staleDigest"
120
+ expect { @demo_rets.get_metadata }.to raise_error
121
+ end
122
+
123
+ it "should get the associated metadata for the server" do
124
+
125
+ @demo_rets.login!.should be_true
126
+ metadata = @demo_rets.get_metadata
127
+
128
+ metadata.should have_key(:resources)
129
+ metadata[:resources].should_not be_empty
130
+
131
+ metadata.should have_key(:classes)
132
+ metadata[:classes].should_not be_empty
133
+
134
+ metadata.should have_key(:tables)
135
+ metadata[:tables].should_not be_empty
136
+
137
+ metadata.should have_key(:objects)
138
+ metadata[:objects].should_not be_empty
139
+
140
+ metadata.should have_key(:search_help)
141
+ metadata[:search_help].should_not be_empty
142
+
143
+ metadata.should have_key(:edit_masks)
144
+ metadata[:edit_masks].should_not be_empty
145
+
146
+ metadata.should have_key(:lookups)
147
+ metadata[:lookups].should_not be_empty
148
+
149
+ metadata.should have_key(:lookup_types)
150
+ metadata[:lookup_types].should_not be_empty
151
+
152
+ end
153
+
154
+ it "should return false on get metadata error" do
155
+ @demo_rets.login!.should be_true
156
+ @demo_rets.params[:metadata][:GetMetadata] = "/rets/retsError"
157
+ @demo_rets.get_metadata.should be_false
158
+ end
159
+
160
+ it "should throw an error if logout called before login" do
161
+ expect { @demo_rets.logout! }.to raise_error
162
+ end
163
+
164
+ it "should correctly log out" do
165
+ @demo_rets.login!.should be_true
166
+ expect { @demo_rets.logout! }.not_to raise_error
167
+ @demo_rets.params[:metadata][:ConnectTime].should match "6"
168
+ @demo_rets.params[:metadata][:Billing].should match "0.04"
169
+ @demo_rets.params[:metadata][:SignOffMessage].should match "Goodbye"
170
+ end
171
+
172
+ it "should return false on logout error" do
173
+ @demo_rets.login!.should be_true
174
+ @demo_rets.params[:metadata][:Logout] = "/rets/retsError"
175
+ @demo_rets.logout!.should be_false
176
+ end
177
+
178
+ it "should perform a basic search" do
179
+ @demo_rets.login!.should be_true
180
+
181
+ expect {
182
+ @demo_rets.search()
183
+ }.to raise_error
184
+
185
+ expect {
186
+ @demo_rets.search(:SearchType => "Property",
187
+ :Class => "",
188
+ :Query => "(ListPrice=0+)")
189
+ }.to raise_error
190
+
191
+ expect {
192
+ @demo_rets.search(:SearchType => "Property",
193
+ :Class => "RES",
194
+ :Query => "")
195
+ }.to raise_error
196
+
197
+ expect {
198
+ @demo_rets.search(:SearchType => "Property",
199
+ :Class => "RES",
200
+ :Query => "(ListPrice=0+)")
201
+ }.not_to raise_error
202
+
203
+ results = @demo_rets.search(:SearchType => "Property",
204
+ :Class => "RES",
205
+ :Query => "(ListPrice=0+)")
206
+ results.should be_kind_of(Array)
207
+ results.each do |result|
208
+ result.should be_kind_of(Hash)
209
+ end
210
+ end
211
+
212
+ it "should return false on search error" do
213
+ @demo_rets.login!.should be_true
214
+ @demo_rets.params[:metadata][:Search] = "/rets/retsError"
215
+ @demo_rets.search(:SearchType => "Property",
216
+ :Class => "RES",
217
+ :Query => "(ListPrice=0+)").should be_false
218
+ end
219
+
220
+ it "should perform a GetObject request" do
221
+ @demo_rets.login!.should be_true
222
+
223
+ result = @demo_rets.get_object(:Type => "Photo",
224
+ :Resource => "Property",
225
+ :ID => "2:*")
226
+ result.should be_true
227
+
228
+ end
229
+
230
+ end
231
+
232
+ end