[REF] Refactoring according to the review of CHS
[odoo/odoo.git] / openerp / service / wsgi_server.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2011-2012 OpenERP s.a. (<http://openerp.com>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 """
23
24 WSGI stack, common code.
25
26 """
27
28 import httplib
29 import urllib
30 import xmlrpclib
31 import StringIO
32
33 import errno
34 import logging
35 import platform
36 import socket
37 import sys
38 import threading
39 import traceback
40
41 import werkzeug.serving
42 import werkzeug.contrib.fixers
43
44 import openerp
45 import openerp.tools.config as config
46 import websrv_lib
47
48 _logger = logging.getLogger(__name__)
49
50 # XML-RPC fault codes. Some care must be taken when changing these: the
51 # constants are also defined client-side and must remain in sync.
52 # User code must use the exceptions defined in ``openerp.exceptions`` (not
53 # create directly ``xmlrpclib.Fault`` objects).
54 RPC_FAULT_CODE_CLIENT_ERROR = 1 # indistinguishable from app. error.
55 RPC_FAULT_CODE_APPLICATION_ERROR = 1
56 RPC_FAULT_CODE_WARNING = 2
57 RPC_FAULT_CODE_ACCESS_DENIED = 3
58 RPC_FAULT_CODE_ACCESS_ERROR = 4
59
60 def xmlrpc_return(start_response, service, method, params, string_faultcode=False):
61     """
62     Helper to call a service's method with some params, using a wsgi-supplied
63     ``start_response`` callback.
64
65     This is the place to look at to see the mapping between core exceptions
66     and XML-RPC fault codes.
67     """
68     # Map OpenERP core exceptions to XML-RPC fault codes. Specific exceptions
69     # defined in ``openerp.exceptions`` are mapped to specific fault codes;
70     # all the other exceptions are mapped to the generic
71     # RPC_FAULT_CODE_APPLICATION_ERROR value.
72     # This also mimics SimpleXMLRPCDispatcher._marshaled_dispatch() for
73     # exception handling.
74     try:
75         result = openerp.netsvc.dispatch_rpc(service, method, params)
76         response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False, encoding=None)
77     except Exception, e:
78         if string_faultcode:
79             response = xmlrpc_handle_exception_string(e)
80         else:
81             response = xmlrpc_handle_exception_int(e)
82     start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
83     return [response]
84
85 def xmlrpc_handle_exception_int(e):
86     if isinstance(e, openerp.osv.orm.except_orm): # legacy
87         fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, openerp.tools.ustr(e.value))
88         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
89     elif isinstance(e, openerp.exceptions.Warning) or isinstance(e, openerp.exceptions.RedirectWarning):
90         fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e))
91         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
92     elif isinstance (e, openerp.exceptions.AccessError):
93         fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_ERROR, str(e))
94         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
95     elif isinstance(e, openerp.exceptions.AccessDenied):
96         fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
97         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
98     elif isinstance(e, openerp.exceptions.DeferredException):
99         info = e.traceback
100         # Which one is the best ?
101         formatted_info = "".join(traceback.format_exception(*info))
102         #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
103         fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
104         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
105     else:
106         if hasattr(e, 'message') and e.message == 'AccessDenied': # legacy
107             fault = xmlrpclib.Fault(RPC_FAULT_CODE_ACCESS_DENIED, str(e))
108             response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
109         else:
110             info = sys.exc_info()
111             # Which one is the best ?
112             formatted_info = "".join(traceback.format_exception(*info))
113             #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
114             fault = xmlrpclib.Fault(RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
115             response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
116     return response
117
118 def xmlrpc_handle_exception_string(e):
119     if isinstance(e, openerp.osv.orm.except_orm):
120         fault = xmlrpclib.Fault('warning -- ' + e.name + '\n\n' + e.value, '')
121         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
122     elif isinstance(e, openerp.exceptions.Warning):
123         fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '')
124         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
125     elif isinstance(e, openerp.exceptions.AccessError):
126         fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '')
127         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
128     elif isinstance(e, openerp.exceptions.AccessDenied):
129         fault = xmlrpclib.Fault('AccessDenied', str(e))
130         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
131     elif isinstance(e, openerp.exceptions.DeferredException):
132         info = e.traceback
133         formatted_info = "".join(traceback.format_exception(*info))
134         fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
135         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
136     else:
137         info = sys.exc_info()
138         formatted_info = "".join(traceback.format_exception(*info))
139         fault = xmlrpclib.Fault(openerp.tools.exception_to_unicode(e), formatted_info)
140         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
141     return response
142
143 def wsgi_xmlrpc(environ, start_response):
144     """ Two routes are available for XML-RPC
145
146     /xmlrpc/<service> route returns faultCode as strings. This is a historic
147     violation of the protocol kept for compatibility.
148
149     /xmlrpc/2/<service> is a new route that returns faultCode as int and is
150     therefore fully compliant.
151     """
152     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith('/xmlrpc/'):
153         length = int(environ['CONTENT_LENGTH'])
154         data = environ['wsgi.input'].read(length)
155
156         # Distinguish betweed the 2 faultCode modes
157         string_faultcode = True
158         if environ['PATH_INFO'].startswith('/xmlrpc/2/'):
159             service = environ['PATH_INFO'][len('/xmlrpc/2/'):]
160             string_faultcode = False
161         else:
162             service = environ['PATH_INFO'][len('/xmlrpc/'):]
163
164         params, method = xmlrpclib.loads(data)
165         return xmlrpc_return(start_response, service, method, params, string_faultcode)
166
167 def wsgi_webdav(environ, start_response):
168     pi = environ['PATH_INFO']
169     if environ['REQUEST_METHOD'] == 'OPTIONS' and pi in ['*','/']:
170         return return_options(environ, start_response)
171     elif pi.startswith('/webdav'):
172         http_dir = websrv_lib.find_http_service(pi)
173         if http_dir:
174             path = pi[len(http_dir.path):]
175             if path.startswith('/'):
176                 environ['PATH_INFO'] = path
177             else:
178                 environ['PATH_INFO'] = '/' + path
179             return http_to_wsgi(http_dir)(environ, start_response)
180
181 def return_options(environ, start_response):
182     # Microsoft specific header, see
183     # http://www.ibm.com/developerworks/rational/library/2089.html
184     if 'Microsoft' in environ.get('User-Agent', ''):
185         options = [('MS-Author-Via', 'DAV')]
186     else:
187         options = []
188     options += [('DAV', '1 2'), ('Allow', 'GET HEAD PROPFIND OPTIONS REPORT')]
189     start_response("200 OK", [('Content-Length', str(0))] + options)
190     return []
191
192 def http_to_wsgi(http_dir):
193     """
194     Turn a BaseHTTPRequestHandler into a WSGI entry point.
195
196     Actually the argument is not a bare BaseHTTPRequestHandler but is wrapped
197     (as a class, so it needs to be instanciated) in a HTTPDir.
198
199     This code is adapted from wbsrv_lib.MultiHTTPHandler._handle_one_foreign().
200     It is a temporary solution: the HTTP sub-handlers (in particular the
201     document_webdav addon) have to be WSGIfied.
202     """
203     def wsgi_handler(environ, start_response):
204
205         headers = {}
206         for key, value in environ.items():
207             if key.startswith('HTTP_'):
208                 key = key[5:].replace('_', '-').title()
209                 headers[key] = value
210             if key == 'CONTENT_LENGTH':
211                 key = key.replace('_', '-').title()
212                 headers[key] = value
213         if environ.get('Content-Type'):
214             headers['Content-Type'] = environ['Content-Type']
215
216         path = urllib.quote(environ.get('PATH_INFO', ''))
217         if environ.get('QUERY_STRING'):
218             path += '?' + environ['QUERY_STRING']
219
220         request_version = 'HTTP/1.1' # TODO
221         request_line = "%s %s %s\n" % (environ['REQUEST_METHOD'], path, request_version)
222
223         class Dummy(object):
224             pass
225
226         # Let's pretend we have a server to hand to the handler.
227         server = Dummy()
228         server.server_name = environ['SERVER_NAME']
229         server.server_port = int(environ['SERVER_PORT'])
230
231         # Initialize the underlying handler and associated auth. provider.
232         con = openerp.service.websrv_lib.noconnection(environ['wsgi.input'])
233         handler = http_dir.instanciate_handler(con, environ['REMOTE_ADDR'], server)
234
235         # Populate the handler as if it is called by a regular HTTP server
236         # and the request is already parsed.
237         handler.wfile = StringIO.StringIO()
238         handler.rfile = environ['wsgi.input']
239         handler.headers = headers
240         handler.command = environ['REQUEST_METHOD']
241         handler.path = path
242         handler.request_version = request_version
243         handler.close_connection = 1
244         handler.raw_requestline = request_line
245         handler.requestline = request_line
246
247         # Handle authentication if there is an auth. provider associated to
248         # the handler.
249         if hasattr(handler, 'auth_provider'):
250             try:
251                 handler.auth_provider.checkRequest(handler, path)
252             except websrv_lib.AuthRequiredExc, ae:
253                 # Darwin 9.x.x webdav clients will report "HTTP/1.0" to us, while they support (and need) the
254                 # authorisation features of HTTP/1.1
255                 if request_version != 'HTTP/1.1' and ('Darwin/9.' not in handler.headers.get('User-Agent', '')):
256                     start_response("403 Forbidden", [])
257                     return []
258                 start_response("401 Authorization required", [
259                     ('WWW-Authenticate', '%s realm="%s"' % (ae.atype,ae.realm)),
260                     # ('Connection', 'keep-alive'),
261                     ('Content-Type', 'text/html'),
262                     ('Content-Length', 4), # len(self.auth_required_msg)
263                     ])
264                 return ['Blah'] # self.auth_required_msg
265             except websrv_lib.AuthRejectedExc,e:
266                 start_response("403 %s" % (e.args[0],), [])
267                 return []
268
269         method_name = 'do_' + handler.command
270
271         # Support the OPTIONS method even when not provided directly by the
272         # handler. TODO I would prefer to remove it and fix the handler if
273         # needed.
274         if not hasattr(handler, method_name):
275             if handler.command == 'OPTIONS':
276                 return return_options(environ, start_response)
277             start_response("501 Unsupported method (%r)" % handler.command, [])
278             return []
279
280         # Finally, call the handler's method.
281         try:
282             method = getattr(handler, method_name)
283             method()
284             # The DAV handler buffers its output and provides a _flush()
285             # method.
286             getattr(handler, '_flush', lambda: None)()
287             response = parse_http_response(handler.wfile.getvalue())
288             response_headers = response.getheaders()
289             body = response.read()
290             start_response(str(response.status) + ' ' + response.reason, response_headers)
291             return [body]
292         except (websrv_lib.AuthRejectedExc, websrv_lib.AuthRequiredExc):
293             raise
294         except Exception, e:
295             start_response("500 Internal error", [])
296             return []
297
298     return wsgi_handler
299
300 def parse_http_response(s):
301     """ Turn a HTTP response string into a httplib.HTTPResponse object."""
302     class DummySocket(StringIO.StringIO):
303         """
304         This is used to provide a StringIO to httplib.HTTPResponse
305         which, instead of taking a file object, expects a socket and
306         uses its makefile() method.
307         """
308         def makefile(self, *args, **kw):
309             return self
310     response = httplib.HTTPResponse(DummySocket(s))
311     response.begin()
312     return response
313
314 # WSGI handlers registered through the register_wsgi_handler() function below.
315 module_handlers = []
316 # RPC endpoints registered through the register_rpc_endpoint() function below.
317 rpc_handlers = {}
318
319 def register_wsgi_handler(handler):
320     """ Register a WSGI handler.
321
322     Handlers are tried in the order they are added. We might provide a way to
323     register a handler for specific routes later.
324     """
325     module_handlers.append(handler)
326
327 def register_rpc_endpoint(endpoint, handler):
328     """ Register a handler for a given RPC enpoint.
329     """
330     rpc_handlers[endpoint] = handler
331
332 def application_unproxied(environ, start_response):
333     """ WSGI entry point."""
334     # cleanup db/uid trackers - they're set at HTTP dispatch in
335     # web.session.OpenERPSession.send() and at RPC dispatch in
336     # openerp.service.web_services.objects_proxy.dispatch().
337     # /!\ The cleanup cannot be done at the end of this `application`
338     # method because werkzeug still produces relevant logging afterwards 
339     if hasattr(threading.current_thread(), 'uid'):
340         del threading.current_thread().uid
341     if hasattr(threading.current_thread(), 'dbname'):
342         del threading.current_thread().dbname
343
344     # Try all handlers until one returns some result (i.e. not None).
345     wsgi_handlers = [wsgi_xmlrpc, wsgi_webdav]
346     wsgi_handlers += module_handlers
347     for handler in wsgi_handlers:
348         result = handler(environ, start_response)
349         if result is None:
350             continue
351         return result
352
353     # We never returned from the loop.
354     response = 'No handler found.\n'
355     start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))])
356     return [response]
357
358 def application(environ, start_response):
359     if config['proxy_mode'] and 'HTTP_X_FORWARDED_HOST' in environ:
360         return werkzeug.contrib.fixers.ProxyFix(application_unproxied)(environ, start_response)
361     else:
362         return application_unproxied(environ, start_response)
363
364
365 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: