[IMP] exceptions: replace ExceptionNoTb with AccessDenied.
[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
40 import openerp
41 import openerp.tools.config as config
42 import service.websrv_lib as websrv_lib
43
44 def xmlrpc_return(start_response, service, method, params):
45     """ Helper to call a service's method with some params, using a
46     wsgi-supplied ``start_response`` callback."""
47     # This mimics SimpleXMLRPCDispatcher._marshaled_dispatch() for exception
48     # handling.
49     try:
50         result = openerp.netsvc.dispatch_rpc(service, method, params)
51         response = xmlrpclib.dumps((result,), methodresponse=1, allow_none=False, encoding=None)
52     except openerp.netsvc.OpenERPDispatcherException, e:
53         fault = xmlrpclib.Fault(2, openerp.tools.exception_to_unicode(e.exception) + '\n' + e.traceback) # TODO map OpenERP-specific exception to some fault code
54         response = xmlrpclib.dumps(fault, allow_none=False, encoding=None)
55     except:
56         exc_type, exc_value, exc_tb = sys.exc_info()
57         fault = xmlrpclib.Fault(1, "%s:%s" % (exc_type, exc_value))
58         response = xmlrpclib.dumps(fault, allow_none=None, encoding=None)
59     start_response("200 OK", [('Content-Type','text/xml'), ('Content-Length', str(len(response)))])
60     return [response]
61
62 def wsgi_xmlrpc(environ, start_response):
63     """ The main OpenERP WSGI handler."""
64     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith('/openerp/6.1/xmlrpc'):
65         length = int(environ['CONTENT_LENGTH'])
66         data = environ['wsgi.input'].read(length)
67
68         params, method = xmlrpclib.loads(data)
69
70         path = environ['PATH_INFO'][len('/openerp/6.1/xmlrpc'):]
71         if path.startswith('/'): path = path[1:]
72         if path.endswith('/'): p = path[:-1]
73         path = path.split('/')
74
75         # All routes are hard-coded.
76
77         # No need for a db segment.
78         if len(path) == 1:
79             service = path[0]
80
81             if service == 'common':
82                 if method in ('create_database', 'list', 'server_version'):
83                     return xmlrpc_return(start_response, 'db', method, params)
84                 else:
85                     return xmlrpc_return(start_response, 'common', method, params)
86         # A db segment must be given.
87         elif len(path) == 2:
88             service, db_name = path
89             params = (db_name,) + params
90
91             if service == 'model':
92                 return xmlrpc_return(start_response, 'object', method, params)
93             elif service == 'report':
94                 return xmlrpc_return(start_response, 'report', method, params)
95
96         # TODO the body has been read, need to raise an exception (not return None).
97
98 def legacy_wsgi_xmlrpc(environ, start_response):
99     if environ['REQUEST_METHOD'] == 'POST' and environ['PATH_INFO'].startswith('/xmlrpc/'):
100         length = int(environ['CONTENT_LENGTH'])
101         data = environ['wsgi.input'].read(length)
102         path = environ['PATH_INFO'][len('/xmlrpc/'):] # expected to be one of db, object, ...
103
104         params, method = xmlrpclib.loads(data)
105         return xmlrpc_return(start_response, path, method, params)
106
107 def wsgi_jsonrpc(environ, start_response):
108     pass
109
110 def wsgi_webdav(environ, start_response):
111     if environ['REQUEST_METHOD'] == 'OPTIONS' and environ['PATH_INFO'] == '*':
112         return return_options(environ, start_response)
113
114     http_dir = websrv_lib.find_http_service(environ['PATH_INFO'])
115     if http_dir:
116         path = environ['PATH_INFO'][len(http_dir.path):]
117         if path.startswith('/'):
118             environ['PATH_INFO'] = path
119         else:
120             environ['PATH_INFO'] = '/' + path
121         return http_to_wsgi(http_dir)(environ, start_response)
122
123 def return_options(environ, start_response):
124     # Microsoft specific header, see
125     # http://www.ibm.com/developerworks/rational/library/2089.html
126     if 'Microsoft' in environ.get('User-Agent', ''):
127         option = [('MS-Author-Via', 'DAV')]
128     else:
129         option = []
130     options += [('DAV', '1 2'), ('Allow', 'GET HEAD PROPFIND OPTIONS REPORT')]
131     start_response("200 OK", [('Content-Length', str(0))] + options)
132     return []
133
134 def http_to_wsgi(http_dir):
135     """
136     Turn a BaseHTTPRequestHandler into a WSGI entry point.
137
138     Actually the argument is not a bare BaseHTTPRequestHandler but is wrapped
139     (as a class, so it needs to be instanciated) in a HTTPDir.
140
141     This code is adapted from wbsrv_lib.MultiHTTPHandler._handle_one_foreign().
142     It is a temporary solution: the HTTP sub-handlers (in particular the
143     document_webdav addon) have to be WSGIfied.
144     """
145     def wsgi_handler(environ, start_response):
146
147         # Extract from the WSGI environment the necessary data.
148         scheme = environ['wsgi.url_scheme']
149
150         headers = {}
151         for key, value in environ.items():
152             if key.startswith('HTTP_'):
153                 key = key[5:].replace('_', '-').title()
154                 headers[key] = value
155             if key == 'CONTENT_LENGTH':
156                 key = key.replace('_', '-').title()
157                 headers[key] = value
158         if environ.get('Content-Type'):
159             headers['Content-Type'] = environ['Content-Type']
160
161         path = urllib.quote(environ.get('PATH_INFO', ''))
162         if environ.get('QUERY_STRING'):
163             path += '?' + environ['QUERY_STRING']
164
165         request_version = 'HTTP/1.1' # TODO
166         request_line = "%s %s %s\n" % (environ['REQUEST_METHOD'], path, request_version)
167
168         class Dummy(object):
169             pass
170
171         # Let's pretend we have a server to hand to the handler.
172         server = Dummy()
173         server.server_name = environ['SERVER_NAME']
174         server.server_port = int(environ['SERVER_PORT'])
175
176         # Initialize the underlying handler and associated auth. provider.
177         con = openerp.service.websrv_lib.noconnection(environ['wsgi.input'])
178         handler = http_dir.instanciate_handler(con, environ['REMOTE_ADDR'], server)
179
180         # Populate the handler as if it is called by a regular HTTP server
181         # and the request is already parsed.
182         handler.wfile = StringIO.StringIO()
183         handler.rfile = environ['wsgi.input']
184         handler.headers = headers
185         handler.command = environ['REQUEST_METHOD']
186         handler.path = path
187         handler.request_version = request_version
188         handler.close_connection = 1
189         handler.raw_requestline = request_line
190         handler.requestline = request_line
191
192         # Handle authentication if there is an auth. provider associated to
193         # the handler.
194         if hasattr(handler, 'auth_provider'):
195             try:
196                 handler.auth_provider.checkRequest(handler, path)
197             except websrv_lib.AuthRequiredExc, ae:
198                 # Darwin 9.x.x webdav clients will report "HTTP/1.0" to us, while they support (and need) the
199                 # authorisation features of HTTP/1.1 
200                 if request_version != 'HTTP/1.1' and ('Darwin/9.' not in handler.headers.get('User-Agent', '')):
201                     start_response("403 Forbidden", [])
202                     return []
203                 start_response("401 Authorization required", [
204                     ('WWW-Authenticate', '%s realm="%s"' % (ae.atype,ae.realm)),
205                     # ('Connection', 'keep-alive'),
206                     ('Content-Type', 'text/html'),
207                     ('Content-Length', 4), # len(self.auth_required_msg)
208                     ])
209                 return ['Blah'] # self.auth_required_msg
210             except websrv_lib.AuthRejectedExc,e:
211                 start_response("403 %s" % (e.args[0],), [])
212                 return []
213
214         method_name = 'do_' + handler.command
215
216         # Support the OPTIONS method even when not provided directly by the
217         # handler. TODO I would prefer to remove it and fix the handler if
218         # needed.
219         if not hasattr(handler, method_name):
220             if handler.command == 'OPTIONS':
221                 return return_options(environ, start_response)
222             start_response("501 Unsupported method (%r)" % handler.command, [])
223             return []
224
225         # Finally, call the handler's method.
226         try:
227             method = getattr(handler, method_name)
228             method()
229             # The DAV handler buffers its output and provides a _flush()
230             # method.
231             getattr(handler, '_flush', lambda: None)()
232             response = parse_http_response(handler.wfile.getvalue())
233             response_headers = response.getheaders()
234             body = response.read()
235             start_response(str(response.status) + ' ' + response.reason, response_headers)
236             return [body]
237         except (websrv_lib.AuthRejectedExc, websrv_lib.AuthRequiredExc):
238             raise
239         except Exception, e:
240             start_response("500 Internal error", [])
241             return []
242
243     return wsgi_handler
244
245 def parse_http_response(s):
246     """ Turn a HTTP response string into a httplib.HTTPResponse object."""
247     class DummySocket(StringIO.StringIO):
248         """
249         This is used to provide a StringIO to httplib.HTTPResponse
250         which, instead of taking a file object, expects a socket and
251         uses its makefile() method.
252         """
253         def makefile(self, *args, **kw):
254             return self
255     response = httplib.HTTPResponse(DummySocket(s))
256     response.begin()
257     return response
258
259 # WSGI handlers registered through the register_wsgi_handler() function below.
260 module_handlers = []
261
262 def register_wsgi_handler(handler):
263     """ Register a WSGI handler.
264
265     Handlers are tried in the order they are added. We might provide a way to
266     register a handler for specific routes later.
267     """
268     module_handlers.append(handler)
269
270 def application(environ, start_response):
271     """ WSGI entry point."""
272
273     # Try all handlers until one returns some result (i.e. not None).
274     wsgi_handlers = [
275         wsgi_xmlrpc,
276         wsgi_jsonrpc,
277         legacy_wsgi_xmlrpc,
278         wsgi_webdav
279         ] + module_handlers
280     for handler in wsgi_handlers:
281         result = handler(environ, start_response)
282         if result is None:
283             continue
284         return result
285
286     # We never returned from the loop.
287     response = 'No handler found.\n'
288     start_response('404 Not Found', [('Content-Type', 'text/plain'), ('Content-Length', str(len(response)))])
289     return [response]
290
291 # The WSGI server, started by start_server(), stopped by stop_server().
292 httpd = None
293
294 def serve():
295     """ Serve HTTP requests via werkzeug development server.
296
297     If werkzeug can not be imported, we fall back to wsgiref's simple_server.
298
299     Calling this function is blocking, you might want to call it in its own
300     thread.
301     """
302
303     global httpd
304
305     # TODO Change the xmlrpc_* options to http_*
306     interface = config['xmlrpc_interface'] or '0.0.0.0'
307     port = config['xmlrpc_port']
308     try:
309         import werkzeug.serving
310         httpd = werkzeug.serving.make_server(interface, port, application, threaded=True)
311         logging.getLogger('wsgi').info('HTTP service (werkzeug) running on %s:%s', interface, port)
312     except ImportError, e:
313         import wsgiref.simple_server
314         logging.getLogger('wsgi').warn('Werkzeug module unavailable, falling back to wsgiref.')
315         httpd = wsgiref.simple_server.make_server(interface, port, application)
316         logging.getLogger('wsgi').info('HTTP service (wsgiref) running on %s:%s', interface, port)
317
318     httpd.serve_forever()
319
320 def start_server():
321     """ Call serve() in its own thread.
322
323     The WSGI server can be shutdown with stop_server() below.
324     """
325     threading.Thread(target=openerp.wsgi.serve).start()
326
327 def stop_server():
328     """ Initiate the shutdown of the WSGI server.
329
330     The server is supposed to have been started by start_server() above.
331     """
332     if httpd:
333         httpd.shutdown()
334
335 # Master process id, can be used for signaling.
336 arbiter_pid = None
337
338 # Application setup before we can spawn any worker process.
339 # This is suitable for e.g. gunicorn's on_starting hook.
340 def on_starting(server):
341     global arbiter_pid
342     arbiter_pid = os.getpid() # TODO check if this is true even after replacing the executable
343     config = openerp.tools.config
344     #openerp.tools.cache = kill_workers_cache
345     openerp.netsvc.init_logger()
346     openerp.osv.osv.start_object_proxy()
347     openerp.service.web_services.start_web_services()
348
349 # Install our own signal handler on the master process.
350 def when_ready(server):
351     # Hijack gunicorn's SIGWINCH handling; we can choose another one.
352     signal.signal(signal.SIGWINCH, make_winch_handler(server))
353
354 # Our signal handler will signal a SGIQUIT to all workers.
355 def make_winch_handler(server):
356     def handle_winch(sig, fram):
357         server.kill_workers(signal.SIGQUIT) # This is gunicorn specific.
358     return handle_winch
359
360 # Kill gracefuly the workers (e.g. because we want to clear their cache).
361 # This is done by signaling a SIGWINCH to the master process, so it can be
362 # called by the workers themselves.
363 def kill_workers():
364     try:
365         os.kill(arbiter_pid, signal.SIGWINCH)
366     except OSError, e:
367         if e.errno == errno.ESRCH: # no such pid
368             return
369         raise            
370
371 class kill_workers_cache(openerp.tools.ormcache):
372     def clear(self, dbname, *args, **kwargs):
373         kill_workers()
374
375 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: