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