a2598a11097da4ac3c85ff3d956d100bc48986cd
[odoo/odoo.git] / openerp / wsgi.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2011 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 """ WSGI stuffs (proof of concept for now)
23
24 This module offers a WSGI interface to OpenERP.
25
26 """
27
28 import httplib
29 import urllib
30 import xmlrpclib
31 import StringIO
32
33 import logging
34 import os
35 import signal
36 import sys
37 import threading
38 import time
39 import traceback
40
41 import openerp
42 import openerp.modules
43 import openerp.tools.config as config
44 import service.websrv_lib as websrv_lib
45
46 # XML-RPC fault codes. Some care must be taken when changing these: the
47 # constants are also defined client-side and must remain in sync.
48 # User code must use the exceptions defined in ``openerp.exceptions`` (not
49 # create directly ``xmlrpclib.Fault`` objects).
50 XML_RPC_FAULT_CODE_CLIENT_ERROR = 1 # again, indistinguishable from app. error.
51 XML_RPC_FAULT_CODE_APPLICATION_ERROR = 1
52 # Unused, deferred errors are indistinguishable from normal application
53 # errors. We keep them so we can use the word 'indistinguishable' twice
54 # in the same comment.
55 XML_RPC_FAULT_CODE_DEFERRED_APPLICATION_ERROR = 2
56 XML_RPC_FAULT_CODE_ACCESS_DENIED = 3
57 XML_RPC_FAULT_CODE_ACCESS_ERROR = 4
58 XML_RPC_FAULT_CODE_WARNING = 5
59
60 # The new (6.1) versioned RPC paths.
61 XML_RPC_PATH = '/openerp/xmlrpc'
62 XML_RPC_PATH_1 = '/openerp/xmlrpc/1'
63 JSON_RPC_PATH = '/openerp/jsonrpc'
64 JSON_RPC_PATH_1 = '/openerp/jsonrpc/1'
65
66 def xmlrpc_return(start_response, service, method, params, legacy_exceptions=False):
67     """
68     Helper to call a service's method with some params, using a wsgi-supplied
69     ``start_response`` callback.
70
71     This is the place to look at to see the mapping between core exceptions
72     and XML-RPC fault codes.
73     """
74     # Map OpenERP core exceptions to XML-RPC fault codes. Specific exceptions
75     # defined in ``openerp.exceptions`` are mapped to specific fault codes;
76     # all the other exceptions are mapped to the generic
77     # XML_RPC_FAULT_CODE_APPLICATION_ERROR value.
78     # This also mimics SimpleXMLRPCDispatcher._marshaled_dispatch() for
79     # exception handling.
80     try:
81         result = openerp.netsvc.dispatch_rpc(service, method, params)
82         response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False, encoding=None)
83     except Exception, e:
84         if legacy_exceptions:
85             response = xmlrpc_handle_exception_legacy(e)
86         else:
87             response = xmlrpc_handle_exception(e)
88     start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
89     return [response]
90
91 def xmlrpc_handle_exception(e):
92     if isinstance(e, openerp.osv.osv.except_osv): # legacy
93         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_WARNING, openerp.tools.ustr(e.value))
94         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
95     elif isinstance(e, openerp.exceptions.Warning):
96         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_WARNING, str(e))
97         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
98     elif isinstance (e, openerp.exceptions.AccessError):
99         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_ACCESS_ERROR, str(e))
100         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
101     elif isinstance(e, openerp.exceptions.AccessDenied):
102         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_ACCESS_DENIED, str(e))
103         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
104     elif isinstance(e, openerp.exceptions.DeferredException):
105         info = e.traceback
106         # Which one is the best ?
107         formatted_info = "".join(traceback.format_exception(*info))
108         #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
109         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
110         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
111     else:
112         if hasattr(e, 'message') and e.message == 'AccessDenied': # legacy
113             fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_ACCESS_DENIED, str(e))
114             response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
115         else:
116             info = sys.exc_info()
117             # Which one is the best ?
118             formatted_info = "".join(traceback.format_exception(*info))
119             #formatted_info = openerp.tools.exception_to_unicode(e) + '\n' + info
120             fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_APPLICATION_ERROR, formatted_info)
121             response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
122     return response
123
124 def xmlrpc_handle_exception_legacy(e):
125     if isinstance(e, openerp.osv.osv.except_osv):
126         fault = xmlrpclib.Fault('warning -- ' + e.name + '\n\n' + e.value, '')
127         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
128     elif isinstance(e, openerp.exceptions.Warning):
129         fault = xmlrpclib.Fault('warning -- Warning\n\n' + str(e), '')
130         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
131     elif isinstance(e, openerp.exceptions.AccessError):
132         fault = xmlrpclib.Fault('warning -- AccessError\n\n' + str(e), '')
133         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
134     elif isinstance(e, openerp.exceptions.AccessDenied):
135         fault = xmlrpclib.Fault('AccessDenied', str(e))
136         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
137     elif isinstance(e, openerp.exceptions.DeferredException):
138         info = e.traceback
139         formatted_info = "".join(traceback.format_exception(*info))
140         fault = xmlrpclib.Fault(openerp.tools.ustr(e.message), formatted_info)
141         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
142     else:
143         info = sys.exc_info()
144         formatted_info = "".join(traceback.format_exception(*info))
145         fault = xmlrpclib.Fault(openerp.tools.exception_to_unicode(e), formatted_info)
146         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
147     return response
148
149 def wsgi_xmlrpc_1(environ, start_response):
150     """ The main OpenERP WSGI handler."""
151     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith(XML_RPC_PATH_1):
152         length = int(environ['CONTENT_LENGTH'])
153         data = environ['wsgi.input'].read(length)
154
155         params, method = xmlrpclib.loads(data)
156
157         path = environ['PATH_INFO'][len(XML_RPC_PATH_1):]
158         if path.startswith('/'): path = path[1:]
159         if path.endswith('/'): p = path[:-1]
160         path = path.split('/')
161
162         # All routes are hard-coded.
163
164         # No need for a db segment.
165         if len(path) == 1:
166             service = path[0]
167
168             if service == 'common':
169                 if method in ('server_version',):
170                     service = 'db'
171             return xmlrpc_return(start_response, service, method, params)
172
173         # A db segment must be given.
174         elif len(path) == 2:
175             service, db_name = path
176             params = (db_name,) + params
177
178             return xmlrpc_return(start_response, service, method, params)
179
180         # A db segment and a model segment must be given.
181         elif len(path) == 3 and path[0] == 'model':
182             service, db_name, model_name = path
183             params = (db_name,) + params[:2] + (model_name,) + params[2:]
184             service = 'object'
185             return xmlrpc_return(start_response, service, method, params)
186
187         # The body has been read, need to raise an exception (not return None).
188         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_CLIENT_ERROR, '')
189         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
190         start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
191         return [response]
192
193 def wsgi_xmlrpc(environ, start_response):
194     """ WSGI handler to return the versions."""
195     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith(XML_RPC_PATH):
196         length = int(environ['CONTENT_LENGTH'])
197         data = environ['wsgi.input'].read(length)
198
199         params, method = xmlrpclib.loads(data)
200
201         path = environ['PATH_INFO'][len(XML_RPC_PATH):]
202         if path.startswith('/'): path = path[1:]
203         if path.endswith('/'): p = path[:-1]
204         path = path.split('/')
205
206         # All routes are hard-coded.
207
208         if len(path) == 1 and path[0] == '' and method in ('version',):
209             return xmlrpc_return(start_response, 'common', method, ())
210
211         # The body has been read, need to raise an exception (not return None).
212         fault = xmlrpclib.Fault(XML_RPC_FAULT_CODE_CLIENT_ERROR, '')
213         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
214         start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
215         return [response]
216
217 def legacy_wsgi_xmlrpc(environ, start_response):
218     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith('/xmlrpc/'):
219         length = int(environ['CONTENT_LENGTH'])
220         data = environ['wsgi.input'].read(length)
221         path = environ['PATH_INFO'][len('/xmlrpc/'):] # expected to be one of db, object, ...
222
223         params, method = xmlrpclib.loads(data)
224         return xmlrpc_return(start_response, path, method, params, True)
225
226 def wsgi_jsonrpc(environ, start_response):
227     pass
228
229 def wsgi_webdav(environ, start_response):
230     pi = environ['PATH_INFO']
231     if environ['REQUEST_METHOD'] == 'OPTIONS' and pi in ['*','/']:
232         return return_options(environ, start_response)
233     elif pi.startswith('/webdav'):
234         http_dir = websrv_lib.find_http_service(pi)
235         if http_dir:
236             path = pi[len(http_dir.path):]
237             if path.startswith('/'):
238                 environ['PATH_INFO'] = path
239             else:
240                 environ['PATH_INFO'] = '/' + path
241             return http_to_wsgi(http_dir)(environ, start_response)
242
243 def return_options(environ, start_response):
244     # Microsoft specific header, see
245     # http://www.ibm.com/developerworks/rational/library/2089.html
246     if 'Microsoft' in environ.get('User-Agent', ''):
247         option = [('MS-Author-Via', 'DAV')]
248     else:
249         option = []
250     options += [('DAV', '1 2'), ('Allow', 'GET HEAD PROPFIND OPTIONS REPORT')]
251     start_response("200 OK", [('Content-Length', str(0))] + options)
252     return []
253
254 def http_to_wsgi(http_dir):
255     """
256     Turn a BaseHTTPRequestHandler into a WSGI entry point.
257
258     Actually the argument is not a bare BaseHTTPRequestHandler but is wrapped
259     (as a class, so it needs to be instanciated) in a HTTPDir.
260
261     This code is adapted from wbsrv_lib.MultiHTTPHandler._handle_one_foreign().
262     It is a temporary solution: the HTTP sub-handlers (in particular the
263     document_webdav addon) have to be WSGIfied.
264     """
265     def wsgi_handler(environ, start_response):
266
267         # Extract from the WSGI environment the necessary data.
268         scheme = environ['wsgi.url_scheme']
269
270         headers = {}
271         for key, value in environ.items():
272             if key.startswith('HTTP_'):
273                 key = key[5:].replace('_', '-').title()
274                 headers[key] = value
275             if key == 'CONTENT_LENGTH':
276                 key = key.replace('_', '-').title()
277                 headers[key] = value
278         if environ.get('Content-Type'):
279             headers['Content-Type'] = environ['Content-Type']
280
281         path = urllib.quote(environ.get('PATH_INFO', ''))
282         if environ.get('QUERY_STRING'):
283             path += '?' + environ['QUERY_STRING']
284
285         request_version = 'HTTP/1.1' # TODO
286         request_line = "%s %s %s\n" % (environ['REQUEST_METHOD'], path, request_version)
287
288         class Dummy(object):
289             pass
290
291         # Let's pretend we have a server to hand to the handler.
292         server = Dummy()
293         server.server_name = environ['SERVER_NAME']
294         server.server_port = int(environ['SERVER_PORT'])
295
296         # Initialize the underlying handler and associated auth. provider.
297         con = openerp.service.websrv_lib.noconnection(environ['wsgi.input'])
298         handler = http_dir.instanciate_handler(con, environ['REMOTE_ADDR'], server)
299
300         # Populate the handler as if it is called by a regular HTTP server
301         # and the request is already parsed.
302         handler.wfile = StringIO.StringIO()
303         handler.rfile = environ['wsgi.input']
304         handler.headers = headers
305         handler.command = environ['REQUEST_METHOD']
306         handler.path = path
307         handler.request_version = request_version
308         handler.close_connection = 1
309         handler.raw_requestline = request_line
310         handler.requestline = request_line
311
312         # Handle authentication if there is an auth. provider associated to
313         # the handler.
314         if hasattr(handler, 'auth_provider'):
315             try:
316                 handler.auth_provider.checkRequest(handler, path)
317             except websrv_lib.AuthRequiredExc, ae:
318                 # Darwin 9.x.x webdav clients will report "HTTP/1.0" to us, while they support (and need) the
319                 # authorisation features of HTTP/1.1 
320                 if request_version != 'HTTP/1.1' and ('Darwin/9.' not in handler.headers.get('User-Agent', '')):
321                     start_response("403 Forbidden", [])
322                     return []
323                 start_response("401 Authorization required", [
324                     ('WWW-Authenticate', '%s realm="%s"' % (ae.atype,ae.realm)),
325                     # ('Connection', 'keep-alive'),
326                     ('Content-Type', 'text/html'),
327                     ('Content-Length', 4), # len(self.auth_required_msg)
328                     ])
329                 return ['Blah'] # self.auth_required_msg
330             except websrv_lib.AuthRejectedExc,e:
331                 start_response("403 %s" % (e.args[0],), [])
332                 return []
333
334         method_name = 'do_' + handler.command
335
336         # Support the OPTIONS method even when not provided directly by the
337         # handler. TODO I would prefer to remove it and fix the handler if
338         # needed.
339         if not hasattr(handler, method_name):
340             if handler.command == 'OPTIONS':
341                 return return_options(environ, start_response)
342             start_response("501 Unsupported method (%r)" % handler.command, [])
343             return []
344
345         # Finally, call the handler's method.
346         try:
347             method = getattr(handler, method_name)
348             method()
349             # The DAV handler buffers its output and provides a _flush()
350             # method.
351             getattr(handler, '_flush', lambda: None)()
352             response = parse_http_response(handler.wfile.getvalue())
353             response_headers = response.getheaders()
354             body = response.read()
355             start_response(str(response.status) + ' ' + response.reason, response_headers)
356             return [body]
357         except (websrv_lib.AuthRejectedExc, websrv_lib.AuthRequiredExc):
358             raise
359         except Exception, e:
360             start_response("500 Internal error", [])
361             return []
362
363     return wsgi_handler
364
365 def parse_http_response(s):
366     """ Turn a HTTP response string into a httplib.HTTPResponse object."""
367     class DummySocket(StringIO.StringIO):
368         """
369         This is used to provide a StringIO to httplib.HTTPResponse
370         which, instead of taking a file object, expects a socket and
371         uses its makefile() method.
372         """
373         def makefile(self, *args, **kw):
374             return self
375     response = httplib.HTTPResponse(DummySocket(s))
376     response.begin()
377     return response
378
379 # WSGI handlers registered through the register_wsgi_handler() function below.
380 module_handlers = []
381
382 def register_wsgi_handler(handler):
383     """ Register a WSGI handler.
384
385     Handlers are tried in the order they are added. We might provide a way to
386     register a handler for specific routes later.
387     """
388     module_handlers.append(handler)
389
390 def application(environ, start_response):
391     """ WSGI entry point."""
392
393     # Try all handlers until one returns some result (i.e. not None).
394     wsgi_handlers = [
395         wsgi_xmlrpc_1,
396         wsgi_xmlrpc,
397         wsgi_jsonrpc,
398         legacy_wsgi_xmlrpc,
399         wsgi_webdav
400         ] + module_handlers
401     for handler in wsgi_handlers:
402         result = handler(environ, start_response)
403         if result is None:
404             continue
405         return result
406
407     # We never returned from the loop.
408     response = 'No handler found.\n'
409     start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))])
410     return [response]
411
412 # The WSGI server, started by start_server(), stopped by stop_server().
413 httpd = None
414
415 def serve():
416     """ Serve HTTP requests via werkzeug development server.
417
418     If werkzeug can not be imported, we fall back to wsgiref's simple_server.
419
420     Calling this function is blocking, you might want to call it in its own
421     thread.
422     """
423
424     global httpd
425
426     # TODO Change the xmlrpc_* options to http_*
427     interface = config['xmlrpc_interface'] or '0.0.0.0'
428     port = config['xmlrpc_port']
429     try:
430         import werkzeug.serving
431         httpd = werkzeug.serving.make_server(interface, port, application, threaded=True)
432         logging.getLogger('wsgi').info('HTTP service (werkzeug) running on %s:%s', interface, port)
433     except ImportError, e:
434         import wsgiref.simple_server
435         logging.getLogger('wsgi').warn('Werkzeug module unavailable, falling back to wsgiref.')
436         httpd = wsgiref.simple_server.make_server(interface, port, application)
437         logging.getLogger('wsgi').info('HTTP service (wsgiref) running on %s:%s', interface, port)
438
439     httpd.serve_forever()
440
441 def start_server():
442     """ Call serve() in its own thread.
443
444     The WSGI server can be shutdown with stop_server() below.
445     """
446     threading.Thread(target=openerp.wsgi.serve).start()
447
448 def stop_server():
449     """ Initiate the shutdown of the WSGI server.
450
451     The server is supposed to have been started by start_server() above.
452     """
453     if httpd:
454         httpd.shutdown()
455
456 # Master process id, can be used for signaling.
457 arbiter_pid = None
458
459 # Application setup before we can spawn any worker process.
460 # This is suitable for e.g. gunicorn's on_starting hook.
461 def on_starting(server):
462     global arbiter_pid
463     arbiter_pid = os.getpid() # TODO check if this is true even after replacing the executable
464     config = openerp.tools.config
465     #openerp.tools.cache = kill_workers_cache
466     openerp.netsvc.init_logger()
467     openerp.osv.osv.start_object_proxy()
468     openerp.service.web_services.start_web_services()
469
470 # Install our own signal handler on the master process.
471 def when_ready(server):
472     # Hijack gunicorn's SIGWINCH handling; we can choose another one.
473     signal.signal(signal.SIGWINCH, make_winch_handler(server))
474
475 # Our signal handler will signal a SGIQUIT to all workers.
476 def make_winch_handler(server):
477     def handle_winch(sig, fram):
478         server.kill_workers(signal.SIGQUIT) # This is gunicorn specific.
479     return handle_winch
480
481 # Kill gracefuly the workers (e.g. because we want to clear their cache).
482 # This is done by signaling a SIGWINCH to the master process, so it can be
483 # called by the workers themselves.
484 def kill_workers():
485     try:
486         os.kill(arbiter_pid, signal.SIGWINCH)
487     except OSError, e:
488         if e.errno == errno.ESRCH: # no such pid
489             return
490         raise            
491
492 class kill_workers_cache(openerp.tools.ormcache):
493     def clear(self, dbname, *args, **kwargs):
494         kill_workers()
495
496 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: