b7a948f06205400eff8095a5bc55a2253b306958
[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     if environ['REQUEST_METHOD'] == 'OPTIONS' and environ['PATH_INFO'] == '*':
231         return return_options(environ, start_response)
232
233     http_dir = websrv_lib.find_http_service(environ['PATH_INFO'])
234     if http_dir:
235         path = environ['PATH_INFO'][len(http_dir.path):]
236         if path.startswith('/'):
237             environ['PATH_INFO'] = path
238         else:
239             environ['PATH_INFO'] = '/' + path
240         return http_to_wsgi(http_dir)(environ, start_response)
241
242 def return_options(environ, start_response):
243     # Microsoft specific header, see
244     # http://www.ibm.com/developerworks/rational/library/2089.html
245     if 'Microsoft' in environ.get('User-Agent', ''):
246         option = [('MS-Author-Via', 'DAV')]
247     else:
248         option = []
249     options += [('DAV', '1 2'), ('Allow', 'GET HEAD PROPFIND OPTIONS REPORT')]
250     start_response("200 OK", [('Content-Length', str(0))] + options)
251     return []
252
253 def http_to_wsgi(http_dir):
254     """
255     Turn a BaseHTTPRequestHandler into a WSGI entry point.
256
257     Actually the argument is not a bare BaseHTTPRequestHandler but is wrapped
258     (as a class, so it needs to be instanciated) in a HTTPDir.
259
260     This code is adapted from wbsrv_lib.MultiHTTPHandler._handle_one_foreign().
261     It is a temporary solution: the HTTP sub-handlers (in particular the
262     document_webdav addon) have to be WSGIfied.
263     """
264     def wsgi_handler(environ, start_response):
265
266         # Extract from the WSGI environment the necessary data.
267         scheme = environ['wsgi.url_scheme']
268
269         headers = {}
270         for key, value in environ.items():
271             if key.startswith('HTTP_'):
272                 key = key[5:].replace('_', '-').title()
273                 headers[key] = value
274             if key == 'CONTENT_LENGTH':
275                 key = key.replace('_', '-').title()
276                 headers[key] = value
277         if environ.get('Content-Type'):
278             headers['Content-Type'] = environ['Content-Type']
279
280         path = urllib.quote(environ.get('PATH_INFO', ''))
281         if environ.get('QUERY_STRING'):
282             path += '?' + environ['QUERY_STRING']
283
284         request_version = 'HTTP/1.1' # TODO
285         request_line = "%s %s %s\n" % (environ['REQUEST_METHOD'], path, request_version)
286
287         class Dummy(object):
288             pass
289
290         # Let's pretend we have a server to hand to the handler.
291         server = Dummy()
292         server.server_name = environ['SERVER_NAME']
293         server.server_port = int(environ['SERVER_PORT'])
294
295         # Initialize the underlying handler and associated auth. provider.
296         con = openerp.service.websrv_lib.noconnection(environ['wsgi.input'])
297         handler = http_dir.instanciate_handler(con, environ['REMOTE_ADDR'], server)
298
299         # Populate the handler as if it is called by a regular HTTP server
300         # and the request is already parsed.
301         handler.wfile = StringIO.StringIO()
302         handler.rfile = environ['wsgi.input']
303         handler.headers = headers
304         handler.command = environ['REQUEST_METHOD']
305         handler.path = path
306         handler.request_version = request_version
307         handler.close_connection = 1
308         handler.raw_requestline = request_line
309         handler.requestline = request_line
310
311         # Handle authentication if there is an auth. provider associated to
312         # the handler.
313         if hasattr(handler, 'auth_provider'):
314             try:
315                 handler.auth_provider.checkRequest(handler, path)
316             except websrv_lib.AuthRequiredExc, ae:
317                 # Darwin 9.x.x webdav clients will report "HTTP/1.0" to us, while they support (and need) the
318                 # authorisation features of HTTP/1.1 
319                 if request_version != 'HTTP/1.1' and ('Darwin/9.' not in handler.headers.get('User-Agent', '')):
320                     start_response("403 Forbidden", [])
321                     return []
322                 start_response("401 Authorization required", [
323                     ('WWW-Authenticate', '%s realm="%s"' % (ae.atype,ae.realm)),
324                     # ('Connection', 'keep-alive'),
325                     ('Content-Type', 'text/html'),
326                     ('Content-Length', 4), # len(self.auth_required_msg)
327                     ])
328                 return ['Blah'] # self.auth_required_msg
329             except websrv_lib.AuthRejectedExc,e:
330                 start_response("403 %s" % (e.args[0],), [])
331                 return []
332
333         method_name = 'do_' + handler.command
334
335         # Support the OPTIONS method even when not provided directly by the
336         # handler. TODO I would prefer to remove it and fix the handler if
337         # needed.
338         if not hasattr(handler, method_name):
339             if handler.command == 'OPTIONS':
340                 return return_options(environ, start_response)
341             start_response("501 Unsupported method (%r)" % handler.command, [])
342             return []
343
344         # Finally, call the handler's method.
345         try:
346             method = getattr(handler, method_name)
347             method()
348             # The DAV handler buffers its output and provides a _flush()
349             # method.
350             getattr(handler, '_flush', lambda: None)()
351             response = parse_http_response(handler.wfile.getvalue())
352             response_headers = response.getheaders()
353             body = response.read()
354             start_response(str(response.status) + ' ' + response.reason, response_headers)
355             return [body]
356         except (websrv_lib.AuthRejectedExc, websrv_lib.AuthRequiredExc):
357             raise
358         except Exception, e:
359             start_response("500 Internal error", [])
360             return []
361
362     return wsgi_handler
363
364 def parse_http_response(s):
365     """ Turn a HTTP response string into a httplib.HTTPResponse object."""
366     class DummySocket(StringIO.StringIO):
367         """
368         This is used to provide a StringIO to httplib.HTTPResponse
369         which, instead of taking a file object, expects a socket and
370         uses its makefile() method.
371         """
372         def makefile(self, *args, **kw):
373             return self
374     response = httplib.HTTPResponse(DummySocket(s))
375     response.begin()
376     return response
377
378 # WSGI handlers registered through the register_wsgi_handler() function below.
379 module_handlers = []
380
381 def register_wsgi_handler(handler):
382     """ Register a WSGI handler.
383
384     Handlers are tried in the order they are added. We might provide a way to
385     register a handler for specific routes later.
386     """
387     module_handlers.append(handler)
388
389 def application(environ, start_response):
390     """ WSGI entry point."""
391
392     # Try all handlers until one returns some result (i.e. not None).
393     wsgi_handlers = [
394         wsgi_xmlrpc_1,
395         wsgi_xmlrpc,
396         wsgi_jsonrpc,
397         legacy_wsgi_xmlrpc,
398         wsgi_webdav
399         ] + module_handlers
400     for handler in wsgi_handlers:
401         result = handler(environ, start_response)
402         if result is None:
403             continue
404         return result
405
406     # We never returned from the loop.
407     response = 'No handler found.\n'
408     start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))])
409     return [response]
410
411 # The WSGI server, started by start_server(), stopped by stop_server().
412 httpd = None
413
414 def serve():
415     """ Serve HTTP requests via werkzeug development server.
416
417     If werkzeug can not be imported, we fall back to wsgiref's simple_server.
418
419     Calling this function is blocking, you might want to call it in its own
420     thread.
421     """
422
423     global httpd
424
425     # TODO Change the xmlrpc_* options to http_*
426     interface = config['xmlrpc_interface'] or '0.0.0.0'
427     port = config['xmlrpc_port']
428     try:
429         import werkzeug.serving
430         httpd = werkzeug.serving.make_server(interface, port, application, threaded=True)
431         logging.getLogger('wsgi').info('HTTP service (werkzeug) running on %s:%s', interface, port)
432     except ImportError, e:
433         import wsgiref.simple_server
434         logging.getLogger('wsgi').warn('Werkzeug module unavailable, falling back to wsgiref.')
435         httpd = wsgiref.simple_server.make_server(interface, port, application)
436         logging.getLogger('wsgi').info('HTTP service (wsgiref) running on %s:%s', interface, port)
437
438     httpd.serve_forever()
439
440 def start_server():
441     """ Call serve() in its own thread.
442
443     The WSGI server can be shutdown with stop_server() below.
444     """
445     threading.Thread(target=openerp.wsgi.serve).start()
446
447 def stop_server():
448     """ Initiate the shutdown of the WSGI server.
449
450     The server is supposed to have been started by start_server() above.
451     """
452     if httpd:
453         httpd.shutdown()
454
455 # Master process id, can be used for signaling.
456 arbiter_pid = None
457
458 # Application setup before we can spawn any worker process.
459 # This is suitable for e.g. gunicorn's on_starting hook.
460 def on_starting(server):
461     global arbiter_pid
462     arbiter_pid = os.getpid() # TODO check if this is true even after replacing the executable
463     config = openerp.tools.config
464     #openerp.tools.cache = kill_workers_cache
465     openerp.netsvc.init_logger()
466     openerp.osv.osv.start_object_proxy()
467     openerp.service.web_services.start_web_services()
468
469 # Install our own signal handler on the master process.
470 def when_ready(server):
471     # Hijack gunicorn's SIGWINCH handling; we can choose another one.
472     signal.signal(signal.SIGWINCH, make_winch_handler(server))
473
474 # Our signal handler will signal a SGIQUIT to all workers.
475 def make_winch_handler(server):
476     def handle_winch(sig, fram):
477         server.kill_workers(signal.SIGQUIT) # This is gunicorn specific.
478     return handle_winch
479
480 # Kill gracefuly the workers (e.g. because we want to clear their cache).
481 # This is done by signaling a SIGWINCH to the master process, so it can be
482 # called by the workers themselves.
483 def kill_workers():
484     try:
485         os.kill(arbiter_pid, signal.SIGWINCH)
486     except OSError, e:
487         if e.errno == errno.ESRCH: # no such pid
488             return
489         raise            
490
491 class kill_workers_cache(openerp.tools.ormcache):
492     def clear(self, dbname, *args, **kwargs):
493         kill_workers()
494
495 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: