fix http error handling
[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 document payload as string
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           obj = model.new( http_do( 'get', url ) )
340           obj.instance_variable_set( '@init_options', params )
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           obj = model.new( objdata )
348           obj.instance_variable_set( '@init_options', params )
349         end
350         return obj
351       end
352
353       def save(object, opt={})
354         logger.debug "saving #{object.inspect}"
355         url = substituted_uri_for( object )
356         http_do 'put', url, :data => object.dump_xml
357       end
358
359       def delete(object, opt={})
360         logger.debug "deleting #{object.inspect}"
361         url = substituted_uri_for( object, :delete, opt )
362         http_do 'delete', url
363       end
364
365       # defines an additional header that is passed to the REST server on every subsequent request
366       # e.g.: set_additional_header( "X-Username", "margarethe" )
367       def set_additional_header( key, value )
368         if value.nil? and @http_header.has_key? key
369           @http_header[key] = nil
370         end
371
372         @http_header[key] = value
373       end
374
375       # delete a header field set with set_additional_header
376       def delete_additional_header( key )
377         if @http_header.has_key? key
378           @http_header.delete key
379         end
380       end
381
382       def direct_http( url, opt={} )
383         defaults = {:method => "GET", :timeout => 60}
384         opt = defaults.merge opt
385
386         #set default host if not set in uri
387         if not url.host
388           host, port = ActiveXML::Config::TransportMap.get_default_server( "rest" )
389           url.host = host
390           url.port = port unless port.nil?
391         end
392
393         logger.debug "--> direct_http url: #{url.inspect}"
394
395         http_do opt[:method], url, opt
396       end
397
398       private
399
400       #replaces the parameter parts in the uri from the config file with the correct values
401       def substitute_uri( uri, params )
402
403         #logger.debug "[REST] reducing args: #{params.inspect}"
404         params.delete(:conditions)
405         #logger.debug "[REST] args is now: #{params.inspect}"
406
407         u = uri.clone
408         u.scheme = "http"
409         u.path = URI.escape(uri.path.split(/\//).map { |x| x =~ /^:(\w+)/ ? params[$1.to_sym] : x }.join("/"))
410         if uri.query
411           new_pairs = []
412           pairs = u.query.split(/&/).map{|x| x.split(/=/, 2)}
413           pairs.each do |pair|
414             if pair.length == 2
415               pair[1] =~ /:(\w+)/
416               next if not params.has_key? $1.to_sym or params[$1.to_sym].nil?
417               pair[1] = CGI.escape(params[$1.to_sym])
418               new_pairs << pair.join("=")
419             elsif pair.length == 1
420               pair[0] =~ /:(\w+)/
421               #new substitution rules:
422               #when param is not there, don't put anything in url
423               #when param is array, put multiple params in url
424               #when param is a hash, put key=value params in url
425               #any other case, stringify param and put it in url
426               next if not params.has_key? $1.to_sym or params[$1.to_sym].nil?
427               sub_val = params[$1.to_sym]
428               if sub_val.kind_of? Array
429                 sub_val.each do |val|
430                   new_pairs << $1 + "=" + CGI.escape(val)
431                 end
432               elsif sub_val.kind_of? Hash
433                 sub_val.each_key do |key|
434                   new_pairs << CGI.escape(key) + "=" + CGI.escape(sub_val[key])
435                 end
436               else
437                 new_pairs << $1 + "=" + CGI.escape(sub_val.to_s)
438               end
439             else
440               raise RuntimeError, "illegal url query pair: #{pair.inspect}"
441             end
442           end
443           u.query = new_pairs.join("&")
444         end
445         u.path.gsub!(/\/+/, '/')
446         return u
447       end
448
449       def substituted_uri_for( object, path_id=nil, opt={} )
450         symbolified_model = object.class.name.downcase.to_sym
451         options = ActiveXML::Config::TransportMap.options_for(symbolified_model)
452         if path_id and options.has_key? path_id
453           uri = options[path_id]
454         else
455           uri = ActiveXML::Config::TransportMap.target_for( symbolified_model )
456         end
457         substitute_uri( uri, object.instance_variable_get("@init_options").merge(opt) )
458       end
459
460       def http_do( method, url, opt={} )
461         retries = 0
462         begin
463           start = Time.now
464           retries += 1
465           keepalive = true
466           if not @http
467             @http = Net::HTTP.new(url.host, url.port)
468             # FIXME: we should get the protocol here instead of depending on the port
469             @http.use_ssl = true if url.port == 443
470             @http.start
471           end
472           @http.read_timeout = opt[:timeout]
473
474           path = url.path
475           path += "?" + url.query if url.query
476           logger.debug "http_do ##{retries}: method: #{method} url: " +
477             "http#{"s" if @http.use_ssl}://#{url.host}:#{url.port}#{path}"
478
479           case method
480           when /get/i
481             http_response = @http.get path, @http_header
482           when /put/i
483             raise "PUT without data" if opt[:data].nil?
484             http_response = @http.put path, opt[:data], @http_header
485           when /post/i
486             raise "POST without data" if opt[:data].nil?
487             http_response = @http.post path, opt[:data], @http_header
488           when /delete/i
489             http_response = @http.delete path, @http_header
490           else
491             raise "unknown HTTP method: #{method.inspect}"
492           end
493         rescue Timeout::Error => err
494           logger.error "--> caught timeout, closing HTTP"
495           @http.finish
496           @http = nil
497           raise err
498         rescue SocketError, Errno::EINTR, Errno::EPIPE, EOFError, Net::HTTPBadResponse, IOError => err
499           logger.error "--> caught #{err.class}: #{err.message}, retrying with new HTTP connection"
500           @http.finish
501           @http = nil
502           retry if retries < 5
503           raise err
504         rescue SystemCallError => err
505           begin
506             @http.finish
507           rescue => e
508             logger.error "Couldn't finish http connection: #{e.message}"
509           end
510           @http = nil
511           raise ConnectionError, "Failed to establish connection: " + err.message
512         ensure
513           logger.debug "Request took #{Time.now - start} seconds"
514         end
515
516         unless keepalive
517           @http.finish
518           @http = nil
519         end
520
521         return handle_response( http_response )
522       end
523
524       def handle_response( http_response )
525         case http_response
526         when Net::HTTPSuccess, Net::HTTPRedirection
527           return http_response.read_body
528         when Net::HTTPNotFound
529           raise NotFoundError, http_response.read_body
530         when Net::HTTPUnauthorized
531           raise UnauthorizedError, http_response.read_body
532         when Net::HTTPForbidden
533           raise ForbiddenError, http_response.read_body
534         when Net::HTTPClientError, Net::HTTPServerError
535           raise Error, http_response.read_body
536         end
537         raise Error, http_response.read_body
538       end
539
540     end
541
542     def self.extract_error_message exception
543       message = exception.message[0..120]
544       code = "unknown"
545       begin
546         api_error = REXML::Document.new( exception.message ).root
547         if api_error and api_error.name == "status"
548           code = api_error.attributes['code']
549           message = api_error.elements['summary'].text
550           api_exception = api_error.elements['exception'] if api_error.elements['exception']
551         end
552       rescue Object => e
553         Rails.logger.error "Couldn't parse error xml: #{e.message[0..120]}"
554       end
555       return message, code, api_exception
556     end
557
558   end
559 end