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