[shared] use plain remove! and use the new HTTP interface
[opensuse:shared-resources.git] / buildservice / lib / activexml / transport.rb
1 require 'rexml/document'
2
3 module ActiveXML
4   module Transport
5
6     class Error < StandardError; end
7     class ConnectionError < Error; end
8     class UnauthorizedError < Error; end
9     class ForbiddenError < Error; end
10     class NotFoundError < Error; end
11     class NotImplementedError < Error; end
12
13     class Abstract
14       class << self
15         def register_protocol( proto )
16           ActiveXML::Config.register_transport self, proto.to_s
17         end
18
19         # spawn is called from within ActiveXML::Config::TransportMap.connect to
20         # generate the actual transport instance for a specific model. May be
21         # overridden in derived classes to implement some sort of connection
22         # cache or singleton transport objects. The default implementation is
23         # to create an own instance for each model.
24         def spawn( target_uri, opt={} )
25           self.new opt
26         end
27
28         def logger
29           ActiveXML::Base.config.logger
30         end
31       end
32
33       attr_accessor :target_uri
34
35       def initialize( target_uri, opt={} )
36       end
37
38       def find( model, *args )
39         raise NotImplementedError;
40       end
41
42       def query( model, query_string )
43         raise NotImplementedError;
44       end
45
46       def save(object, opt={})
47         raise NotImplementedError;
48       end
49
50       def delete(object, opt={})
51         raise NotImplementedError;
52       end
53
54       def login( user, password )
55         raise NotImplementedError;
56       end
57
58       def logger
59         ActiveXML::Base.config.logger
60       end
61     end
62
63     ##############################################
64     #
65     # BSSQL plugin
66     #
67     ##############################################
68
69     require 'active_support'
70     class BSSQL < Abstract
71       register_protocol 'bssql'
72
73       class << self
74         def spawn( target_uri, opt={} )
75           @transport_obj ||= new( target_uri, opt )
76         end
77       end
78
79       def initialize( target_uri, opt={} )
80         logger.debug "[BSSQL] initialize( #{target_uri.inspect}, #{opt.inspect} )"
81
82         @xml_to_db_model_map = {
83           :project => "DbProject",
84           :package => "DbPackage"
85         }
86       end
87
88       def xml_to_db_model( xml_model )
89         unless @xml_to_db_model_map.has_key? xml_model
90           raise RuntimeError, "no model association defined for '#{xml_model.inspect}'"
91         end
92
93         case xml_model
94         when :project
95           return DbProject
96         when :package
97           return DbPackage
98         end
99       end
100
101       def find( model, *args )
102         logger.debug "[BSSQL] find( #{model.inspect}, #{args.inspect} )"
103
104         symbolified_model = model.name.downcase.to_sym
105         uri = ActiveXML::Config::TransportMap.target_for( symbolified_model )
106         options = ActiveXML::Config::TransportMap.options_for( symbolified_model )
107
108         # get matching database model class
109         db_model = xml_to_db_model( symbolified_model )
110
111         query = String.new
112         case args[0]
113         when String
114           params = args[1]
115         when Hash
116           params = args[0]
117         when Symbol
118           # :all
119           params = args[1]
120         else
121           raise Error, "illegal parameter to find"
122         end
123
124         query = query_from_options( params )
125         builder = Builder::XmlMarkup.new( :indent => 2 )
126
127         if( query.empty? )
128           items = db_model.find(:all)
129         else
130           querymap = Hash.new
131           query.split( /\s+and\s+/ ).map {|x| x.split(/=/) }.each do |pair|
132             querymap[pair[0]] = pair[1]
133           end
134
135           join_fragments = Array.new
136           cond_fragments = Array.new
137           cond_values = Array.new
138
139           querymap.each do |k,v|
140             unless( md = k.match(/^@(.*)/) )
141               raise NotFoundError, "Illegal query: [#{query}]"
142             end
143
144             #unquote (I don't think this is safe enough...)
145             v.gsub!(/^['"]/, '')
146             v.gsub!(/['"]$/, '')
147
148             #FIXME: hack for project parameter in Package.find
149             if( symbolified_model == :package and md[1] == "project" )
150               cond_fragments << ["db_packages.db_project_id = prj.id"]
151               cond_fragments << ["prj.name = ?"]
152
153               cond_values << v
154               next
155             end
156
157             unless( db_model.column_names.include? md[1] )
158               raise NotFoundError, "Unknown attribute '#{md[1]}' in query '#{query}'"
159             end
160
161             v.gsub!(/([%_])/, '\\\\\1') #escape mysql LIKE special chars
162             v.gsub!(/\*/, '%')
163
164             cond_fragments << ["#{db_model.table_name}.#{md[1]} LIKE BINARY ?"]
165             cond_values << v
166           end
167
168           joins = nil
169           unless join_fragments.empty?
170             joins = ", " + join_fragments.join(", ")
171             logger.debug "[BSSQL] join string: #{joins.inspect}"
172           end
173
174           conditions = [cond_fragments.join(" AND "), cond_values].flatten
175           logger.debug "[BSSQL] find conditions: #{conditions.inspect}"
176
177           items = db_model.find( :all, :select => "#{db_model.table_name}.*", :joins => joins, :conditions => conditions )
178         end
179         xml = String.new
180
181         if args[0] == :all 
182           items.sort! {|a,b| a.name.downcase <=> b.name.downcase}
183           builder = Builder::XmlMarkup.new( :indent => 2 )
184           xml = builder.directory( :count => items.length ) do |dir|
185             items.each do |item|
186               dir.entry( :name => item.name )
187             end
188           end
189           return xml, params
190         end
191
192         items.each do |item|
193           logger.debug "---> "+item.methods.grep(/^to_a/).inspect
194           #if not item.respond_to? :to_axml
195           #  raise RuntimeError, "unable to transform to xml: #{item.inspect}"
196           #end
197           return item.to_axml, params
198         end
199
200         logger.debug "[BSSQL] query #{query} returned no objects"
201         raise NotFoundError, "#{model.name.downcase} query \"#{query}\" produced no results"
202       end
203
204       def login( user, password )
205         return true
206       end
207
208       def save(object, opt={})
209         #logger.debug "[BSSQL] saving object #{object}"
210
211         db_model = xml_to_db_model(object.class.name.downcase.to_sym)
212
213         if db_model.respond_to? :store_axml
214           db_model.store_axml( object )
215         else
216           raise Error, "[BSSQL] Unable to store objects of type '#{object.class.name}'"
217         end
218       end
219
220       def query_from_options( opt_hash )
221         logger.debug "[BSSQL] query_from_options: #{opt_hash.inspect}"
222         query_fragments = Array.new
223         opt_hash.each do |k,v|
224           query_fragments << "@#{k}='#{v}'"
225         end if opt_hash
226         query = query_fragments.join( " and " )
227         logger.debug "[BSSQL] query_from_options: query is: '#{query}'"
228         return query
229       end
230
231       def xml_error( opt={} )
232         default_opts = {
233           :code => 500,
234           :summary => "Default summary",
235         }
236         opt = default_opts.merge opt
237
238         builder = Builder::XmlMarkup.new
239         xml = builder.status( :code => opt[:code] ) do |s|
240           s.summary( opt[:summary] )
241           s.details( opt[:details] ) if opt.has_key? :details
242         end
243
244         xml
245       end
246     end
247
248     ##############################################
249     #
250     # REST plugin
251     #
252     ##############################################
253
254     #TODO: put lots of stuff into base class
255
256     require 'base64'
257     require 'net/https'
258     require 'net/http'
259
260     class Rest < Abstract
261       register_protocol 'rest'
262
263       class << self
264         def spawn( target_uri, opt={} )
265           @transport_obj ||= new( target_uri, opt )
266         end
267       end
268
269       def initialize( target_uri, opt={} )
270         logger.debug "[REST] initialize( #{target_uri.inspect}, #{opt.inspect} )"
271         @options = opt
272         if @options.has_key? :all
273           @options[:all].scheme = "http"
274         end
275         @http_header = {"Content-Type" => "text/plain"}
276       end
277
278       def target_uri=(uri)
279         uri.scheme = "http"
280         @target_uri = uri
281       end
282
283       def login( user, password )
284         @http_header ||= Hash.new
285         @http_header['Authorization'] = 'Basic ' + Base64.encode64( "#{user}:#{password}" )
286       end
287
288       # returns object
289       def find( model, *args )
290
291         logger.debug "[REST] find( #{model.inspect}, #{args.inspect} )"
292         params = Hash.new
293         data = nil
294         own_mimetype = nil
295         symbolified_model = model.name.downcase.to_sym
296         uri = ActiveXML::Config::TransportMap.target_for( symbolified_model )
297         options = ActiveXML::Config::TransportMap.options_for( symbolified_model )
298         case args[0]
299         when Symbol
300           #logger.debug "Transport.find: using symbol"
301           #raise ArgumentError, "Illegal symbol, must be :all (or String/Hash)" unless args[0] == :all
302           uri = options[args[0]]
303           if args.length > 1
304             #:conditions triggers atm. always a post request, the conditions are
305             # transmitted as post-data
306             if args[1].has_key? :conditions
307               data = args[1][:conditions]
308             end
309             params = args[1].merge params
310           end
311         when String
312           raise ArgumentError.new "find with string is no longer allowed #{args.inspect}"
313         when Hash
314           #logger.debug "Transport.find: using hash"
315           if args[0].has_key? :predicate and args[0].has_key? :what
316             own_mimetype = "application/x-www-form-urlencoded"
317           end
318           params = args[0]
319         else
320           raise "Illegal first parameter, must be Symbol/String/Hash"
321         end
322
323         #logger.debug "params #{params.inspect}"
324         #logger.debug "uri is: #{uri}"
325         url = substitute_uri( uri, params )
326         if own_mimetype
327           data = url.query
328           url.query = nil
329         end
330         #use get-method if no conditions defined <- no post-data is set.
331         if data.nil?
332           #logger.debug"[REST] Transport.find using GET-method"
333           objdata = http_do( 'get', url, :timeout => 300 )
334           raise RuntimeError.new("GET to %s returned no data" % url) if objdata.empty?
335         else
336           #use post-method
337           logger.debug"[REST] Transport.find using POST-method"
338           #logger.debug"[REST] POST-data as xml: #{data.to_s}"
339           objdata = http_do( 'post', url, :data => data.to_s, :content_type => own_mimetype)
340           raise RuntimeError.new("POST to %s returned no data" % url) if objdata.empty?
341         end
342         return [objdata, params]
343       end
344
345       def create(object, opt={})
346         logger.debug "creating object #{object.class} (#{object.init_options.inspect}) to api:\n #{object.data}"
347         url = substituted_uri_for( object, :create, opt )
348         http_do 'post', url, :data => object.dump_xml
349       end
350
351       def save(object, opt={})
352         logger.debug "saving object #{object.class} (#{object.init_options.inspect}) to api:\n #{object.data}"
353         url = substituted_uri_for( object )
354         http_do 'put', url, :data => object.dump_xml
355       end
356
357       def delete(object, opt={})
358         logger.debug "delete object #{object.class} (#{object.init_options.inspect}) to api:\n #{object.data}"
359         url = substituted_uri_for( object, :delete, opt )
360         http_do 'delete', url
361       end
362
363       # defines an additional header that is passed to the REST server on every subsequent request
364       # e.g.: set_additional_header( "X-Username", "margarethe" )
365       def set_additional_header( key, value )
366         if value.nil? and @http_header.has_key? key
367           @http_header[key] = nil
368         end
369
370         @http_header[key] = value
371       end
372
373       # delete a header field set with set_additional_header
374       def delete_additional_header( key )
375         if @http_header.has_key? key
376           @http_header.delete key
377         end
378       end
379
380       def direct_http( url, opt={} )
381         defaults = {:method => "GET"}
382         opt = defaults.merge opt
383
384         #set default host if not set in uri
385         if not url.host
386           url.scheme, url.host, url.port = ActiveXML::Config::TransportMap.get_default_server("rest")
387         end
388
389         logger.debug "--> direct_http url: #{url.inspect}"
390
391         http_do opt[:method], url, opt
392       end
393
394       private
395
396       #replaces the parameter parts in the uri from the config file with the correct values
397       def substitute_uri( uri, params )
398
399         #logger.debug "[REST] reducing args: #{params.inspect}"
400         params.delete(:conditions)
401         #logger.debug "[REST] args is now: #{params.inspect}"
402
403         u = uri.clone
404         u.scheme = uri.scheme
405         u.path = URI.escape(uri.path.split(/\//).map { |x| x =~ /^:(\w+)/ ? params[$1.to_sym] : x }.join("/"))
406         if uri.query
407           new_pairs = []
408           pairs = u.query.split(/&/).map{|x| x.split(/=/, 2)}
409           pairs.each do |pair|
410             if pair.length == 2
411               if pair[1] =~ /:(\w+)/
412                 next if not params.has_key? $1.to_sym or params[$1.to_sym].nil?
413                 pair[1] = CGI.escape(params[$1.to_sym])
414               end
415               new_pairs << pair.join("=")
416             elsif pair.length == 1
417               pair[0] =~ /:(\w+)/
418               #new substitution rules:
419               #when param is not there, don't put anything in url
420               #when param is array, put multiple params in url
421               #when param is a hash, put key=value params in url
422               #any other case, stringify param and put it in url
423               next if not params.has_key? $1.to_sym or params[$1.to_sym].nil?
424               sub_val = params[$1.to_sym]
425               if sub_val.kind_of? Array
426                 sub_val.each do |val|
427                   new_pairs << $1 + "=" + CGI.escape(val)
428                 end
429               elsif sub_val.kind_of? Hash
430                 sub_val.each_key do |key|
431                   new_pairs << CGI.escape(key) + "=" + CGI.escape(sub_val[key])
432                 end
433               else
434                 new_pairs << $1 + "=" + CGI.escape(sub_val.to_s)
435               end
436             else
437               raise RuntimeError, "illegal url query pair: #{pair.inspect}"
438             end
439           end
440           u.query = new_pairs.join("&")
441         end
442         u.path.gsub!(/\/+/, '/')
443         return u
444       end
445
446       def substituted_uri_for( object, path_id=nil, opt={} )
447         symbolified_model = object.class.name.downcase.to_sym
448         options = ActiveXML::Config::TransportMap.options_for(symbolified_model)
449         if path_id and options.has_key? path_id
450           uri = options[path_id]
451         else
452           uri = ActiveXML::Config::TransportMap.target_for( symbolified_model )
453         end
454         substitute_uri( uri, object.instance_variable_get("@init_options").merge(opt) )
455       end
456
457       def http_do( method, url, opt={} )
458         defaults = {:timeout => 60}
459         opt = defaults.merge opt
460         max_retries = 1
461
462         case method
463         when /put/i, /post/i, /delete/i
464           @http.finish if @http
465           @http = nil
466         when /get/i
467           # if the http is existed before, we shall retry
468           max_retries = 2 if @http
469         end
470         retries = 0
471         begin
472           start = Time.now
473           retries += 1
474           keepalive = true
475           if not @http
476             @http = Net::HTTP.new(url.host, url.port)
477             @http.use_ssl = true if url.scheme == "https"
478             @http.start
479           end
480           @http.read_timeout = opt[:timeout]
481
482           path = url.path
483           path += "?" + url.query if url.query
484           logger.debug "http_do ##{retries}: method: #{method} url: " +
485             "http#{"s" if @http.use_ssl?}://#{url.host}:#{url.port}#{path}"
486
487           clength = { "Content-Length" => "0" }
488           clength["Content-Length"] = opt[:data].length().to_s() unless opt[:data].nil?
489           clength["Content-Type"] = opt[:content_type] unless opt[:content_type].nil?
490  
491           case method
492           when /get/i
493             http_response = @http.get path, @http_header
494           when /put/i
495             http_response = @http.put path, opt[:data], @http_header.merge(clength)
496           when /post/i
497             http_response = @http.post path, opt[:data], @http_header.merge(clength)
498           when /delete/i
499             http_response = @http.delete path, @http_header
500           else
501             raise "unknown HTTP method: #{method.inspect}"
502           end
503         rescue Timeout::Error => err
504           logger.error "--> caught timeout, closing HTTP"
505           @http.finish
506           @http = nil
507           raise err
508         rescue SocketError, Errno::EINTR, Errno::EPIPE, EOFError, Net::HTTPBadResponse, IOError => err
509           @http.finish
510           @http = nil
511           if retries < max_retries
512             logger.error "--> caught #{err.class}: #{err.message}, retrying with new HTTP connection"
513             retry
514           end
515           raise Error, "Connection failed #{err.class}: #{err.message}"
516         rescue SystemCallError => err
517           begin
518             @http.finish
519           rescue => e
520             logger.error "Couldn't finish http connection: #{e.message}"
521           end
522           @http = nil
523           raise ConnectionError, "Failed to establish connection: " + err.message
524         ensure
525           logger.debug "Request took #{Time.now - start} seconds"
526         end
527
528         unless keepalive
529           @http.finish
530           @http = nil
531         end
532
533         return handle_response( http_response )
534       end
535
536       def handle_response( http_response )
537         case http_response
538         when Net::HTTPSuccess, Net::HTTPRedirection
539           return http_response.read_body
540         when Net::HTTPNotFound
541           raise NotFoundError, http_response.read_body
542         when Net::HTTPUnauthorized
543           raise UnauthorizedError, http_response.read_body
544         when Net::HTTPForbidden
545           raise ForbiddenError, http_response.read_body
546         when Net::HTTPGatewayTimeOut
547           raise Timeout::Error 
548         when Net::HTTPBadGateway
549           raise Timeout::Error
550         end
551         message = http_response.read_body
552         message = http_response.to_s if message.blank?
553         raise Error, message
554       end
555
556     end
557
558     def self.extract_error_message exception
559       message = exception.message[0..120]
560       code = "unknown"
561       begin
562         api_error = REXML::Document.new( exception.message ).root
563         if api_error and api_error.name == "status"
564           code = api_error.attributes['code']
565           message = api_error.elements['summary'].text
566           api_exception = api_error.elements['exception'] if api_error.elements['exception']
567         end
568       rescue Object => e
569         Rails.logger.error "Couldn't parse error xml: #{e.message[0..120]}"
570       end
571       return message, code, api_exception
572     end
573
574   end
575 end