jwulff-rets4r 1.1.1

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/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 John Wulff
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,7 @@
1
+ = rets4r
2
+
3
+ Description goes here.
4
+
5
+ == Copyright
6
+
7
+ Copyright (c) 2009 John Wulff. See LICENSE for details.
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :major: 1
3
+ :minor: 1
4
+ :patch: 1
@@ -0,0 +1,73 @@
1
+ require 'digest/md5'
2
+
3
+ module RETS4R
4
+ class Auth
5
+ # This is the primary method that would normally be used, and while it
6
+ def Auth.authenticate(response, username, password, uri, method, requestId, useragent, nc = 0)
7
+ if response['www-authenticate'].blank?
8
+ raise "Missing required header 'www-authenticate'. Got: #{response}"
9
+ end
10
+
11
+ authHeader = Auth.parse_header(response['www-authenticate'])
12
+
13
+ cnonce = cnonce(useragent, password, requestId, authHeader['nonce'])
14
+
15
+ authHash = calculate_digest(username, password, authHeader['realm'], authHeader['nonce'], method, uri, authHeader['qop'], cnonce, nc)
16
+
17
+ header = ''
18
+ header << "Digest username=\"#{username}\", "
19
+ header << "realm=\"#{authHeader['realm']}\", "
20
+ header << "qop=\"#{authHeader['qop']}\", "
21
+ header << "uri=\"#{uri}\", "
22
+ header << "nonce=\"#{authHeader['nonce']}\", "
23
+ header << "nc=#{('%08x' % nc)}, "
24
+ header << "cnonce=\"#{cnonce}\", "
25
+ header << "response=\"#{authHash}\", "
26
+ header << "opaque=\"#{authHeader['opaque']}\""
27
+
28
+ return header
29
+ end
30
+
31
+ def Auth.calculate_digest(username, password, realm, nonce, method, uri, qop = false, cnonce = false, nc = 0)
32
+ a1 = "#{username}:#{realm}:#{password}"
33
+ a2 = "#{method}:#{uri}"
34
+
35
+ response = '';
36
+
37
+ requestId = Auth.request_id unless requestId
38
+
39
+ if (qop)
40
+ throw ArgumentException, 'qop requires a cnonce to be provided.' unless cnonce
41
+
42
+ response = Digest::MD5.hexdigest("#{Digest::MD5.hexdigest(a1)}:#{nonce}:#{('%08x' % nc)}:#{cnonce}:#{qop}:#{Digest::MD5.hexdigest(a2)}")
43
+ else
44
+ response = Digest::MD5.hexdigest("#{Digest::MD5.hexdigest(a1)}:#{nonce}:#{Digest::MD5.hexdigest(a2)}")
45
+ end
46
+
47
+ return response
48
+ end
49
+
50
+ def Auth.parse_header(header)
51
+ type = header[0, header.index(' ')]
52
+ args = header[header.index(' '), header.length].strip.split(',')
53
+
54
+ parts = {'type' => type}
55
+
56
+ args.each do |arg|
57
+ name, value = arg.split('=')
58
+
59
+ parts[name.downcase.strip] = value.tr('"', '').strip
60
+ end
61
+
62
+ return parts
63
+ end
64
+
65
+ def Auth.request_id
66
+ Digest::MD5.hexdigest(Time.new.to_f.to_s)
67
+ end
68
+
69
+ def Auth.cnonce(useragent, password, requestId, nonce)
70
+ Digest::MD5.hexdigest("#{useragent}:#{password}:#{requestId}:#{nonce}")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,14 @@
1
+ module RETS4R
2
+ class Client
3
+ # Represents a row of data. Nothing more than a glorfied Hash with a custom constructor and a
4
+ # type attribute.
5
+ class Data < ::Hash
6
+ attr_accessor :type
7
+
8
+ def initialize(type = false)
9
+ super
10
+ self.type = type
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ module RETS4R
2
+ class Client
3
+ # Represents a RETS object (as returned by the get_object) transaction.
4
+ class DataObject
5
+ attr_accessor :type, :data
6
+
7
+ alias :info :type
8
+
9
+ def initialize(type, data)
10
+ self.type = type
11
+ self.data = data
12
+ end
13
+
14
+ def success?
15
+ return true if self.data
16
+ return false
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ require 'rets4r/client/data'
2
+
3
+ module RETS4R
4
+ class Client
5
+ # Represents a set of metadata. It is simply an extended Array with type and attributes accessors.
6
+ class Metadata < Array
7
+ attr_accessor :type, :attributes
8
+
9
+ def initialize(type = false)
10
+ self.type = type if type
11
+ self.attributes = {}
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,82 @@
1
+ module RETS4R
2
+ class Client
3
+ # Provides a means of indexing metadata to allow faster searching of it.
4
+ # This is in dire need of a review and cleanup, so I advise you not to use it until that has been done.
5
+ class MetadataIndex
6
+ # Constructor that takes an array of Metadata objects as its sole argument.
7
+ def initialize(metadata)
8
+ @metadata = metadata
9
+ @index = index(@metadata)
10
+ end
11
+
12
+ # WARNING! Not working properly (does NOT pass unit test)
13
+ # This is more of a free form search than #lookup, but it is also slower because it must iterate
14
+ # through the entire metadata array. This also returns multiple matches, which #lookup doesn't do.
15
+ def search(type, attributes = {})
16
+ matches = []
17
+
18
+ @metadata.each do |meta|
19
+ catch :mismatch do
20
+ if meta.type == type
21
+ attributes.each do |k,v|
22
+ throw :mismatch unless meta.attributes[k] == v
23
+ end
24
+
25
+ matches << meta
26
+ end
27
+ end
28
+ end
29
+
30
+ return matches
31
+ end
32
+
33
+ # This is a streamlined and probably the preferred method for looking up metadata because it
34
+ # uses a index to quickly access the data. The downside is that it is not very flexible. This also
35
+ # only returns one (the "best") match. If you need multiple matches, then you should use #search.
36
+ # Tests show about a 690% speed increase by using #lookup over #search, so you've been warned.
37
+ def lookup(type, *attributes)
38
+ key = type
39
+ key << attributes.join('')
40
+
41
+ @index[key]
42
+ end
43
+
44
+ private
45
+
46
+ # Provided an array of metadata, it indexes it for faster lookup.
47
+ # Takes a +Metadata+ object as its argument.
48
+ def index(metadata)
49
+ index = {}
50
+
51
+ metadata.each do |meta|
52
+ key = generate_key(meta)
53
+
54
+ index[key] = meta
55
+ end
56
+
57
+ return index
58
+ end
59
+
60
+ # Used to generate the key for a specified piece of metadata. Takes a +Metadata+ object as its
61
+ # argument.
62
+ def generate_key(meta)
63
+ key = meta.type
64
+
65
+ case (meta.type)
66
+ when 'METADATA-LOOKUP'
67
+ key << meta.attributes['Resource']
68
+ when 'METADATA-LOOKUP_TYPE'
69
+ key << meta.attributes['Resource'] << meta.attributes['Lookup']
70
+ when 'METADATA-CLASS'
71
+ key << meta.attributes['Resource']
72
+ when 'METADATA-OBJECT'
73
+ key << meta.attributes['Resource']
74
+ when 'METADATA-TABLE'
75
+ key << meta.attributes['Resource'] << meta.attributes['Class']
76
+ end
77
+
78
+ return key
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,135 @@
1
+ # This is the generic parser
2
+ #
3
+ # Supports responses, data, metadata, reply codes, and reply text.
4
+ #
5
+ # Supports the following tags:
6
+ # RETS, METADATA-.*, MAXROWS, COLUMNS, DATA, COUNT, DELIMITER
7
+ #
8
+ # Metadata is built as:
9
+ # (Metadata 1-> data row
10
+ # -> data row),
11
+ # (Metadata 2 -> data row),
12
+ # etc.
13
+ #
14
+ # Data is built as:
15
+ # Data 1, Data 2, Data N
16
+ #
17
+ #
18
+ # TODO
19
+ # Add comments/documentation
20
+ # Handle more tags (if any are unaccounted for)
21
+ # Handle standard (non-compact) output
22
+ # Case Insensitivity?
23
+ # There is still some holdovers from the previous organization of parsers, and they should be cleaned
24
+ # up at some point.
25
+
26
+ require 'cgi'
27
+
28
+ module RETS4R
29
+ class Client
30
+ module Parser
31
+ attr_accessor :output, :logger
32
+
33
+ def initialize
34
+ @transaction = Transaction.new
35
+ @current = []
36
+ @output = 2
37
+ end
38
+
39
+ def get_transaction
40
+ @transaction
41
+ end
42
+
43
+ #### Stream Listener Events
44
+ def tag_start(name, attrs)
45
+ @current.push(name)
46
+
47
+ case name
48
+ when 'RETS'
49
+ @transaction.reply_code = attrs['ReplyCode']
50
+ @transaction.reply_text = attrs['ReplyText']
51
+ when /METADATA.*/
52
+ @metadata = Metadata.new(name)
53
+ @metadata.attributes = attrs
54
+ when 'MAXROWS'
55
+ @transaction.maxrows = true
56
+ when 'COUNT'
57
+ @transaction.count = attrs['Records'].to_i
58
+ when 'DELIMITER'
59
+ @transaction.delimiter = attrs['value'].to_i
60
+ end
61
+ end
62
+
63
+ def text(text)
64
+ case @current[-1]
65
+ when 'COLUMNS'
66
+ @transaction.header = parse_compact_line(text, @transaction.ascii_delimiter)
67
+
68
+ when 'DATA'
69
+ if @transaction.header.length > 0
70
+ data_type << parse_data(text, @transaction.header)
71
+ else
72
+ data_type << parse_compact_line(text, @transaction.ascii_delimiter)
73
+ end
74
+
75
+ when 'RETS-RESPONSE'
76
+ @transaction.response = parse_key_value_body(text)
77
+ end
78
+ end
79
+
80
+ def tag_end(name)
81
+ @current.pop
82
+
83
+ @transaction.data << @metadata if name =~ /METADATA.*/
84
+ end
85
+
86
+ #### Helper Methods
87
+ def clean_xml(xml)
88
+ # This is a hack, and it assumes that we're using compact mode, but it's the easiest way to
89
+ # strip out those bad "<" and ">" characters that were not properly escaped...
90
+ xml.gsub!(/<DATA>(.*)<\/DATA>/i) do |match|
91
+ "<DATA>#{CGI::escapeHTML(CGI::unescapeHTML($1))}</DATA>"
92
+ end
93
+ end
94
+
95
+ def data_type
96
+ if @current[-2].index('METADATA') === 0
97
+ return @metadata
98
+ else
99
+ return @transaction.data
100
+ end
101
+ end
102
+
103
+ def parse_compact_line(data, delim = "\t")
104
+ begin
105
+ return data.to_s.strip.split(delim)
106
+ rescue
107
+ raise "Error while parsing compact line: #{$!} with data: #{data}"
108
+ end
109
+ end
110
+
111
+ def parse_data(data, header)
112
+ results = Data.new(@current[-2])
113
+
114
+ parsed_data = parse_compact_line(data, @transaction.ascii_delimiter)
115
+
116
+ header.length.times do |pos|
117
+ results[header[pos]] = parsed_data[pos]
118
+ end
119
+
120
+ results
121
+ end
122
+
123
+ def parse_key_value_body(data)
124
+ parsed = {}
125
+
126
+ data.each do |line|
127
+ (key, value) = line.strip.split('=')
128
+ parsed[key] = value
129
+ end
130
+
131
+ return parsed
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,41 @@
1
+ # Parses XML response containing 'COMPACT' data format.
2
+
3
+ require 'cgi'
4
+
5
+ module RETS4R
6
+ class Client
7
+ class CompactDataParser
8
+ def parse_results(doc)
9
+
10
+ delimiter = doc.get_elements('/RETS/DELIMITER')[0].attributes['value'].to_i.chr
11
+ columns = doc.get_elements('/RETS/COLUMNS')[0]
12
+ rows = doc.get_elements('/RETS/DATA')
13
+
14
+ parse_data(columns, rows, delimiter)
15
+ end
16
+
17
+ def parse_data(column_element, row_elements, delimiter = "\t")
18
+
19
+ column_names = column_element.text.split(delimiter)
20
+
21
+ result = []
22
+
23
+ data = row_elements.each do |data_row|
24
+ data_row = data_row.text.split(delimiter)
25
+
26
+ row_result = {}
27
+
28
+ column_names.each_with_index do |col, x|
29
+ row_result[col] = data_row[x]
30
+ end
31
+
32
+ row_result.reject! { |k,v| k.blank? }
33
+
34
+ result << row_result
35
+ end
36
+
37
+ return result
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,92 @@
1
+ require 'rexml/document'
2
+ require 'yaml'
3
+ require 'rets4r/client/parsers/compact'
4
+
5
+ module RETS4R
6
+ class Client
7
+ class MetadataParser
8
+
9
+ TAGS = [ 'METADATA-RESOURCE',
10
+ 'METADATA-CLASS',
11
+ 'METADATA-TABLE',
12
+ 'METADATA-OBJECT',
13
+ 'METADATA-LOOKUP',
14
+ 'METADATA-LOOKUP_TYPE' ]
15
+
16
+ def initialize()
17
+ @parser = RETS4R::Client::CompactDataParser.new
18
+ end
19
+
20
+ def parse_file(file = 'metadata.xml')
21
+ xml = File.read(file)
22
+ doc = REXML::Document.new(xml)
23
+ parse(doc)
24
+ end
25
+
26
+ def parse(doc)
27
+
28
+ rets_resources = {}
29
+
30
+ doc.get_elements('/RETS/*').each do |elem|
31
+
32
+ next unless TAGS.include?(elem.name)
33
+
34
+ columns = elem.get_elements('COLUMNS')[0]
35
+ rows = elem.get_elements('DATA')
36
+
37
+ data = @parser.parse_data(columns, rows)
38
+
39
+ resource_id = elem.attributes['Resource']
40
+
41
+ case elem.name
42
+ when 'METADATA-RESOURCE'
43
+ data.each do |resource_info|
44
+ id = resource_info.delete('ResourceID')
45
+ rets_resources[id] = resource_info
46
+ end
47
+
48
+ when 'METADATA-CLASS'
49
+ data.each do |class_info|
50
+ class_name = class_info.delete('ClassName')
51
+ rets_resources[resource_id][:classes] ||= {}
52
+ rets_resources[resource_id][:classes][class_name] = class_info
53
+ end
54
+
55
+ when 'METADATA-TABLE'
56
+ class_name = elem.attributes['Class']
57
+ data.each do |table_info|
58
+ system_name = table_info.delete('SystemName')
59
+ rets_resources[resource_id][:classes][class_name][:tables] ||= {}
60
+ rets_resources[resource_id][:classes][class_name][:tables][system_name] = table_info
61
+ end
62
+
63
+ when 'METADATA-OBJECT'
64
+ data.each do |object_info|
65
+ object_type = object_info.delete('ObjectType')
66
+ rets_resources[resource_id][:objects] ||= {}
67
+ rets_resources[resource_id][:objects][object_type] = object_info
68
+ end
69
+
70
+ when 'METADATA-LOOKUP'
71
+ data.each do |lookup_info|
72
+ lookup_name = lookup_info.delete('LookupName')
73
+ rets_resources[resource_id][:lookups] ||= {}
74
+ rets_resources[resource_id][:lookups][lookup_name] = lookup_info
75
+ end
76
+
77
+ when 'METADATA-LOOKUP_TYPE'
78
+ lookup = elem.attributes['Lookup']
79
+ rets_resources[resource_id][:lookup_types] ||= {}
80
+ rets_resources[resource_id][:lookup_types][lookup] = {}
81
+ data.each do |lookup_type_info|
82
+ value = lookup_type_info.delete('Value')
83
+ rets_resources[resource_id][:lookup_types][lookup][value] = lookup_type_info
84
+ end
85
+ end
86
+ end
87
+
88
+ rets_resources
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,103 @@
1
+ require 'rets4r/client/transaction'
2
+ require 'rexml/document'
3
+
4
+ module RETS4R
5
+ class Client
6
+ class ResponseParser
7
+ def parse_key_value(xml)
8
+ parse_common(xml) do |doc|
9
+ parsed = nil
10
+ first_child = doc.get_elements('/RETS/RETS-RESPONSE')[0]
11
+ unless first_child.nil?
12
+ parsed = {}
13
+ first_child.text.each do |line|
14
+ (key, value) = line.strip.split('=')
15
+ key.strip! if key
16
+ value.strip! if value
17
+ parsed[key] = value
18
+ end
19
+ else
20
+ raise 'Response was not a proper RETS XML doc!'
21
+ end
22
+
23
+ if parsed.nil?
24
+ raise "Response was not valid key/value format"
25
+ else
26
+ parsed
27
+ end
28
+ end
29
+ end
30
+
31
+ def parse_results(xml, format)
32
+ parse_common(xml) do |doc|
33
+ parser = get_parser_by_name(format)
34
+ parser.parse_results(doc)
35
+ end
36
+ end
37
+
38
+ def parse_count(xml)
39
+ parse_common(xml) do |doc|
40
+ doc.get_elements('/RETS/COUNT')[0].attributes['Records']
41
+ end
42
+ end
43
+
44
+ def parse_metadata(xml, format)
45
+ parse_common(xml) do |doc|
46
+ return REXML::Document.new(xml)
47
+ end
48
+ end
49
+
50
+ def parse_object_response(xml)
51
+ parse_common(xml) do |doc|
52
+ # XXX
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def parse_common(xml, &block)
59
+ if xml == ''
60
+ raise RETSException, 'No transaction body was returned!'
61
+ end
62
+
63
+ File.open('/tmp/meh', 'w') do |file|
64
+ file.write(xml)
65
+ end
66
+
67
+ doc = REXML::Document.new(xml)
68
+
69
+ root = doc.root
70
+ if root.nil? || root.name != 'RETS'
71
+ raise "Response had invalid root node. Document was: #{doc.inspect}"
72
+ end
73
+
74
+ transaction = Transaction.new
75
+ transaction.reply_code = root.attributes['ReplyCode']
76
+ transaction.reply_text = root.attributes['ReplyText']
77
+ transaction.maxrows = (doc.get_elements('/RETS/MAXROWS').length > 0)
78
+
79
+
80
+ # XXX: If it turns out we need to parse the response of errors, then this will
81
+ # need to change.
82
+ if transaction.reply_code.to_i > 0
83
+ exception_type = Client::EXCEPTION_TYPES[transaction.reply_code.to_i] || RETSTransactionException
84
+ raise exception_type, "#{transaction.reply_code} - #{transaction.reply_text}"
85
+ end
86
+
87
+ transaction.response = yield doc
88
+ return transaction
89
+ end
90
+
91
+ def get_parser_by_name(name)
92
+ case name
93
+ when 'COMPACT'
94
+ type = RETS4R::Client::CompactDataParser
95
+ else
96
+ raise "Invalid format #{name}"
97
+ end
98
+ type.new
99
+ end
100
+ end
101
+ end
102
+ end
103
+
@@ -0,0 +1,34 @@
1
+ module RETS4R
2
+ class Client
3
+ class Transaction
4
+ attr_accessor :reply_code, :reply_text, :response, :metadata, :header, :data, :maxrows,
5
+ :count, :delimiter, :secondary_response
6
+
7
+ def initialize
8
+ self.maxrows = false
9
+ self.header = []
10
+ self.data = []
11
+ self.delimiter = ?\t
12
+ end
13
+
14
+ def success?
15
+ return true if self.reply_code == '0'
16
+ return false
17
+ end
18
+
19
+ def has_data?
20
+ return true if self.data.length > 0
21
+ return false
22
+ end
23
+
24
+ def maxrows?
25
+ return true if self.maxrows
26
+ return false
27
+ end
28
+
29
+ def ascii_delimiter
30
+ self.delimiter.chr
31
+ end
32
+ end
33
+ end
34
+ end